mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-08 21:00:43 +02:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5f3a1f9ca | ||
|
|
a41f635a7c | ||
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
28073e212a | ||
|
|
63c1a9ff55 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
7308ac09b1 |
No files matched your search
@@ -14,7 +14,31 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# One job makes the draft, before the builds: three matrix jobs each running
|
||||
# "view || create" could race and make duplicate drafts. The draft is tied to
|
||||
# the pushed tag (--verify-tag, then tag_name set explicitly), so
|
||||
# scripts/publish-release.sh and `gh release upload` find it by tag.
|
||||
draft:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create the draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
|
||||
if [ -z "$id" ]; then
|
||||
gh release create "$tag" --draft --verify-tag --title "Frame Control ${tag#v}" --notes ""
|
||||
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\") | .id" | head -n 1)
|
||||
fi
|
||||
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
|
||||
|
||||
build:
|
||||
needs: draft
|
||||
# Still runs for pull requests and manual runs, where the draft job is skipped.
|
||||
if: ${{ !failure() && !cancelled() }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -46,9 +70,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes ""
|
||||
gh release upload "$tag" ${{ matrix.files }} --clobber
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -51,6 +51,27 @@ test("update.json assets always download from this repository's release", () =>
|
||||
assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
|
||||
});
|
||||
|
||||
test("the release page is this repository's tag page, never a draft's untagged-... link", () => {
|
||||
const { fromManifest, fromApi } = require("../updater");
|
||||
const tagPage = "https://github.com/saphid/frame-control/releases/tag/v0.4.0";
|
||||
const page = (p) => fromManifest({ version: "0.4.0", assets: [], page: p }).page;
|
||||
assert.strictEqual(page(tagPage), tagPage);
|
||||
// 0.4.0's real update.json: the draft's address, a 404 once published.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/untagged-c6ddfed7f75d67db2e99"), tagPage);
|
||||
assert.strictEqual(page(undefined), tagPage);
|
||||
assert.strictEqual(page("https://evil.example/releases/tag/v0.4.0"), tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.4.0?x=1"), tagPage);
|
||||
// Paths that normalize to another repository.
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/..\\..\\..\\..\\other-owner\\other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/%2e%2e/%2e%2e/%2e%2e/%2e%2e/other-owner/other-repo"),
|
||||
tagPage);
|
||||
assert.strictEqual(page("https://github.com/saphid/frame-control/releases/tag/v0.3.9"), tagPage); // only its own tag
|
||||
assert.strictEqual(fromApi({ tag_name: "../../x", assets: [] }).page, "https://github.com/saphid/frame-control/releases");
|
||||
assert.strictEqual(fromApi({ tag_name: "v0.4.0", assets: [],
|
||||
html_url: "https://github.com/saphid/frame-control/releases/tag/untagged-x" }).page, tagPage);
|
||||
});
|
||||
|
||||
test("prepare refuses a release that isn't newer (no downgrades)", async () => {
|
||||
const { prepare } = require("../updater");
|
||||
const release = { version: "0.3.1", assets: [] };
|
||||
|
||||
+18
-5
@@ -106,12 +106,24 @@ async function getJson(url, headers) {
|
||||
return JSON.parse(body);
|
||||
}
|
||||
|
||||
// The release page the banner links to. update.json for 0.4.0 carried the draft's
|
||||
// address (releases/tag/untagged-...), which is dead once the release is published,
|
||||
// and a page from the manifest could also be steered elsewhere (e.g. tag/..\..\other/repo
|
||||
// normalizes to another repository) before shell.openExternal. So the given page is
|
||||
// ignored: the link is always this repository's tag page, built from a valid version.
|
||||
function releasePage(version) {
|
||||
const v = parseVersion(version);
|
||||
if (!v) return RELEASES;
|
||||
return `${RELEASES}/tag/v${v.nums.join(".")}${v.pre ? "-" + v.pre : ""}`;
|
||||
}
|
||||
|
||||
// update.json and the API's release both become { version, notes, page, assets }.
|
||||
function fromManifest(m) {
|
||||
if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
|
||||
return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
|
||||
page: m.page || RELEASES,
|
||||
const version = String(m.version).replace(/^v/i, "");
|
||||
const base = `https://github.com/${REPO}/releases/download/v${version}/`;
|
||||
return { version, notes: String(m.notes || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
// Assets always come from this repository's release, whatever the manifest says.
|
||||
assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
|
||||
size: a.size, digest: a.digest || null })) };
|
||||
@@ -119,8 +131,9 @@ function fromManifest(m) {
|
||||
|
||||
function fromApi(r) {
|
||||
if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
|
||||
return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
|
||||
page: r.html_url || RELEASES,
|
||||
const version = String(r.tag_name || "").replace(/^v/i, "");
|
||||
return { version, notes: String(r.body || "").slice(0, 4000),
|
||||
page: releasePage(version),
|
||||
assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
|
||||
digest: a.digest || null })) };
|
||||
}
|
||||
|
||||
@@ -88,8 +88,13 @@ counts them while they run.
|
||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
asks for the sudo password over SSH.
|
||||
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||
sign in with the Developer Mode password. Sleep, restart and
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
+19
-5
@@ -107,7 +107,14 @@ wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||
email address goes with it only if you tick **The maintainer may contact me
|
||||
with follow-up questions** (the report then carries `contact_followup: true`);
|
||||
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
|
||||
your analytics events.
|
||||
your analytics events. With that box ticked, the address also becomes your
|
||||
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||
like any other. If it's a different address from the one saved there, it
|
||||
replaces it, and update notices stop until you turn them on again (they were
|
||||
agreed for the old address); the form says so before you send. The report then also
|
||||
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||
see below), so removing or changing the address later takes back the
|
||||
follow-up permission given with the report too.
|
||||
|
||||
With **Include diagnostics** ticked (the default), the report adds:
|
||||
|
||||
@@ -141,8 +148,8 @@ are two separate choices, both off until you tick them:
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
You're asked once, in a bar at the top of the page, after the Frame has
|
||||
connected for the first time, and never while or straight after the
|
||||
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
|
||||
connected for the first time, and never in the same visit as the first-run
|
||||
privacy notice. **No thanks** hides it for good, and it isn't
|
||||
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||
is where you add, change or remove the address and either choice at any time.
|
||||
|
||||
@@ -151,7 +158,10 @@ Frame Control's PostHog project, the same place as problem reports, as a
|
||||
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
|
||||
or `withdraw`) and the common properties above. Only the maintainer can read
|
||||
that project, and nothing in it is published or shared. It's sent only when
|
||||
you save, whatever the analytics settings are, because you chose to. It
|
||||
you save, or when you send a problem report with follow-up questions ticked,
|
||||
whatever the analytics settings are, because you chose to. With a report, the
|
||||
address and choices are saved before the report is sent and stay saved if it
|
||||
fails; like any change, they're sent as soon as PostHog can be reached. It
|
||||
carries its own random contact id, not the analytics id, so it isn't linked
|
||||
to your usage events, and a `rev` number that goes up with each change, so
|
||||
the newest choice always wins. Like everything else sent, it's listed under
|
||||
@@ -164,7 +174,11 @@ deletes it from this computer, including from the **Show what's been sent**
|
||||
log (in earlier contact events and problem reports), and sends a `withdraw`
|
||||
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
|
||||
then on the address isn't listed for either choice. Unticking one choice
|
||||
works the same way for that choice. If you're offline, the change waits on
|
||||
works the same way for that choice. This also covers problem reports you sent
|
||||
from this copy with follow-up questions ticked: if your newest choice since the
|
||||
report (by change number, not the clock) no longer agrees to follow-up
|
||||
questions at that address, the maintainer's inbox shows the permission as
|
||||
withdrawn and leaves the address out. If you're offline, the change waits on
|
||||
this computer and is sent when PostHog can be reached. The earlier event
|
||||
stays in PostHog until its data retention removes it; to have it deleted
|
||||
sooner, ask the maintainer (for example in a problem report).
|
||||
|
||||
+11
-4
@@ -24,13 +24,20 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
4. Publish:
|
||||
|
||||
```sh
|
||||
scripts/publish-release.sh --dry-run v0.4.0 # checks and shows update.json, changes nothing
|
||||
scripts/publish-release.sh v0.4.0
|
||||
```
|
||||
|
||||
The script checks that all eight installers are attached, each with the
|
||||
SHA-256 digest GitHub records. It attaches `update.json` (the version, the
|
||||
notes and each installer's digest), then publishes the release and marks it
|
||||
latest. From then on, running copies see the update. They check about 8
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
`tag_name` still reads `untagged-…`, and GraphQL is often rate-limited.
|
||||
Such a draft is found by its exact title (`Frame Control 0.4.0`, or that
|
||||
followed by `: subtitle`) and tied to the tag when it's published. The release page in `update.json` is always
|
||||
`releases/tag/<tag>`, because a draft's own address (`releases/tag/untagged-…`)
|
||||
stops working once it's published. From then on, running copies see the update. They check about 8
|
||||
seconds after starting, then every 6 hours, and anyone can use **Check for
|
||||
Updates…** (the app menu on macOS, the Help menu elsewhere).
|
||||
|
||||
|
||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
|
||||
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
|
||||
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
|
||||
Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
|
||||
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
|
||||
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
|
||||
fills in `steamos` there on Windows, Remmina and FreeRDP.
|
||||
- The desktop is a separate login session (Xorg on display `:10`), not the
|
||||
headset's view. It uses about 1.3 GB of the Frame's memory.
|
||||
- Closing the client leaves the session running, and the next login
|
||||
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
|
||||
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
|
||||
gamescope or SteamVR session.
|
||||
|
||||
## B. Show the Mac's desktop inside the Frame
|
||||
|
||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||
|
||||
+74
-16
@@ -3,23 +3,65 @@
|
||||
# (docs/releasing.md). Checks every installer is attached with a SHA-256
|
||||
# digest first, since the app's updater refuses assets without one, then
|
||||
# attaches update.json, the manifest the updater reads.
|
||||
# Usage: scripts/publish-release.sh v0.4.0
|
||||
# Usage: scripts/publish-release.sh [--dry-run] v0.4.0
|
||||
#
|
||||
# Everything goes through the REST API (gh api), not `gh release view/edit`:
|
||||
# those look a draft up by its tag, and a draft can show tag_name
|
||||
# "untagged-..." until it's published, so they report "release not found"
|
||||
# (and GraphQL is often rate-limited). A draft's html_url is an untagged-...
|
||||
# link that dies on publishing, so update.json's page is built from the tag.
|
||||
set -eu
|
||||
tag="${1:?usage: $0 vX.Y.Z}"
|
||||
dry=""
|
||||
[ "${1:-}" = "--dry-run" ] && { dry=1; shift; }
|
||||
tag="${1:?usage: $0 [--dry-run] vX.Y.Z}"
|
||||
repo=saphid/frame-control
|
||||
expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
|
||||
Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
|
||||
Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
|
||||
page="https://github.com/$repo/releases/tag/$tag"
|
||||
|
||||
info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
|
||||
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
|
||||
[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
|
||||
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# Publishing sets tag_name; if the tag didn't exist GitHub would create it on
|
||||
# the default branch, which isn't what was built and tested.
|
||||
gh api "repos/$repo/git/ref/tags/$tag" >/dev/null 2>&1 \
|
||||
|| { echo "tag $tag isn't on GitHub; push it first (git push origin $tag)" >&2; exit 1; }
|
||||
|
||||
# Every release, drafts included, one JSON object per line.
|
||||
gh api --paginate "repos/$repo/releases?per_page=100" --jq '.[]' > "$tmp/releases"
|
||||
# The release whose tag_name is the tag; failing that, the one untagged-... draft
|
||||
# titled "Frame Control X.Y.Z" (release.yml's title), optionally ": subtitle".
|
||||
python3 - "$tag" "$tmp/releases" > "$tmp/release.json" <<'EOF'
|
||||
import json, re, sys
|
||||
tag, path = sys.argv[1], sys.argv[2]
|
||||
rels = [json.loads(line) for line in open(path) if line.strip()]
|
||||
hits = [r for r in rels if r.get("tag_name") == tag]
|
||||
if not hits:
|
||||
# Exactly this version: "Frame Control 0.4.0", or that followed by ": <subtitle>".
|
||||
# Never "Frame Control 0.4.0-rc.1" or "0.4.00", and only drafts with no real tag.
|
||||
title = re.compile(r"Frame Control " + re.escape(tag.lstrip("v")) + r"(: .*)?", re.S)
|
||||
hits = [r for r in rels if r.get("draft") and str(r.get("tag_name") or "").startswith("untagged-")
|
||||
and title.fullmatch(r.get("name") or "")]
|
||||
if len(hits) != 1:
|
||||
why = "no release" if not hits else "%d releases (ids %s)" % (len(hits), ", ".join(str(r["id"]) for r in hits))
|
||||
sys.exit("found %s for %s; expected one draft" % (why, tag))
|
||||
r = hits[0]
|
||||
if not r.get("draft"):
|
||||
print("note: %s is already published; refreshing update.json and marking it latest" % tag, file=sys.stderr)
|
||||
json.dump(r, sys.stdout)
|
||||
EOF
|
||||
id=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["id"])' "$tmp/release.json")
|
||||
echo "release $id ($(python3 -c 'import json,sys; r=json.load(open(sys.argv[1])); print(("draft" if r["draft"] else "published") + ", tag_name " + str(r["tag_name"]))' "$tmp/release.json"))"
|
||||
|
||||
missing=""
|
||||
for name in $expected; do
|
||||
digest=$(printf '%s' "$info" | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin); n=sys.argv[1]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
|
||||
digest=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1])); n=sys.argv[2]
|
||||
print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$tmp/release.json" "$name")
|
||||
case "$digest" in
|
||||
sha256:*) echo "ok $name" ;;
|
||||
absent) echo "MISSING $name"; missing=1 ;;
|
||||
@@ -30,16 +72,32 @@ done
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
names=set(sys.argv[1].split())
|
||||
print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
|
||||
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
tag, page, names = sys.argv[2], sys.argv[3], set(sys.argv[4].split())
|
||||
print(json.dumps({"version": tag.lstrip("v"), "page": page, "notes": (d.get("body") or "")[:4000],
|
||||
"assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
|
||||
gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
|
||||
for a in d["assets"] if a["name"] in names]}, indent=1))
|
||||
EOF
|
||||
old=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))' "$tmp/release.json")
|
||||
|
||||
if [ -n "$dry" ]; then
|
||||
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
|
||||
cat "$tmp/update.json"
|
||||
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for asset in $old; do
|
||||
gh api -X DELETE "repos/$repo/releases/assets/$asset" >/dev/null
|
||||
done
|
||||
gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=update.json" \
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||
echo "ok update.json"
|
||||
|
||||
gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
|
||||
echo "published $tag; running copies will offer it at their next check"
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||
echo "running copies will offer $tag at their next check"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py. Serves
|
||||
the releases in $FAKEGH_RELEASES (a JSON list, drafts included) and the tags in
|
||||
$FAKEGH_TAGS (space-separated); an upload's body is written to $FAKEGH_UPLOAD.
|
||||
Every call is appended to $FAKEGH_LOG as a JSON line. Anything else fails, so the
|
||||
script under test can't fall back to `gh release ...` (GraphQL) unnoticed."""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKEGH_LOG"], "a") as f:
|
||||
f.write(json.dumps(args) + "\n")
|
||||
if not args or args[0] != "api":
|
||||
sys.exit("fakegh: only `gh api` is supported: %r" % args)
|
||||
|
||||
method, endpoint, fields, inp, i = "GET", None, {}, None, 1
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a == "-X":
|
||||
method = args[i + 1]; i += 2
|
||||
elif a in ("-f", "-F"):
|
||||
k, _, v = args[i + 1].partition("="); fields[k] = v; i += 2
|
||||
elif a == "--input":
|
||||
inp = args[i + 1]; i += 2
|
||||
elif a in ("-H", "--jq"):
|
||||
i += 2
|
||||
elif a.startswith("-"):
|
||||
i += 1
|
||||
elif endpoint is None:
|
||||
endpoint = a; i += 1
|
||||
else:
|
||||
sys.exit("fakegh: unexpected argument %r" % a)
|
||||
|
||||
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
|
||||
repo = "repos/saphid/frame-control/"
|
||||
if method == "GET" and endpoint.startswith(repo + "git/ref/tags/"):
|
||||
tag = endpoint.rsplit("/", 1)[1]
|
||||
if tag not in os.environ.get("FAKEGH_TAGS", "").split():
|
||||
sys.exit("gh: Not Found (HTTP 404)")
|
||||
print(json.dumps({"ref": "refs/tags/" + tag}))
|
||||
elif method == "GET" and endpoint.startswith(repo + "releases?"):
|
||||
for r in releases: # what --jq '.[]' prints
|
||||
print(json.dumps(r))
|
||||
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
|
||||
pass
|
||||
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
|
||||
with open(inp) as src, open(os.environ["FAKEGH_UPLOAD"], "w") as dst:
|
||||
dst.write(src.read())
|
||||
print("{}")
|
||||
elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
|
||||
print("published %s at https://github.com/saphid/frame-control/releases/tag/%s"
|
||||
% (fields.get("tag_name"), fields.get("tag_name")))
|
||||
else:
|
||||
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
|
||||
+134
-2
@@ -59,6 +59,16 @@ class Contact(Base):
|
||||
self.assertEqual(fc.load()["email"], "")
|
||||
self.assertEqual(self.got, [])
|
||||
|
||||
def test_only_a_real_true_counts_as_consent(self):
|
||||
for wrong in ("false", "true", 1, 0, [], {}):
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
||||
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
||||
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
||||
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
||||
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
||||
|
||||
def test_each_choice_is_sent_privately_on_its_own(self):
|
||||
fc.save({"email": " me@example.com ", "updates": True})
|
||||
fc.save({"email": "me@example.com", "updates": False, "followup": True})
|
||||
@@ -239,15 +249,121 @@ class Contact(Base):
|
||||
|
||||
# ---- reports and the maintainer's list
|
||||
|
||||
def reports(self):
|
||||
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
||||
|
||||
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com"})
|
||||
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
without, with_ = (e["properties"] for e in self.events())
|
||||
self.assertEqual((without["contact"], without["contact_followup"]), ("", False))
|
||||
without, with_ = self.reports()
|
||||
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
||||
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
||||
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
||||
with self.assertRaisesRegex(ValueError, "email address"):
|
||||
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||
|
||||
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
||||
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
||||
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
||||
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
||||
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
||||
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
||||
fc.save({"email": ""}) # Remove my email
|
||||
last = self.events()[-1]
|
||||
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
||||
("withdraw", "", 2))
|
||||
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>", "<removed>"])
|
||||
|
||||
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
||||
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
||||
fc.save({"email": "old@example.com", "updates": True})
|
||||
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
||||
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
||||
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
||||
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
||||
s = fc.state()
|
||||
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
||||
|
||||
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
||||
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
||||
rev (so the removal is newer) and is logged without the address."""
|
||||
post, removed = tm.post, []
|
||||
|
||||
def slow_post(events, **kw):
|
||||
post(events, **kw)
|
||||
if not removed and events[0]["event"] == "contact_consent":
|
||||
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
||||
with mock.patch.object(tm, "post", side_effect=slow_post):
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
report = self.reports()[0]
|
||||
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||
self.assertEqual(logged, ["<removed>"])
|
||||
|
||||
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
||||
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
||||
"0.4.0", "Windows", "", "", followup, cid, rev]
|
||||
|
||||
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
||||
reports = [self.report_row(), # removed later
|
||||
self.report_row(cid="other"), # another copy, still agrees
|
||||
self.report_row(rev=3), # sent after the removal
|
||||
self.report_row(cid="moved"), # address changed later
|
||||
self.report_row(cid="news-only"), # follow-up unticked later
|
||||
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
||||
self.report_row(cid="", followup=True), # no contact id: left alone
|
||||
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
||||
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
||||
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
||||
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
||||
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
||||
fr.mark_withdrawn(reports, consents)
|
||||
self.assertEqual([r[10] for r in reports],
|
||||
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
||||
|
||||
def test_the_change_number_decides_not_the_clock(self):
|
||||
"""The clock went back between the report and the removal: the removal still counts."""
|
||||
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
||||
fc.save({"email": ""})
|
||||
report = self.reports()[0]
|
||||
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||
for e in self.events() if e["event"] == "contact_consent"]
|
||||
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
||||
fr.mark_withdrawn([row], consents)
|
||||
self.assertEqual(row[10], "withdrawn")
|
||||
|
||||
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
||||
reports = [self.report_row(), ["short"]]
|
||||
consents = [["copy", "", False, 2]]
|
||||
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
||||
mock.patch("builtins.print") as out:
|
||||
fr.main()
|
||||
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
||||
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
||||
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||
self.assertIn("follow-up permission since withdrawn", printed)
|
||||
self.assertNotIn("me@example.com", printed)
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
||||
fr.inbox()
|
||||
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
||||
|
||||
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
|
||||
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
|
||||
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
|
||||
@@ -272,6 +388,22 @@ class Contact(Base):
|
||||
self.assertIs(server.POST["/api/contact"], fc.save)
|
||||
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
|
||||
|
||||
def test_saving_is_not_headset_work(self):
|
||||
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
||||
import io
|
||||
import server
|
||||
seen = []
|
||||
for path in ("/api/contact", "/api/contact/prompt"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
||||
h.path, h.rfile = path, io.BytesIO(body)
|
||||
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
||||
h.local_request = lambda: True
|
||||
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
||||
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
||||
h.do_POST()
|
||||
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
||||
|
||||
|
||||
# Run these once, in test_telemetry, not again through the import above.
|
||||
del Base, ReportProblem
|
||||
|
||||
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_devices as fd # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
|
||||
CONFIG = """Host lxso1
|
||||
HostName 192.168.1.109
|
||||
@@ -274,7 +275,8 @@ class Pins(Base):
|
||||
def test_hashed_and_non_default_port_entries(self):
|
||||
kh = self.ssh / "known_hosts"
|
||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||
@@ -283,7 +285,8 @@ class Pins(Base):
|
||||
target = fd.known_hosts("d4")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
||||
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||
self.assertNotIn("frame-control-d4", target.read_text())
|
||||
self.assertTrue(fd.pinned("d4"))
|
||||
self.assertTrue(fd.forget_pin("d4"))
|
||||
|
||||
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||
data.install_obb(PKG, [path])
|
||||
stream.assert_not_called()
|
||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||
data._stream('command')
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||
import sandbox # noqa: F401
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||
import frame_host
|
||||
|
||||
|
||||
class CapturedSSH(unittest.TestCase):
|
||||
def run_command(self, source, **kwargs):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||
|
||||
def test_binary_output_and_input(self):
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||
capture_output=True, input=b"data\x00\xff")
|
||||
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||
self.assertEqual(result.stderr, b"error\r\n")
|
||||
|
||||
def test_text_output_normalizes_newlines(self):
|
||||
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||
capture_output=True, input="hello\n", text=True)
|
||||
self.assertEqual(result.stdout, "hello\n")
|
||||
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||
|
||||
def test_explicit_encoding_and_errors(self):
|
||||
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||
capture_output=True, encoding="ascii", errors="replace")
|
||||
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||
|
||||
def test_check_preserves_error_output(self):
|
||||
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||
capture_output=True, text=True, check=True)
|
||||
self.assertEqual(caught.exception.returncode, 7)
|
||||
self.assertEqual(caught.exception.stdout, "out\n")
|
||||
self.assertEqual(caught.exception.stderr, "err\n")
|
||||
|
||||
def test_timeout_preserves_partial_stderr(self):
|
||||
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||
capture_output=True, text=True, timeout=1)
|
||||
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||
|
||||
def test_streamed_stdout_is_kept_separate(self):
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||
"sys.stderr.buffer.write(b'error')",
|
||||
stdout=output, stderr=subprocess.PIPE)
|
||||
output.seek(0)
|
||||
self.assertEqual(output.read(), b"file")
|
||||
self.assertIsNone(result.stdout)
|
||||
self.assertEqual(result.stderr, b"error")
|
||||
|
||||
def test_uncaptured_windows_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||
|
||||
def test_posix_call_is_unchanged(self):
|
||||
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||
|
||||
def test_capture_rejects_explicit_streams(self):
|
||||
for stream in ("stdout", "stderr"):
|
||||
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||
self.assertEqual(result.returncode, 255)
|
||||
self.assertIn("Could not resolve hostname", result.stderr)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""scripts/publish-release.sh against a stand-in gh (tests/fakegh/gh): finds the draft
|
||||
through the REST API even when its tag_name still says untagged-..., refuses
|
||||
missing installers or digests, writes update.json's page from the tag, and
|
||||
publishes with one PATCH. Nothing here talks to GitHub.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SCRIPT = ROOT / "scripts" / "publish-release.sh"
|
||||
FAKEGH = ROOT / "tests" / "fakegh"
|
||||
INSTALLERS = ["Frame-Control-mac-arm64.dmg", "Frame-Control-mac-arm64.zip", "Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip", "Frame-Control-linux-x86_64.AppImage",
|
||||
"Frame-Control-linux-arm64.AppImage", "Frame-Control-linux-amd64.deb",
|
||||
"Frame-Control-linux-arm64.deb"]
|
||||
|
||||
|
||||
def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7", rid=42, assets=None):
|
||||
if assets is None:
|
||||
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
|
||||
for i, n in enumerate(INSTALLERS)]
|
||||
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
|
||||
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
|
||||
"assets": assets}
|
||||
|
||||
|
||||
class PublishRelease(unittest.TestCase):
|
||||
def run_script(self, releases, *args, tags="v9.8.7"):
|
||||
d = Path(tempfile.mkdtemp())
|
||||
(d / "releases.json").write_text(json.dumps(releases))
|
||||
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
|
||||
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS=tags,
|
||||
FAKEGH_LOG=str(d / "log"), FAKEGH_UPLOAD=str(d / "upload.json"))
|
||||
p = subprocess.run(["sh", str(SCRIPT), *args], env=env, capture_output=True, text=True, timeout=30)
|
||||
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
|
||||
upload = json.loads((d / "upload.json").read_text()) if (d / "upload.json").exists() else None
|
||||
return p, log, upload
|
||||
|
||||
def test_publishes_an_untagged_draft_by_title_with_the_tag_page(self):
|
||||
old = {"id": 7, "name": "update.json", "size": 1, "digest": None}
|
||||
rel = draft(assets=draft()["assets"] + [old])
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(upload["page"], "https://github.com/saphid/frame-control/releases/tag/v9.8.7")
|
||||
self.assertEqual(upload["version"], "9.8.7")
|
||||
self.assertEqual(sorted(a["name"] for a in upload["assets"]), sorted(INSTALLERS))
|
||||
self.assertIn(["api", "-X", "DELETE", "repos/saphid/frame-control/releases/assets/7"], log)
|
||||
patch = next(c for c in log if "PATCH" in c)
|
||||
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
|
||||
for f in ("tag_name=v9.8.7", "draft=false", "prerelease=false", "make_latest=true"):
|
||||
self.assertIn(f, patch)
|
||||
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...` (GraphQL)
|
||||
|
||||
def test_an_untagged_draft_may_carry_a_subtitle(self):
|
||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7: faster")], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(upload["version"], "9.8.7")
|
||||
|
||||
def test_prefers_the_release_whose_tag_name_matches(self):
|
||||
p, log, upload = self.run_script([draft(name="Frame Control 9.8.7 old", rid=1),
|
||||
draft(tag_name="v9.8.7", name="Renamed", rid=2)], "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertEqual(next(c for c in log if "PATCH" in c)[3], "repos/saphid/frame-control/releases/2")
|
||||
|
||||
def test_refuses_missing_or_unhashed_installers(self):
|
||||
assets = draft()["assets"][1:]
|
||||
assets[0] = dict(assets[0], digest=None)
|
||||
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("MISSING Frame-Control-mac-arm64.dmg", p.stdout)
|
||||
self.assertIn("NO HASH Frame-Control-mac-arm64.zip", p.stdout)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
|
||||
|
||||
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
|
||||
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("2 releases", p.stderr)
|
||||
p, _, _ = self.run_script([draft(name="Frame Control 9.8.70")], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("no release", p.stderr)
|
||||
# A pre-release's draft, or one bound to an unrelated tag, is never taken for v9.8.7.
|
||||
for rel in (draft(name="Frame Control 9.8.7-rc.1"), draft(name="Frame Control 9.8.7.1"),
|
||||
draft(tag_name="kdeconnect-frame-1"), draft(tag_name="")):
|
||||
p, log, upload = self.run_script([rel], "v9.8.7")
|
||||
self.assertNotEqual(p.returncode, 0, rel)
|
||||
self.assertIn("no release", p.stderr)
|
||||
self.assertIsNone(upload)
|
||||
self.assertFalse(any("PATCH" in c for c in log))
|
||||
p, log, _ = self.run_script([draft()], "v9.8.7", tags="")
|
||||
self.assertNotEqual(p.returncode, 0)
|
||||
self.assertIn("push it first", p.stderr)
|
||||
|
||||
def test_dry_run_changes_nothing(self):
|
||||
p, log, upload = self.run_script([draft()], "--dry-run", "v9.8.7")
|
||||
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
|
||||
self.assertIn('"page": "https://github.com/saphid/frame-control/releases/tag/v9.8.7"', p.stdout)
|
||||
self.assertIsNone(upload)
|
||||
self.assertTrue(all("-X" not in c for c in log))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
|
||||
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
|
||||
staying quiet when the page goes away mid-reply, which on Windows is
|
||||
ConnectionAbortedError (WinError 10053).
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import email.message
|
||||
import io
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_host # noqa: E402
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def platform(name):
|
||||
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
|
||||
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
|
||||
LINUX=name == "linux")
|
||||
|
||||
|
||||
class OpenRdp(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.xrdp = socket.socket()
|
||||
self.xrdp.bind(("127.0.0.1", 0))
|
||||
self.xrdp.listen(4)
|
||||
self.addCleanup(self.xrdp.close)
|
||||
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
|
||||
port.start()
|
||||
self.addCleanup(port.stop)
|
||||
self.spawned = []
|
||||
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
|
||||
spawn.start()
|
||||
self.addCleanup(spawn.stop)
|
||||
cache = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(cache.cleanup)
|
||||
self.cache = Path(cache.name)
|
||||
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
|
||||
where.start()
|
||||
self.addCleanup(where.stop)
|
||||
|
||||
def test_windows_signs_in_as_steamos(self):
|
||||
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
|
||||
with platform("windows"):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(len(self.spawned), 1)
|
||||
argv = self.spawned[0]
|
||||
self.assertEqual(argv[0], "mstsc.exe")
|
||||
self.assertNotIn("/v:127.0.0.1", argv)
|
||||
rdp = Path(argv[1])
|
||||
self.assertEqual(rdp.suffix, ".rdp")
|
||||
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
|
||||
self.assertNotIn(b"\r\r", data)
|
||||
lines = data.decode("utf-8").split("\r\n")
|
||||
self.assertIn("full address:s:127.0.0.1", lines)
|
||||
self.assertIn("username:s:steamos", lines)
|
||||
self.assertIn("steamos", message)
|
||||
self.assertIn("Developer Mode password", message)
|
||||
self.assertIn("certificate", message)
|
||||
self.assertIn("Connect", message)
|
||||
|
||||
def test_nothing_listening_says_why_and_opens_nothing(self):
|
||||
self.xrdp.close()
|
||||
for name in ("windows", "mac", "linux"):
|
||||
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_says_which_way_it_failed(self):
|
||||
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||
(socket.timeout("timed out"), "didn't answer"),
|
||||
(OSError(65, "No route to host"), "didn't answer")):
|
||||
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||
frame_host.open_rdp("frame", "frame.local")
|
||||
self.assertIn(says, str(cm.exception))
|
||||
self.assertNotIn("refused", str(cm.exception))
|
||||
self.assertEqual(self.spawned, [])
|
||||
|
||||
def test_server_says_it_as_the_persons_to_fix(self):
|
||||
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||
self.xrdp.close()
|
||||
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||
self.assertRaises(server.Failure) as cm:
|
||||
server.open_thing({"what": "rdp"})
|
||||
self.assertEqual(cm.exception.status, 400)
|
||||
self.assertIn("Developer Mode", str(cm.exception))
|
||||
|
||||
def test_one_file_per_address(self):
|
||||
with platform("windows"):
|
||||
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||
self.assertEqual(len({a, b, c, d}), 4)
|
||||
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||
|
||||
def test_address_cant_add_lines_to_the_file(self):
|
||||
with platform("windows"), self.assertRaises(frame_host.HostError):
|
||||
frame_host.rdp_file("frame\r\nusername:s:root")
|
||||
self.assertEqual(list(self.cache.iterdir()), [])
|
||||
|
||||
def test_linux_clients_get_the_user(self):
|
||||
with platform("linux"), mock.patch.object(frame_host, "which",
|
||||
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
|
||||
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
|
||||
self.assertIn("steamos", message)
|
||||
|
||||
|
||||
class PageGoneAway(unittest.TestCase):
|
||||
"""The report's server log: the page closed while index.html was being sent, and the
|
||||
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
|
||||
|
||||
def handler(self, path="/"):
|
||||
h = server.Handler.__new__(server.Handler)
|
||||
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
|
||||
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
|
||||
h.headers = email.message.Message()
|
||||
h.headers["Host"] = "127.0.0.1:1"
|
||||
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
|
||||
h.close_connection = True
|
||||
return h
|
||||
|
||||
def test_not_a_server_error(self):
|
||||
h = self.handler()
|
||||
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
|
||||
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
|
||||
h.do_GET()
|
||||
diagnostic.assert_not_called()
|
||||
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
|
||||
|
||||
def test_server_logs_nothing(self):
|
||||
srv = server.LoopbackServer.__new__(server.LoopbackServer)
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(sys, "stderr", err):
|
||||
try:
|
||||
raise server.ClientGone()
|
||||
except server.ClientGone:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertEqual(err.getvalue(), "")
|
||||
try:
|
||||
raise RuntimeError("real")
|
||||
except RuntimeError:
|
||||
srv.handle_error(None, ("127.0.0.1", 1))
|
||||
self.assertIn("RuntimeError: real", err.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -391,15 +391,16 @@ class ReportProblem(Base):
|
||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||
got = self.serve()
|
||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com", "contactFollowup": True})
|
||||
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
|
||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||
"contact": "me@example.com", "contactFollowup": True})
|
||||
path, body = got[0]
|
||||
event = body["batch"][0]
|
||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||
props = event["properties"]
|
||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
||||
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||
self.assertIs(props["contact_followup"], True)
|
||||
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
|
||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
|
||||
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||
self.assertIn(res["id"], res["message"])
|
||||
@@ -422,8 +423,8 @@ class ReportProblem(Base):
|
||||
|
||||
def test_the_inbox_skips_malformed_reports(self):
|
||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||
"0.4.0", "macOS", "", "", None]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None],
|
||||
"0.4.0", "macOS", "", "", None, None, None]
|
||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
||||
["short"], good]
|
||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_host # noqa: E402
|
||||
import frame_devices as fd # noqa: E402
|
||||
|
||||
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
|
||||
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
|
||||
|
||||
|
||||
def ran(*results):
|
||||
"""subprocess.run stand-in answering whoami, then icacls."""
|
||||
calls = []
|
||||
|
||||
def run(argv, **kw):
|
||||
calls.append(argv)
|
||||
return results[len(calls) - 1]
|
||||
return run, calls
|
||||
|
||||
|
||||
class MakePrivate(unittest.TestCase):
|
||||
def test_windows_sets_owner_only_acl_by_sid(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
|
||||
subprocess.CompletedProcess([], 0))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
|
||||
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
|
||||
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
|
||||
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
|
||||
|
||||
def test_windows_falls_back_to_username_and_reports_failure(self):
|
||||
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
|
||||
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
|
||||
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
|
||||
self.assertFalse(frame_host.make_private(Path("config")))
|
||||
self.assertIn("bob:F", calls[1])
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "POSIX modes")
|
||||
def test_posix_chmods_600(self):
|
||||
with tempfile.NamedTemporaryFile() as f:
|
||||
os.chmod(f.name, 0o644)
|
||||
self.assertTrue(frame_host.make_private(f.name))
|
||||
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
|
||||
|
||||
|
||||
class ConfigWrites(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
self.config = self.ssh / "config"
|
||||
|
||||
def test_devices_and_connect_writes_make_the_file_private(self):
|
||||
import frame_connect as fc
|
||||
self.config.write_text("Host other\n User me\n", encoding="utf-8")
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
|
||||
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
|
||||
fc.write_config("10.0.0.5")
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
fd.rewrite_block("frame", path=self.config, user="deck")
|
||||
self.assertEqual(private.call_count, 3)
|
||||
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
|
||||
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
|
||||
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
|
||||
|
||||
def test_setup_runs_isolated_as_the_app_starts_it(self):
|
||||
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
|
||||
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
|
||||
self.assertNotIn("ModuleNotFoundError", r.stderr)
|
||||
self.assertIn("frame_connect.py", r.stdout + r.stderr)
|
||||
|
||||
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
|
||||
self.config.write_bytes(data)
|
||||
with mock.patch.object(frame_host, "make_private", return_value=True):
|
||||
self.assertTrue(fd.repair_permissions(self.config))
|
||||
self.assertEqual(self.config.read_bytes(), data)
|
||||
|
||||
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
|
||||
self.config.write_bytes(b"Host a\n")
|
||||
before = self.config.stat().st_ino
|
||||
with mock.patch.object(frame_host, "make_private", return_value=False):
|
||||
self.assertFalse(fd.repair_permissions(self.config))
|
||||
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
|
||||
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
|
||||
|
||||
|
||||
class ServerRepair(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.server = server
|
||||
|
||||
def setUp(self):
|
||||
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
|
||||
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
|
||||
mock.patch.object(frame_host, "WINDOWS", True),
|
||||
mock.patch.object(self.server, "_config_repaired", False)]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_repairs_the_refused_config_once(self):
|
||||
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
|
||||
self.assertTrue(self.server.repair_ssh_config(REFUSED))
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_called_once()
|
||||
|
||||
def test_leaves_other_files_and_errors_alone(self):
|
||||
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
|
||||
with mock.patch.object(fd, "repair_permissions") as repair:
|
||||
self.assertFalse(self.server.repair_ssh_config(key))
|
||||
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
|
||||
with mock.patch.object(frame_host, "WINDOWS", False):
|
||||
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||
repair.assert_not_called()
|
||||
|
||||
def test_ssh_retries_after_repairing(self):
|
||||
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
|
||||
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
|
||||
mock.patch.object(fd, "repair_permissions", return_value=True), \
|
||||
mock.patch.object(self.server, "LINK", None):
|
||||
self.assertEqual(self.server.ssh("true"), "ok")
|
||||
|
||||
|
||||
class LinkLocalZone(unittest.TestCase):
|
||||
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
|
||||
|
||||
def probe(self, windows):
|
||||
import frame_link as fl
|
||||
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
|
||||
sock = mock.MagicMock()
|
||||
with mock.patch.object(frame_host, "WINDOWS", windows), \
|
||||
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
|
||||
mock.patch.object(fl.socket, "socket", return_value=sock), \
|
||||
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
|
||||
return fl.probe("frame.local", 22)["ip"]
|
||||
|
||||
def test_windows_uses_the_numeric_zone(self):
|
||||
self.assertEqual(self.probe(True), "fe80::1%12")
|
||||
|
||||
def test_elsewhere_uses_the_interface_name(self):
|
||||
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+3
-3
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'timed out talking to {FRAME}')
|
||||
if p.returncode != 0:
|
||||
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
||||
else:
|
||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
|
||||
@@ -11,16 +11,17 @@ import tempfile
|
||||
import uuid
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||
|
||||
|
||||
def _stream(command, src=None, dst=None):
|
||||
try:
|
||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||
stdin=src if src else subprocess.DEVNULL,
|
||||
stdout=dst if dst else subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=1800)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise android.FrameError('app-data transfer timed out')
|
||||
except OSError as error:
|
||||
|
||||
+10
-5
@@ -24,6 +24,10 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
# The app runs this with python -I, which leaves the script's folder off sys.path.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import frame_host # noqa: E402
|
||||
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
@@ -330,8 +334,9 @@ def _write_config(host, port, user):
|
||||
block = config_block(host, port, user)
|
||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
tmp.chmod(0o600)
|
||||
if not frame_host.make_private(tmp):
|
||||
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
|
||||
"Frame Control repairs it when it next connects")
|
||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
||||
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||
for attempt in range(60):
|
||||
@@ -349,9 +354,9 @@ def _write_config(host, port, user):
|
||||
|
||||
def key_login_works():
|
||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
def configured_user():
|
||||
|
||||
+36
-2
@@ -71,6 +71,31 @@ def valid_email(email):
|
||||
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
|
||||
|
||||
|
||||
def flag(body, key):
|
||||
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
|
||||
v = body.get(key)
|
||||
if v is not None and not isinstance(v, bool):
|
||||
raise ValueError(f'{key} must be true or false')
|
||||
return v is True
|
||||
|
||||
|
||||
def from_report(email):
|
||||
"""Follow-up questions agreed to with a problem report: the address becomes the contact
|
||||
email with that choice ticked, so it shows in Settings and is removed the same way. Update
|
||||
notices stay on only for the same address: a different one replaces the old address with
|
||||
follow-up questions only (the report form says so before sending). Returns (contact id,
|
||||
rev) for the report to carry, read together with the change itself: a later change from
|
||||
this copy has a higher rev, and the newest such change decides whether the report's
|
||||
follow-up permission still stands, whatever the clocks say."""
|
||||
with _lock:
|
||||
s = load()
|
||||
same = s['email'].lower() == email.lower()
|
||||
changed, cid, rev = _apply({'email': s['email'] if same else email,
|
||||
'updates': s['updates'] and same, 'followup': True})
|
||||
_deliver(changed)
|
||||
return cid, rev
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
|
||||
and the Frame has connected at least once (setup worked), so it never greets a new install."""
|
||||
@@ -155,8 +180,14 @@ def redact_removed(event, started):
|
||||
def save(body):
|
||||
"""Set, change or remove the address and the two choices. An address needs at least one
|
||||
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
|
||||
_deliver(_apply(body)[0])
|
||||
return state()
|
||||
|
||||
|
||||
def _apply(body):
|
||||
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
|
||||
email = str(body.get('email') or '').strip()
|
||||
updates, followup = bool(body.get('updates')), bool(body.get('followup'))
|
||||
updates, followup = flag(body, 'updates'), flag(body, 'followup')
|
||||
if email and not valid_email(email):
|
||||
raise ValueError("that doesn't look like an email address")
|
||||
if email and not (updates or followup):
|
||||
@@ -181,9 +212,12 @@ def save(body):
|
||||
_forget_locally(old)
|
||||
except OSError:
|
||||
pass
|
||||
return changed, s['id'], s['rev']
|
||||
|
||||
|
||||
def _deliver(changed):
|
||||
if changed and not _send_pending(block=False):
|
||||
_wake.set() # offline, or a send under way that will take this change with it
|
||||
return state()
|
||||
|
||||
|
||||
def prompt(body):
|
||||
|
||||
+36
-6
@@ -241,8 +241,7 @@ def _write_config(path, text, expected):
|
||||
try:
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
if not frame_host.WINDOWS:
|
||||
tmp.chmod(0o600)
|
||||
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
|
||||
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||
if read_config(path) != expected:
|
||||
return False
|
||||
@@ -271,6 +270,37 @@ def _edit_config(path, change):
|
||||
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
||||
|
||||
|
||||
def repair_permissions(path=None):
|
||||
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
|
||||
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
|
||||
got that ACL and replaced the file."""
|
||||
path = Path(path or ssh_config())
|
||||
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||
tmp = Path(tmp)
|
||||
try:
|
||||
with os.fdopen(fd_, "wb") as fh:
|
||||
fh.write(data)
|
||||
if not frame_host.make_private(tmp):
|
||||
return False
|
||||
for attempt in range(20): # a running ssh.exe can hold the file for a moment
|
||||
if path.read_bytes() != data:
|
||||
return False
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except PermissionError:
|
||||
time.sleep(0.25)
|
||||
return False
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
||||
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
||||
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||
@@ -333,8 +363,8 @@ def remove_block(alias, path=None):
|
||||
def effective_port(alias, config):
|
||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 22
|
||||
m = re.search(r"^port (\d+)$", out, re.M)
|
||||
@@ -346,8 +376,8 @@ def effective_port(alias, config):
|
||||
|
||||
def _keygen(*args):
|
||||
try:
|
||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10)
|
||||
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
+128
-8
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
||||
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
MAC = sys.platform == "darwin"
|
||||
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Unreachable(HostError):
|
||||
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
|
||||
|
||||
|
||||
def run_ssh(argv, **kwargs):
|
||||
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
|
||||
|
||||
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
|
||||
while keeping subprocess.run's captured output, text, check and timeout API.
|
||||
"""
|
||||
if not WINDOWS:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
if kwargs.pop("capture_output", False):
|
||||
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
|
||||
raise ValueError("stdout and stderr arguments may not be used with capture_output")
|
||||
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
if kwargs.get("stderr") != subprocess.PIPE:
|
||||
return subprocess.run(argv, **kwargs)
|
||||
check = kwargs.pop("check", False)
|
||||
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
|
||||
with tempfile.TemporaryFile() as stderr:
|
||||
kwargs["stderr"] = stderr
|
||||
try:
|
||||
result = subprocess.run(argv, **kwargs)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
stderr.seek(0)
|
||||
error.stderr = stderr.read()
|
||||
raise
|
||||
stderr.seek(0)
|
||||
if text:
|
||||
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
|
||||
result.stderr = reader.read()
|
||||
else:
|
||||
result.stderr = stderr.read()
|
||||
if check:
|
||||
result.check_returncode()
|
||||
return result
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||
@@ -241,10 +284,46 @@ def clipboard_text():
|
||||
raise HostError("Can't read the clipboard")
|
||||
|
||||
|
||||
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
|
||||
BAD_PERMISSIONS = "Bad owner or permissions on "
|
||||
|
||||
|
||||
def make_private(path):
|
||||
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
|
||||
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
|
||||
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
|
||||
refuses it if that grants anyone else, even an account deleted long ago
|
||||
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
|
||||
if not WINDOWS:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
me = os.environ.get("USERNAME", "")
|
||||
try: # "desktop\me","S-1-5-21-..."
|
||||
# Bytes: the account name is in the console's code page, the SID is ASCII.
|
||||
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
|
||||
if sid.startswith("S-1-"):
|
||||
me = "*" + sid
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if not me:
|
||||
return False
|
||||
try:
|
||||
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
|
||||
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def ssh_hostname(alias):
|
||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return alias
|
||||
for line in out.splitlines():
|
||||
@@ -277,24 +356,65 @@ def open_steam_link():
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
RDP_PORT = 3389
|
||||
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
|
||||
# xrdp's certificate is its own, so every client warns about it first.
|
||||
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
|
||||
"with your Developer Mode password")
|
||||
|
||||
|
||||
def check_rdp(host, timeout=3):
|
||||
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
|
||||
try:
|
||||
with socket.create_connection((host, RDP_PORT), timeout=timeout):
|
||||
return
|
||||
except ConnectionRefusedError:
|
||||
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
|
||||
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
|
||||
"then restart it and try again.") from None
|
||||
except socket.gaierror:
|
||||
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
|
||||
"address on the Devices tab.") from None
|
||||
except OSError as e:
|
||||
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
|
||||
"switched off or on another network; if it's on, check Developer Mode is on "
|
||||
"in Steam Settings > System.") from None
|
||||
|
||||
|
||||
def rdp_file(host):
|
||||
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
|
||||
computer's Windows account, which xrdp turns away; the file names steamos instead."""
|
||||
if any(c in host for c in "\r\n"):
|
||||
raise HostError("That headset address can't be used for remote desktop")
|
||||
# One file per address, so two launches close together can't swap headsets.
|
||||
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
|
||||
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
|
||||
return path
|
||||
|
||||
|
||||
def open_rdp(alias, host=None):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||
host = host or ssh_hostname(alias)
|
||||
# The client would open either way and then fail on its own, with nothing said here.
|
||||
check_rdp(host)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||
return "Windows App isn't installed; opened its App Store page"
|
||||
if WINDOWS:
|
||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
||||
return f"Opened Remote Desktop to {host}"
|
||||
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||
# Windows asks about the unsigned connection file first.
|
||||
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||
if which("remmina"):
|
||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
||||
return f"Opened Remmina to {host}"
|
||||
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||
for name in ("xfreerdp3", "xfreerdp"):
|
||||
if which(name):
|
||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}"
|
||||
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||
|
||||
|
||||
|
||||
+10
-9
@@ -74,8 +74,8 @@ def ssh_g(alias):
|
||||
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
||||
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10).stdout
|
||||
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
out = ""
|
||||
got = {}
|
||||
@@ -108,7 +108,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
||||
ip = addr[0]
|
||||
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
|
||||
try: # a link-local IPv6 address only works with its interface
|
||||
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
|
||||
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
|
||||
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
left = deadline - now()
|
||||
@@ -765,8 +766,8 @@ class Link:
|
||||
if not self.control:
|
||||
return False
|
||||
try:
|
||||
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
@@ -777,8 +778,8 @@ class Link:
|
||||
pending.kill()
|
||||
if self.control and self.alias:
|
||||
try:
|
||||
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5)
|
||||
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||
stdin=subprocess.DEVNULL, timeout=5)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
if proc and proc.poll() is None:
|
||||
@@ -983,8 +984,8 @@ class Link:
|
||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
||||
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=20)
|
||||
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors="replace", timeout=20)
|
||||
err = r.stderr.strip()
|
||||
if r.returncode == 0:
|
||||
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
|
||||
|
||||
+47
-9
@@ -112,18 +112,22 @@ def send(body):
|
||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||
title, text, diag = compose(body)
|
||||
followup = bool(body.get('contactFollowup'))
|
||||
followup = frame_contact.flag(body, 'contactFollowup')
|
||||
contact = str(body.get('contact') or '').strip() if followup else ''
|
||||
if followup and not frame_contact.valid_email(contact):
|
||||
raise ValueError('add your email address for follow-up questions, or untick that box')
|
||||
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
|
||||
# It becomes the contact email in Settings, where it's changed or removed like any other.
|
||||
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
|
||||
ref = uuid.uuid4().hex[:8].upper()
|
||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
|
||||
# Only with an address: a later change from this copy (higher rev) can take it back.
|
||||
'contact_id': contact_id, 'contact_rev': contact_rev,
|
||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
||||
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
|
||||
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
|
||||
started = time.time()
|
||||
try:
|
||||
frame_telemetry.post([event], timeout=30)
|
||||
except frame_telemetry.SendError as e:
|
||||
@@ -143,15 +147,50 @@ class ReportError(RuntimeError):
|
||||
|
||||
def inbox(days=30):
|
||||
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
||||
frame_compat_db.sync uses)."""
|
||||
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
|
||||
questions now: 'withdrawn' when a later choice from the same copy took it back."""
|
||||
import frame_compat_db
|
||||
days = int(days)
|
||||
res = frame_compat_db._posthog_query(
|
||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
|
||||
"properties.contact_followup "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
|
||||
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
||||
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
|
||||
"ORDER BY timestamp DESC LIMIT 200")
|
||||
return res.get('results') or []
|
||||
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
||||
if any(r[11] and _yes(r[10]) for r in rows):
|
||||
later = frame_compat_db._posthog_query(
|
||||
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
|
||||
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
|
||||
mark_withdrawn(rows, later.get('results') or [])
|
||||
return rows
|
||||
|
||||
|
||||
def mark_withdrawn(reports, consents):
|
||||
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
|
||||
the same copy made after the report (a higher rev than it carries, not a later clock) no
|
||||
longer agrees to follow-up questions at that address."""
|
||||
newest = {}
|
||||
for c in consents:
|
||||
if not isinstance(c, list) or len(c) != 4:
|
||||
continue
|
||||
cid, email, followup, rev = c
|
||||
try:
|
||||
rev = int(rev or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if rev > newest.get(str(cid), (-1,))[0]:
|
||||
newest[str(cid)] = (rev, str(email or ''), followup)
|
||||
for r in reports:
|
||||
if not (r[11] and _yes(r[10])):
|
||||
continue
|
||||
try:
|
||||
sent_at = int(r[12] or 0)
|
||||
except (TypeError, ValueError):
|
||||
sent_at = 0
|
||||
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
|
||||
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
|
||||
r[10] = 'withdrawn'
|
||||
|
||||
|
||||
def _yes(v):
|
||||
@@ -204,14 +243,13 @@ def main():
|
||||
if cmd != 'inbox':
|
||||
sys.exit(USAGE)
|
||||
for row in inbox(*(args[:1] or [30])):
|
||||
if not isinstance(row, list) or len(row) != 11:
|
||||
continue
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||
reply = contact and (row[10] is None or _yes(row[10]))
|
||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
|
||||
f"{', may follow up at ' + contact if reply else ''}")
|
||||
f"{', may follow up at ' + contact if reply else ''}"
|
||||
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
|
||||
print(' ' + text.replace('\n', '\n '))
|
||||
if diag:
|
||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||
|
||||
+21
-5
@@ -1268,8 +1268,9 @@
|
||||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
|
||||
<span class="sub">Optional. Your email address goes with this report only when this is ticked.</span></label>
|
||||
<span class="sub">Optional. Your email address goes with this report only when this is ticked, and is kept as your contact email in Privacy & updates, where you can remove it.</span></label>
|
||||
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
|
||||
<p class="hint" id="bugReplaces" role="status" hidden></p>
|
||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||
@@ -4131,6 +4132,7 @@ async function offerTest(m) {
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
@@ -4141,6 +4143,7 @@ function renderTelemetry(s) {
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) privacyNoticeShown = true;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
@@ -4183,13 +4186,14 @@ async function loadContact() {
|
||||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||
checkContactPrompt();
|
||||
}
|
||||
// One time only, only once the Frame has connected, and never on top of the privacy notice or
|
||||
// straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
|
||||
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||
async function checkContactPrompt() {
|
||||
if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return;
|
||||
await telemetryLoaded;
|
||||
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
let s;
|
||||
try { s = await api("/api/contact"); } catch { return; }
|
||||
if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").hidden) return;
|
||||
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
$("contactNotice").hidden = false;
|
||||
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
|
||||
}
|
||||
@@ -4257,6 +4261,7 @@ function openBugReport() {
|
||||
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
|
||||
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
|
||||
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
|
||||
bugReplaces();
|
||||
$("bugDlg").showModal();
|
||||
loadBugPreview();
|
||||
}
|
||||
@@ -4267,7 +4272,17 @@ $("bugFollowup").onchange = () => {
|
||||
const on = $("bugFollowup").checked;
|
||||
$("bugContact").disabled = !on; $("bugContact").required = on;
|
||||
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
|
||||
bugReplaces();
|
||||
};
|
||||
// Sending with another address replaces the saved one (ui/frame_contact.py from_report): say so first.
|
||||
function bugReplaces() {
|
||||
const email = $("bugContact").value.trim(), old = contact.email;
|
||||
const replaces = $("bugFollowup").checked && email && old && email.toLowerCase() !== old.toLowerCase();
|
||||
$("bugReplaces").hidden = !replaces;
|
||||
$("bugReplaces").textContent = !replaces ? "" : `Sending replaces ${old} as your contact email${contact.updates
|
||||
? ", and update notices stop until you turn them on again in Privacy & updates" : ""}.`;
|
||||
}
|
||||
$("bugContact").oninput = bugReplaces;
|
||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||
$("bugCopy").onclick = async () => {
|
||||
const { title, body } = bugReportText();
|
||||
@@ -4290,6 +4305,7 @@ $("bugForm").onsubmit = async e => {
|
||||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||||
$("bugSend").disabled = false;
|
||||
}
|
||||
api("/api/contact").then(renderContact).catch(() => {}); // the report may have saved the address
|
||||
};
|
||||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||||
|
||||
|
||||
+56
-8
@@ -134,6 +134,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -333,12 +334,14 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||
text=text, errors="replace" if text else None, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
if r.returncode == 255 and repair_ssh_config(err):
|
||||
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
|
||||
if r.returncode == 255 and LINK and unreachable(err):
|
||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
@@ -347,6 +350,30 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
return r.stdout
|
||||
|
||||
|
||||
_config_repaired = False
|
||||
|
||||
|
||||
def repair_ssh_config(err):
|
||||
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
|
||||
once per run. -> True if it did, so the command is worth retrying."""
|
||||
global _config_repaired
|
||||
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
|
||||
return False
|
||||
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
|
||||
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
|
||||
config = frame_devices.ssh_config()
|
||||
if not named.lower().endswith("/" + config.name.lower()):
|
||||
return False # a key or another file: not ours to rewrite
|
||||
_config_repaired = True
|
||||
try:
|
||||
if frame_devices.repair_permissions(config):
|
||||
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def strip_ansi(s):
|
||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||
|
||||
@@ -504,8 +531,8 @@ def save_shots(body):
|
||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||
try:
|
||||
try:
|
||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
||||
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure("Copying screenshots timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -1199,6 +1226,8 @@ def open_thing(body):
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
|
||||
except frame_host.Unreachable as e:
|
||||
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
|
||||
except frame_host.HostError as e:
|
||||
raise Failure(str(e), 500)
|
||||
raise Failure("unknown target", 400)
|
||||
@@ -2254,7 +2283,7 @@ def push_file(path, dest="Downloads/"):
|
||||
else:
|
||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Failure(f"Copying {name} timed out")
|
||||
if r.returncode != 0:
|
||||
@@ -2262,6 +2291,11 @@ def push_file(path, dest="Downloads/"):
|
||||
return f"Sent {name} to ~/{dest}"
|
||||
|
||||
|
||||
class ClientGone(Exception):
|
||||
"""The page went away (a reload, the app quitting) before its reply was written:
|
||||
nobody to answer, and nothing went wrong here."""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "FrameControl/1"
|
||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||
@@ -2294,8 +2328,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
# Nobody may frame the UI (clickjacking).
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
try:
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
|
||||
raise ClientGone() from e
|
||||
|
||||
def send_json(self, obj, status=200):
|
||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||
@@ -2338,6 +2375,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
from apk_sources import _images
|
||||
try:
|
||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||
except ClientGone:
|
||||
raise
|
||||
except Exception:
|
||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||
elif path == "/api/sources/details":
|
||||
@@ -2415,6 +2454,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
headers=[("X-Capture-Source", "gamescope")])
|
||||
else:
|
||||
self.send_json({"error": "not found"}, 404)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except ValueError as e:
|
||||
@@ -2446,9 +2487,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||
result = handler(body)
|
||||
self.send_json(result)
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
@@ -2624,6 +2668,10 @@ class LoopbackServer(ThreadingHTTPServer):
|
||||
socketserver.TCPServer.server_bind(self)
|
||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
if not isinstance(sys.exc_info()[1], ClientGone):
|
||||
super().handle_error(request, client_address)
|
||||
|
||||
|
||||
_ONE_SERVER = None
|
||||
|
||||
|
||||
Reference in new issue
Block a user