Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 a8d5c4b44d Panels: one deadline for the whole run; read gamescope's properties once
Round-2 review of #83: the display search ran before the usual 5 s property
and geometry reads, and its 30 s memory never helped because the server
starts a fresh `python3 - panels` per request. With X tools answering in
4.6 s the run passed the server's 20 s.

- One xprop call reads all of gamescope's root properties; :0 is read first
  and, when it has them, used with no other probe. Other displays are only
  asked (2 s each) if :0 has none.
- focus() and focusable() reuse that reading instead of re-reading.
- A `panels` run has one deadline (12 s); every xprop/xwininfo is cut to
  what's left, and past it the run returns what it found with a "why"
  saying the headset was too slow.
- The display memory is documented as being for the long-lived input helper
  only.
- Tests: `frame_touch.py panels` run as a fresh process with fake xprop and
  xwininfo on PATH (lists panels; 4.6 s tools return in about 12 s, where
  the previous head ran past 60 s), plus unit tests for the single read,
  probing, the deadline and partial results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:53 +11:00
saphid 4e374de040 Merge remote-tracking branch 'origin/main' into fix/no-app-panel-73
# Conflicts:
#	ui/index.html
2026-10-12 14:20:08 +11:00
Alex Southwell d17e911c5b Merge pull request #84 from saphid/fix/android-tab-blank-68
Android store: app pages open again instead of a bare line (#68)
2026-10-12 14:19:35 +11:00
saphidandClaude Opus 5.5 6746a8220d Review fixes: parser-based duplicate check, quiet panel polls, safe error state
- The duplicate-name check now compiles each page's scripts together as one
  module with node --check, where a top-level name declared twice is a
  SyntaxError. Node's parser handles nested scopes, comments and strings,
  which the old line scanner did not. Fixture tests show it catches same-line,
  cross-block and const/function duplicates and ignores nested ones.
- The live view's loadPanels no longer logs expected offline or stale poll
  failures (as refresh() does).
- After a render failure, openStoreApp skips the details request, and
  loadOfferDetails no longer writes to a missing #detailInstallNote, which
  threw an unhandled TypeError. store_detail.mjs now covers the full open flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:54 +11:00
saphidandClaude Opus 5.5 62d233f9c9 Panels: bound the gamescope display search; show a failed first listing in Control
Review of #83: the display search shares one 6 s deadline (2 s per display)
and is remembered (found for good, not found for 30 s), so a stalled display
can't use up the server's 20 s. Control now shows a failed panel listing
instead of "Finding the headset's panels..." when the first one fails.
Page test (tests/page/desk_panels.mjs) covers the Desktop listing: the
headset's reason, initial failure and recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:13 +11:00
saphidandClaude Opus 5.5 2861567d15 Android store: app pages open again instead of a bare line (#68)
Clicking any app in the Android tab opened an empty dialog, which shows as
a single horizontal line (its 1px border). The store's renderDetail was
replaced by the Devices page's renderDetail, declared further down the same
script: a later function declaration silently wins. The store's call then
rendered "Pick a headset." into the Devices panel and left #detailBody empty.
Every platform and both 0.4.x releases were affected.

The store's renderer is now renderAppDetail, and a render failure shows a
clear message with a Close button rather than an empty dialog. The same
clash hid the live view's loadPanels behind the Tools panel switcher's; that
one is now loadPanelSwitcher. Both names match the open store PR (#56).

Tests: a guard that no top-level name is declared twice in the page's
shared script scope, and tests/page/store_detail.mjs, which runs the real
store functions for a good listing and a broken one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:00:03 +11:00
saphidandClaude Opus 5.5 fcf2d12411 Desktop view: say why no panel was found; desktop-closed clipboard is a 409
Issue #73 (SteamOS 0.4.5): Capture on the Desktop view failed with "no app
panel is open in the headset" while the Plasma desktop was plainly open.
frame_touch.py's panels() came back empty and the page only knew to say
"none open".

- frame_touch.py: read gamescope's properties from :0 first, probing the
  other displays if :0 has none; list displays from /proc/net/unix's
  abstract X sockets as well as /tmp/.X11-unix; when nothing is found,
  return a "why" (X tools missing, listed windows that aren't X11 windows,
  desktop running but not listed, or nothing open).
- index.html: the Desktop view's Capture and Control show that reason, or
  the listing error, instead of a bare "no app panel".
- server.py: /api/clipboard answers 409 when the headset's desktop isn't
  open (plasmashell not running), so it no longer reaches telemetry as an
  $exception; other paste failures stay 502.

Fixes #73

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:59:30 +11:00
Alex Southwell b5c8d2f19d Merge pull request #82 from saphid/fix/release-draft-id
release.yml: read the new draft's id from the REST POST, not the lagging release list
2026-10-08 20:54:18 +11:00
saphidandClaude Opus 5.5 4a489e4606 tests: skip the release draft step test on Windows
There `bash` is the WSL launcher (no distribution on GitHub's runners), so
the step couldn't run; it only ever runs on ubuntu-latest.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:46:55 +11:00
saphidandClaude Opus 5.5 0304575e5b release.yml: create the draft through the REST API so its id comes straight back
v0.4.2's first run created the draft with gh release create, then looked its
id up in the release list, which didn't show the fresh draft yet; the PATCH
went to releases/ (404) and the builds were skipped. Now the draft is POSTed
and the id read from the response, after checking the tag exists on GitHub
(what --verify-tag guarded). A rerun still reuses the draft found by tag_name,
and an empty id stops the job instead of PATCHing releases/.

tests/test_release_workflow.py runs the step against tests/fakegh, which now
answers POST releases and applies --jq through jq when installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:28:07 +11:00
saphidandClaude Opus 5.5 81bf646b0e Release 0.4.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:20:54 +11:00
Alex Southwell 4f99cd75d0 Merge pull request #80 from saphid/fix/server-sigterm-segfault
Server: no more occasional segfault on SIGTERM (exit without interpreter teardown)
2026-10-08 20:17:41 +11:00
Alex Southwell 3e814cfa1a Merge pull request #79 from saphid/feat/report-connection-diagnostics
Reports: always include a scrubbed connection summary; log connector failures
2026-10-08 20:09:21 +11:00
saphidandClaude Opus 5.5 6e566db1a7 Test: stop the server the way each platform really does
On Windows, terminate() is TerminateProcess (a hard kill, exit 1, no cleanup);
the app stops the server there by closing stdin. The staging-folder test now
stops it by closing stdin on every platform, and also by SIGTERM off Windows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:07:09 +11:00
saphidandClaude Opus 5.5 db9bee2903 Server: clear in-flight staging folders on the way out, and a dead server's at start
Leaving through os._exit skips the weakref finalizers that clean up a
TemporaryDirectory still in use by a background thread, so quitting during
a patched VR APK transfer left the APK behind (and likewise a file staged
for the assistant, or a half-downloaded app index in the cache folder).

Those folders now carry the server's PID (frame-vr-, frame-agent-, and
.download- in the apk-sources cache folder), like the web-install and title
staging folders already did. sweep_tmp covers all five; it still runs at
start for dead servers' folders, and with own=True at the end of the
shutdown cleanup for this server's. The exit comment now says which exit
work is skipped and why that is safe.

Tests: the sweep test covers every prefix and own=True; a new server test
stops a server with in-flight folders and checks they are gone, and that a
dead server's are removed at the next start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:58:44 +11:00
saphidandClaude Opus 5.5 b25855d13a Server: leave without interpreter teardown after a clean stop (SIGTERM segfault)
On Linux with Python 3.13 (GitHub's ubuntu-latest runner, CPython 3.13.16),
about 1 stop in 100 crashed the server with SIGSEGV. A native backtrace
taken at the crash shows background threads inside OpenSSL
(X509_STORE_set_default_paths_ex, called from _ssl while the app-index
download threads build their TLS context) while the main thread was already
in exit(), where libcrypto's own atexit cleanup frees the state those
threads are using.

After the shutdown cleanup has run, the server now flushes stdout/stderr and
calls os._exit(0). Daemon threads (index downloads, stdin watcher,
telemetry, contact, headset link, request handlers) cannot be stopped
promptly, and nothing in the server registers atexit work; the OS frees the
one-server lock with the process. The stop test now runs its scenario five
times with faulthandler on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:41:38 +11:00
17 changed files with 1045 additions and 107 deletions

No files matched your search

+13 -4
View File
@@ -16,8 +16,12 @@ permissions:
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.
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
draft:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
@@ -28,11 +32,16 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
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)
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
|| { echo "tag $tag isn't on GitHub" >&2; exit 1; }
id=$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="Frame Control ${tag#v}" \
-F draft=true -f body="" --jq .id)
fi
[ -n "$id" ] || { echo "no release id for $tag; not PATCHing releases/" >&2; exit 1; }
gh api -X PATCH "repos/$GH_REPO/releases/$id" -f tag_name="$tag" --jq '"release " + (.id|tostring) + " tag_name " + .tag_name'
build:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.1",
"version": "0.4.2",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.1",
"version": "0.4.2",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+10
View File
@@ -261,6 +261,16 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
-window_id <window> -i :<display>` works on gamescope's redirected windows,
while grabbing the root gives black. It streams as H.264 like the headset view
(about 30 fps at 720p).
- **When no panel is found**, `frame_touch.py panels` says why (`"why"`), and the
Desktop view shows that instead of a bare "no app panel": X tools missing,
gamescope listing windows that aren't X11 windows on any display, the desktop
running but not listed, or nothing open. It reads gamescope's properties from
`:0` first (one `xprop` call) and probes the other displays, 2 s each, only if `:0`
has none. A whole run stops after 12 s, inside the server's 20 s, and says the headset
was too slow. Added for
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
open but not found). The likely cause there was the page itself: a second
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
+43 -11
View File
@@ -1,11 +1,17 @@
#!/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."""
"""A stand-in for the GitHub CLI's `gh api`, for tests/test_publish_release.py and
tests/test_release_workflow.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. POST .../releases (release.yml's draft step) answers as release
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
import json
import os
import shutil
import subprocess
import sys
args = sys.argv[1:]
@@ -14,7 +20,7 @@ with open(os.environ["FAKEGH_LOG"], "a") as f:
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
method, endpoint, fields, inp, jq, i = "GET", None, {}, None, None, 1
while i < len(args):
a = args[i]
if a == "-X":
@@ -23,7 +29,9 @@ while i < len(args):
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"):
elif a == "--jq":
jq = args[i + 1]; i += 2
elif a == "-H":
i += 2
elif a.startswith("-"):
i += 1
@@ -34,14 +42,34 @@ while i < len(args):
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
repo = "repos/saphid/frame-control/"
def answer(response, fallback):
"""Print response through --jq when jq is here, else the fixed fallback text."""
if jq and shutil.which("jq"):
out = subprocess.run(["jq", "-r", "-c", jq], input=json.dumps(response),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
sys.stdout.write(out.stdout)
if out.returncode:
sys.exit("fakegh: jq failed: " + out.stderr)
else:
print(fallback)
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))
# The fallback is what --jq '.[]' prints.
answer(releases, "\n".join(json.dumps(r) for r in releases))
elif method == "POST" and endpoint == repo + "releases":
new = {"id": int(os.environ.get("FAKEGH_NEW_ID", "9001")), "tag_name": fields.get("tag_name"),
"name": fields.get("name"), "draft": fields.get("draft") == "true",
"body": fields.get("body"), "assets": [],
"html_url": "https://github.com/saphid/frame-control/releases/tag/untagged-be29e900f7855d794136"}
answer(new, json.dumps(new))
elif method == "DELETE" and endpoint.startswith(repo + "releases/assets/"):
pass
elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + repo + "releases/"):
@@ -49,7 +77,11 @@ elif method == "POST" and endpoint.startswith("https://uploads.github.com/" + re
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")))
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
sys.exit("gh: Not Found (HTTP 404)")
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
"published %s at %s" % (fields.get("tag_name"), page))
else:
sys.exit("fakegh: unhandled %s %s" % (method, endpoint))
+82
View File
@@ -0,0 +1,82 @@
// The live view's Desktop panel list (#73), run in node against the real functions from
// ui/index.html with a fake server: the headset's reason when it lists no panels, a failed
// listing shown (first time too) rather than "Finding…", and recovery after it.
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const grab = name => {
const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
if (!m) throw new Error("not found: " + name);
return m[0];
};
// The page's scripts share one global scope, so a second loadPanels would silently replace
// the Desktop view's (what broke #73 until #84): the one grabbed here must be the only one.
for (const name of ["loadPanels", "noPanelWhy", "deskPanel", "ctrlTarget"]) {
const count = (src.match(new RegExp(`\\n(?:async )?function ${name}\\(`, "g")) || []).length;
if (count !== 1) { console.log(`FAIL ${name} is defined ${count} times`); process.exit(1); }
}
const code = ["desk", "panelKey", "noPanelWhy", "deskPanel", "loadPanels", "ctrlTarget"].map(grab).join("");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function page(answer) {
const calls = [], logged = [];
const env = {
view: "flat", live: false, ctrlMode: () => "abs", renderPanelPick: () => {}, toggleLive: () => {}, ctrlShow: () => {},
log: (m) => logged.push(m),
api: async (path, body) => { calls.push([path, body]); return answer(); },
};
const fns = new Function(...Object.keys(env), `${code}
return { desk, noPanelWhy, loadPanels, ctrlTarget };`)(...Object.values(env));
return { ...fns, calls, logged };
}
// The Desktop view asks for the headset's panels with a plain GET (no switcher action).
{
const p = page(() => ({ panels: [], focus: null, why: "The headset's desktop is running, but SteamOS isn't listing it as a panel." }));
if (p.ctrlTarget().why !== "Finding the headset's panels…") fail("before loading: " + p.ctrlTarget().why);
await p.loadPanels();
if (!(p.calls.length === 1 && p.calls[0][0] === "/api/panels" && p.calls[0][1] === undefined))
fail("request " + JSON.stringify(p.calls));
if (!p.desk.loaded) fail("not marked loaded");
if (!p.noPanelWhy().startsWith("The headset's desktop is running")) fail("why for capture: " + p.noPanelWhy());
if (!p.ctrlTarget().why.startsWith("The headset's desktop is running")) fail("why for control: " + p.ctrlTarget().why);
}
// An older headset script without "why": the generic advice.
{
const p = page(() => ({ panels: [], focus: null }));
await p.loadPanels();
if (!p.noPanelWhy().startsWith("No app panel is open in the headset. Open the desktop")) fail("generic: " + p.noPanelWhy());
}
// The first listing fails: Control and Capture say so (logged once), then recover.
{
let broken = true;
const p = page(() => {
if (broken) throw new Error("Timed out talking to frame");
return { panels: [{ window: 7, display: ":0", name: "Desktop" }], focus: 7, focus_display: ":0" };
});
await p.loadPanels(); await p.loadPanels();
const why = p.ctrlTarget().why;
if (why !== "Couldn't list the headset's panels: Timed out talking to frame") fail("initial failure in Control: " + why);
if (p.noPanelWhy() !== why) fail("initial failure in Capture: " + p.noPanelWhy());
if (p.logged.length !== 1) fail("logged " + p.logged.length + " times");
broken = false;
await p.loadPanels();
if (p.desk.error) fail("error kept after recovery");
const t = p.ctrlTarget();
if (!t.why || t.why.includes("Couldn't list")) fail("after recovery: " + JSON.stringify(t));
if (p.desk.panels.length !== 1) fail("panels after recovery");
}
// Waiting for the Frame: shown in Control, not logged every poll. An answer about the
// previous headset (stale) is ignored.
{
let err;
const p = page(() => { throw err; });
err = Object.assign(new Error("Can't reach the Frame"), { offline: true });
await p.loadPanels(); await p.loadPanels();
if (p.logged.length) fail("offline logged: " + p.logged);
if (p.ctrlTarget().why !== "Couldn't list the headset's panels: Can't reach the Frame") fail("offline: " + p.ctrlTarget().why);
const q = page(() => { throw Object.assign(new Error("old headset"), { offline: true, stale: true }); });
await q.loadPanels();
if (q.logged.length || q.desk.error) fail("stale answer used: " + q.desk.error);
if (q.ctrlTarget().why !== "Finding the headset's panels…") fail("stale: " + q.ctrlTarget().why);
}
console.log("ok");
+93
View File
@@ -0,0 +1,93 @@
// The Android store's app page, run in node against the real functions from ui/index.html.
// Issue #68: clicking an app opened an empty dialog (a bare horizontal line), because the
// Devices page's renderDetail replaced the store's. The store's is renderAppDetail now.
// (tests/test_server.py PageScripts checks no top-level name is declared twice.)
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
// A top-level declaration, from its first line to where its braces balance.
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
// #detailBody holds the real app page, as in the browser.
function page(api = async () => ({})) {
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
: (els[id] ||= { innerHTML: "", textContent: "", hidden: false, value: 0, removeAttribute() {}, querySelectorAll: () => [] });
const sourceState = { apps: [], detail: null, detailRequest: 0, jobs: new Map() };
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
return { els, sourceState, calls, ...fns };
}
const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid", name: "Open Brush", summary: "Paint in VR",
description: "Paint in 3D.", version: "2.1", version_code: 21, package: "org.example.brush",
artwork: { icon: "/source-image/abc", screenshots: [] }, downloadable: true, verdict: { tone: "works", label: "Works" } };
// An app from the catalogue: its page renders, with its name, install and sources.
{
const p = page();
p.sourceState.detail = { app: { name: "Open Brush", offers: [{ ...offer }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("a good listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('id="detailTitle">Open Brush<')) fail("app page has no title: " + html.slice(0, 200));
if (!html.includes('id="detailInstall"') || !html.includes("Get it from")) fail("app page has no install panel");
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
p.sourceState.detail = { app: { name: "Broken <App>", offers: [] }, choice: 0 };
if (p.renderAppDetail() !== false) fail("a broken listing reported success");
const html = p.els.detailBody.innerHTML;
if (!html.includes('role="alert"') || !html.includes("We couldn't show Broken &lt;App&gt;")) fail("no error state: " + html);
if (!html.includes('data-close="appDetail"')) fail("error state has no way to close");
}
// Opening a listing that can't render: the error stays up, no details request, nothing thrown.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Broken", offers: [] }];
await p.openStoreApp(0);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
if (p.calls.length) fail("asked for details of a listing that couldn't render");
}
// Opening a good listing: the dialog opens with its page, and fresher details fill it in.
{
const p = page(async () => ({ description: "Fresh description." }));
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes("Fresh description.")) fail("details didn't fill in the page");
if (!String(p.calls[0]?.[0]).startsWith("/api/sources/details?")) fail("no details request: " + JSON.stringify(p.calls));
}
// A good listing whose details request fails keeps its page, with a note.
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
await p.openStoreApp(0);
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
}
console.log("ok");
+106
View File
@@ -0,0 +1,106 @@
"""The "Create the draft release" step of .github/workflows/release.yml, taken out of
the workflow and run with bash (as Actions runs it) against a stand-in gh
(tests/fakegh/gh): a new draft's id comes straight from the POST even when the
release list doesn't show it yet (v0.4.2's first run), a rerun reuses the draft
found by tag_name, and a tag that isn't on GitHub stops it. 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 shutil
import subprocess
import tempfile
import textwrap
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
def step_script():
"""The step's `run: |` block, dedented."""
lines = WORKFLOW.read_text().splitlines()
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
while lines[i].strip() != "run: |":
i += 1
indent = len(lines[i]) - len(lines[i].lstrip())
body = []
for line in lines[i + 1:]:
if line.strip() and len(line) - len(line.lstrip()) <= indent:
break
body.append(line)
return textwrap.dedent("\n".join(body)).strip() + "\n"
def release(rid, tag_name, name="Frame Control 9.8.7", draft=True):
return {"id": rid, "tag_name": tag_name, "name": name, "draft": draft, "assets": []}
# The step only runs on ubuntu-latest. On Windows `bash` is often the WSL launcher
# (with no distribution on GitHub's runners), not a bash that can run tests/fakegh.
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq (for gh --jq); the step runs on ubuntu only")
class DraftStep(unittest.TestCase):
def run_step(self, releases, tags="v9.8.7", tag="v9.8.7"):
d = Path(tempfile.mkdtemp())
(d / "step.sh").write_text(step_script())
(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"), GITHUB_REF_NAME=tag, GH_REPO="saphid/frame-control")
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()] if (d / "log").exists() else []
return p, log
def posts(self, log):
return [c for c in log if c[1:4] == ["-X", "POST", "repos/saphid/frame-control/releases"]]
def patches(self, log):
return [c for c in log if c[1:3] == ["-X", "PATCH"]]
def test_new_draft_uses_the_id_the_post_returns(self):
# The list doesn't show the fresh draft (it never does in the fake): v0.4.2's case.
p, log = self.run_step([])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
[post] = self.posts(log)
for f in ("tag_name=v9.8.7", "name=Frame Control 9.8.7", "draft=true"):
self.assertIn(f, post)
self.assertIn(["api", "repos/saphid/frame-control/git/ref/tags/v9.8.7"], log)
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/9001")
self.assertIn("tag_name=v9.8.7", patch)
self.assertIn("release 9001 tag_name v9.8.7", p.stdout)
self.assertTrue(all(c[0] == "api" for c in log)) # never `gh release ...`
def test_a_rerun_reuses_the_draft_with_the_tag(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6"), release(42, "v9.8.7")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(self.posts(log), [])
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
def test_other_releases_alone_mean_a_new_draft(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
release(8, "v9.8.70", "Frame Control 9.8.70")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(len(self.posts(log)), 1)
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/9001")
def test_stops_when_the_tag_is_not_on_github(self):
p, log = self.run_step([], tags="")
self.assertNotEqual(p.returncode, 0)
self.assertIn("tag v9.8.7 isn't on GitHub", p.stderr)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log), [])
if __name__ == "__main__":
unittest.main()
+166 -9
View File
@@ -10,6 +10,7 @@ import http.client
import io
import json
import os
import shutil
import socket
import struct
import subprocess
@@ -287,15 +288,77 @@ class OneServer(unittest.TestCase):
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit,
and later, now and then, crash it (SIGSEGV) while background threads were still
loading TLS certificates. Run a few times: that crash came about 1 run in 100."""
for attempt in range(5):
with self.subTest(attempt=attempt):
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid", "PYTHONFAULTHANDLER": "1"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
shutil.rmtree(env["FRAME_CONTROL_DATA_DIR"], ignore_errors=True)
def test_staging_folders_cleared_when_stopped_mid_transfer(self):
"""The server leaves without interpreter teardown, so TemporaryDirectory cleanup
doesn't run for work still in progress: its own staging folders go on the way out,
and a dead server's at the next start."""
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
home = tempfile.mkdtemp(prefix="frame-stop-home-")
self.addCleanup(shutil.rmtree, home, ignore_errors=True)
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": os.path.join(home, "data"),
"FRAME_ALIAS": "frame-control-test.invalid", "HOME": home, "XDG_CACHE_HOME": os.path.join(home, ".cache"),
"LOCALAPPDATA": home, "APPDATA": home}
index_dir = Path(subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_host; "
"print(frame_host.cache_dir('apk-sources'))", str(ROOT / "ui")],
env=env, capture_output=True, text=True, check=True).stdout.strip())
index_dir.mkdir(parents=True)
tmp = Path(tempfile.gettempdir())
def staged(folder, prefix, pid):
d = Path(tempfile.mkdtemp(prefix=f"{prefix}{pid}-", dir=folder))
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
(d / "app.apk").write_bytes(b"\0" * 4096)
return d
# Stopped as the app stops it: by closing stdin (the only way on Windows,
# where terminate() is a hard kill) and, elsewhere, by SIGTERM too.
for stop in ["stdin"] + (["sigterm"] if os.name != "nt" else []):
with self.subTest(stop=stop):
left_by_dead = [staged(tmp, "frame-vr-", dead.pid), staged(index_dir, ".download-", dead.pid)]
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
try:
self.assertIn("Frame Control on", proc.stdout.readline())
self.assertEqual([d for d in left_by_dead if d.exists()], [])
in_flight = [staged(tmp, "frame-vr-", proc.pid), staged(tmp, "frame-agent-", proc.pid),
staged(index_dir, ".download-", proc.pid)]
if stop == "stdin":
proc.stdin.close()
else:
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
self.assertEqual([d for d in in_flight if d.exists()], [])
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
proc.stdin.close()
proc.stdout.close()
class ArtworkSettings(unittest.TestCase):
@@ -348,6 +411,59 @@ class ArtworkSettings(unittest.TestCase):
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageScripts(unittest.TestCase):
"""A page's scripts share one global scope, where a later function declaration
silently replaces an earlier one of the same name. Issue #68: the Devices page's
renderDetail replaced the Android store's, so every app opened as an empty
dialog (a bare horizontal line). Compiled together as one module, any top-level
name declared twice is a SyntaxError; node's parser handles nested scopes,
comments and strings."""
PAGES = ("index.html", "assistant.html", "mac-view.html")
@staticmethod
def page_scripts(html):
"""A page's inline scripts, then its /*.js scripts, in one source."""
import re
parts = re.findall(r"<script>([\s\S]*?)</script>", html)
parts += [(ROOT / "ui" / src).read_text(encoding="utf-8") for src in re.findall(r'<script src="/([\w.-]+\.js)">', html)]
return "\n;\n".join(parts)
@staticmethod
def module_check(source):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "page.mjs"
path.write_text(source, encoding="utf-8")
r = subprocess.run(["node", "--check", str(path)], capture_output=True, text=True)
return r.returncode, r.stderr
def test_the_check_catches_top_level_duplicates_only(self):
dup = "<script>function a() {}\nfunction a() {}</script>"
across = "<script>function a() {}</script><p>x</p><script> async function a() {}</script>"
same_line = "<script>function a() {} function a() {}</script>"
const_and_function = "<script>const a = 1, b = 2;\nfunction b() {}</script>"
for html in (dup, across, same_line, const_and_function):
rc, err = self.module_check(self.page_scripts(html))
self.assertNotEqual(rc, 0, html)
self.assertIn("has already been declared", err, html)
nested = ("<script>function a() {}\nfunction f() { function a() {} if (1) { const a = 2; } }\n"
"// function a() {}\nconst s = `function a() {}`;</script>")
self.assertEqual(self.module_check(self.page_scripts(nested)), (0, ""))
def test_no_top_level_name_is_declared_twice(self):
for name in self.PAGES:
source = self.page_scripts((ROOT / "ui" / name).read_text(encoding="utf-8"))
self.assertIn("function", source, name)
rc, err = self.module_check(source)
self.assertEqual(rc, 0, f"{name}: {err}")
def test_store_app_page_renders(self):
"""tests/page/store_detail.mjs runs the real store functions from index.html."""
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
@@ -435,6 +551,47 @@ class UnreachableMessages(unittest.TestCase):
self.assertEqual(self.server.error_body(raw), ({"error": raw}, None), raw)
class ClipboardDesktopClosed(unittest.TestCase):
"""The headset's desktop being closed is the person's to fix: a 409, not a 5xx fault for telemetry."""
@classmethod
def setUpClass(cls):
sys.path.insert(0, str(ROOT / "ui"))
import server
cls.server = server
def paste_failing_with(self, message):
s = self.server
saved = s.ssh
def fake_ssh(remote, **kw):
raise s.Failure(message)
s.ssh = fake_ssh
try:
s.clipboard({"text": "hello"})
finally:
s.ssh = saved
def test_desktop_closed_is_a_409(self):
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("The headset's desktop isn't open (plasmashell is not running). "
"Open the desktop in the headset, then try again.")
self.assertEqual(cm.exception.status, 409)
self.assertIn("Open the desktop", str(cm.exception))
# Older wording (scripts/paste-to-frame.sh) is recognised too.
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("plasmashell is not running: open the desktop in the headset first.")
self.assertEqual(cm.exception.status, 409)
def test_other_failures_stay_faults(self):
with self.assertRaises(self.server.Failure) as cm:
self.paste_failing_with("Klipper call failed (bus: none)")
self.assertEqual(cm.exception.status, 502)
def test_paste_script_says_it(self):
self.assertIn(self.server.DESKTOP_CLOSED, self.server.PASTE)
class StatusProbe(unittest.TestCase):
# frame_status.py only ever runs on the Frame (Linux); it needs os.statvfs.
@unittest.skipIf(os.name == "nt", "Frame-side script; POSIX only")
+243 -5
View File
@@ -2,9 +2,14 @@
Run: python3 -m unittest discover -s tests
"""
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
@@ -51,12 +56,9 @@ class Mapping(unittest.TestCase):
self.assertEqual(t.locate(5, None)["display"], ":0")
def test_focus_display_is_decoded(self):
t = self.t
saved = t.xprop_root
self.addCleanup(lambda: setattr(t, "xprop_root", saved))
for values, want in (([12602, 0, 58], ":1"), ([12346], ":0"), ([], None), ([0x41], None)):
t.xprop_root = lambda d, n, v=values: v
self.assertEqual(t.focus_display(), want)
self.assertEqual(self.t.focus_display({"GAMESCOPE_FOCUS_DISPLAY": values}), want)
self.assertIsNone(self.t.focus_display({}))
def test_ascii_table_covers_printable_characters(self):
for code in range(0x20, 0x7F):
@@ -259,6 +261,236 @@ class ServerChecks(unittest.TestCase):
self.s.remote_touch({"events": [{"dx": 1}] * (self.s.INPUT_BATCH_LIMIT + 1)})
class FindingPanels(unittest.TestCase):
"""panels() with faked headset probes: where gamescope's properties are, and why nothing was found (#73)."""
@classmethod
def setUpClass(cls):
import frame_touch
cls.t = frame_touch
def setUp(self):
t = self.t
names = ("read_props", "window_info", "window_pid", "displays", "desktop_running")
saved = {n: getattr(t, n) for n in names}
saved_which = t.shutil.which
self.real_displays = saved["displays"]
self.real_read_props = saved["read_props"]
def restore():
for n, v in saved.items():
setattr(t, n, v)
t.shutil.which = saved_which
t._ROOT.clear()
self.addCleanup(restore)
t._ROOT.clear()
self.props = {} # (display, property) -> values
self.windows = {} # (display, window) -> info
self.reads = [] # displays whose gamescope properties were read
t.read_props = lambda d, limit=5.0: self.reads.append(d) or {n: list(v) for (dd, n), v in self.props.items() if dd == d}
t.window_info = lambda d, w: self.windows.get((d, w))
t.window_pid = lambda d, w: None
t.displays = lambda: [":0", ":1", ":2"]
t.desktop_running = lambda: False
t.shutil.which = lambda tool: "/usr/bin/" + tool
def plasma_on(self, display):
# SteamOS 0.4.1: the nested Plasma desktop (kwin_wayland, app id 0) is a window on :0.
self.props[(display, "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222]
self.props[(display, "GAMESCOPE_FOCUSED_WINDOW")] = [4194307]
self.props[(display, "GAMESCOPE_FOCUS_DISPLAY")] = [0x303A] # ":0"
self.windows[(":0", 4194307)] = {"name": "KDE Wayland Compositor", "x": 0, "y": 0, "width": 1280, "height": 800}
self.windows[(":0", "root")] = {"width": 1280, "height": 720}
def test_desktop_panel_on_display_0(self):
self.plasma_on(":0")
out = self.t.panels()
self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]], [(4194307, ":0", True)])
self.assertNotIn("why", out)
def test_display_0_costs_one_read(self):
# The layout of every build seen so far: one read of :0's properties for the whole run.
self.plasma_on(":0")
self.t.panels()
self.assertEqual(self.reads, [":0"])
def test_gamescope_properties_on_another_display_are_found(self):
# A build that numbers its displays differently: :0 has no gamescope properties.
self.plasma_on(":1")
self.assertEqual(self.t.gamescope()[0], ":1")
self.reads.clear()
self.t._ROOT.clear()
self.assertEqual(len(self.t.panels()["panels"]), 1)
self.assertEqual(self.reads, [":0", ":1"]) # read once, then reused by focus() and focusable()
def test_other_displays_are_probed_briefly(self):
limits = []
self.t.read_props = lambda d, limit=5.0: limits.append((d, limit)) or {}
self.assertEqual(self.t.gamescope(), (":0", {}))
self.assertEqual(limits, [(":0", 5.0), (":1", self.t.ROOT_PROBE_TIMEOUT), (":2", self.t.ROOT_PROBE_TIMEOUT)])
def test_input_helper_remembers_the_display(self):
# The input helper lives for a session: a found display is kept, a failed search retried later.
t = self.t
self.plasma_on(":1")
t.gamescope(); t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":1"])
self.props.clear()
self.reads.clear()
t._ROOT.clear()
t.gamescope(); t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":2", ":0"]) # given up: :0 for ROOT_RETRY
t._ROOT[1] -= t.ROOT_RETRY
self.reads.clear()
t.gamescope()
self.assertEqual(self.reads, [":0", ":1", ":2"])
def test_read_props_parses_xprop(self):
t = self.t
saved = t.subprocess.run
self.addCleanup(setattr, t.subprocess, "run", saved)
out = ("GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 0, 2, 3, 769, 4\n"
"GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 0\nGAMESCOPE_FOCUS_DISPLAY: not found.\n")
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, out, "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": [1, 0, 2, 3, 769, 4],
"GAMESCOPE_FOCUSED_WINDOW": [0]})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSED_WINDOW: not found.\n", "")
self.assertEqual(self.real_read_props(":0"), {})
def hang(*a, **k):
raise subprocess.TimeoutExpired(a[0], k.get("timeout"))
t.subprocess.run = hang
self.assertEqual(self.real_read_props(":0"), {})
def test_deadline_returns_what_was_found(self):
t, clock = self.t, [0.0]
self.plasma_on(":0")
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = [4194307, 0, 2222, 5, 0, 1, 6, 0, 1]
saved = t.time.monotonic
self.addCleanup(setattr, t.time, "monotonic", saved)
t.time.monotonic = lambda: clock[0]
info = self.windows.get
def slow(d, w): # every xwininfo takes 1 s: the run is out of time after a few
t.budget()
clock[0] += 1
return info((d, w))
t.window_info = slow
out = t.panels(limit=7) # focus: 2 reads; the first panel: 3 displays and its root
self.assertEqual([p["window"] for p in out["panels"]], [4194307])
self.assertIn("took too long", out["why"])
self.assertIn("Some may be missing", out["why"])
self.assertIsNone(t.DEADLINE[0]) # the next run (or the input helper) has no deadline left over
def test_listed_window_that_isnt_x11_explains_itself(self):
self.plasma_on(":0")
del self.windows[(":0", 4194307)] # say, a desktop drawn as a Wayland window
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("can't find its window", out["why"])
self.assertIn("Headset view", out["why"])
def test_desktop_running_but_not_listed(self):
self.t.desktop_running = lambda: True
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("desktop is running", out["why"])
def test_nothing_open(self):
out = self.t.panels()
self.assertEqual(out, {"focus": None, "focus_display": None, "panels": [],
"why": "No app panel is open in the headset. Open the desktop or an app in "
"the headset, then try again."})
def test_missing_x_tools_are_named(self):
self.plasma_on(":0")
self.t.shutil.which = lambda tool: None if tool == "xwininfo" else "/usr/bin/" + tool
self.windows.clear() # xwininfo can't run, so nothing resolves
self.assertIn("no xwininfo", self.t.panels()["why"])
def test_displays_include_abstract_sockets(self):
# /tmp/.X11-unix not visible to the SSH session: the abstract sockets still name the displays.
import builtins
t = self.t
real_open, real_listdir = builtins.open, t.os.listdir
def fake_open(path, *a, **k):
if path == "/proc/net/unix":
return io.StringIO("0000: 00000002 0 ... @/tmp/.X11-unix/X1\n0000: ... @/tmp/.X11-unix/X0\n"
"0000: ... /run/user/1000/wayland-0\n")
return real_open(path, *a, **k)
def fake_listdir(path):
if path == "/tmp/.X11-unix":
raise FileNotFoundError(path)
return real_listdir(path)
builtins.open, t.os.listdir = fake_open, fake_listdir
try:
self.assertEqual(self.real_displays(), [":0", ":1"])
finally:
builtins.open, t.os.listdir = real_open, real_listdir
FAKE_XPROP = """#!/bin/sh
sleep "${FAKE_X_DELAY:-0}"
if [ "$1" = "-root" ] && [ "$DISPLAY" = ":0" ]; then
echo "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 11, 0, 101, 12, 769, 102"
echo "GAMESCOPE_FOCUSED_WINDOW(CARDINAL) = 11"
echo "GAMESCOPE_FOCUS_DISPLAY(CARDINAL) = 12346"
fi
"""
FAKE_XWININFO = """#!/bin/sh
sleep "${FAKE_X_DELAY:-0}"
[ "$DISPLAY" = ":0" ] || exit 1
echo 'xwininfo: Window id: 0xb "Desktop"'
echo ' Absolute upper-left X: 0'
echo ' Absolute upper-left Y: 0'
echo ' Width: 1280'
echo ' Height: 800'
echo ' Map State: IsViewable'
"""
@unittest.skipIf(sys.platform == "win32", "fake X tools are shell scripts; frame_touch.py runs on the Frame")
class PanelsAsAFreshProcess(unittest.TestCase):
"""`frame_touch.py panels` the way the server runs it: a new process each request, with
fake xprop and xwininfo first on PATH (two displays, :0 and :1)."""
def run_panels(self, delay):
with tempfile.TemporaryDirectory() as bin_dir:
for name, body in (("xprop", FAKE_XPROP), ("xwininfo", FAKE_XWININFO)):
path = Path(bin_dir) / name
path.write_text(body)
path.chmod(0o755)
env = dict(os.environ, PATH=bin_dir + os.pathsep + os.environ.get("PATH", ""), FAKE_X_DELAY=str(delay),
PYTHONDONTWRITEBYTECODE="1")
code = ("import sys; sys.path.insert(0, sys.argv[1]); import frame_touch as t; "
"t.displays = lambda: [':0', ':1']; t.desktop_running = lambda: False; "
"sys.argv = ['frame_touch.py', 'panels']; sys.exit(t.main())")
start = time.monotonic()
r = subprocess.run([sys.executable, "-c", code, str(ROOT / "ui")], env=env, capture_output=True,
text=True, timeout=60)
took = time.monotonic() - start
self.assertEqual(r.returncode, 0, r.stderr)
return json.loads(r.stdout), took
def test_lists_panels(self):
out, _ = self.run_panels(0)
self.assertEqual(out["focus"], 11)
self.assertEqual(out["focus_display"], ":0")
self.assertEqual([(p["window"], p["display"], p["focused"]) for p in out["panels"]],
[(11, ":0", True), (12, ":0", False)])
self.assertNotIn("why", out)
def test_slow_x_tools_stay_inside_the_servers_timeout(self):
# Review of #83: X tools answering in 4.6 s each would take well over the server's
# 20 s; one deadline for the run (PANELS_DEADLINE, 12 s) returns in time, saying why.
out, took = self.run_panels(4.6)
self.assertLess(took, 15, out)
self.assertEqual(out["panels"], [])
self.assertIn("took too long", out["why"])
@unittest.skipUnless(shutil.which("node"), "needs node")
class PageGestures(unittest.TestCase):
"""Control's gestures and queue (tests/page/ctrl_gestures.mjs runs the real functions from index.html)."""
@@ -268,6 +500,12 @@ class PageGestures(unittest.TestCase):
timeout=60)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_desktop_panel_list(self):
# #73: the headset's reason for no panels, and a failed listing, reach Capture and Control.
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "desk_panels.mjs")], capture_output=True, text=True,
timeout=60)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
class Script(unittest.TestCase):
def test_helper_compiles_on_the_frames_python(self):
+22 -11
View File
@@ -386,17 +386,28 @@ class ServerJobs(unittest.TestCase):
def test_dead_servers_leftovers_swept(self):
dead = subprocess.Popen([sys.executable, "-c", "pass"])
dead.wait()
# Downloads and title staging (unzipped titles) are both swept.
for prefix in (self.server.WEB_TMP_PREFIX, self.server.frame_titles.TMP_PREFIX):
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-")
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-")
try:
self.server.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
s = self.server
with tempfile.TemporaryDirectory() as cache, \
mock.patch.object(s.frame_host, "cache_dir", lambda *parts: Path(cache).joinpath(*parts)):
# Downloads, title staging, patched VR APKs, files staged for the
# assistant, and app-index downloads (in the cache folder).
places = s._tmp_places()
self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX,
s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX,
s.apk_fdroid.TMP_PREFIX})
for folder, prefix in places:
folder.mkdir(parents=True, exist_ok=True)
gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder)
live = tempfile.mkdtemp(prefix=f"{prefix}{os.getpid()}-", dir=folder)
try:
s.sweep_tmp()
self.assertFalse(os.path.exists(gone), prefix)
self.assertTrue(os.path.exists(live), prefix)
s.sweep_tmp(own=True) # on the way out: this server's own go too
self.assertFalse(os.path.exists(live), prefix)
finally:
shutil.rmtree(gone, ignore_errors=True)
shutil.rmtree(live, ignore_errors=True)
def test_temp_dir_failure_ends_the_job(self):
job, dispatch = self.run_job(mkdtemp_error=OSError("disk full"))
+2 -1
View File
@@ -27,6 +27,7 @@ from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
_refreshing = {} # source id -> background refresh thread
@@ -530,7 +531,7 @@ def _load(source, force=False):
return found[:2]
cache.parent.mkdir(parents=True, exist_ok=True)
try:
with tempfile.TemporaryDirectory(dir=str(cache.parent)) as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-', dir=str(cache.parent)) as tmp:
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
+3 -1
View File
@@ -9,6 +9,8 @@ import subprocess
import threading
import time
TMP_PREFIX = 'frame-agent-' # then the server's PID, so server.sweep_tmp can clear a stopped run's
class Approvals:
def __init__(self):
@@ -109,7 +111,7 @@ def call(server, body):
if name == 'send_file':
# Stage the reviewed bytes before the existing transfer helper reads them.
import tempfile
with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
source = Path(action['arguments']['path'])
with source.open('rb') as stream:
data = stream.read(16 * 1024**2 + 1)
+2 -1
View File
@@ -23,6 +23,7 @@ from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
TMP_PREFIX = 'frame-vr-' # then the server's PID, so server.sweep_tmp can clear a stopped run's patched APK
APPS_DIR = 'Applications/Android' # relative to the Frame's $HOME
COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
@@ -200,7 +201,7 @@ def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr
info['xr_layer_missing'] = True
with _install_lock:
if add or info['repairable']:
with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
patched = os.path.join(tmp, 'app.apk')
try:
info['patched'] = patch(apk_path, patched, add)['patched']
+172 -35
View File
@@ -25,7 +25,9 @@ Standard library only (ctypes for libei), like the rest of what runs on the Fram
import ctypes
import json
import os
import re
import select
import shutil
import subprocess
import sys
import time
@@ -53,14 +55,41 @@ def say(state, **more):
# ---- which panel has focus -----------------------------------------------------
def xprop_root(display, name):
PROPS = ("GAMESCOPE_FOCUSABLE_WINDOWS", "GAMESCOPE_FOCUSED_WINDOW", "GAMESCOPE_FOCUS_DISPLAY")
TOOL_TIMEOUT = 5.0 # seconds for any one xprop or xwininfo
PANELS_DEADLINE = 12.0 # seconds for a whole `panels` run: well inside the server's 20 s for /api/panels
DEADLINE = [None] # when the current `panels` run must stop asking X tools (monotonic), if it has one
class OutOfTime(Exception):
"""The current run's deadline passed: stop and report what was found."""
def budget(limit=TOOL_TIMEOUT):
"""Seconds an X tool may take now: its own limit, cut to what's left of the run's deadline."""
if DEADLINE[0] is None:
return limit
left = DEADLINE[0] - time.monotonic()
if left <= 0:
raise OutOfTime()
return min(limit, left)
def read_props(display, limit=TOOL_TIMEOUT):
"""gamescope's root properties on one display, in one xprop call: {name: [values]} for
those the display has, {} if none (or xprop didn't answer)."""
try:
out = subprocess.run(["xprop", "-root", name], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
out = subprocess.run(["xprop", "-root", *PROPS], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=budget(limit)).stdout
except (OSError, subprocess.SubprocessError):
return []
values = out.split("=", 1)[1] if "=" in out else ""
return [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
return {}
props = {}
for line in out.splitlines():
name, sep, values = line.partition("=")
name = name.split("(", 1)[0].strip()
if sep and name in PROPS:
props[name] = [int(v) for v in values.replace(",", " ").split() if v.isdigit()]
return props
def window_info(display, window):
@@ -68,7 +97,7 @@ def window_info(display, window):
try:
which = ["-root"] if window == "root" else ["-id", str(window)]
out = subprocess.run(["xwininfo", *which], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
capture_output=True, text=True, timeout=budget()).stdout
except (OSError, subprocess.SubprocessError):
return None
if "IsViewable" not in out:
@@ -86,13 +115,58 @@ def window_info(display, window):
def displays():
return sorted(f":{n[1:]}" for n in os.listdir("/tmp/.X11-unix") if n[1:].isdigit())
"""Every X display on the Frame: the sockets in /tmp/.X11-unix, and the abstract ones
X servers on Linux usually listen on as well (in /proc/net/unix), in case a SteamOS
build stops sharing /tmp/.X11-unix with SSH sessions."""
found = set()
try:
found.update(int(n[1:]) for n in os.listdir("/tmp/.X11-unix") if n[:1] == "X" and n[1:].isdigit())
except OSError:
pass
try:
with open("/proc/net/unix") as f:
found.update(int(n) for n in re.findall(r"@/tmp/\.X11-unix/X(\d+)", f.read()))
except OSError:
pass
return [f":{n}" for n in sorted(found)]
# The input helper (`frame_touch.py` reading events) lives for a whole Control session and
# reads focus often, so it remembers which display has gamescope's properties: found, for
# good; not found, for ROOT_RETRY. A `panels` run is a fresh process each time, so this
# saves it nothing; it costs nothing either while :0 has the properties.
_ROOT = [] # [display, when it was given up on, or None if found]
ROOT_PROBE_TIMEOUT = 2.0 # seconds for each display other than :0, so a stalled one can't use the run
ROOT_RETRY = 30.0
def gamescope():
"""(display, properties): the X display whose root window carries gamescope's panel
properties (GAMESCOPE_FOCUSABLE_WINDOWS and friends), and what they say now.
That's :0 on every build seen so far (SteamOS 0.3.0 to 0.4.1), so :0 is read first and,
when it has them, used with no other probe. Only if it hasn't are the other displays
asked, briefly each; with none, :0 and no properties.
"""
if _ROOT and (_ROOT[1] is None or time.monotonic() - _ROOT[1] < ROOT_RETRY):
return _ROOT[0], read_props(_ROOT[0])
props, found = read_props(":0"), ":0"
if not props:
found = None
for display in displays():
if display != ":0":
props = read_props(display, ROOT_PROBE_TIMEOUT)
if props:
found = display
break
_ROOT[:] = [found, None] if found else [":0", time.monotonic()]
return _ROOT[0], props
def window_pid(display, window):
try:
out = subprocess.run(["xprop", "-id", str(window), "_NET_WM_PID"], env=dict(os.environ, DISPLAY=display),
capture_output=True, text=True, timeout=5).stdout
capture_output=True, text=True, timeout=budget()).stdout
except (OSError, subprocess.SubprocessError):
return None
value = out.rsplit("=", 1)[-1].strip() if "=" in out else ""
@@ -120,20 +194,20 @@ def locate(window, pid):
"root": [root.get("width", info["width"]), root.get("height", info["height"])]}
def focusable():
"""gamescope's focusable windows as (window, app id, pid)."""
t = xprop_root(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")
def focusable(props=None):
"""gamescope's focusable windows as (window, app id, pid), from props if already read."""
t = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUSABLE_WINDOWS", [])
return [tuple(t[i:i + 3]) for i in range(0, len(t) - 2, 3)]
def focus_display():
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on :0's root.
def focus_display(props=None):
"""The display of the focused window, from GAMESCOPE_FOCUS_DISPLAY on gamescope's root.
gamescope writes the name (":1") as 32-bit items, so its first four bytes land,
little-endian, in the first value: 12602 is 0x313A, ":1" (steamcompmgr.cpp;
seen 2026-09-29).
"""
values = xprop_root(":0", "GAMESCOPE_FOCUS_DISPLAY")
values = (gamescope()[1] if props is None else props).get("GAMESCOPE_FOCUS_DISPLAY")
if not values:
return None
name = (values[0] & 0xFFFFFFFF).to_bytes(4, "little").split(b"\0", 1)[0].decode("ascii", "replace")
@@ -141,19 +215,23 @@ def focus_display():
def focus_now():
"""Just which window and display have focus: two property reads, for checking a press."""
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
return (window or None, focus_display() if window else None)
"""Just which window and display have focus: one property read, for checking a press."""
props = gamescope()[1]
window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
return (window or None, focus_display(props) if window else None)
def focus():
def focus(props=None):
"""The panel that has focus in the headset: window, display, name and sizes (gamescope
publishes the window and its display on :0's root)."""
window = (xprop_root(":0", "GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
publishes the window and its display on its root display, :0 so far). props: gamescope's
properties if already read."""
if props is None:
props = gamescope()[1]
window = (props.get("GAMESCOPE_FOCUSED_WINDOW") or [0])[0]
if not window:
return {"window": None}
app, pid = next(((a, p) for w, a, p in focusable() if w == window), (None, None))
display = focus_display()
app, pid = next(((a, p) for w, a, p in focusable(props) if w == window), (None, None))
display = focus_display(props)
info = window_info(display, window) if display else None
if info:
root = window_info(display, "root") or {}
@@ -164,18 +242,77 @@ def focus():
return {**panel, "app": app} if panel else {"window": None}
def panels():
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus."""
now = focus()
found = []
for window, app, pid in focusable():
panel = locate(window, pid)
if panel and panel["width"] > 1 and panel["height"] > 1 and \
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
panel.pop("root", None)
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
(now.get("window"), now.get("display"))})
return {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
def panels(limit=None):
"""Every app panel (gamescope's focusable windows), for watching one that hasn't focus.
One deadline (PANELS_DEADLINE) covers the whole run, inside the server's timeout: past
it, the panels found so far come back with a "why" saying the headset was too slow.
"""
DEADLINE[0] = time.monotonic() + (PANELS_DEADLINE if limit is None else limit)
now, found, unfound, late = {}, [], 0, False
try:
props = gamescope()[1]
now = focus(props)
for window, app, pid in focusable(props):
panel = locate(window, pid)
unfound += panel is None
if panel and panel["width"] > 1 and panel["height"] > 1 and \
not any(f["window"] == window and f["display"] == panel["display"] for f in found):
panel.pop("root", None)
found.append({**panel, "app": app, "focused": (window, panel["display"]) ==
(now.get("window"), now.get("display"))})
late = time.monotonic() >= DEADLINE[0] # the last read may have been cut short
except OutOfTime:
late = True
finally:
DEADLINE[0] = None
out = {"focus": now.get("window"), "focus_display": now.get("display"), "panels": found}
if late:
out["why"] = ("The headset took too long to list its panels (its X tools didn't answer in time). "
+ ("Some may be missing. " if found else "") + "Try again, or use the Headset view.")
elif not found:
out["why"] = why_none(unfound)
return out
# The headset's Linux desktop, by process name (docs/how-the-frame-works.md).
DESKTOP = ("plasmashell", "kwin_wayland", "kwin_wayland_wrapper", "steamos-nested-desktop", "startplasma-wayland")
def desktop_running():
"""Whether the headset's Linux desktop (the nested Plasma session) is running."""
try:
pids = [p for p in os.listdir("/proc") if p.isdigit()]
except OSError:
return False
for pid in pids:
try:
with open(f"/proc/{pid}/comm") as f:
if f.read().strip() in DESKTOP:
return True
except OSError:
continue
return False
def why_none(listed):
"""Why panels() found nothing, in words the person at the computer can act on.
listed: how many of the windows gamescope lists aren't X11 windows on any display."""
missing = [tool for tool in ("xprop", "xwininfo") if not shutil.which(tool)]
if missing:
return (f"This SteamOS build has no {' or '.join(missing)}, which Frame Control uses to find the "
"headset's panels. Use the Headset view instead, and please report your SteamOS version.")
if listed:
# gamescope lists windows, but none is an X11 window on any display: a Wayland
# window (a desktop drawn straight to gamescope, say) can't be found or captured this way.
return (f"The headset has {listed} panel{'s' if listed > 1 else ''} open, but Frame Control can't "
f"find {'its' if listed == 1 else 'their'} window. This SteamOS build may draw "
f"{'it' if listed == 1 else 'them'} in a way the Desktop view can't capture yet. "
"Use the Headset view instead, and please report your SteamOS version.")
if desktop_running():
return ("The headset's desktop is running, but SteamOS isn't listing it as a panel. Look at it in "
"the headset (or close and reopen it), then try again, or use the Headset view.")
return "No app panel is open in the headset. Open the desktop or an app in the headset, then try again."
def to_root(panel, fx, fy):
+38 -16
View File
@@ -1889,7 +1889,7 @@ async function capture() {
var shotPanel = null;
if (view === "flat") {
shotPanel = deskPanel() || (await loadPanels(), deskPanel());
if (!shotPanel) throw new Error("no app panel is open in the headset");
if (!shotPanel) throw new Error(noPanelWhy());
url_ = `/api/screenshot?view=panel&window=${shotPanel.window}&display=${encodeURIComponent(shotPanel.display)}`;
}
const r = await fetch(url_, { headers: {"X-Frame-UI": UI_KEY} });
@@ -2101,8 +2101,14 @@ document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.
// Control reaches, so the view follows it unless you pick another to watch.
// Panels are "display/window": window ids are per X display, and the Frame has two.
// desk.shown is the panel whose picture is on screen now; Control only acts on that one.
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null };
// desk.why is the headset's own reason when it lists no panels; desk.error, why listing them failed.
const desk = { panels: [], focus: null, pick: "", timer: null, loaded: false, shown: null, why: "", error: "" };
const panelKey = p => p ? `${p.display}/${p.window}` : null;
// Why the Desktop view has no panel to show: the headset's own reason when it gave one.
function noPanelWhy() {
if (desk.error) return "Couldn't list the headset's panels: " + desk.error;
return desk.why || "No app panel is open in the headset. Open the desktop or an app in the headset, then try again.";
}
function deskPanel() {
const want = desk.pick || desk.focus;
// Nothing in use and nothing picked: show the first panel rather than nothing.
@@ -2112,7 +2118,8 @@ async function loadPanels() {
try {
const before = panelKey(deskPanel());
const r = await api("/api/panels");
desk.panels = r.panels || []; desk.loaded = true;
desk.panels = r.panels || []; desk.loaded = true; desk.error = "";
desk.why = r.why || "";
desk.focus = r.focus ? `${r.focus_display}/${r.focus}` : null;
if (desk.pick && !desk.panels.some(p => panelKey(p) === desk.pick)) desk.pick = "";
renderPanelPick();
@@ -2120,7 +2127,11 @@ async function loadPanels() {
if (view === "flat" && live && now !== before) { toggleLive(false); toggleLive(true); }
ctrlShow();
} catch (e) {
log("Couldn't list the headset's panels: " + e.message, "e");
if (e.stale) return; // an answer about the previous headset says nothing about this one
// Polled every few seconds: waiting for the Frame, or the same failure again, isn't news.
if (!e.offline && e.message !== desk.error) log("Couldn't list the headset's panels: " + e.message, "e");
desk.error = e.message;
ctrlShow();
}
}
function watchPanels() {
@@ -2165,8 +2176,7 @@ function ctrlMode() { return view === "flat" ? "abs" : "rel"; }
function ctrlTarget() {
if (ctrlMode() === "rel") return { ok: true };
const p = deskPanel();
if (!desk.loaded) return { why: "Finding the headset's panels…" };
if (!p) return { why: "No app panel is open in the headset." };
if (!p) return { why: desk.loaded || desk.error ? noPanelWhy() : "Finding the headset's panels…" };
if (!desk.focus) return { why: "Nobody's using a panel in the headset yet. Taps work once one is in use." };
if (panelKey(p) !== desk.focus) return { why: "Only the panel in use takes taps. Pick “Panel in use” to control it." };
if (desk.shown !== desk.focus) return { why: live ? "Waiting for the picture of the panel in use…"
@@ -3172,7 +3182,19 @@ async function searchSources(quiet) {
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function renderDetail() {
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
function renderAppDetail() {
try { renderAppDetailBody(); return true; }
catch(e) {
console.error("App details failed to render", e);
const name=sourceState.detail?.app?.name;
$("detailBody").innerHTML=`<div class="store-empty detail-error" role="alert"><h2 id="detailTitle">${name ? `We couldn't show ${esc(name)}` : "We couldn't show this app"}</h2><p>Something went wrong opening its page. Close this and try again, or pick another app.</p><button class="action" data-close="appDetail">Close</button></div>`;
return false;
}
}
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
@@ -3192,8 +3214,8 @@ function updateInstall() {
async function openStoreApp(index) {
const app=sourceState.apps[index];if(!app)return;
sourceState.detail={app:{...app,offers:app.offers.map(o=>({...o}))},choice:0};
renderDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
await loadOfferDetails();
const shown=renderAppDetail();if(!$("appDetail").open)$("appDetail").showModal();$("appDetail").scrollTop=0;
if(shown)await loadOfferDetails();
}
async function loadOfferDetails() {
const d=sourceState.detail,o=currentOffer(), request=++sourceState.detailRequest;
@@ -3201,8 +3223,8 @@ async function loadOfferDetails() {
const detail=await api('/api/sources/details?'+new URLSearchParams({source:o.source,id:o.id}));
if(request!==sourceState.detailRequest || d!==sourceState.detail)return;
const artwork={icon:detail.artwork?.icon||o.artwork?.icon,banner:detail.artwork?.banner||o.artwork?.banner,screenshots:detail.artwork?.screenshots?.length?detail.artwork.screenshots:o.artwork?.screenshots||[]};
d.app.offers[d.choice]={...o,...detail,artwork};renderDetail();
} catch(e) { if(d===sourceState.detail)$("detailInstallNote").textContent="Some app details couldn't be refreshed. You can still view this listing."; }
d.app.offers[d.choice]={...o,...detail,artwork};renderAppDetail();
} catch(e) { const note=$("detailInstallNote");if(d===sourceState.detail && note)note.textContent="Some app details couldn't be refreshed. You can still view this listing."; }
}
async function installStoreApp() {
const o=currentOffer(), key=`${o.source}:${o.id}`, existing=sourceState.jobs.get(key);
@@ -3232,8 +3254,8 @@ $("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer)
$("sourceFilter").onchange=e=>{sourceState.source=e.target.value;searchSources();};
$("searchFilters").onclick=e=>{const b=e.target.closest('[data-filter]');if(!b)return;const f=b.dataset.filter;if(f==='all'){sourceState.vr=null;sourceState.installable=false;}else if(f==='installable')sourceState.installable=!sourceState.installable;else sourceState.vr=sourceState.vr===(f==='vr')?null:f==='vr';storeChips();searchSources();};
$("searchGrid").onclick=e=>{const b=e.target.closest('[data-app]');if(b)openStoreApp(+b.dataset.app);if(e.target.closest('#storeReset')){$('sourceQ').value='';sourceState.vr=null;sourceState.installable=false;sourceState.source='';storeChips();searchSources();}};
$("detailBody").onclick=e=>{if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderDetail();loadOfferDetails();}};
$("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDetail").close();if(e.target.closest('#detailInstall'))installStoreApp();const b=e.target.closest('[data-gallery]');if(b)$('detailGallery').scrollBy({left:+b.dataset.gallery*$('detailGallery').clientWidth*.86,behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'instant':'smooth'});};
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
@@ -3785,7 +3807,7 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden && on
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
async function loadPanels() {
async function loadPanelSwitcher() {
const seq = ++panelSeq;
try {
const s = await api("/api/panels", {action: "list"});
@@ -3796,7 +3818,7 @@ async function loadPanels() {
: '<div class="sub">No open panels.</div>';
} catch (e) { if (seq === panelSeq) failed($("panelList"), e); }
}
$("panelsRefresh").onclick = loadPanels;
$("panelsRefresh").onclick = loadPanelSwitcher;
$("panelsOpen").onclick = e => act("Open panel switcher in headset",
() => api("/api/panels", {action: "open"}), e.currentTarget);
$("panelList").onclick = e => {
@@ -3894,7 +3916,7 @@ function showPage() {
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (page === "tools") { loadMacView(); loadPanels(); }
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
}
window.addEventListener("hashchange", showPage);
// While a text field has focus, phones hide the bottom tab bar (see body.typing in the CSS).
+47 -10
View File
@@ -45,6 +45,7 @@ import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
@@ -696,7 +697,7 @@ PASTE = r"""set -u
text=$(cat; printf x); text=${text%x}
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
if [ -z "$pid" ]; then
echo "plasmashell is not running: open the desktop in the headset first." >&2
echo "The headset's desktop isn't open (plasmashell is not running). Open the desktop in the headset, then try again." >&2
exit 2
fi
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
@@ -708,6 +709,7 @@ else
exit 2
fi
"""
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE says when the headset's desktop isn't open
# base64 keeps the script intact through every local shell's quoting rules.
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
@@ -724,7 +726,13 @@ def clipboard(body):
text = body.get("text")
if not isinstance(text, str) or not text:
raise Failure("nothing to send", 400)
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
try:
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
except Failure as e:
if DESKTOP_CLOSED in str(e):
# The headset's state, for the person to change, not a fault: a 409 isn't reported as one.
raise Failure(str(e), 409) from None
raise
# ---- keyboard and pointer (KDE Connect on the Frame, see frame_input_agent.py) ----
@@ -1935,23 +1943,36 @@ def _pid_alive(pid):
return True
def sweep_tmp():
"""Delete download and title staging folders left by a server killed mid-install.
def _tmp_places():
"""Where each kind of staging folder goes, and its name before the server's PID."""
tmp = Path(tempfile.gettempdir())
return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX),
(tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)]
Folders carry the server's PID, so only a dead server's are taken.
def sweep_tmp(own=False):
"""Delete staging folders (downloads, unzipped titles, patched APKs, staged files,
app-index downloads) left by a server that stopped mid-way.
Folders carry the server's PID, so only a dead server's are taken; own=True (on
the way out, see main) takes this server's too.
"""
for prefix in (WEB_TMP_PREFIX, frame_titles.TMP_PREFIX):
for d in Path(tempfile.gettempdir()).glob(f"{prefix}*"):
_sweep_one(prefix, d)
for folder, prefix in _tmp_places():
try:
found = list(folder.glob(f"{prefix}*"))
except OSError:
continue
for d in found:
_sweep_one(prefix, d, own)
def _sweep_one(prefix, d):
def _sweep_one(prefix, d, own=False):
m = re.fullmatch(re.escape(prefix) + r"(\d+)-.*", d.name)
if not m:
return
pid = int(m[1])
try:
if pid != os.getpid() and not _pid_alive(pid) and d.is_dir():
if (own if pid == os.getpid() else not _pid_alive(pid)) and d.is_dir():
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
@@ -2757,6 +2778,22 @@ def main():
if proc.poll() is None:
proc.terminate()
_purge_titles(now=float("inf")) # unconfirmed title uploads
# Staging folders of work still running (a patched APK mid-copy, an index
# download): leaving below skips the cleanup their TemporaryDirectory would
# get at interpreter exit. sweep_tmp at the next start catches any missed.
sweep_tmp(own=True)
# Stopped as asked, and everything above is cleaned up. Leave now, without
# Python's interpreter teardown: daemon threads are still running (app index
# downloads, the stdin watcher, telemetry, the headset link, request handlers)
# and none can be stopped promptly. Tearing the interpreter down under them
# occasionally crashed the process (SIGSEGV, seen on Python 3.13 on Linux):
# OpenSSL's exit cleanup freed state those threads were using. That teardown
# also runs atexit handlers and weakref finalizers; nothing here registers
# atexit work, the only finalizers are TemporaryDirectory cleanups (swept
# above), and the OS frees the one-server lock with the process.
sys.stdout.flush()
sys.stderr.flush()
os._exit(0)
if __name__ == "__main__":