Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:48:00 +11:00
saphidandClaude Opus 5.5 07fc7ca3ff Headset store review fixes: ship ui/apk_sources in the Frame bundle; live installed state; GitHub installs remembered; game data its own job; no stuck loading; lookup waits count against the budget
The iPhone app's Frame bundle (also used by store-on-frame.sh) left out
ui/apk_sources, so ui/server.py couldn't start from it; a test now runs
the server's imports from an extracted bundle.

From review round 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:42:09 +11:00
saphidandClaude Opus 5.5 e885e9adba APK Store in the headset on the new store; images load first time; phone layout
- ui/store.html, scripts/store-on-frame.sh and the /store route (built in
  another thread and left uncommitted in the main checkout) join this branch,
  rebuilt on /api/search: the same sources, categories, phone-only rule and
  artwork as the Store, as a 1280x800 laser panel. A category rail with
  Installed, eight art cards a page, a featured VR app on Home, a detail
  sheet with screenshots, install progress and game data, the thumbstick
  turning pages and the on-screen keyboard.
- Artwork: the proxy allowed four name lookups at once and failed the rest
  immediately, so a page of cards lost most of its images until the retry.
  Public answers are now kept for five minutes and a lookup waits for a slot
  within its own budget.
- Phones: one-line shelf headings, a shorter store header, wider shelf
  cards, and a two-column library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:31:34 +11:00
saphidandClaude Opus 5.5 7aa7c55be9 Store categories: "does not require root" isn't a root tool
From review round 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:08:05 +11:00
saphidandClaude Opus 5.5 2554309c7d Store categories: searches always find phone-only apps; messengers and broad-only sources escape a phone side; name-only phone words; hardware the Frame lacks
From review round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:44 +11:00
saphidandClaude Opus 5.5 adc1e44cf1 Store categories: keep useful apps with a phone side, ignore "no root", map IzzyOnDroid's Office; keep sidebar focus; never feature an app that won't run
From review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:10 +11:00
saphidandClaude Opus 5.5 7f46cd54a4 Android tab: a Store with Frame categories and a card Library
The store browsed F-Droid alphabetically, so it opened on proxies, port
checkers and root tools. It now groups apps by what a headset is good for
(VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web &
chat, Tools) from F-Droid's own categories, hides phone-only apps unless
asked, and browses each source's best-illustrated, newest apps first.

- Store: a category sidebar (chips on narrow windows), one shelf per
  category on the home page with no repeats, a featured VR app, and cards
  that show a banner, phone screenshots side by side, or the icon over its
  own blur instead of a letter, plus whether it fits the Frame.
- Library: its own view with art cards, Play, and a menu for Test, Report
  and Remove; filters for VR, flat and running apps; display settings and
  reports below. Artwork comes from the saved source indexes.
- Images: listings register three screenshots per app instead of twelve,
  handles are indexed by URL, and the bound is 16,384, so a large browse no
  longer forgets its own images before the page loads them.
- F-Droid cache version 3 keeps each app's categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
Alex Southwell ba0ac6022b Merge pull request #96 from saphid/feat/frame-control-center
Add comprehensive Steam Frame Control Center
2026-10-14 18:51:07 +11:00
saphid 8b9bd2119c Add searchable Steam Frame Control Center with live metrics 2026-10-14 18:40:59 +11:00
Alex Southwell a8888cb2fc Merge pull request #94 from saphid/feat/telemetry-timezone
Usage statistics: send a country (from the time zone setting), and show the notice again when it changes
2026-10-14 13:22:27 +11:00
saphidandClaude Opus 5.5 1f51cc89d2 Frame Control 0.4.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:14:46 +11:00
saphidandClaude Opus 5.5 bcdb4c3b78 Usage statistics: no country for clock-rule zones, and Johnston Atoll is UM
From review: EET and WET link to Athens and Lisbon but name clock rules,
not Greece or Portugal, so they and the other rule names (CET, EST5EDT, …)
send nothing. Pacific/Johnston has left zone.tab, which made its Honolulu
group look only American; it's set to UM by hand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:01:46 +11:00
saphidandClaude Opus 5.5 e9b02d61e7 Usage statistics: don't give an old zone name the country of the zone it now links to
From review: tzdata links an old name to any zone with the same clocks,
even in another country (Africa/Asmera, Eritrea, links to Africa/Nairobi).
An old name now gets a country only when every zone sharing its target is
in that country, or when it's a hand-checked old name for a place listed in
zone.tab. The rest (UTC, CET, GMT, …) send nothing. Every zone Node's ICU
reports has an entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:55:18 +11:00
saphidandClaude Opus 5.5 02419ffbd4 Usage statistics: send a country, not the time zone; only the notice itself counts as seen
From review: the time zone setting could hold any text (a TZ like
home/alex/private passed the name check), so the app now looks the zone up
in a table made from the tz database (scripts/timezones.py) and sends only
its two-letter country; anything not in the table sends nothing.

Turning a setting on no longer counts as having seen the new notice, and
the country is taken out at send time until it has been, so it can't
leave early whatever order things happen in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:46 +11:00
saphidandClaude Opus 5.5 5db9631571 Usage statistics: send the time zone, and show the notice again when it lists something new
Opens, installs and updates carry the computer's time zone name (from
Electron, which knows it on every OS), so we can see roughly which
countries Frame Control is used in without ever seeing an address.

The first-run notice now has a version. Copies that saw the older notice
see it again, and keep sending their usual statistics, but leave the time
zone out until they've seen the new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:41:19 +11:00
Alex Southwell 141b95ddde Merge pull request #89 from saphid/feat/headset-telemetry-id
Frame Control 0.4.5: count headsets, lighter Comfort checks, sturdier APK installs
2026-10-13 17:18:52 +11:00
saphidandClaude Opus 5.5 4ec2ff40c3 APK installs: decode the Frame's ssh output as UTF-8 on every OS
Windows decoded it with its code page, so the Windows CI saw 'ok ÿ' where
the test expects a replacement character, and real UTF-8 messages from the
headset came out garbled there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:07:12 +11:00
saphidandClaude Opus 5.5 39e819872a Privacy notice and setting: say headsets are counted, by a one-way hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:00:08 +11:00
saphidandClaude Opus 5.5 ee7951e8d0 Release 0.4.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:55:41 +11:00
saphidandClaude Opus 5.5 d456aa1f3d Take the #81 log scrubbing rework out of this release
Three review rounds kept finding ways that matching names, ssh operands and
user@host in one pass shows something the scrubber on main hid (URLs, home
folders, keys). The two #81 edge cases are minor; the scrubber goes back to
main's, and #81 stays open with the findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:07:35 +11:00
saphidandClaude Opus 5.5 6c7b2ca22f Log scrubbing: match names in the text as it came, with user@host found alongside
Round 2 of the review: scrubbing the text before matching names stopped a
name the scrubber partly rewrote ("frame.local Jane Doe headset") from
matching whole, and cut the text before names were hidden. Instead the
shared scrubber's user@host patterns are found in the original text as
spans too, so a name can't take their @. A randomised comparison with the
scrubber on main (160,000 cases) found nothing it hid that this shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:59:42 +11:00
saphidandClaude Opus 5.5 8a06848b5f Log scrubbing: find overlapping ssh operands; scrub user names before display names
Review of #81 found two cases that scrubbed less than before: "to hostname X"
left X visible, and a display name ending in "@<host>" took the @ the user
name scrubber needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:53:04 +11:00
saphid 9cca67529b Merge rel/issue87 into the release batch 2026-10-12 22:42:12 +11:00
saphidandClaude Opus 5.5 b656f80922 Host-key recovery UI: whole reasons, no doubled stops, header fits, selection follows (#87)
Follow-up to #85 from #87:
- A failure can carry a short reason (SSH identity changed, Saved SSH identity
  missing, This computer's key refused). The address that failed shows it
  instead of repeating the whole message, so the offline banner no longer
  reads "...may be answering here.." or "...must be on).."; the banner also
  drops a closing full stop before adding its own and keeps "SSH"/"Frame"
  capitalised.
- The header pill leads with that reason (then the countdown), so a narrow
  pill cuts the countdown, not the reason. The Devices tab's changed-key and
  refused-key explanations wrap; the list row shows the short reason with the
  whole message as its tooltip.
- The header's pill and buttons sit in one group pushed right with tighter
  gaps; the pill and headset picker give way, the buttons don't, and the
  shortcut hints hide at 1366 px and below. Refresh no longer overhangs at
  1280 px.
- Use this headset selects the headset it switched to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:41:56 +11:00
saphid 7cc418662d Merge rel/apkcat into the release batch 2026-10-12 22:33:17 +11:00
saphid b2bbff4305 Merge rel/comfort into the release batch 2026-10-12 22:33:17 +11:00
saphid 2cfcdaaf0e Merge rel/issue81 into the release batch 2026-10-12 22:33:17 +11:00
saphidandClaude Opus 5.5 2960581541 APK installs: time large copies by size, readable errors, no leftovers
- _copy allowed a fixed 10 minutes, failing any APK over about 300 MB on a
  4 Mbit/s link; it now allows at least 512 KiB/s.
- ssh() and _copy decode output with errors='replace', so a message in
  another encoding is a FrameError, not a UnicodeDecodeError; ssh() with
  bytes input no longer reports "b'...'".
- A failed update removes the partial app.apk.part it copied.
- frame_apk.apk_info turns a damaged APK's zlib.error, EOFError,
  ValueError or RuntimeError into ApkError (the upload path didn't catch
  them, so they gave a 500 and no install report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 8b1c290730 Telemetry: name the APK install failures that were 'other'
steam_not_running, steam_not_responding, steam_js_error and artwork_failed
for the Frame-side Steam client script (its "Connection refused" and
"timed out" were filed as frame_unreachable), copy_timeout and copy_failed
for rsync/scp, internal_error for Python's own errors (KeyError,
JSONDecodeError, ...) whatever they say, and apk_unreadable now covers
every frame_apk.ApkError wording. docs/privacy.md lists them all, and a
test keeps it in sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:32:54 +11:00
saphidandClaude Opus 5.5 b64082c974 Comfort: run the helper cached on the headset; send the sources only when missing
Every call (one per 5 s poll) sent ~35 KB of frame_comfort.py, frame_status.py
and frame_steam.py over ssh, though they are already cached on the headset in a
content-addressed folder. Now the first ssh only checks that folder and runs
the cached copy; if any file is missing it answers {"comfort": "missing"} and
a second call writes the sources as before (umask 077, temp file then rename,
same version hash).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 7c1d698400 Comfort: don't report the poll meeting an unreachable Frame, or poll while it is
The page polled POST /api/comfort status every 5 s whatever the connection,
and every POST Failure >= 500 went to diagnostics. ssh already marks link
failures, but those are still sent once per session per category, so each
session with a sleeping, away or re-keyed headset sent up to three reports
from this poll (most of 0.4.2's error reports). GET /api/status never reports
Failures at all.

- server: a polled POST read (POLLS: comfort status) that couldn't reach the
  headset isn't a diagnostic, as with GET /api/status. Real comfort errors,
  and link failures from starting or cancelling a session, still are.
- page: the comfort poll starts after the first status check and skips its
  ssh while the Frame is offline (online === false); status checks bring
  it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:29:32 +11:00
saphidandClaude Opus 5.5 b6406a90a4 Log scrubbing: find names and ssh operands in the original text (#81)
Follow-up to #79. Known names and the hosts ssh names are now located as
spans in the text as it came, overlapping spans merged, and replaced in one
pass. A display name containing a scrubber token ("<host> Jane Doe headset")
now goes whole, and a name like "to host" can no longer hide ssh's
"connect to host X" operand. Tokens on their own are still left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:27:44 +11:00
saphidandClaude Opus 5.5 9b26c907ce Telemetry: count headsets, not just installs
frame_connected now carries headset_id, a one-way hash of the headset's
/etc/machine-id worked out on the headset (frame_status.py), and
headsets_saved, how many headsets this copy of the app has set up. It is
sent once per build for each headset. The hash goes to usage statistics
only: the status endpoint and MCP clients don't get it. docs/privacy.md
says what is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:03 +11:00
Alex Southwell 5f3798b79b Merge pull request #88 from saphid/fix/tests-real-ssh-dir
Tests: keep every test (and the servers they start) off the real ~/.ssh
2026-10-12 16:34:34 +11:00
saphidandClaude Opus 5.5 6e0b140efc e2e: run only inside scripts/e2e.sh's container, and never start a server otherwise
test_agents had no harness.require() and FrameTestCase started ui/server.py
unconditionally, so plain discovery over tests/e2e started a server on this
computer's real app data, ~/.ssh and cache. FRAME_E2E=1 alone outside the
container did the same with the real cache.

require() now also needs FRAME_E2E_CONTAINER=1 (set in compose.yaml) and the
container runtime's marker file, and says why it refuses; FrameTestCase and
Server.start call it before starting anything; test_agents calls it at
module level. test_sandbox checks discovery over tests/e2e, with and
without FRAME_E2E=1, skips everything and starts no server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:21:09 +11:00
saphidandClaude Opus 5.5 226ec40720 Tests: keep the e2e container's real home; sandbox the per-user cache folders too
tests/e2e/test_android imported its APK helpers from the sandboxed
test_frame_apk, so the HOME move reached the whole e2e process: frame_connect
wrote keys and config into the temp home while OpenSSH, which finds ~/.ssh
from the password database, kept reading the container user's. The helpers
now live in the sandbox-free tests/apk_fixtures.py, and the e2e harness sets
the data dir, telemetry and compat DB guards itself (it used to get them
through that import).

sandbox.py also points LOCALAPPDATA, APPDATA and the XDG folders into the
throwaway home, so test servers on Windows and Linux no longer warm indexes
or sweep staging folders in the real app cache. test_sandbox checks both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:56:26 +11:00
saphidandClaude Opus 5.5 f1aaed6279 Tests: keep every test (and the servers they start) off the real ~/.ssh
Servers started by test_server's OneServer and LocalMode tests got no
FRAME_CONTROL_SSH_DIR, so each one imported the real ~/.ssh/config into a
fresh throwaway devices.json and seeded a pin into the real
~/.ssh/frame-control-hosts: about a dozen files per suite run.

tests/sandbox.py now gives the whole run a throwaway HOME/USERPROFILE and
FRAME_CONTROL_SSH_DIR (inherited by child processes), every test module
imports it first, and frame_connect.SSH_DIR honours FRAME_CONTROL_SSH_DIR
like frame_devices.ssh_dir(). test_sandbox checks the paths and reruns the
tests that leaked from the original environment, asserting nothing under
the real ~/.ssh changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:40:01 +11:00
saphidandClaude Opus 5.5 b89538f506 Release 0.4.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:36:27 +11:00
Alex Southwell 311fb5f6c7 Merge pull request #85 from saphid/fix/host-key-recovery
Recover from changed, missing and refused SSH keys inside the app
2026-10-12 15:24:25 +11:00
saphidandClaude Opus 5.5 93ae2b1bd1 Host key recovery: quote the jump hop's ProxyCommand; IPv6 jumps (review pr85-review-r3)
- Every word of the hardened ProxyCommand is quoted for the shell ssh runs
  it with (shlex.quote; list2cmdline on Windows) and its % doubled for
  ssh's token expansion. The ssh path is always the absolute one, spaces
  and all; an ssh at a path with $(), backticks, quotes or ; no longer runs
  shell code.
- The forward is -W [%h]:%p (an IPv6 headset), and an IPv6 jump host is
  passed bare with -p.
- The stand-in ssh now runs ProxyCommand as OpenSSH does (tokens, then
  /bin/sh -c exec), with regressions for hostile paths and IPv6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:15:44 +11:00
saphidandClaude Opus 5.5 12bd10ee42 Host key recovery: harden jump hops, keep identity history (review pr85-review-r2)
- Key check through a ProxyJump host: build the hop as our own ProxyCommand
  (no agent/X11/port forwarding, no LocalCommand or RemoteCommand, no tty,
  no further proxy), with -F none for the device; ProxyJump's own ssh would
  inherit ForwardAgent and LocalCommand from ~/.ssh/config. Refuse
  ProxyCommand and multi-hop routes, which can't be hardened.
- Identity history is judged on the current registry (named_route used a
  frozen copy and fell back to accept-new).
- A key ssh saves on first contact is recorded, made private and the route
  tightened as soon as it's saved, even if the login is then refused, and
  for keys commands save (the loop settles them every tick).
- identity_established is a device-level flag that never clears (migrated
  from last_ok), so editing or removing the address that worked can't make
  a headset new again.
- The handshake waits for its reader's end marker before taking ssh's exit
  as the end, so a late "Permission denied" isn't lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:56:02 +11:00
saphidandClaude Opus 5.5 218df4f312 Release 0.4.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:48:49 +11:00
Alex Southwell ce1a271064 Merge pull request #86 from saphid/fix/flaky-deadline-assert
Tests: stop the SteamGridDB deadline check flaking on Windows
2026-10-12 14:44:39 +11:00
Alex Southwell dbf6ffc79c Merge pull request #83 from saphid/fix/no-app-panel-73
Desktop view: say why no panel was found; desktop-closed clipboard is a 409 (#73)
2026-10-12 14:44:29 +11:00
saphidandClaude Opus 5.5 b365a85665 Tests: SteamGridDB deadline check no longer flakes on Windows' coarse clock
Compare the deadline passed on with the caller's own deadline instead of a
fresh time.monotonic(), which failed on windows-latest with
5.000000000000028 > 5 (CI run 38200136884).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:36:13 +11:00
saphidandClaude Opus 5.5 8aeeaef666 Host key recovery: fail closed on a lost pin, read keys only (review pr85-review-r1)
- Key check: ssh writes the key during the key exchange and is stopped at once;
  no authentication, agent, forwarding, commands (-N), terminal or local
  command, all forced ahead of ~/.ssh/config, and -F none unless a jump host
  needs the config. A device accepting "none" gets nothing.
- accept-new only for a headset that never had an identity: the saved key's
  fingerprint is recorded in devices.json, and a recorded identity, an
  existing pin file or a connection that ever worked makes a deleted, empty,
  unreadable or unparseable pin fail closed ("missing or can't be read").
- forget-identity is refused (it let the next contact save any key unseen).
- A key read before the headset's port, user or that address changed can't be
  trusted.
- Without a ControlMaster (Windows) the handshake's stderr goes to a file read
  as it grows, not a pipe.
- Pin files and the check's throwaway known_hosts get make_private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:32:08 +11:00
saphidandClaude Opus 5.5 5fb02770db Recover from changed, missing and refused SSH keys inside the app
0.4.2 sent $exception reports from the status poll (POST /api/comfort status,
frame_auth) on Linux and Windows for three states the person could not fix from
the app:

- A changed host key (REMOTE HOST IDENTIFICATION HAS CHANGED): the advice was
  "Run Set Up Connection again", which checks ~/.ssh/known_hosts and never
  replaces the headset's pinned key; Forget identity trusted whatever answered
  next without showing it.
- No pinned key ("No ED25519 host key is known for frame-control-<id> and you
  have requested strict checking"): every command said
  StrictHostKeyChecking=yes, so any command that ran before (or without) the
  connector's first handshake saving the key failed.
- Permission denied (publickey,password): not something a retry fixes.

Commands now use accept-new until a key is saved and yes after (accept-new
never replaces a saved key). A changed key fails at the identity stage with its
fingerprint; the banner and Devices tab offer "Check the new key", which reads
the key with a throwaway known_hosts file and no login, shows it beside the
saved one, and pins exactly that key only when the person confirms. Refused
keys point to Set Up Connection. ssh's own host key and login refusals are
marked as link failures and frame_auth joins the expected categories, so each
is reported at most once a session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:10:50 +11:00
68 changed files with 5616 additions and 353 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+3 -2
View File
@@ -115,10 +115,11 @@ function ping(target) {
}
async function startServer() {
// The version and whether this is a built app go to ui/frame_telemetry.py, which
// sends nothing from a source checkout.
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
// which sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.2",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.2",
"version": "0.4.6",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.2",
"version": "0.4.6",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -50,6 +50,7 @@
"*.py",
"*.html",
"*.js",
"*.css",
"telemetry.json"
]
},
+57
View File
@@ -177,6 +177,63 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
+56 -6
View File
@@ -104,7 +104,9 @@ connection is an SSH ControlMaster that every command shares; when it dies (the
headset slept or left the network) the connector notices and starts again. On
Windows, where OpenSSH can't share a connection, the same handshake runs once
and each command then connects on its own; a command that can't reach the
headset makes the connector start again.
headset makes the connector start again. There ssh's stderr goes to a file that
the connector reads as it grows, not a pipe: Windows' OpenSSH can block writing
to a redirected pipe.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
@@ -121,9 +123,57 @@ lease that moved) would look like a new host there. The app keeps one known_host
file per headset instead, so saving or forgetting one headset's key never touches
another's: a different device answering at one of its
addresses is refused, and the pill says so. A headset's first connection trusts
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
headset has a new key; **Forget identity** on the Devices tab lets the next
connection save the new one.
the key it shows, as Set Up Connection does: while a headset has never had an
identity here, every ssh command gets `StrictHostKeyChecking=accept-new` (the
first contact saves it, whichever command makes it), and from then on
`StrictHostKeyChecking=yes`. `accept-new` never replaces a saved key, so it
can't hide a change. The saved key's fingerprint is also recorded in
`devices.json` (`identity`), along with a flag that never clears
(`identity_established`), as soon as ssh saves the key: even if the login is then
refused. A headset counts as having had an identity if the flag is set, a
fingerprint is recorded, its pin file exists at all, or a connection to it ever
worked (older files get the flag from that when loaded, so editing or removing
the address that worked doesn't undo it); then
a pin file that's deleted, empty, unreadable or not parseable **fails closed**
(*Checking the headset's identity* fails, "missing or can't be read") rather than
trusting whatever answers, and only **Check its key…** saves a key again. It
says when the key it reads matches the recorded fingerprint.
**When the key changes** (SteamOS reinstalled or reset, a new headset at the
same address, or a different device answering there), nothing is accepted by
itself. The connection fails at *Checking the headset's identity*; the banner
and the headset on the Devices tab say so, with **Check the new key…** (also
**Check its key…** on the Devices tab at any time). That reads the key the
headset shows now, and nothing more: ssh writes it to a throwaway known_hosts
file during the key exchange, before any login, and is stopped as soon as it
has. Every other kind of contact is ruled out on the command line, ahead of
anything `~/.ssh/config` says: no authentication of any kind, no agent
(`IdentityAgent=none`, `ForwardAgent=no`), no X11 or port forwarding, no local
or remote command (`-N`), no terminal, and `-F none`. Through a ProxyJump host
it builds that hop itself, as a ProxyCommand whose ssh forwards nothing and runs
no local command (ProxyJump's own ssh would take `ForwardAgent` and
`LocalCommand` from `~/.ssh/config`); it still logs in to the jump host as
configured. Every word of that ProxyCommand is quoted for the shell that runs it
(and `%` doubled for ssh's own tokens), the forward is `[%h]:%p` so an IPv6
headset works, and an IPv6 jump host is passed bare with its port. Through a ProxyCommand from `~/.ssh/config`, or several jump hosts,
it refuses, since those can't be made safe from here: add an address it can
reach directly, or compare the key by hand. It shows the key's SHA256 fingerprint next to
the saved one, with the command that prints it on the headset
(`ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, in Konsole on its desktop).
**This is my headset: trust its new key** then saves exactly that key, replacing
the old one, and reconnects. The server keeps the key it showed for 10 minutes
and saves it only if the page sends back the same fingerprint, and only while
the headset's port, user and that address are unchanged; it never saves whatever
answers next. The API's old `forget-identity`, which deleted the pin so that the
next contact saved any key unseen, is refused. Set Up Connection can't fix a changed key: it checks your
own `known_hosts`, not the headset's file.
**When the headset refuses this computer's key** (`Permission denied
(publickey,…)`), the connection stops at *Logging in*, and the banner and the
Devices tab offer **Set Up Connection…**, which pairs this computer again.
Neither is reported as a fault: the diagnostics report each kind at most once
a session ([privacy.md](privacy.md)).
## One server at a time
@@ -144,7 +194,7 @@ All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
| `GET /api/devices/mdns?id=` | Headsets found on this network |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `identity-scan` (read the key the headset shows: `scan` with `type`, `fingerprint`, `host`, `saved`, `recorded`, `matches`, `recorded_match`), `identity-trust` (`fingerprint`: save the key `identity-scan` showed), `forget-identity` (refused: use the two before), `name-network`, `setup` (alias, optional host), `retry` |
Every host, alias and user is checked against strict patterns before it's
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
@@ -161,7 +211,7 @@ share the format later (it still connects to one host; see
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"identity_files": ["~/.ssh/id_ed25519_frame"], "identity": ["SHA256:…"], "identity_established": true,
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+59 -3
View File
@@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has five tabs: **Home** (headset view, status, screenshots),
The window has six tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (sending files and text,
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
and **Control** (the searchable Control Center, metrics, supported settings and
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
@@ -96,6 +97,61 @@ counts them while they run.
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## Control Center
Open **Control** or `#control` to search controls, status and help. Categories
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
preferences. Search is local: it matches labels, descriptions and familiar terms
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
existing screen; it does not install, cast, open a helper or change a setting.
Home, Games, Android, Tools and Devices retain their existing features.
The overview and detail cards use the shared `/api/status` sample: charge and
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
and VR timings. Values refresh with the normal 30-second poll. A failed status
request, lost connection, headset switch or sample received more than 90 seconds
ago clears current readings and disables audio writes. The client receipt time
and failed-read state are retained through page startup and UI redraws. Only a
new successful status read clears a failure; reusing the retained sample does not.
The headset timestamp is shown without assuming
its clock matches this computer. Missing sensors show **Unavailable**;
they are not converted to zero. Compositor FPS is separate from application FPS;
GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
blocks duplicate/pending actions, and requests status after command acknowledgment
for readback, draining an earlier poll first. Mute preserves an unapplied volume
draft; switching headsets discards it. It reports
unconfirmed or differing output separately from confirmed state. API validation
rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
Hardware guidance is deliberately separate from controls. Microphone/routing,
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
tracking-origin changes, saved spatial layouts, firmware updates and recovery
have no verified remote setting here. Use the headset's own controls or the
documented manual procedures. Movement comfort remains per-game; automatic
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
Android data backup/restore remain script/MCP capabilities, not new UI switches.
**Verification:** the Control Center's JavaScript runs under Node with mocked
API and DOM boundaries; the server tests mock SSH and reject invalid values
before any device command. Packaging tests cover the shared CSS/JS in desktop
and iPhone bundles. These checks establish wiring, search, navigation, offline,
error and device-switch behavior; they do not establish wearer experience,
physical settings persistence or a new hardware capability.
## How it works
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

+30 -12
View File
@@ -33,15 +33,28 @@ computer, exactly as they were sent.
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
two-letter code such as `DE`, worked out on your computer from its time zone
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
Control is used without knowing your address. The time zone itself isn't
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
Copies that showed the first-run notice before it mentioned this (before
0.4.6) show it again, and don't send a country until it has been seen.
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
so the machine-id itself never leaves it. It is the same for one headset on
every computer, which lets us count headsets rather than installs; it can't
be turned back into the machine-id or a serial number. `headsets_saved` is how
many headsets this copy of Frame Control has set up.
## Usage events
| Event | When | Properties besides the common ones |
|---|---|---|
| `app_installed` | First run | |
| `app_updated` | First run of a new version | `from_version` |
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `app_installed` | First run | `country` |
| `app_updated` | First run of a new version | `from_version`, `country` |
| `app_opened` | At most once a day | `country` |
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
@@ -51,12 +64,15 @@ error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
Control's OpenXR compatibility layer, because this copy of the app is missing
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
names what was installed only when that's already public:
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
APK that installed without Frame Control's OpenXR compatibility layer,
because this copy of the app is missing it, carries `xr_layer_missing: true`;
otherwise that field is left out. It names what was installed only when
that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
@@ -101,8 +117,10 @@ scrubbed:
- `token=`, `key=`, `password=` and similar values are replaced
The same error is sent at most once every 10 minutes. When ssh reports that
it couldn't reach the Frame (asleep, away, or not set up yet), that is sent
at most once per kind each time Frame Control runs.
it couldn't reach the Frame (asleep, away, or not set up yet), or that ssh and
the Frame refused each other's keys (a changed host key, or this computer's
key not accepted), that is sent at most once per kind each time Frame Control
runs.
## Report a problem
+1
View File
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+23
View File
@@ -15,6 +15,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
python3 -m unittest discover -s tests
```
The Control Center is covered by `tests/test_control_center.py` and its Node
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
They exercise production search, metrics, navigation and action handlers with
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
stale samples, selected-headset capture, duplicate actions and validation before
SSH. Integration regressions run the production shared refresh, Home renderer,
host callback and Control Center bootstrap: a pre-command status request must be
drained before audio readback starts, and delayed startup retains sample receipt
age, failed-read state and early host capabilities. The actual Flatpak install
redraw cannot revive a failed sample; a new successful poll restores metrics and
audio after either redraw or delayed mount. Draft regressions cover a pending headset
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
or JavaScript. A focused run is:
```sh
python3 -m unittest discover -s tests -p test_control_center.py -v
node --test tests/control_center.cjs
node --check ui/control-center.js
```
Responsive layout, actual browser rendering and keyboard/touch behavior still
need browser verification. No Control Center test connects to a real headset.
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env zsh
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
# panel on the Steam Frame, so you can browse and install apps from inside.
# See docs/apks.md → "The store in the headset".
#
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
# needed after `open`.
#
# Usage:
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
# scripts/store-on-frame.sh close # close the panel and stop the server
# scripts/store-on-frame.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
ROOT=${HERE:h}
PORT=47812 # the Frame's 127.0.0.1 only
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
REMOTE=.local/share/frame-control/store-server
cmd=${1:-}
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
# server on the Frame has exactly the files the desktop app ships.
deploy() {
local tmp version
tmp=$(mktemp -d)
trap "rm -rf ${(q)tmp}" EXIT
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
print -u2 "==> Copying Frame Control ($version) to the Frame"
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
fi
# Older versions, unless a server is still running from one.
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
print -r -- "$version"
}
# Runs on the Frame. Args: dir port appid key
OPEN=$(cat <<'EOF'
set -u
dir=$HOME/$1 port=$2 appid=$3 key=$4
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
if [ -x "$HOME/chromium-xr/chrome" ]; then
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
else
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
exit 3
fi
# A fresh server each time, so it runs this version with this key.
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
# Match the browser by its profile flag and the server by its script path: this
# launcher's own command line holds the bundle path, so a looser pattern kills it.
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
sleep 0.5
cd "$dir" || exit 1
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
>/tmp/frame-store-server.log 2>&1 </dev/null &
echo $! > /tmp/frame-store-server.pid
up=
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
sleep 0.25
done
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
--disable-features=Translate,MediaRouter --window-size=1280,800 \
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
# Tag the window so it's its own panel (docs/panels.md).
for _ in $(seq 1 60); do
sleep 0.5
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "Chromium started, but its window didn't appear within 30 s:" >&2
tail -n 15 /tmp/frame-store.log >&2
exit 1
EOF
)
case "$cmd" in
open)
version=$(deploy | tail -n 1)
key=$(openssl rand -hex 16)
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
;;
close)
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
;;
status)
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
;;
*) sed -n '2,15p' "$0"; exit 2 ;;
esac
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
names that link to them), as installed on macOS and most Linux systems. It prints the
old names left out because their zone is shared by several countries: check any new
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
"""
import sys
from pathlib import Path
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
country = {}
for line in (src / 'zone.tab').read_text().splitlines():
if line and not line.startswith('#'):
cc, _, zone = line.split('\t')[:3]
country[zone] = cc
links = {}
for line in (src / 'tzdata.zi').read_text().splitlines():
if line.startswith('L '):
_, target, alias = line.split()
links[alias] = target
def resolve(z):
for _ in range(5):
z = links.get(z, z)
return z
# An old name links to a zone with the same clocks, which can be in another country
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
# zone.tab zone sharing that target is in one country.
countries_of = {}
for zone, cc in country.items():
countries_of.setdefault(resolve(zone), set()).add(cc)
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
# Places that have left zone.tab, so their target's group looks like another country's.
PLACES = {'Pacific/Johnston': 'UM'}
dropped = []
for alias in links:
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
continue
if alias in PLACES:
country[alias] = PLACES[alias]
continue
ccs = countries_of.get(resolve(alias), set())
if len(ccs) == 1:
country[alias] = next(iter(ccs))
else:
dropped.append(alias)
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
OLD_SPELLINGS = {
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
'Pacific/Yap': 'Pacific/Chuuk'}
for alias, zone in OLD_SPELLINGS.items():
if alias in links and zone in country:
country[alias] = country[zone]
dropped.remove(alias)
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
Zones that aren't in one country, such as UTC, aren't here."""
COUNTRY = {{
{rows}}}
''')
print(f'{len(country)} zones, tz {version} -> {out}')
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
+87
View File
@@ -0,0 +1,87 @@
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
throwaway container and keeps its real home, which sandbox would move."""
import io
import os
import struct
import sys
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
+471
View File
@@ -0,0 +1,471 @@
// Real Control Center logic with a minimal DOM; no network, browser or headset.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const cc = require('../ui/control-center.js');
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
function fixture() {
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
return elements.get(id);
}
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
const page = element('controlCenter');
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
return {document:{getElementById:element},element,cards,buttons};
}
function mounted(options = {}) {
const f = fixture(), calls = [];
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
}
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
// Execute the actual shared refresh/deduplication, host callback and mount
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
// rendering helpers, timers and transport are stubbed.
function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
api:(path,body,device)=>{
if(path==='/api/host')return host.promise;
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
watchConnection(){context.watched=true;}};
vm.createContext(context);
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
if(productionApi) {
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
// rejection classification and the real switch/reset notifications run.
context.fetch=(path,opts)=>{
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
}
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
vm.runInContext(pageSource.slice(installStart,installEnd),context);
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
}
const response = data => ({ok:true,status:200,json:async()=>data});
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
const f=sharedPage({productionApi:true});
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
const old=f.refresh(),body=deferred();
assert.equal(f.requests[0].path,'/api/status');
if(rejection==='body') {
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
}
f.switchHeadset();
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
f.requests[1].resolve(response(next));await flush();
const receipt=vm.runInContext('stateReceivedAt',f.context);
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
assert.equal(vm.runInContext('stateRead.error',f.context),null);
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
const assertB=()=>{
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
assert.equal(f.element('metric-battery').textContent,'20%');
assert.equal(f.element('ccVolume').value,20);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
};
assertB();vm.runInContext('render(state)',f.context);assertB();
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
// An ordinary failure on B must still invalidate the current sample.
const failed=f.refresh();
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
else f.requests[2].reject(Error('B transport failed'));
await failed;
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
});
}
test('production headset-free API keeps transport errors and responses across a switch', async t => {
for(const failure of [false,true]) {
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
const pending=vm.runInContext('api("/api/devices")',f.context);
const result=pending.then(value=>({value}),error=>({error}));
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
if(failure)f.requests[0].reject(Error('Device list transport failed'));
else f.requests[0].resolve(response({devices:['frame-b']}));
const settled=await result;
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
else assert.deepEqual(settled.value,{devices:['frame-b']});
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
}
});
test('search finds settings by aliases, all words, category and guidance', () => {
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
assert.ok(find('DPI').includes('display'));
assert.ok(find('sleep inactivity').includes('power'));
assert.ok(find('MOTION SICKNESS').includes('playspace'));
assert.ok(find('font','apps').includes('display'));
assert.deepEqual(find('font','vr'),[]);
assert.deepEqual(find('<script>alert(1)</script>'),[]);
assert.ok(find(' ').length > 20);
});
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
const m = cc.metrics(sample());
assert.equal(m.battery,'99%');
assert.equal(m.power,'0 W');
assert.match(m.batteryDetail,/not charging/);
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
assert.equal(m.thermal,'No hot / critical trip reported');
assert.equal(cc.metrics(null).fps,'Unavailable');
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
});
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
await assert.rejects(controller.audio(change),/valid mute state/);
}
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
assert.equal(calls.length,0);
});
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
const pending=controller.audio({level:.7});
await assert.rejects(controller.audio({muted:true}),/still in progress/);
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
assert.equal(await pending,null);assert.equal(controller.busy,false);
});
test('controller surfaces real API errors and allows a deliberate retry', async () => {
let calls=0;
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
assert.equal(controller.busy,false);
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
});
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
const f=mounted();t.after(()=>f.ui.destroy());
assert.equal(f.element('ccApplyVolume').disabled,true);
f.ui.status(sample());
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('ccVolume').value,40);
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
assert.equal(f.calls.length,0);
assert.equal(f.element('ccVolumeValue').textContent,'65%');
f.ui.status(sample()); // a poll must not overwrite an unapplied value
assert.equal(f.element('ccVolume').value,'65');
await f.element('ccApplyVolume').onclick();
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
assert.equal(f.refreshes,1);
await f.element('ccMute').onclick();
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccAudioResult').textContent,'');
});
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
f.context.online=false;f.ui.connection(false);
assert.equal(f.element('metric-fps').textContent,'Unavailable');
assert.equal(f.element('ccVolume').disabled,true);
await f.element('ccMute').onclick();
assert.equal(f.calls.length,0);
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
f.ui.unavailable(Error('Local server is unavailable'));
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
f.context.online=true;f.ui.status(sample());
assert.equal(f.element('ccVolume').disabled,false);
});
test('mounted audio failure is visible and never reports success', async t => {
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.refreshes,0);
});
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
let expected=.65, mode='confirmed';
const f=mounted({refresh:async()=>{
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
mode='offline';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
assert.equal(f.element('ccApplyVolume').disabled,true);
});
test('pending audio never overwrites the new headset with old success or error', async t => {
let complete;
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
const pending=f.element('ccMute').onclick();
assert.equal(f.element('ccApplyVolume').disabled,true);
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
complete({message:'Old headset muted'});await pending;
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
assert.equal(f.element('ccAudioResult').textContent,'');
assert.equal(f.refreshes,0);
assert.equal(f.element('ccMute').textContent,'Unmute');
f.ui.unavailable({stale:true,message:'Switched headsets'});
assert.equal(f.element('metric-battery').textContent,'99%');
assert.equal(f.element('ccMute').disabled,false);
});
test('pending old-headset volume cannot become a draft on the new headset', async t => {
for(const failure of [false,true]) {
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const pending=f.element('ccApplyVolume').onclick();
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
const next=sample();next.volume.level=.2;f.ui.status(next);
assert.equal(f.element('ccApplyVolume').disabled,true);
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
await pending;
assert.equal(f.element('ccVolume').value,20);
assert.equal(f.element('ccVolumeValue').textContent,'20%');
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('ccAudioResult').textContent,'');
}
});
test('production host response works before and after mounting with the actual bridge shape', async t => {
for(const early of [true,false]) {
const f=sharedPage();let ui;
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
f.host.resolve({os:'macOS',mobile:false});await flush();
if(early){ui=f.mount();t.after(()=>ui.destroy());}
assert.equal(f.context.watched,true);
assert.equal(f.element('macLink').hidden,false);
f.element('ccSearch').value='mac';f.element('ccSearch').input();
assert.equal(f.element('macLink').hidden,false);
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
}
});
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
const prior=f.refresh();assert.equal(f.requests.length,1);
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const command=f.element('ccApplyVolume').onclick();await flush();
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
f.requests[0].resolve(sample());await prior;await flush();
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
assert.equal(f.element('ccApplyVolume').disabled,true);
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
assert.equal(f.element('ccApplyVolume').disabled,false);
});
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
for(const failedReadback of [false,true]) {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const command=f.element('ccApplyVolume').onclick();await flush();
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
assert.equal(f.requests.length,2);
assert.equal(f.element('ccApplyVolume').disabled,true);
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
await command;
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
}
});
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
const receipt=vm.runInContext('stateReceivedAt',f.context);
assert.equal(f.element('ccApplyVolume').disabled,false);
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
f.installFlatpak(); // actual production render(state), with job held pending
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
const fresh=f.refresh();
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
const next=sample();next.battery.percent=98;next.volume.level=.2;
f.requests[2].resolve(next);await fresh;
assert.equal(f.element('metric-battery').textContent,'98%');
assert.equal(f.element('ccVolume').value,20);
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
});
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
for(const priorSuccess of [true,false]) {
const f=sharedPage();
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
assert.equal(f.requests.length,calls);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
f.requests.at(-1).resolve(sample());await fresh;
assert.equal(f.element('metric-battery').textContent,'99%');
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
}
});
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
f.ui.status(reading,Date.now()+1000);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.match(f.element('ccSampleState').textContent,/Status failure/);
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
});
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
f.requests[0].resolve(reading);await prior;
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.match(f.element('ccSampleState').textContent,/expired/);
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('metric-battery').textContent,'99%');
});
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
now+=91000;f.ui.status(reading);
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
f.ui.status({...sample(),time});
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
}
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
}
});
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
let reading=sample();const f=mounted({api:async(path,change,device)=>{
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
await f.element('ccMute').onclick();
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
await f.element('ccApplyVolume').onclick();
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
assert.equal(f.element('ccVolume').value,65);
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
});
test('live samples expire even when the status poll is paused', t => {
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
now+=91000;
// A connection update must also re-check freshness before enabling a control.
f.ui.connection(true);
assert.equal(f.element('ccApplyVolume').disabled,true);
});
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
const f=mounted();t.after(()=>f.ui.destroy());
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
assert.equal(f.element('ccResultCount').textContent,'1 section');
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
assert.equal(f.element('ccEmpty').hidden,false);
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
});
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
let activePage,scrolled;
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
if(!href.startsWith('#')||href==='#connection')continue;
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
assert.equal(activePage,expected[id],href);
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
}
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
assert.equal(vm.runInContext('PAGES[5]',context),'control');
});
+27 -4
View File
@@ -10,6 +10,7 @@ import atexit
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
@@ -22,7 +23,22 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
ENABLED = os.environ.get('FRAME_E2E') == '1'
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
# the container runtime's marker file): these tests drive ui/server.py with the real
# home, ~/.ssh and cache, which is only safe in a throwaway container.
REQUESTED = os.environ.get('FRAME_E2E') == '1'
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
ENABLED = REQUESTED and IN_CONTAINER
if ENABLED:
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
# database, not $HOME. (These tests used to get this through test_frame_apk.)
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
atexit.register(shutil.rmtree, _data, ignore_errors=True)
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
HOME = '/home/steamos'
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
def require():
"""Call at module level: skips the module unless the fake Frame is up."""
if not ENABLED:
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
"""Skips (the module, at module level; the class, in setUpClass) unless this is
scripts/e2e.sh's container with the fake Frame up."""
if ENABLED:
return
if REQUESTED:
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
# ---- the fake Frame's control port --------------------------------------------
@@ -97,6 +118,7 @@ class Server:
@classmethod
def start(cls):
require() # never a server on this computer's real data
if cls.proc and cls.proc.poll() is None:
return
with socket.socket() as s:
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
require()
Server.start()
def setUp(self):
+2
View File
@@ -6,6 +6,8 @@ import sys
import harness
from harness import api, ok, finished, ssh
harness.require()
sys.path.insert(0, str(harness.ROOT / 'ui'))
import frame_mcp
+1 -1
View File
@@ -5,7 +5,7 @@ import unittest
import harness
from harness import HOME, exists, ok, state, upload, wait_for
from test_frame_apk import apk, manifest, resources
from apk_fixtures import apk, manifest, resources
harness.require()
+1
View File
@@ -43,6 +43,7 @@ services:
FAKEFRAME_CTL: http://fakeframe:9999
FAKEFRAME_HOST: fakeframe
FRAME_E2E: "1"
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
healthcheck:
test: ["CMD", "test", "-f", "/home/tester/.ready"]
interval: 1s
+96 -3
View File
@@ -3,10 +3,20 @@
each step of a connection, and plays a ControlMaster. What each HostName does comes
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
"slow" (hangs after connecting);
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only.
With $FAKESSH_PINS set, host keys are checked as OpenSSH does against UserKnownHostsFile under
HostKeyAlias: "wrong" is then just a different key from the others' (a reinstalled headset),
StrictHostKeyChecking=yes refuses a key not saved, accept-new saves a first one, and a saved key
that differs is refused. "evil" is a device with its own key that lets anyone in without
authentication ("none") and asks for the agent: it records in $FAKESSH_DIR what it got
(agent-forwarded, command-ran). Like ssh, the first value of an option counts."""
import base64
import hashlib
import json
import os
import signal
import subprocess
import sys
import time
@@ -17,12 +27,12 @@ hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
opts = {}
i = 0
while i < len(args) and args[i].startswith("-"):
if args[i] in ("-o", "-O", "-p", "-l"):
if args[i] in ("-o", "-O", "-p", "-l", "-F", "-W"):
key = args[i]
val = args[i + 1]
if key == "-o":
k, _, v = val.partition("=")
opts[k.lower()] = v
opts.setdefault(k.lower(), v)
else:
opts[key] = val
i += 2
@@ -30,6 +40,12 @@ while i < len(args) and args[i].startswith("-"):
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
command = args[i + 1:]
if opts.get("-F") != "none":
# $FAKESSH_CONFIG: what ~/.ssh/config says (JSON, lower-case option -> value), below the
# command line's options, as ssh has it; `-F none` reads no config.
for k, v in json.loads(os.environ.get("FAKESSH_CONFIG", "{}")).items():
opts.setdefault(k, v)
host = opts.get("hostname", alias).replace("%%", "%")
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
@@ -44,6 +60,23 @@ if opts.get("-O") == "exit":
os.unlink(marker)
sys.exit(0)
if "-W" in opts: # a jump host's ssh, forwarding to the headset: the call is logged, that's all
sys.exit(0)
if opts.get("proxycommand", "none") != "none":
# As OpenSSH: expand %-tokens, then `$SHELL -c "exec <line>"` (here /bin/sh).
line, out, n = opts["proxycommand"], "", 0
while n < len(line):
if line[n] == "%" and n + 1 < len(line):
out += {"%": "%", "h": host, "p": opts.get("port", "22"), "r": opts.get("user", "")}.get(
line[n + 1], line[n:n + 2])
n += 2
else:
out += line[n]
n += 1
if subprocess.run(["/bin/sh", "-c", "exec " + out], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL).returncode:
say("kex_exchange_identification: Connection closed by remote host")
sys.exit(255)
what = hosts.get(host)
if what is None:
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
@@ -53,6 +86,66 @@ say("debug1: Connection established.")
if what == "slow":
time.sleep(30)
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
if os.environ.get("FAKESSH_PINS") and "hostkeyalias" in opts and "userknownhostsfile" in opts:
name, path = opts["hostkeyalias"], os.path.expanduser(opts["userknownhostsfile"])
blob = base64.b64encode(b"fake-" + {"wrong": b"other", "evil": b"evil"}.get(what, b"headset") + b"-key").decode()
fp = base64.b64encode(hashlib.sha256(base64.b64decode(blob)).digest()).decode().rstrip("=")
say(f"debug1: Server host key: ssh-ed25519 SHA256:{fp}")
try:
saved = [ln.split() for ln in open(path, errors="replace").read().splitlines() if ln.strip()]
except OSError:
saved = []
saved = [f[2] for f in saved if len(f) >= 3 and name in f[0].split(",")]
strict = opts.get("stricthostkeychecking", "ask")
if saved and blob not in saved:
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
say("Host key verification failed.")
sys.exit(255)
if not saved and strict != "accept-new":
say(f"No ED25519 host key is known for {name} and you have requested strict checking.")
say("Host key verification failed.")
sys.exit(255)
if not saved:
try:
with open(path, "a") as f:
f.write(f"{name} ssh-ed25519 {blob}\n")
say(f"Warning: Permanently added '{name}' (ED25519) to the list of known hosts.")
except OSError: # ssh doesn't create the folder: it goes on, saving nothing
say(f"Failed to add the host to the list of known hosts ({path}).")
else:
say(f"debug1: Host '{name}' is known and matches the ED25519 host key.")
say("debug1: Next authentication method: publickey")
if what == "evil":
say(f'Authenticated to {host} ([127.0.0.1]:22) using "none".')
got = os.path.join(os.environ["FAKESSH_DIR"], "evil-")
if opts.get("forwardagent", "no") != "no" and opts.get("identityagent", "") != "none":
open(got + "agent-forwarded", "w").close()
if "-N" not in opts and (command or opts.get("remotecommand", "none") != "none"):
open(got + "command-ran", "w").close()
sys.exit(0)
time.sleep(30) # -N: holds the session open
sys.exit(0)
if opts.get("preferredauthentications") == "none":
say(f"tester@{host}: Permission denied (publickey,password).")
sys.exit(255)
if what == "denied":
say(f"tester@{host}: Permission denied (publickey).")
sys.exit(255)
what = "ok"
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
if opts.get("controlmaster") != "yes":
sys.exit(0)
open(marker, "w").close()
def bye(*_):
if os.path.exists(marker):
os.unlink(marker)
sys.exit(0)
signal.signal(signal.SIGTERM, bye)
while True:
time.sleep(0.2)
if not os.path.exists(marker):
sys.exit(0)
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
if what == "wrong":
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
+31 -1
View File
@@ -1,12 +1,20 @@
"""Imported first by every test module: nothing a test does reaches this person's
app data, their telemetry, or the shared compatibility database.
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
Must run before any ui module is imported, since those read these at import time.
The variables are set in os.environ, so servers and scripts the tests start
inherit them too (they pass {**os.environ, ...}).
"""
import atexit
import os
import shutil
import tempfile
from pathlib import Path
# The environment and real ~/.ssh from before the changes below: test_sandbox
# reruns tests from that environment and checks ~/.ssh is left alone.
ORIGINAL_ENV = dict(os.environ)
REAL_SSH_DIR = Path.home() / ".ssh"
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
# into the real ~/.ssh/frame-control-hosts on every run.
#
# tests/e2e must not import this (directly or through a test_* module): they run in
# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
# need live in sandbox-free modules such as apk_fixtures.py.
HOME = os.path.join(_dir, "home")
SSH_DIR = os.path.join(HOME, ".ssh")
os.makedirs(SSH_DIR, mode=0o700)
os.environ["HOME"] = HOME
os.environ["USERPROFILE"] = HOME # Path.home() on Windows
os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
+1
View File
@@ -1,4 +1,5 @@
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+10 -3
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
@@ -18,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
_images._cache.clear()
_images._dns.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
@@ -70,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
with patch.object(_images, 'MAX_TOKENS', 50):
first = _images.register('https://example.com/0.png')
for i in range(1, 60):
_images.register('https://example.com/%d.png' % i)
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
+2 -1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
@@ -121,7 +122,7 @@ class SearchTests(SettingsTest):
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'], ('second', 50))
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
+45
View File
@@ -1,4 +1,5 @@
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import json
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
self.assertIsNone(status.activity_level())
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
self.assertEqual(status.activity_level(), 3)
class DesktopBundleTests(unittest.TestCase):
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
def test_sources_are_sent_once_per_version(self):
import server
with tempfile.TemporaryDirectory() as tmp:
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
here.mkdir()
home.mkdir()
for name in server.COMFORT_FILES:
(here / name).write_text('# ' + name + '\n')
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
sent = []
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
sent.append(stdin)
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
env={**os.environ, 'HOME': str(home)}).stdout
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
first = server.comfort({'action': 'status'})
self.assertEqual(json.loads(first['got']), {'action': 'status'})
self.assertEqual(len(sent), 2) # the check, then the sources
self.assertNotIn('# frame_steam.py', sent[0])
self.assertIn('# frame_steam.py', sent[1])
self.assertEqual(server.comfort({'action': 'status'}), first)
self.assertEqual(len(sent), 3)
self.assertNotIn('# frame_steam.py', sent[2])
cached, = (home / '.cache/frame-control/comfort').iterdir()
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
# A copy cut short is completed; a changed helper is a new version.
(cached / 'frame_status.py').unlink()
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 5)
self.assertTrue((cached / 'frame_status.py').exists())
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 7)
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
+30
View File
@@ -1,4 +1,5 @@
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import shutil
import subprocess
@@ -58,3 +59,32 @@ const log = ()=>{}, toast = ()=>{};
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_poll_waits_while_the_frame_is_unreachable(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
start = page.index('async function pollComfort()')
code = page[start:page.index('$("comfortForm").onsubmit', start)]
js = r'''
const assert = require('node:assert/strict');
const el = {textContent: 'Checking session…'};
const $ = () => el;
let online = false, comfortBusy = false, calls = 0, timers = 0;
const api = async () => { calls++; return {}; };
const renderComfort = () => {};
const setTimeout = () => { timers++; };
''' + code + r'''
(async () => {
await pollComfort();
assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
assert.match(el.textContent, /when the Frame is connected/);
assert.equal(timers, 1); // still scheduled, to resume when it's back
online = true;
await pollComfort();
assert.equal(calls, 1);
online = null; // a status check that failed for another reason: keep polling
await pollComfort();
assert.equal(calls, 2);
})().catch(e => { console.error(e); process.exitCode = 1; });
'''
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+89
View File
@@ -0,0 +1,89 @@
"""Control Center API guards and shared-page behavior, without a real headset."""
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import sys
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import server
import frame_status
class AudioValidation(unittest.TestCase):
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
for body in invalid:
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
with self.assertRaises(server.Failure) as raised:
server.set_volume(body)
self.assertEqual(raised.exception.status, 400)
ssh.assert_not_called()
def test_supported_actions_use_wpctl_with_exact_values(self):
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
def test_device_error_propagates_without_claiming_success(self):
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
server.set_volume({'level': .5})
class Coverage(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
def test_control_center_behavior(self):
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_shared_assets_ship_to_iphone_and_desktop(self):
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
files = set(bundle.files())
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
self.assertIn(ROOT / 'ui' / name, files)
config = json.loads((ROOT / 'app/package.json').read_text())
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
self.assertIn('*.js', ui['filter'])
self.assertIn('*.css', ui['filter'])
def test_bundle_runs_the_server_on_its_own(self):
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
import io, tarfile, tempfile
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
tar.extractall(tmp)
result = subprocess.run([sys.executable, '-I', '-B', '-c',
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
cwd=tmp, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
# Run the real aggregator with every external probe stubbed.
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
probes['read'].return_value = '120 0'
probes['process_names'].return_value = set()
result = frame_status.status()
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
+24
View File
@@ -264,6 +264,30 @@ class Pins(Base):
self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("d1"))
def test_a_trusted_key_replaces_the_saved_one_and_fingerprints_match_ssh_keygen(self):
key = self.dir / "hostkey"
frame_host.run_ssh(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=20)
kind, blob = key.with_suffix(".pub").read_text().split()[:2]
want = frame_host.run_ssh(["ssh-keygen", "-l", "-E", "sha256", "-f", str(key.with_suffix(".pub"))],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=10).stdout.split()[1]
self.assertEqual(fd.fingerprint(blob), want)
self.assertIsNone(fd.fingerprint("not base64!"))
(self.ssh / "known_hosts").write_text(f"frame.local {KEY}\n")
fd.seed_pin("d1", ["frame.local"])
old = fd.pinned_keys("d1")
fd.pin_key("d1", kind, blob)
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {kind} {blob}\n") # old key gone
self.assertEqual(fd.pinned_keys("d1"), [(kind, want)])
self.assertNotEqual(old, fd.pinned_keys("d1"))
self.assertTrue(fd.pinned("d1"))
for bad in (("ssh-ed25519\nHost *", blob), ("ssh-ed25519", "AAAA AAAA"), ("evil", blob)):
with self.assertRaises(fd.DeviceError):
fd.pin_key("d1", *bad)
# Only plain entries for this headset's alias count.
self.assertIsNone(fd.key_entry(f"frame-control-d2 {kind} {blob}", "frame-control-d1"))
self.assertIsNone(fd.key_entry(f"# frame-control-d1 {kind} {blob}", "frame-control-d1"))
def test_each_headset_has_its_own_file(self):
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
fd.seed_pin("da", ["a.local"])
+1
View File
@@ -1,4 +1,5 @@
"""Offline authenticated repository fixtures; no tests contact a server."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+67 -2
View File
@@ -1,4 +1,5 @@
"""Offline artwork, Steam API and launcher supervision regressions."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import importlib.util
import json
import os
@@ -121,6 +122,11 @@ FIXTURES = ROOT / 'tests/fixtures/library'
class ArtworkTests(unittest.TestCase):
def setUp(self):
from apk_sources import _images
_images._dns.clear() # each test answers lookups its own way
self.addCleanup(_images._dns.clear)
def test_source_inputs_and_url(self):
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
@@ -263,11 +269,14 @@ class ArtworkTests(unittest.TestCase):
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
import frame_steamgriddb as sgdb
from apk_sources import _images
deadline = time.monotonic() + 5
with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
self.assertEqual(sgdb._get('/search/x', 'secret', deadline), [1])
self.assertEqual(get.call_args.kwargs['redirects'], 0)
self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
# The caller's deadline is passed on, never extended. (Comparing against a fresh
# time.monotonic() flaked on Windows, whose clock ticks coarsely.)
self.assertLessEqual(get.call_args.kwargs['deadline'], deadline)
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
@@ -419,6 +428,19 @@ class InstallTests(unittest.TestCase):
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
patch.object(android, 'ssh', return_value='') as ssh, \
patch.object(android, 'shortcut_tool') as api:
with self.assertRaisesRegex(android.FrameError, 'timed out'):
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
commands = [c.args[0] for c in ssh.call_args_list]
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
api.assert_not_called()
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
patch.object(android, 'stop'), \
@@ -482,6 +504,49 @@ class InstallTests(unittest.TestCase):
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
class CopyAndSshTests(unittest.TestCase):
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
gets the time it needs."""
def fake(self, stderr, code=1, stdout=b''):
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
f'sys.exit({code})')
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
def test_large_apk_copy_timeout_grows_with_its_size(self):
seen = {}
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
android._copy(f.name, 'Applications/Android/x/app.apk.part')
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
android._copy(f.name, 'x/launch.sh', timeout=120)
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
# A message from a Windows code page, or a file name, isn't UTF-8.
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.shutil, 'which', return_value=None), \
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
android._copy(f.name, 'x/app.apk.part')
def test_ssh_failure_messages_are_text(self):
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
android.ssh('mv a b')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
with self.assertRaises(android.FrameError) as caught:
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
+15 -76
View File
@@ -11,82 +11,7 @@ import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_apk # noqa: E402
def pool(strings, utf8=False):
"""A ResStringPool chunk."""
data, offsets = b'', []
for s in strings:
offsets.append(len(data))
if utf8:
b = s.encode()
data += bytes([len(s), len(b)]) + b + b'\0'
else:
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
data += b'\0' * (-len(data) % 4)
start = 28 + 4 * len(strings)
body = struct.pack(f'<{len(strings)}I', *offsets) + data
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
package_raw=False drops the package's raw string (as some repackers do);
foreign_label adds a non-android `label` attribute after android:label.
"""
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
def element(name, attrs):
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
for aname, raw, dtype, value in attrs:
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
none = 0xffffffff
chunks = (pool(strings) + resmap
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
(2, none, frame_apk.T_REF, version_ref),
(11, none, frame_apk.T_INT_DEC, 210)])
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def resources(values):
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
pkg_body = b''
for (tid, lang, density), entries in values.items():
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
count = max(entries) + 1
offsets, data = [], b''
for i in range(count):
if i in entries:
offsets.append(len(data))
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
else:
offsets.append(0xffffffff)
header = 20 + 64
estart = header + 4 * count
body = struct.pack(f'<{count}I', *offsets) + data
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
pkg = pkg_header + pkg_body
table = pool(strings, utf8=True) + pkg
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
def apk(files):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
for name, data in files.items():
z.writestr(name, data)
return buf.getvalue()
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
class ApkInfo(unittest.TestCase):
@@ -168,6 +93,20 @@ class ApkInfo(unittest.TestCase):
frame_apk.MAX_MANIFEST = limit
self.assertLess(peak, 8 * 1024**2)
def test_damaged_download_is_an_apk_error(self):
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
# (a 500, and no install report) and frame_android.install reported as 'other'.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
data = bytearray(buf.getvalue())
at = data.index(b'PK\x03\x04')
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
for i in range(start, start + 40):
data[i] ^= 0x5a
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
self.read(bytes(data))
def test_refuses_compression_android_cant_read(self):
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
buf = io.BytesIO()
+2 -1
View File
@@ -1,4 +1,5 @@
"""Local-only VR manifest, raw ZIP and independent signature checks."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import os
from pathlib import Path
@@ -15,7 +16,7 @@ import frame_apk
import frame_apk_vr as vr
import frame_apk_sign as signing
import frame_android
from test_frame_apk import pool
from apk_fixtures import pool
DEFAULT = 'android.intent.category.DEFAULT'
+1
View File
@@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*.
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
+1
View File
@@ -1,4 +1,5 @@
"""Every public sideload entry point reaches the mandatory five-slot writer."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import contextlib
import io
import json
+444 -5
View File
@@ -9,6 +9,7 @@ import http.client
import io
import json
import os
import shlex
import shutil
import socket
import subprocess
@@ -45,6 +46,17 @@ def explain(msg):
return None
class FailureDetail(unittest.TestCase):
def test_an_address_that_failed_says_why_in_a_few_words(self):
# #87: the banner put each address's detail after the headline, and with the whole
# message as the detail it said everything twice ("...may be answering here..").
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
"SSH identity changed")
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
"The connection to the Frame dropped. Try again.")
self.assertEqual(fl.failure_detail(None), "SSH failed")
class Probe(unittest.TestCase):
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
@@ -199,7 +211,8 @@ class Connecting(unittest.TestCase):
rows = {p["host"]: p for p in s["probes"]}
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(rows["::1"]["state"], "sshfailed")
self.assertIn("different headset", rows["::1"]["detail"])
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
# Every ssh command was pointed at the winner, with the host key pinned per device.
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-t")
@@ -208,9 +221,10 @@ class Connecting(unittest.TestCase):
self.assertIn(f"Port={self.port}", opts)
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
self.assertNotIn("StrictHostKeyChecking=yes", master)
# This stand-in ssh saved no key, so commands may still save the first one (accept-new);
# test_a_first_connection_pins_and_then_checks_strictly has real pins.
self.assertIn("StrictHostKeyChecking=accept-new", opts)
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
# It learned: 127.0.0.1 works on this network.
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
@@ -257,6 +271,9 @@ class Connecting(unittest.TestCase):
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
self.assertIn("SSH key", s["error"]["message"])
self.assertEqual(s["error"]["auth"], "denied") # the page offers Set Up Connection
self.assertEqual(s["error"]["short"], "This computer's key refused") # the header pill's words (#87)
self.assertEqual(s["probes"][0]["detail"], "This computer's key refused")
def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost")
@@ -266,6 +283,428 @@ class Connecting(unittest.TestCase):
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=yes", master)
def pins(self):
"""The stand-in ssh checks host keys as OpenSSH does (tests/fakessh/ssh)."""
p = mock.patch.dict(os.environ, {"FAKESSH_PINS": "1"})
p.start()
self.addCleanup(p.stop)
def command(self, device, host="127.0.0.1"):
"""One ssh command as the server runs it, with the route the connector applied."""
return subprocess.run(["ssh", *self.link.host_opts(device, host), device["alias"], "true"],
capture_output=True, text=True, timeout=20)
def test_a_first_connection_pins_and_then_checks_strictly(self):
# 0.4.2 on Windows: "No ED25519 host key is known for frame-control-<id> and you have
# requested strict checking." Every command said "yes" before anything was saved, so one
# that ran before the connector's handshake saved the key (or without it) failed.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
r = self.command(d)
self.assertEqual(r.returncode, 0, r.stderr) # the first contact saves the key...
self.assertIn("Permanently added", r.stderr)
self.assertTrue(fd.pinned(d["id"]))
self.assertIn("StrictHostKeyChecking=yes", self.link.host_opts(d, "127.0.0.1")) # ...then it's checked
self.assertEqual(self.reg.get(d["id"])["identity"], [k[1] for k in fd.pinned_keys(d["id"])]) # and recorded
# The connector's first handshake, for another new headset.
self.reg.remove_device(d["id"])
d = self.device("localhost")
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(fd.pinned(d["id"]))
self.assertTrue(self.reg.get(d["id"])["identity"])
alias, opts = self.routes[-1]
self.assertIn("StrictHostKeyChecking=yes", opts) # every command after it checks strictly
self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
def lose_pin(self, device_id, how):
f = fd.known_hosts(device_id)
if how == "deleted":
f.unlink()
elif how == "empty":
f.write_text("")
elif how == "unreadable":
f.chmod(0)
self.addCleanup(lambda: f.exists() and f.chmod(0o600))
elif how == "another alias":
f.write_text(f.read_text().replace(f"frame-control-{device_id}", "frame-control-someone-else"))
elif how == "not utf-8":
f.write_bytes(b"\xff\xfe" + f.read_bytes())
def test_a_lost_or_unusable_pin_fails_closed(self):
# Review pr85-review-r1: accept-new can't tell a new headset from one whose saved key
# went missing; real OpenSSH then trusted a replacement key. Once a headset had an
# identity, nothing is trusted again until the person checks it.
self.pins()
cases = ["deleted", "empty", "another alias", "not utf-8"]
if os.name == "posix" and os.geteuid() != 0:
cases.append("unreadable") # root reads anything
for n, how in enumerate(cases):
with self.subTest(how=how):
self.hosts({"localhost": "ok"})
d = self.reg.add_device(f"frame-l{n}", port=self.port, hosts=[])
self.reg.add_address(d["id"], "localhost", kind="lan")
self.link.override = None
self.reg.set_active(d["id"])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.link.close_master()
d = self.reg.get(d["id"])
self.lose_pin(d["id"], how)
self.hosts({"localhost": "wrong"}) # another device, or a reinstalled headset
r = self.command(d)
self.assertEqual(r.returncode, 255, r.stderr)
self.assertIn("Host key verification failed", r.stderr)
self.assertNotIn("Permanently added", r.stderr)
self.link.connect(["dropped"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
self.assertIn("missing or can't be read", s["error"]["message"])
self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
self.assertTrue(view["identity_lost"])
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertFalse(scan["matches"] or scan["recorded_match"]) # not the key it had
self.hosts({"localhost": "ok"})
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertTrue(scan["recorded_match"]) # its own key, as recorded
self.assertFalse(scan["matches"]) # but not usable from the file: trusting restores it
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
self.link.close_master()
def test_a_key_check_only_reads_the_key(self):
# Review pr85-review-r1: with ForwardAgent yes in ~/.ssh/config, a device that accepts
# "none" got the agent and ran a command before anyone trusted it.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "evil"})
os.environ["FAKESSH_CONFIG"] = json.dumps({"forwardagent": "yes", "remotecommand": "touch /tmp/x",
"permitlocalcommand": "yes", "requesttty": "force"})
self.addCleanup(os.environ.pop, "FAKESSH_CONFIG", None)
for proxied in (False, True):
route = ("jump", "me@jumpy:2222") if proxied else (None, None)
with self.subTest(proxied=proxied), mock.patch.object(fl, "proxy_route", return_value=route):
t0 = time.monotonic()
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertLess(time.monotonic() - t0, 10) # stopped once it had the key, not left open
self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
self.assertEqual([p.name for p in self.dir.iterdir() if p.name.startswith("evil-")], [])
call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1]
self.assertEqual(call[call.index("-F") + 1], "none") # never ~/.ssh/config for the device
self.assertIn("-N", call)
hop = [c for c in call if c.startswith("ProxyCommand=")]
if proxied:
# Review pr85-review-r2: ProxyJump's own ssh inherits the config (ForwardAgent,
# LocalCommand); the scan builds the jump host's ssh itself, hardened.
self.assertEqual(len(hop), 1)
words = shlex.split(hop[0][len("ProxyCommand="):])
self.assertEqual(words[-7:], ["-l", "me", "-p", "2222", "-W", "[%h]:%p", "jumpy"])
for o in fl.Link.HOP_OPTS:
self.assertIn(o, words)
self.assertLess(call.index(hop[0]), call.index("HostKeyAlias=frame-control-" + d["id"]))
else:
self.assertEqual(hop, [])
for o in ("ForwardAgent=no", "IdentityAgent=none", "ClearAllForwardings=yes", "RemoteCommand=none",
"PermitLocalCommand=no", "RequestTTY=no", "PubkeyAuthentication=no"):
self.assertIn(o, call)
self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("HostKeyAlias=frame-control-"
+ d["id"]))
self.assertFalse(fd.pinned(d["id"]))
self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), []) # no scan files
@unittest.skipIf(os.name != "posix", "the stand-in ssh runs ProxyCommand with /bin/sh")
def test_the_jump_hosts_command_line_is_quoted_whatever_the_names(self):
# Review pr85-review-r3: the hop's words went into the ProxyCommand line unquoted, so an
# ssh at /tmp/x/bin$(:>INJECTED)/ssh ran shell code; and an IPv6 jump host broke.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
for n, name in enumerate(["with space", "bin$(touch INJECTED-0)", "back`touch INJECTED-1`tick",
"quote'it\"s", "per%cent %h %%", "semi;touch INJECTED-2"]):
with self.subTest(name=name):
bindir = self.dir / f"b{n}" / name
bindir.mkdir(parents=True)
(bindir / "ssh").symlink_to(FAKESSH / "ssh")
for spec, at in (("me@jumpy:2222", "jumpy"), ("[::1]:2200", "::1")):
before = len(self.calls())
with mock.patch.dict(os.environ, {"PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}"}), \
mock.patch.object(fl, "proxy_route", return_value=("jump", spec)):
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
self.assertTrue(scan["fingerprint"].startswith("SHA256:"))
hops = [c for c in self.calls()[before:] if "-W" in c]
self.assertEqual(len(hops), 1, self.calls()[before:])
self.assertEqual(hops[0][-1], at) # the jump host, bare
self.assertEqual(hops[0][hops[0].index("-W") + 1], "[localhost]:%d" % self.port)
outer = [c for c in self.calls()[before:] if "PreferredAuthentications=none" in c][-1]
line = next(c for c in outer if c.startswith("ProxyCommand="))
self.assertTrue(line[len("ProxyCommand="):].startswith(shlex.quote(str(bindir / "ssh")
.replace("%", "%%"))))
self.assertEqual([p.name for p in self.dir.rglob("INJECTED*")], [])
self.assertFalse(list(Path.cwd().glob("INJECTED*")))
def test_a_key_check_refuses_routes_it_cant_harden(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
before = len(self.calls())
for route in (("command", "nc %h %p"), ("jump", "a,b"), ("jump", "-oProxyCommand=x"), ("jump", "ssh://a"),
("jump", "a b"), ("jump", "a%h"), ("jump", "jumpy:99999")):
with self.subTest(route=route), mock.patch.object(fl, "proxy_route", return_value=route):
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertIn("can't make safe", str(caught.exception))
self.assertFalse(any("PreferredAuthentications=none" in c for c in self.calls()[before:]))
def test_a_key_read_before_the_address_or_port_changed_isnt_trusted(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
for change in ({"action": "update", "id": d["id"], "port": self.port + 1},
{"action": "address-remove", "id": d["id"], "host": "localhost"}):
with self.subTest(change=change["action"]):
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
fl.devices_action(self.link, change, None)
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": scan["fingerprint"]}, None)
self.assertFalse(fd.pinned(d["id"]))
self.reg.update_device(d["id"], port=self.port)
if not self.reg.get(d["id"])["addresses"]:
self.reg.add_address(d["id"], "localhost", kind="lan")
def test_terminals_judge_identity_by_the_registry_not_a_frozen_route(self):
# Review pr85-review-r2: named_route used the device as routed (before its first key was
# recorded), so a Terminal window fell back to accept-new once the pin was deleted.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.assertFalse(self.link.routed_device.get("identity")) # the frozen copy knows nothing of it
fd.known_hosts(d["id"]).unlink()
alias, opts = self.link.named_route()
self.assertIn("StrictHostKeyChecking=yes", opts)
self.assertNotIn("StrictHostKeyChecking=accept-new", opts)
self.hosts({"localhost": "wrong"})
r = subprocess.run(["ssh", *opts, alias, "true"], capture_output=True, text=True, timeout=20)
self.assertEqual(r.returncode, 255, r.stderr)
self.assertFalse(fd.pinned(d["id"]))
def test_a_key_saved_before_a_refused_login_is_kept_and_checked(self):
# Review pr85-review-r2: ssh saves the key during the key exchange; if the login is then
# refused, it was neither recorded nor protected, and the route stayed accept-new.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
got = self.reg.get(d["id"])
self.assertEqual(got["identity"], [f for _, f in fd.pinned_keys(d["id"])])
self.assertTrue(got["identity_established"])
if os.name == "posix":
self.assertEqual(fd.known_hosts(d["id"]).stat().st_mode & 0o777, 0o600)
self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
fd.known_hosts(d["id"]).unlink()
self.hosts({"localhost": "wrong"})
r = self.command(self.reg.get(d["id"]))
self.assertEqual(r.returncode, 255)
self.assertNotIn("Permanently added", r.stderr)
def test_a_key_a_command_saved_tightens_the_route(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "denied"})
with self.link.route_lock:
self.link.routed_device = d
self.link.apply(d["alias"], self.link.host_opts(d, "127.0.0.1"))
opts = self.routes[-1][1]
self.assertIn("StrictHostKeyChecking=accept-new", opts)
r = subprocess.run(["ssh", *opts, d["alias"], "true"], capture_output=True, text=True, timeout=20)
self.assertIn("Permanently added", r.stderr)
self.assertEqual(r.returncode, 255) # saved the key, then refused the login
self.link.settle_pin() # what the connector's loop does every tick while accept-new is applied
self.assertIn("StrictHostKeyChecking=yes", self.routes[-1][1])
self.assertNotIn("StrictHostKeyChecking=accept-new", self.routes[-1][1])
self.assertTrue(self.reg.get(d["id"])["identity"])
def test_editing_the_address_that_worked_keeps_the_identity_history(self):
# Review pr85-review-r2: last_ok lives on the address, so editing or removing it made a
# headset whose pin was gone (and had no fingerprint recorded) new again.
path = self.dir / "legacy.json"
path.write_text(json.dumps({"version": 1, "active": "abcd1234", "networks": {}, "devices": [
{"id": "abcd1234", "name": "Old", "alias": "frame-old", "user": "steamos", "port": 22,
"identity_files": [], "addresses": [{"host": "10.0.0.5", "kind": "lan", "label": "",
"networks": [], "last_ok": 1790000000.0, "last_rtt_ms": 3}]}]}))
reg = fd.Registry(path)
self.assertTrue(reg.get("abcd1234")["identity_established"]) # migrated from last_ok
reg.update_address("abcd1234", "10.0.0.5", new_host="10.0.0.6")
reg.remove_address("abcd1234", "10.0.0.6")
reg.add_address("abcd1234", "10.0.0.7")
d = fd.Registry(path).get("abcd1234") # saved, too
self.assertFalse(any(a.get("last_ok") for a in d["addresses"]))
self.assertTrue(fd.identity_established(d))
self.assertEqual(self.link.strictness(d), "yes")
# A connection that works sets it as well, for a headset added since.
n = self.reg.add_device("frame-new", hosts=["10.0.0.8"])
self.reg.record_success(n["id"], "10.0.0.8", None, 1)
self.reg.remove_address(n["id"], "10.0.0.8")
self.assertTrue(self.reg.get(n["id"])["identity_established"])
def test_ssh_last_words_arrive_even_when_the_reader_lags(self):
# Review pr85-review-r2: ssh's exit with nothing queued yet was taken as the end, and a
# late reader lost "Permission denied" ("ssh stopped").
self.pins()
link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
apply=lambda *a: None, explain=explain)
self.addCleanup(link.stop)
real = threading.Thread
class Late(real):
def __init__(self, *a, target=None, **kw):
if target is not None and getattr(target, "__name__", "") == "tail":
inner = target
def target():
time.sleep(1)
inner()
super().__init__(*a, target=target, **kw)
self.device("localhost")
self.hosts({"localhost": "denied"})
with mock.patch.object(fl.threading, "Thread", Late):
link.connect(["start"])
s = link.snapshot()
self.assertEqual((s["error"]["stage"], s["error"].get("auth")), ("login", "denied"), s["error"])
self.assertIn("SSH key", s["error"]["message"])
def test_forgetting_an_identity_is_refused(self):
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
saved = fd.known_hosts(d["id"]).read_text()
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "forget-identity", "id": d["id"]}, None)
self.assertIn("identity-scan", str(caught.exception))
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved)
def test_without_a_shared_connection_ssh_writes_to_a_file_not_a_pipe(self):
# Windows: no ControlMaster, and its OpenSSH can block writing to a stderr pipe.
self.pins()
tmp = self.dir / "tmp"
tmp.mkdir()
link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes"], control=None,
apply=lambda *a: None, explain=explain)
self.addCleanup(link.stop)
real = subprocess.Popen
seen = []
def popen(argv, **kw):
if "-v" in argv: # the handshake (run_ssh calls go through Popen too)
seen.append(kw.get("stderr"))
return real(argv, **kw)
with mock.patch.object(tempfile, "tempdir", str(tmp)), mock.patch.object(fl.subprocess, "Popen", popen):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
link.connect(["start"])
s = link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
self.assertIn("saved this headset's identity", next(st for st in s["stages"] if st["id"] == "identity")["detail"])
self.reg.update_device(d["id"], user="nobody")
self.hosts({"localhost": "denied"})
link.connect(["switch"])
s = link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"], s["error"].get("auth")), ("failed", "login", "denied"))
self.assertTrue(seen and all(isinstance(x, int) for x in seen)) # a file descriptor, never PIPE
self.assertNotIn(subprocess.PIPE, seen)
for _ in range(50): # the reader removes its file once ssh has exited
if not list(tmp.iterdir()):
break
time.sleep(0.05)
self.assertEqual(list(tmp.iterdir()), [])
def test_a_changed_key_is_shown_never_accepted_and_trusted_on_request(self):
# 0.4.2 on Linux and Windows: "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!", with
# "Run Set Up Connection again" as the advice, which can't replace the saved key.
self.pins()
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.assertEqual(self.link.snapshot()["phase"], "connected")
saved = fd.known_hosts(d["id"]).read_text()
old = fd.pinned_keys(d["id"])
self.link.close_master()
self.hosts({"localhost": "wrong"}) # SteamOS reinstalled: a new host key
self.link.connect(["dropped"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
shown = s["error"]["hostkey"]
self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
self.assertIn("Devices tab", s["error"]["message"])
self.assertEqual(s["error"]["short"], "SSH identity changed")
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
r = self.command(d)
self.assertEqual(r.returncode, 255)
self.assertIn("REMOTE HOST IDENTIFICATION HAS CHANGED", r.stderr) # nor by any command
# Trusting needs the key read first, and exactly the fingerprint shown.
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
scan = out["scan"]
self.assertEqual(scan["fingerprint"], shown["fingerprint"])
self.assertFalse(scan["matches"])
self.assertEqual(scan["saved"], [{"type": t, "fingerprint": f} for t, f in old])
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # reading it saves nothing
self.assertEqual(sorted(p.name for p in fd.known_hosts(d["id"]).parent.iterdir()), [d["id"]]) # no scan files
call = [c for c in self.calls() if "PreferredAuthentications=none" in c][-1] # no login offered
self.assertLess(call.index("StrictHostKeyChecking=accept-new"), call.index("StrictHostKeyChecking=yes"))
self.assertNotIn(f"UserKnownHostsFile={fd.known_hosts_opt(d['id'])}",
call[:call.index("StrictHostKeyChecking=yes")]) # a throwaway file, first
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:other"}, None)
self.link.scans[d["id"]]["at"] -= fl.SCAN_TTL + 1
with self.assertRaises(fd.DeviceError): # shown too long ago
fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
with mock.patch.object(self.link, "kick") as kick:
out = fl.devices_action(self.link, {"action": "identity-trust", "id": d["id"],
"fingerprint": shown["fingerprint"]}, None)
kick.assert_called_with("switch")
self.assertEqual(fd.pinned_keys(d["id"]), [("ssh-ed25519", shown["fingerprint"])]) # the old key is gone
self.assertEqual(next(x for x in out["devices"] if x["id"] == d["id"])["keys"],
[{"type": "ssh-ed25519", "fingerprint": shown["fingerprint"]}])
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "connected", self.link.snapshot()["error"])
out = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertTrue(out["scan"]["matches"])
def test_test_now_never_saves_a_key(self):
self.pins()
d = self.device("127.0.0.1")
self.hosts({"127.0.0.1": "ok"})
self.link.test(d["id"])
rows = self.link.snapshot()["tests"][d["id"]]["rows"]
self.assertEqual(rows[0]["ssh"], "unpinned")
self.assertFalse(fd.pinned(d["id"]))
def test_reading_a_key_from_a_headset_that_doesnt_answer_says_why(self):
self.pins()
d = self.device("nothing.invalid")
with self.assertRaises(fd.DeviceError) as caught:
fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)
self.assertIn("Can't find the Frame", str(caught.exception))
self.assertEqual([p.name for p in fd.known_hosts(d["id"]).parent.iterdir()], [])
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
@@ -501,7 +940,7 @@ class Connecting(unittest.TestCase):
{"action": "update", "id": d["id"], "port": 2222},
{"action": "address-remove", "id": d["id"], "host": "localhost"},
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
{"action": "forget-identity", "id": d["id"]}):
{"action": "identity-trust", "id": d["id"], "fingerprint": "SHA256:x"}):
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
# Renaming, or changing another headset, is fine.
+136
View File
@@ -0,0 +1,136 @@
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import re
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
def between(start, end):
return PAGE[PAGE.index(start):PAGE.index(end)]
SETUP = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
return elements.get(id);
};
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const serverNow = () => 1000;
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
const ago = () => 'a minute ago';
const openTrust = () => {};
const window = {};
let online = false;
const link = {live: true, s: null};
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
let rendered = 0;
const renderDevices = () => { rendered++; };
let refuse = false;
const act = async (label, fn) => refuse ? undefined : fn();
const api = async () => ({ok: true});
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
+ 'If not, a different device may be answering here.'}};
'''
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
class LinkUI(unittest.TestCase):
def run_page(self, checks):
code = (between("function netLabel(s) {", "function renderConnDlg() {")
+ between("async function useDevice(id) {", "async function devAction(")
+ between("function deviceStatus(d) {", "function renderDevices() {"))
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_the_pill_leads_with_the_reason(self):
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
# the reason comes first, in a few words, and only the countdown gets cut.
self.run_page(r'''
link.s = changed;
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
assert.match($('conn').innerHTML, /<span>Checking the headset&#39;s identity failed<\/span>/);
''')
def test_the_banner_never_doubles_a_full_stop(self):
# "…may be answering here.." and "…(Developer Mode must be on).." (#87).
self.run_page(r'''
link.s = changed;
renderBanner();
assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
link.s = {...changed, probes: [
{host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
{host: '100.64.0.2', detail: 'No answer'}]};
renderBanner();
const text = $('offDetail').textContent;
assert.ok(!/\.\./.test(text), text);
assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
''')
def test_the_devices_list_keeps_the_whole_message_within_reach(self):
self.run_page(r'''
link.s = changed;
const [dot, text, full] = deviceStatus({active: true, addresses: []});
assert.equal(dot, 'off');
assert.equal(text, 'SSH identity changed');
assert.equal(full, changed.error.message); // the row's tooltip
''')
def test_using_a_headset_selects_it(self):
# After "Use this headset" on Office Frame, the last headset's details stayed on show.
self.run_page(r'''
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
dv.sel = 'd0';
await useDevice('d1');
assert.equal(dv.sel, 'd1');
assert.equal(dv.found, null);
assert.equal(dv.editing, null);
assert.equal(rendered, 1);
// Refused (an install is running): the selection stays, and the header shows the one in use.
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
dv.sel = 'd0'; refuse = true;
await useDevice('d2');
assert.equal(dv.sel, 'd0');
assert.equal($('devSel').value, 'd0');
''')
class HeaderLayout(unittest.TestCase):
def test_the_devices_tab_explanation_wraps(self):
# It was cut to one line with no way to read the rest.
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
def test_the_header_buttons_dont_give_way(self):
# Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
header = between("<header>", "</header>")
end = header[header.index('<div class="hdr-end">'):]
for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
self.assertIn(el, end)
self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -7,6 +7,7 @@ input reaching the source, and closing.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import http.client
import json
+1
View File
@@ -1,4 +1,5 @@
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import argparse
import io
import json
+1
View File
@@ -1,4 +1,5 @@
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
from pathlib import Path
+122
View File
@@ -0,0 +1,122 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import re
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_connect # noqa: E402
import frame_devices # noqa: E402
import frame_host # noqa: E402
def inside(path, folder):
path, folder = os.path.realpath(path), os.path.realpath(folder)
return path == folder or path.startswith(folder + os.sep)
def snapshot(folder):
"""{relative path: (size, mtime)} for everything under FOLDER."""
out = {}
for base, dirs, files in os.walk(folder):
for name in dirs + files:
p = os.path.join(base, name)
try:
st = os.lstat(p)
except OSError:
continue
out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns)
return out
class Sandbox(unittest.TestCase):
"""Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every
run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config)."""
def test_home_and_ssh_dir_are_throwaway(self):
tmp = tempfile.gettempdir()
self.assertTrue(inside(Path.home(), tmp), Path.home())
self.assertTrue(inside(os.path.expanduser("~"), tmp))
for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR,
os.environ["FRAME_CONTROL_SSH_DIR"], frame_host.cache_dir(), frame_host.data_dir()):
self.assertTrue(inside(d, tmp), d)
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
def test_cache_folders_on_every_platform_are_throwaway(self):
"""cache_dir() reads LOCALAPPDATA on Windows and XDG_CACHE_HOME on Linux, which a
moved HOME alone doesn't cover: servers sweep staging folders there."""
tmp = tempfile.gettempdir()
for platform in ("MAC", "WINDOWS", None):
with mock.patch.object(frame_host, "MAC", platform == "MAC"), \
mock.patch.object(frame_host, "WINDOWS", platform == "WINDOWS"):
self.assertTrue(inside(frame_host.cache_dir("apk-sources"), tmp), platform)
for name in ("LOCALAPPDATA", "APPDATA", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
self.assertTrue(inside(os.environ[name], tmp), name)
def test_end_to_end_tests_stay_out_of_it(self):
"""tests/e2e keeps the container's real home (OpenSSH reads ~/.ssh from the password
database, not $HOME), so nothing there may import sandbox, even through a test_* module."""
here = Path(__file__).resolve().parent
pattern = re.compile(r"^\s*(import sandbox|from sandbox |import test_|from test_)", re.M)
for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]:
self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f)
def test_end_to_end_tests_start_no_server_outside_their_container(self):
"""Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything
and never start ui/server.py: outside scripts/e2e.sh's container it would run on
this computer's real app data, ~/.ssh and cache."""
e2e = str(Path(__file__).resolve().parent / "e2e")
code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n"
"harness.Server.start = classmethod(lambda cls: calls.append(cls))\n"
"suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n"
"r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n"
"print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))")
for extra in ({}, {"FRAME_E2E": "1"}):
env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")}
env.update(extra, PYTHONDONTWRITEBYTECODE="1")
out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True,
timeout=120)
self.assertEqual(out.returncode, 0, out.stderr[-2000:])
started, ran, skipped, broken = map(int, out.stdout.split())
self.assertEqual((started, broken), (0, 0), (extra, out.stdout))
self.assertEqual(ran, skipped, (extra, out.stdout))
self.assertGreater(skipped, 0, extra)
def test_child_processes_inherit_it(self):
out = subprocess.run(
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"
" from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)",
str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
capture_output=True, text=True, check=True).stdout.split("\n")
for d in filter(None, out):
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
self.assertTrue(inside(d, tempfile.gettempdir()), d)
def test_real_ssh_folder_untouched_by_tests_that_start_servers(self):
"""Reruns, from the environment the suite was started in, the tests that used to
leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect
tests, and checks nothing under the real ~/.ssh changed."""
real = sandbox.REAL_SSH_DIR
if inside(real, tempfile.gettempdir()):
self.skipTest("this run's HOME is already a temporary folder")
before = snapshot(real)
env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"}
env["PYTHONDONTWRITEBYTECODE"] = "1"
r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode",
"test_devices", "test_connect"],
cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True,
timeout=300)
self.assertEqual(r.returncode, 0, r.stderr[-3000:])
after = snapshot(real)
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)
and not k.startswith("agent")) # ssh-agent sockets come and go on their own
self.assertEqual(changed, [], f"tests changed the real {real}")
if __name__ == "__main__":
unittest.main()
+20 -1
View File
@@ -79,6 +79,21 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<html", payload.lower())
def test_control_center_assets_served_with_correct_types(self):
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
('/control-center.css', 'text/css', b'.cc-workspace')):
with self.subTest(path=path):
status, headers, payload = self.request('GET', path)
self.assertEqual(status, 200)
self.assertIn(mime, headers['Content-Type'])
self.assertIn(marker, payload)
def test_store_page_served_with_anti_framing_headers(self):
status, headers, payload = self.request("GET", "/store")
self.assertEqual(status, 200)
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
@@ -535,7 +550,11 @@ class UnreachableMessages(unittest.TestCase):
"ssh: connect to host 192.168.1.9 port 22: No route to host": "isn't answering",
"ssh: connect to host 192.168.1.9 port 22: Connection refused": "refused",
"steamos@192.168.1.9: Permission denied (publickey,password).": "SSH key",
"Host key verification failed.": "identity changed",
"Host key verification failed.": "identity has changed",
"@@@@\n@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\nHost key verification failed.":
"Check the new key on the Devices tab",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict checking.\n"
"Host key verification failed.": "isn't saved yet",
"kex_exchange_identification: read: Connection reset by peer": "dropped",
"Timed out talking to frame": "too long",
}
+1
View File
@@ -1,3 +1,4 @@
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
+361
View File
@@ -0,0 +1,361 @@
"""The store's Frame categories (ui/apk_sources/_categories.py): which apps go where, which are
phone-only and hidden, and how the browse, search and library artwork use them. Offline."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, _categories, _images, fdroid, search # noqa: E402
import test_fdroid_sources as fixtures # noqa: E402 (a module, so its tests don't run twice)
FIXTURES, PIN, URL = fixtures.FIXTURES, fixtures.PIN, fixtures.URL
def kind(**entry):
return _categories.classify(dict({'name': 'App', 'summary': ''}, **entry))
class Classify(unittest.TestCase):
def test_vr_first_whatever_else_it_is(self):
self.assertEqual(kind(vr=True, categories=['Phone & SMS']), {'category': 'vr', 'phone_only': False})
def test_fine_categories_decide(self):
self.assertEqual(kind(categories=['Games', 'Puzzle Game'])['category'], 'games')
self.assertEqual(kind(categories=['Games', 'Emulator'])['category'], 'emulators')
# A chat app is in Multimedia too; the finer Social Network wins.
self.assertEqual(kind(categories=['Multimedia', 'Internet', 'Social Network'])['category'], 'web')
self.assertEqual(kind(categories=['Reading', 'Ebook Reader'])['category'], 'read')
self.assertEqual(kind(categories=['Multimedia', 'Podcast'])['category'], 'watch')
def test_broad_categories_alone(self):
self.assertEqual(kind(categories=['Multimedia']), {'category': 'watch', 'phone_only': False})
self.assertEqual(kind(categories=['Writing']), {'category': 'work', 'phone_only': False})
def test_phone_only(self):
for categories in (['System', 'Launcher'], ['Internet', 'VPN & Proxy'], ['Theming', 'Wallpaper'],
['Sports & Health'], ['Connectivity'], ['Time', 'Alarm Clock'], ['Habit Tracker']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(name='[ROOT] Night Light', categories=['Development'])['phone_only'])
self.assertTrue(kind(name='5G Proxy Client', summary='Routes traffic')['phone_only'])
def test_games_and_media_keep_phone_words(self):
# 'Battery' in a game's name or a widget in a music player doesn't hide a game or a player.
self.assertFalse(kind(name='Widget Wars', categories=['Strategy Game'])['phone_only'])
self.assertFalse(kind(name='Music', summary='with a home screen widget', categories=['Local Media Player'])['phone_only'])
def test_emulators_by_name_without_categories(self):
self.assertEqual(kind(name='RetroArch', summary='Frontend for emulators')['category'], 'emulators')
def test_unknown_is_neither_hidden_nor_guessed(self):
self.assertEqual(kind(name='Thing'), {'category': None, 'phone_only': False})
self.assertEqual(kind(categories=['Something New']), {'category': None, 'phone_only': False})
def test_matches(self):
phone = {'category': 'tools', 'phone_only': True}
self.assertFalse(_categories.matches(phone))
self.assertTrue(_categories.matches(phone, phone=True))
self.assertTrue(_categories.matches({'category': 'games', 'phone_only': False}, 'games'))
self.assertFalse(_categories.matches({'category': 'watch', 'phone_only': False}, 'games'))
def test_every_store_category_has_a_label(self):
self.assertEqual({c for c, _ in _categories.CATEGORIES}, _categories.IDS)
targets = set(_categories.FINE) | {c for c in _categories.BROAD.values() if c}
self.assertLessEqual(targets, _categories.IDS)
self.assertLessEqual(set(_categories.PRIORITY), _categories.IDS)
class FDroidCategories(unittest.TestCase):
setUp, fetch, add = fixtures.Repositories.setUp, fixtures.Repositories.fetch, fixtures.Repositories.add
def index(self, version, categories):
raw = json.loads((FIXTURES / ('artwork-' + version + '.json')).read_text())
if version == 'v2':
raw['packages']['org.example.app']['metadata']['categories'] = categories
else:
raw['apps'][0]['categories'] = categories
path = self.root / ('index-' + version + '.json')
path.write_text(json.dumps(raw))
if version == 'v1':
fdroid._v1(path.read_bytes(), path)
return path
def test_categories_survive_v1_and_v2(self):
source = self.add()
for version in ('v1', 'v2'):
with self.subTest(version=version):
apps = fdroid._reduce(self.index(version, ['Games', 'Puzzle Game', 7]), source)
self.assertEqual(apps['org.example.app']['categories'], ['Games', 'Puzzle Game'])
def save(self, source, apps):
fdroid._write(fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json'),
{'version': fdroid.CACHE_VERSION, 'url': URL, 'fingerprint': PIN, 'apps': apps})
def app(self, pkg, name, updated, categories=(), banner=None):
return {'source': 'x', 'id': pkg, 'package': pkg, 'name': name, 'summary': '', 'updated': updated,
'categories': list(categories), 'images': {'icon': None, 'banner': banner, 'screenshots': []}}
def test_browse_shows_artwork_then_newest_and_filters_before_the_limit(self):
source = self.add()
self.save(source, {a['id']: a for a in (
self.app('a.old', 'Aaa', '2020-01-01', ['Puzzle Game']),
self.app('b.new', 'Bbb', '2026-01-01', ['Puzzle Game']),
self.app('c.art', 'Ccc', '2019-01-01', ['Puzzle Game'], banner=URL + 'b.png'),
self.app('d.sms', 'Ddd', '2026-02-01', ['Phone & SMS']))})
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=10)], ['c.art', 'd.sms', 'b.new', 'a.old'])
keep = lambda e: _categories.matches(dict(e, **_categories.classify(e)))
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=1, keep=keep)], ['c.art'])
self.assertEqual([a['id'] for a in fdroid.search(source, 'a', limit=10)], ['a.old', 'c.art']) # a query sorts by name
def test_lookup_reads_the_saved_index_only(self):
source = self.add()
self.assertEqual(fdroid.lookup(dict(source, id='never-loaded'), ['org.example.app']), {})
self.save(source, {'a.b': self.app('a.b', 'A', None)})
before = self.fetch_mock.call_count
self.assertEqual(list(fdroid.lookup(source, ['a.b', 'missing'])), ['a.b'])
self.assertEqual(self.fetch_mock.call_count, before)
self.assertEqual(fdroid.lookup(dict(source, enabled=False), ['a.b']), {})
def fake_source(entries, kind_='fake', lookup=None):
mod = types.SimpleNamespace(KIND=kind_)
mod.sources = lambda: [{'id': kind_, 'kind': kind_, 'name': kind_.title(), 'url': 'https://example.org/',
'builtin': True, 'enabled': True, 'trust': 'community'}]
mod.search = lambda source, query, limit=50: [dict(e) for e in entries][:limit]
mod.details = lambda source, entry_id: next(dict(e) for e in entries if e['id'] == entry_id)
if lookup is not None:
mod.lookup = lookup
return mod
ENTRY = {'source': 'fake', 'package': None, 'summary': '', 'free': True, 'downloadable': True,
'min_sdk': 24, 'abis': ['arm64-v8a'], 'vr': None}
class Search(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
for state in (search._running, search._pending, search._status):
state.clear()
self.addCleanup(state.clear)
def run_search(self, entries, **kwargs):
with patch.object(search, 'modules', return_value=([fake_source(entries)], [])):
return search.search(**kwargs)
def test_category_and_phone_filters(self):
entries = [dict(ENTRY, id='g', name='Game', categories=['Puzzle Game']),
dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS']),
dict(ENTRY, id='v', name='Video', categories=['Local Media Player'])]
result = self.run_search(entries)
self.assertEqual(sorted(a['name'] for a in result['apps']), ['Game', 'Video'])
self.assertEqual({a['name']: a['category'] for a in result['apps']}, {'Game': 'games', 'Video': 'watch'})
self.assertIn({'id': 'emulators', 'label': 'Emulators'}, result['categories'])
self.assertEqual([a['name'] for a in self.run_search(entries, category='games')['apps']], ['Game'])
self.assertEqual(len(self.run_search(entries, phone=True)['apps']), 3)
with self.assertRaisesRegex(SourceError, 'Unknown category'):
self.run_search(entries, category='phones')
def test_listing_offers_three_screenshots_details_all(self):
shots = ['https://example.org/%d.png' % i for i in range(8)]
entry = dict(ENTRY, id='g', name='Game', images={'icon': None, 'banner': None, 'screenshots': shots})
listed = self.run_search([entry])['apps'][0]['offers'][0]
self.assertEqual(len(listed['artwork']['screenshots']), 3)
self.assertEqual(len(search.decorate(entry)['artwork']['screenshots']), 8)
def test_browse_asks_sources_for_more_than_a_search(self):
seen = []
mod = fake_source([])
mod.search = lambda source, query, limit=50: seen.append((query, limit)) or []
with patch.object(search, 'modules', return_value=([mod], [])):
search.search('')
search.search('chess')
self.assertEqual(seen, [('', search.BROWSE_LIMIT), ('chess', 50)])
class LibraryArtwork(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
def test_by_package_from_saved_indexes_then_by_name_for_curated_apps(self):
icon = {'images': {'icon': 'https://example.org/icon.png', 'banner': None, 'screenshots': []}}
repo = fake_source([], 'fdroid', lookup=lambda source, packages: {
p: dict(icon, name='x') for p in packages if p == 'org.antennapod'})
github = fake_source([dict(icon, id='icosa/open-brush', name='Open Brush')], 'github')
bare = fake_source([dict(icon, id='x', name='Elsewhere', package='org.elsewhere')], 'itch')
apps = [{'package': 'org.antennapod', 'label': 'AntennaPod'}, {'package': 'com.Icosa.OpenBrush', 'label': 'Open Brush'},
{'package': 'org.elsewhere', 'label': 'Elsewhere'}, {'package': 'org.none', 'label': 'None'}]
with patch.object(search, 'modules', return_value=([repo, github, bare], [])):
found = search.artwork_for(apps)
self.assertEqual(sorted(found), ['com.Icosa.OpenBrush', 'org.antennapod'])
self.assertTrue(found['org.antennapod']['icon'].startswith('/source-image/'))
def test_a_failing_source_is_skipped(self):
def broken(source, packages):
raise SourceError('no')
with patch.object(search, 'modules', return_value=([fake_source([], 'fdroid', lookup=broken)], [])):
self.assertEqual(search.artwork_for([{'package': 'a.b', 'label': 'A'}]), {})
class ImageHandles(unittest.TestCase):
def test_same_url_same_handle_without_scanning(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
a = _images.register('https://example.org/a.png')
self.assertEqual(_images.register('https://example.org/a.png'), a)
self.assertNotEqual(_images.register('https://example.org/b.png'), a)
class Route(unittest.TestCase):
def test_search_filters_are_checked(self):
import server
with patch.object(server.apk_search, 'search', return_value={'apps': []}) as run:
server.source_search('q=&category=games&phone=1')
self.assertEqual(run.call_args.kwargs['category'], 'games')
self.assertIs(run.call_args.kwargs['phone'], True)
server.source_search('q=chess')
self.assertEqual((run.call_args.kwargs['category'], run.call_args.kwargs['phone']), (None, False))
for bad in ('phone=maybe', 'category=' + 'x' * 41):
with self.subTest(bad=bad), self.assertRaises(server.Failure):
server.source_search(bad)
class ReviewCases(unittest.TestCase):
"""Real index entries (October 2026) that the first rules got wrong."""
def test_useful_apps_with_a_phone_side_stay(self):
for categories, category in ((['Connectivity', 'Email', 'Internet', 'Writing'], 'web'), # Thunderbird
(['Connectivity', 'Internet', 'Multimedia', 'Podcast'], 'watch'), # FocusPodcast
(['Contact', 'Email'], 'web'),
(['Cloud Storage & File Sync', 'File Manager', 'System'], 'tools')):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': category, 'phone_only': False})
def test_phone_side_alone_or_with_a_broad_category_is_hidden(self):
for categories in (['Internet', 'VPN & Proxy'], ['App Manager', 'Development', 'System'], ['Navigation']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(categories=['Launcher', 'Games'])['phone_only']) # system ones always
def test_no_root_needed_is_not_a_root_tool(self):
for summary in ('Linux on Android, no root required', 'Linux Without Root Access', 'SSH server without root',
'Works on non-rooted phones', 'Rootless and simple'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Development'])['phone_only'])
self.assertTrue(kind(summary='Needs root to tune the kernel', categories=['Development'])['phone_only'])
def test_izzyondroid_names(self):
self.assertEqual(kind(categories=['Office'])['category'], 'work')
self.assertTrue(kind(categories=['Xposed'])['phone_only'])
class ReviewCasesTwo(unittest.TestCase):
"""Round two of real entries (October 2026)."""
def test_messengers_with_phone_or_push_stay(self):
for categories in (['Internet', 'Messaging', 'Phone & SMS'], ['Internet', 'Messaging', 'Push'],
['Connectivity', 'Messaging', 'Phone & SMS', 'Voice & Video Chat']):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': 'web', 'phone_only': False})
def test_broad_only_sources_can_escape_a_phone_side(self):
self.assertEqual(kind(categories=['System', 'Development']), {'category': 'tools', 'phone_only': False})
self.assertEqual(kind(categories=['Connectivity', 'Internet']), {'category': 'web', 'phone_only': False})
self.assertTrue(kind(categories=['VPN & Proxy', 'Internet'])['phone_only']) # it has finer ones, none useful
def test_summary_features_dont_make_a_phone_app(self):
self.assertFalse(kind(name='OSS Weather', summary='Forecasts with home screen widgets', categories=['Weather'])['phone_only'])
self.assertFalse(kind(name='Digital Calculator', summary='Hardware keyboard support', categories=['Calculator', 'System'])['phone_only'])
self.assertTrue(kind(name='Clock Widget', categories=['Clock'])['phone_only']) # but the name says what it is
def test_saying_root_isnt_needed(self):
for summary in ('VNC server app that does not require root privileges', "Backups that don't need root access",
"It doesn't need root", 'Linux without root', 'Works on non-rooted phones', 'Rootless'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Remote Access'])['phone_only'])
for summary in ('Requires root', 'Needs root access to change the kernel', 'For rooted devices'):
with self.subTest(summary=summary):
self.assertTrue(kind(summary=summary, categories=['Remote Access'])['phone_only'])
def test_hardware_the_frame_lacks(self):
self.assertTrue(kind(name='IR Blaster Remote', categories=['Multimedia', 'Remote Controller', 'System'])['phone_only'])
self.assertTrue(kind(name='Wear Sync for Breezy', summary='Weather on your Wear OS watch', categories=['Connectivity', 'Weather'])['phone_only'])
class SearchFindsPhoneApps(unittest.TestCase):
setUp, run_search = Search.setUp, Search.run_search
def test_a_search_finds_what_browsing_hides(self):
entries = [dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS'])]
self.assertEqual(self.run_search(entries)['apps'], [])
found = self.run_search(entries, query='texts')['apps']
self.assertEqual([(a['name'], a['offers'][0]['phone_only']) for a in found], [('Texts', True)])
class ArtworkLookups(unittest.TestCase):
"""A page of cards asks for dozens of images at once; they mustn't fail for want of a lookup slot."""
def setUp(self):
_images._dns.clear()
self.addCleanup(_images._dns.clear)
def test_public_answers_are_kept_private_ones_are_not(self):
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public) as lookup:
for _ in range(3):
self.assertEqual(_images._resolve('example.org', 443, 5), public)
self.assertEqual(lookup.call_count, 1)
private = [(2, 1, 6, '', ('10.0.0.5', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=private) as lookup:
_images._resolve('inside.example', 443, 5)
_images._resolve('inside.example', 443, 5)
self.assertEqual(lookup.call_count, 2)
def test_waiting_for_a_slot_counts_against_the_budget(self):
import threading, time
gate = threading.Event()
for _ in range(4): # every slot busy for the whole test
_images._resolvers.acquire()
threading.Timer(0.06, lambda: [_images._resolvers.release() for _ in range(4)]).start()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(2) and []):
start = time.monotonic()
with self.assertRaises(SourceError):
_images._resolve('slow.example', 443, 0.1)
self.assertLess(time.monotonic() - start, 0.16)
gate.set()
def test_many_at_once_wait_for_a_slot(self):
import threading, time
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
def slow(*a, **k):
time.sleep(0.05)
return public
errors = []
def one(i):
try:
_images._resolve('host%d.example' % i, 443, 5)
except SourceError as e:
errors.append(e)
with patch.object(_images.socket, 'getaddrinfo', side_effect=slow):
threads = [threading.Thread(target=one, args=(i,)) for i in range(12)]
[t.start() for t in threads]
[t.join() for t in threads]
self.assertEqual(errors, [])
if __name__ == '__main__':
unittest.main()
+223 -6
View File
@@ -138,7 +138,7 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "other", "other", "other"])
["frame_unreachable", "internal_error", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
@@ -159,11 +159,79 @@ class Lifecycle(Base):
e = self.queued()[-1]
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
def test_opens_installs_and_updates_carry_the_country(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
tm.app_started()
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
def test_only_zones_in_the_tz_table_give_a_country(self):
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
("Japan", "JP"), ("UTC", None), ("alice", None),
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
self.assertEqual(tm.country(), want, tz)
def test_country_from_tz_or_localtime_in_a_checkout(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
self.assertEqual(tm.country(), "JP")
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
with mock.patch.object(tm.os.path, "realpath", return_value=target):
self.assertEqual(tm.country(), want, target)
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
tm.update_settings({"compat": True})
self.assertFalse(tm.state()["noticeShown"])
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
def test_frame_build_once(self):
tm.frame_seen("20260922.1", "3.8")
tm.frame_seen("20260922.1", "3.8")
self.assertEqual(len(self.queued()), 1)
def test_frame_once_per_headset_with_saved_count(self):
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
props = [e["properties"] for e in self.queued()]
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
def test_frame_many_headsets_are_not_sent_twice(self):
ids = ["%016x" % n for n in range(25)]
for _ in range(2):
for h in ids:
tm.frame_seen("20260922.1", "3.8", h, 25)
self.assertEqual(len(self.queued()), 25)
def test_frame_headset_id_must_be_the_hash(self):
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
p = self.queued()[0]["properties"]
self.assertNotIn("headset_id", p)
self.assertNotIn("headsets_saved", p)
class HeadsetId(unittest.TestCase):
def test_hash_of_machine_id_and_none_without_one(self):
import hashlib
import frame_status
with mock.patch.object(frame_status, "read", return_value="abc123"):
self.assertEqual(frame_status.headset_id(),
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
with mock.patch.object(frame_status, "read", return_value=None):
self.assertIsNone(frame_status.headset_id())
class Sending(Base):
def serve(self, status=200):
@@ -198,6 +266,39 @@ class Sending(Base):
self.assertEqual(self.queued(), [])
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
def test_an_older_notice_still_sends(self):
got = self.serve()
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
tm.capture("app_opened")
self.assertEqual(tm.flush(), 1)
self.assertEqual(len(got), 1)
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # as 0.4.5 saved it
self.assertFalse(tm.state()["noticeShown"])
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1) # the usual statistics still go
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
tm._save({**tm.settings(), "last_open_day": None}) # the next day
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
def test_turning_usage_on_still_waits_for_the_new_notice(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # 0.4.5, usage off
tm.update_settings({"usage": True})
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
def test_a_failed_send_keeps_the_events(self):
self.serve(status=500)
tm.update_settings({"noticeShown": True})
@@ -263,6 +364,72 @@ class Scrub(unittest.TestCase):
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
def test_apk_install_failures_that_were_other(self):
"""What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
(or as frame_unreachable, when it was the Steam client or a slow copy)."""
tail = 'Traceback (most recent call last):\n File "<stdin>", line 19, in target_ws\n'
cases = {
"steam_not_running": [
"SharedJSContext not found: is the Steam client running?",
tail + "urllib.error.URLError: <urlopen error [Errno 111] Connection refused>",
tail + "ConnectionRefusedError: [Errno 111] Connection refused",
],
"steam_not_responding": [
"websocket handshake failed",
tail + "TimeoutError: timed out",
tail + "socket.timeout: timed out",
tail + ' File "<stdin>", line 45, in _read\nEOFError',
],
"steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
'{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
"artwork_failed": [
"Steam artwork renderer did not produce every slot",
tail + "ValueError: incomplete artwork render",
tail + "ValueError: hero render is 0 bytes of png",
"could not prepare artwork: unknown artwork slot",
tail + "ValueError: all five Steam artwork slots are required",
],
"copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
"copying launch.sh to the Frame timed out"],
"copy_failed": [
"copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
"so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
"copying game.apk to the Frame failed: ",
],
"apk_unreadable": [
"x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
"x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
"x.apk: the APK manifest has no package name",
"x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
"x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
],
}
for category, messages in cases.items():
for message in messages:
self.assertEqual(tm.categorize(message)[0], category, message)
# Still where they were: ssh's own failures, and a full disk during a copy.
self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
"port 22: Connection refused")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
"\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
def test_python_errors_are_internal_whatever_they_say(self):
import json as _json
for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
KeyError("timed out")):
self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
def test_privacy_doc_lists_every_category(self):
doc = (ROOT / "docs" / "privacy.md").read_text()
for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
self.assertIn(f"`{name}`", doc)
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -335,6 +502,26 @@ class LinkFailureProvenance(Base):
tm.diagnostic("job steam", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_unreachable"])
def test_host_key_and_login_refusals_are_sent_once(self):
# 0.4.2: each of these came in as a $exception from the status poll, again every 10 minutes.
for _ in range(3):
for stderr in ("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\r\n"
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\n"
"IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!\r\nHost key verification failed.\r\n",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
"checking.\r\nHost key verification failed.\r\n",
"jane doe@frame-control-f7b4e9ac: Permission denied (publickey,password).\r\n",
"steamos@192.168.1.9: Permission denied (publickey,password).\n"):
e = self.ssh_fails(self.server, 255, stderr)
self.assertTrue(getattr(e, "frame_link_failed", False), stderr)
tm.diagnostic("POST /api/comfort status", e)
self.assertEqual([p["properties"]["error_category"] for p in self.queued()], ["frame_auth"])
# A command on the Frame that was refused something isn't ssh refusing the headset.
for code, stderr in ((1, "steamos@frame: Permission denied (publickey).\n"),
(255, "cat: /root/x: Permission denied\n"),
(255, "scp: /home/steamos/x: Permission denied\n")):
self.assertFalse(getattr(self.ssh_fails(self.server, code, stderr), "frame_link_failed", False), stderr)
def test_a_command_that_fails_on_the_frame_is_not_marked(self):
self.poll_fails()
for module in (self.server, self.android):
@@ -356,14 +543,17 @@ class LinkFailureProvenance(Base):
c.close()
return result
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the status poll's first connection failure, theirs are held back too,
# also past the 10-minute window.
def serve(self):
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the first connection failure, theirs are held back too, also past the
# 10-minute window.
self.serve()
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
@@ -379,7 +569,34 @@ class LinkFailureProvenance(Base):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
# The comfort poll's own failure isn't one (see the next test).
self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
# 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
# refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
# by hand still is, once, and a real failure of the poll is too.
self.serve()
for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
"checking.\r\nHost key verification failed.\r\n",
"steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
self.assertEqual(self.queued(), [])
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.request("/api/comfort", {"action": "cancel"})
broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
with mock.patch.object(frame_host, "run_ssh", return_value=broken):
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
def test_download_failures_after_a_poll_failure_are_still_sent(self):
# Review round 3: these file names, in the real checksum message through the web-link worker,
+1
View File
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
+1
View File
@@ -1,4 +1,5 @@
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import ctypes
import json
from pathlib import Path
+116
View File
@@ -0,0 +1,116 @@
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
them for a headset instead: VR first, then games and the flat apps worth having in a panel
(video, reading, making things). Apps built around a phone's hardware or system (calls and
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
"""
import re
# (id, label), in the order the store shows them.
CATEGORIES = (
('vr', 'VR'),
('games', 'Games'),
('emulators', 'Emulators'),
('watch', 'Watch & listen'),
('read', 'Read & learn'),
('create', 'Create'),
('work', 'Work'),
('web', 'Web & chat'),
('tools', 'Tools'),
)
IDS = {c for c, _ in CATEGORIES}
# F-Droid's category names -> a store category; None = only useful on a phone.
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
BROAD = {
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
'Development': 'tools', 'Security': 'tools',
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
'System': None, 'Theming': None,
}
FINE = {
'emulators': ('Emulator',),
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
'Strategy Game', 'Visual Novel', 'Word Game'),
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
'Ambient Sound'),
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
'Bookmark'),
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
}
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
PHONE_SYSTEM = {
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
'Theming', 'Emergency Action', 'Xposed', 'Automation',
}
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
# category says otherwise. A finer one when the source has them; a broad one when it only has
# broad ones (IzzyOnDroid often does).
PHONE_SIDE = {
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
'Connectivity',
}
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
'Meditation', 'Mental Health', 'Sports & Health'}
# When an app has several, the one that best describes it on a headset wins.
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
# What an app is, from its name: these are phone parts, whatever the categories say.
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
r'magisk|xposed|battery)\b|\[root\]', re.I)
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
# Needs hardware or a device the Frame doesn't have, from the name or summary.
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
def classify(entry):
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
if entry.get('vr') is True:
return {'category': 'vr', 'phone_only': False}
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
broad = {BROAD[c] for c in names if BROAD.get(c)}
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
if category is None and EMULATOR.search(name + ' ' + summary):
category = 'emulators'
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
useful = fine or (broad if only_broad else set())
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
for c in names):
phone = True
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
return {'category': category, 'phone_only': phone}
def matches(entry, category=None, phone=False):
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
if entry.get('phone_only') and not phone:
return False
return not category or entry.get('category') == category
+38 -11
View File
@@ -12,7 +12,9 @@ from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
_lock = threading.Lock()
_urls = OrderedDict()
_urls = OrderedDict() # token -> URL, oldest first
_tokens = {} # URL -> token
MAX_TOKENS = 16384
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
@@ -31,24 +33,24 @@ def register(url):
if not isinstance(url, str) or not valid_url(url):
return None
with _lock:
for token, known in _urls.items():
if known == url:
_urls.move_to_end(token)
return '/source-image/' + token
token = _tokens.get(url)
if token in _urls:
_urls.move_to_end(token)
return '/source-image/' + token
token = secrets.token_urlsafe(24)
_urls[token] = url
while len(_urls) > 4096:
_urls.popitem(last=False)
_urls[token], _tokens[url] = url, token
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
_tokens.pop(_urls.popitem(last=False)[1], None)
return '/source-image/' + token
def artwork(entry):
def artwork(entry, shots=12):
images = entry.get('images') or {}
if not isinstance(images, dict):
images = {}
return {'icon': register(images.get('icon') or entry.get('icon')),
'banner': register(images.get('banner')),
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
def image_type(data):
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
_resolvers = threading.BoundedSemaphore(4)
# A page of store cards asks for dozens of images from the same few hosts at once: their public
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
_dns = {} # (host, port) -> (addresses, expires)
DNS_TTL = 300
def _resolve(host, port, timeout):
with _lock:
known = _dns.get((host, port))
if known and known[1] > time.monotonic():
return known[0]
found = _lookup(host, port, timeout)
try:
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
except (TypeError, ValueError, IndexError):
public = False
if public: # private answers are refused by the caller, and never kept
with _lock:
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
while len(_dns) > 256:
_dns.pop(next(iter(_dns)))
return found
def _lookup(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
if not _resolvers.acquire(blocking=False):
# A request waits for a free slot within its own budget rather than failing at once.
started = time.monotonic()
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
found = {}
def run():
+29 -4
View File
@@ -26,7 +26,7 @@ from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
CACHE_VERSION = 3 # 3: categories
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 = {}
@@ -432,6 +432,7 @@ def _reduce(path, source):
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
@@ -450,6 +451,7 @@ def _v1(content, path):
localized = app.get('localized', {})
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
meta['categories'] = app.get('categories') or []
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
images = {}
for locale, fields in localized.items():
@@ -625,16 +627,39 @@ def set_enabled(source_id, enabled):
_write(_storage(), settings)
def search(source, query, limit=50):
def _showcase(a):
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
images = a.get('images') or {}
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
def search(source, query, limit=50, keep=None):
"""keep: optional filter applied before the limit (the store's category browse)."""
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
and (keep is None or keep(a))]
if words:
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
else:
found.sort(key=_showcase)
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def lookup(source, packages):
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
if not source.get('enabled', True):
return {}
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
if not found:
return {}
apps = found[0]
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
+58 -14
View File
@@ -15,7 +15,7 @@ import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited
from apk_sources import SourceError, SourceLimited, _categories as categories
_lock = threading.RLock()
_running = {}
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
def modules():
@@ -158,9 +159,11 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry):
def decorate(entry, shots=12):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
**categories.classify(entry))
def details(source_id, entry_id):
@@ -178,10 +181,12 @@ def offer_rank(entry):
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
for entry in entries:
entry = decorate(entry)
entry = decorate(entry, shots=3)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
@@ -195,7 +200,7 @@ def group(entries, query='', vr=None, installable=False):
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'offers': offers})
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
@@ -215,10 +220,10 @@ def group(entries, query='', vr=None, installable=False):
return result
def _launch(module, source, query, limit):
def _launch(module, source, query, limit, category=None, phone=False):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
@@ -237,13 +242,17 @@ def _launch(module, source, query, limit):
def _start(key, task):
module, source = task['job']
query, limit = task['query']
query, limit, category, phone = task['query']
kwargs = {}
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
def run():
queued = None
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -259,14 +268,20 @@ def _start(key, task):
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
if category and category not in categories.IDS:
raise SourceError('Unknown category')
if not query.strip():
limit = max(limit, BROWSE_LIMIT)
else:
phone = True # the phone-only guess hides apps from browsing, never from a search for them
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
@@ -281,7 +296,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
statuses.append(status)
with _lock:
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
def warm():
@@ -291,7 +307,35 @@ def warm():
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
def artwork_for(apps):
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
from apk_sources import _images
items, _ = registry()
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
found = {}
for module, source in items:
if not source['enabled'] or source.get('page_only'):
continue
try:
if hasattr(module, 'lookup'):
hits = module.lookup(source, [p for p in wanted if p not in found])
elif module.KIND == 'github': # a small local list; its entries have names, not packages
names = {n: p for p, n in wanted.items() if n and p not in found}
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
if normalise(e.get('name')) in names}
else:
continue
except (SourceError, OSError, ValueError, KeyError, TypeError):
continue
for package, entry in hits.items():
art = _images.artwork(entry)
if art['icon'] or art['banner'] or art['screenshots']:
found.setdefault(package, art)
return found
def install(source_id, entry_id, version_code=None, progress=None):
+49
View File
@@ -0,0 +1,49 @@
/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
.cc { gap:24px; }
.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
.cc-hero > div { min-width:0; }
.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
.cc-topline button { margin-left:auto; border-radius:6px; }
.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
+318
View File
@@ -0,0 +1,318 @@
/* The Control Center uses the shared page's guarded api() and status poll.
* Browsing never starts a stream, installs a helper or changes the headset. */
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.FrameControlCenter = factory();
})(typeof window === 'object' ? window : globalThis, function () {
'use strict';
const categories = [
['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
];
// Each destination is an existing workflow, with its own guards and confirmations.
const catalog = [
{id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
keywords:'sound speakers headphones loud quiet', special:'audio'},
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
{id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
{id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
{id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
{id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
{id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
{id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
{id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
{id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
{id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
{id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
{id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
{id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
{id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
];
const paths = {
grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
};
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[name] || paths.settings}"/></svg>`;
function matches(item, query, category) {
const text = [item.title, item.description, item.keywords, item.guidance, item.label,
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
}
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
function metrics(sample) {
const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
const capacity = b.percent;
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
const charging = b.status === 'Charging';
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
return {
battery:number(capacity, '%'), batteryDetail:batteryState,
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
};
}
// Strict validation and captured device identity apply even if a stale enabled
// button is clicked during a disconnect, headset switch, or pending request.
function createController({api, getContext}) {
let busy = false;
return {
get busy() { return busy; },
async audio(change) {
if (busy) throw new Error('An audio change is still in progress.');
const context = getContext();
if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
const keys = Object.keys(change);
if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
busy = true;
try {
const result = await api('/api/volume', change, context.device);
if (getContext().generation !== context.generation) return null;
return result;
} finally { busy = false; }
},
};
}
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
const get = id => document.getElementById(id), page = get('controlCenter');
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
const specials = {audio, performance:perf, health, storage};
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
// Host-only features stay searchable with a reason; only their unsupported
// action disappears. The host API, rather than viewport/UA, decides availability.
let macSupported = platform === 'darwin';
const visibleCatalog = catalog;
function filter() {
const query = get('ccSearch').value;
let count = 0;
catalog.forEach(item => {
const visible = visibleCatalog.includes(item) && matches(item, query, category);
page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
if (visible) count++;
});
get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
get('ccEmpty').hidden = count > 0;
get('ccClear').hidden = !query;
page.querySelectorAll('[data-cc-category]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
});
page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
}
get('ccSearch').addEventListener('input', filter);
function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
get('ccResetFilter').onclick = resetFilter;
get('ccCategories').onclick = event => {
const button = event.target.closest('[data-cc-category]');
if (!button) return;
category = button.dataset.ccCategory; filter();
};
page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
// The headset clock can differ from this client's. Age uses the client
// receipt time, retained by the shared poll when it buffers an early sample.
const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
const controller = createController({api, getContext:context});
function audioAvailability() {
const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
}
function renderSample() {
const values = metrics(isFresh() ? sample : null);
page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
get('ccSampleState').classList.toggle('cc-offline', !isFresh());
get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
get('ccStatusDot').classList.toggle('on', isFresh());
get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
const services = isFresh() ? sample.services || {} : {};
get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`<span><i class="dot ${services[key] === true ? 'on' : ''}"></i>${label}<b>${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}</b></span>`).join('');
if (sample?.volume && isFresh()) {
const pct = Math.round(sample.volume.level * 100);
if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
} else {
get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
get('ccVolumeValue').textContent = '—';
}
audioAvailability();
}
async function changeAudio(change) {
if (audioPending) return;
const generation = getContext().generation;
audioPending = true;
get('ccAudioResult').textContent = 'Applying…';
try {
const pending = controller.audio(change);
audioAvailability();
const result = await pending;
if (!result || generation !== getContext().generation) return;
if ('level' in change) dirty = false;
get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
await refreshAudio();
if (generation !== getContext().generation) return;
if (!isFresh() || !sample?.volume) {
get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
} else {
const reported = sample.volume;
const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
(!('muted' in change) || reported.muted === change.muted);
get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
}
} catch (e) {
if (generation !== getContext().generation) return;
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
} finally { audioPending = false; renderSample(); }
}
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
get('ccRefresh').onclick = async () => {
get('ccRefresh').disabled = true;
try { await refresh(); } finally { get('ccRefresh').disabled = false; }
};
filter(); renderSample();
// Expire UI readings even when the shared poll is paused in a hidden tab.
const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
return {
host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
status(value, receipt = Date.now(), read = {valid: true, error: null}) {
// A redraw is not a successful read: preserve receipt age and any local
// invalidation. Shared validity also carries failures across late mount.
const replay = sample === value;
receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
sample = value;
if (!read.valid) {
available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
} else if (!replay) { available = true; error = ''; }
sampleGeneration = getContext().generation; renderSample();
},
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
destroy() { clearInterval(timer); },
};
}
return {catalog, categories, matches, metrics, createController, mount};
});
+28 -5
View File
@@ -61,13 +61,20 @@ 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}
# The Frame writes UTF-8, whatever this computer's code page (Windows would decode it as
# cp1252). errors='replace': stray bytes (a file name) mustn't turn a failed command, or
# one that worked, into a UnicodeDecodeError.
text = ({'text': True, 'encoding': 'utf-8', 'errors': 'replace'}
if isinstance(input, str) or input is None else {})
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
timeout=timeout, **text)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
# Bytes input gives bytes output: decode it, so the message isn't "b'...'".
stderr, stdout = (v if isinstance(v, str) else (v or b'').decode(errors='replace')
for v in (p.stderr, p.stdout))
error = FrameError((stderr or stdout or f'ssh exited {p.returncode}').strip()[-600:])
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -136,11 +143,19 @@ def check_installable(info):
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
COPY_MIN_RATE = 512 * 1024
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp.
timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
name = os.path.basename(src)
try:
timeout = max(timeout, os.path.getsize(src) / COPY_MIN_RATE)
except OSError:
pass # rsync or scp says what's wrong with the file
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
@@ -148,7 +163,8 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
frame_host.run_ssh(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,
errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
@@ -279,6 +295,13 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
elif not ok:
# An update that failed: the app still works, but a partial copy of the new APK
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
try:
ssh(f'rm -f {d}/app.apk.part', timeout=30)
except FrameError:
pass
+4 -1
View File
@@ -7,6 +7,7 @@ the resource table in resources.arsc.
"""
import struct
import zipfile
import zlib
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
@@ -221,7 +222,9 @@ def apk_info(path):
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
from frame_apk_vr import detection
vr_info = detection(manifest_data, names)
except (struct.error, IndexError, zipfile.BadZipFile) as e:
# A damaged download: corrupt deflate data (zlib.error), a member cut short (EOFError),
# undecodable text (UnicodeDecodeError) or an encrypted entry (RuntimeError).
except (struct.error, IndexError, zipfile.BadZipFile, zlib.error, EOFError, ValueError, RuntimeError) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
+2 -1
View File
@@ -31,7 +31,8 @@ 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")
SSH_DIR = Path.home() / ".ssh"
# $FRAME_CONTROL_SSH_DIR in tests, as frame_devices.ssh_dir(), so they never touch the real one.
SSH_DIR = Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
KEY = SSH_DIR / "id_ed25519_frame"
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
+118 -12
View File
@@ -28,8 +28,10 @@ remembered IP is caught.
Python stdlib only.
"""
import base64
import contextlib
import copy
import hashlib
import json
import os
import re
@@ -418,19 +420,100 @@ def seed_pin(device_id, hosts, port=22, sources=None):
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
keys.append(f"{name} {f[1]} {f[2]}")
if keys:
target = known_hosts(device_id)
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
os.replace(tmp, target) # whole file at once: ssh never sees half of it
_write_pin(device_id, keys)
return True
return False
def pin_dir():
"""Where the per-headset files go, created private: ssh won't create it itself."""
d = ssh_dir() / PIN_DIR
d.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
return d
def _write_pin(device_id, lines):
target = known_hosts(device_id)
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(pin_dir()))
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write("\n".join(dict.fromkeys(lines)) + "\n")
frame_host.make_private(tmp) # Windows: this user only, not the folder's inherited ACL
os.replace(tmp, target) # whole file at once: ssh never sees half of it
# The host key types OpenSSH writes to known_hosts.
KEY_TYPE_RE = re.compile(r"ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp(?:256|384|521)|"
r"sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com")
def fingerprint(blob):
"""OpenSSH's SHA256 fingerprint of a base64 key blob (what `ssh-keygen -l` and ssh -v print),
or None if it isn't base64."""
try:
raw = base64.b64decode(blob, validate=True)
except (ValueError, TypeError):
return None
return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=")
def key_entry(line, name):
"""(type, blob, fingerprint) from a known_hosts line for `name`, or None."""
f = (line or "").split()
if len(f) < 3 or line.startswith("#") or name not in f[0].split(",") or not KEY_TYPE_RE.fullmatch(f[1]):
return None
fp = fingerprint(f[2])
return (f[1], f[2], fp) if fp else None
def pinned_keys(device_id):
"""[(type, fingerprint)] of the keys saved for a headset (plain entries only)."""
try:
lines = known_hosts(device_id).read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeDecodeError):
return []
name = host_key_alias(device_id)
return [(e[0], e[2]) for e in (key_entry(line, name) for line in lines) if e]
def pin_key(device_id, key_type, blob):
"""Save one key as the headset's whole identity, replacing whatever was saved: what the
person confirmed after seeing its fingerprint (see frame_link.Link.trust_identity)."""
if not KEY_TYPE_RE.fullmatch(key_type or "") or not fingerprint(blob):
raise DeviceError("That isn't an SSH host key")
_write_pin(device_id, [f"{host_key_alias(device_id)} {key_type} {blob}"])
def scan_file(device_id):
"""(path, how ssh is told) for a throwaway known_hosts file that a key check writes the
headset's key to, without touching the saved one. `~` as in known_hosts_opt."""
name = f".scan-{device_id}-{secrets.token_hex(4)}"
path = pin_dir() / name
path.touch(**({} if frame_host.WINDOWS else {"mode": 0o600}))
frame_host.make_private(path)
return path, (str(path) if os.environ.get("FRAME_CONTROL_SSH_DIR") else f"~/.ssh/{PIN_DIR}/{name}")
def identity_established(device):
"""Whether this headset ever had an SSH identity here: the device's own flag (set once it
had a key or a connection that worked, and never cleared, whatever happens to its
addresses), a fingerprint recorded in devices.json, a pin file at all (even empty,
unreadable or not parseable), or an address that worked. Then a missing or unusable pin
fails closed, needing Check its key; only a headset with none of these may save the
first key it meets (accept-new)."""
if device.get("identity_established") or device.get("identity"):
return True
try:
if os.path.lexists(known_hosts(device["id"])):
return True
except OSError:
return True
return any(a.get("last_ok") for a in device.get("addresses") or ())
def forget_pin(device_id):
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
trusts whatever key the headset shows, as a first connection does."""
"""Drop a headset's saved key (the headset was removed, or the API's forget-identity).
The next connection trusts whatever key the headset shows, as a first connection does;
the page's Trust a new key flow (frame_link) shows the key first instead."""
try:
known_hosts(device_id).unlink()
return True
@@ -510,6 +593,10 @@ class Registry:
for a in d["addresses"]:
a.setdefault("networks", [])
a.setdefault("label", "")
if d.get("identity") or any(a.get("last_ok") for a in d["addresses"]):
# Files from before the flag existed: it had an identity (or a connection that
# worked, which saved one). Kept from now on, whatever happens to its addresses.
d["identity_established"] = True
return NAME_RE.fullmatch(d.get("id", "")) is not None
except (KeyError, TypeError, DeviceError):
return False
@@ -712,8 +799,25 @@ class Registry:
a["networks"] = (a["networks"] + [network_id])[-16:]
a["last_ok"] = time.time()
a["last_rtt_ms"] = rtt_ms
self._find(device_id)["identity_established"] = True # survives editing the address away
self.save()
def record_identity(self, device_id, fingerprints):
"""Remember the fingerprints of the key saved for a headset, so that losing the pin
file later can't turn into trusting whatever answers (identity_established)."""
fingerprints = [f for f in fingerprints if isinstance(f, str) and f.startswith("SHA256:")][:8]
if not fingerprints:
return
with self._changing():
try:
d = self._find(device_id)
except DeviceError:
return
if d.get("identity") != fingerprints or not d.get("identity_established"):
d["identity"] = fingerprints
d["identity_established"] = True
self.save()
def undismiss(self, alias):
"""Set Up Connection is about to run for this alias: import its block again."""
with self._changing():
@@ -797,15 +901,17 @@ class Registry:
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
d["config_host"] = host
changed = True
if seed and host:
seed_pin(d["id"], [host], b["port"])
if seed and host and seed_pin(d["id"], [host], b["port"]):
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or d.get("identity")
d["identity_established"] = True
elif host and host != d.get("config_host"):
# Set Up Connection ran again and found the headset somewhere new.
d["config_host"] = host
if not any(a["host"] == host for a in d["addresses"]):
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
if seed:
seed_pin(d["id"], [host], b["port"])
if seed and seed_pin(d["id"], [host], b["port"]) and not d.get("identity"):
d["identity"] = [f for _, f in pinned_keys(d["id"])][:8] or None
d["identity_established"] = True
changed = True
if d.get("config_login") != [user, b["port"]]:
# Set Up Connection (or an edit) changed who to log in as, or the port.
+5 -1
View File
@@ -38,9 +38,13 @@ class HostError(RuntimeError):
# The start of a line in which ssh itself says the link to the headset failed (not the command it ran).
# That includes ssh refusing the headset's host key, and the headset refusing this computer's
# key ("user@host: Permission denied (publickey,...)."): either way no command ran.
SSH_LINK_FAILED = re.compile(r"^(?:ssh: connect to host |ssh: Could not resolve hostname |banner exchange: |"
r"kex_exchange_identification: |mux_client_\w+: |client_loop: |"
r"Connection (?:closed|reset) by \S+ port \d+|Connection timed out during banner exchange)",
r"Connection (?:closed|reset) by \S+ port \d+|Connection timed out during banner exchange|"
r"Host key verification failed\.|No \S+ host key is known for |"
r"[^\n@:]{1,64}@\S+: Permission denied \()", # a Windows user name may have spaces
re.M)
+424 -35
View File
@@ -28,11 +28,14 @@ Python stdlib only. Runs on this computer, never on the Frame.
import copy
import hashlib
import ipaddress
import os
import queue
import re
import shlex
import socket
import subprocess
import sys
import tempfile
import threading
import time
@@ -50,6 +53,7 @@ NETWORK_EVERY = 5 # how often the network fingerprint is read
TAILSCALE_EVERY = 30
RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure
REQUEST_GAP = 5 # a request may start a new attempt this long after the last one
SCAN_TTL = 600 # a key shown for "Trust a new key" may be trusted this long after it was read
STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"),
("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"),
@@ -90,6 +94,32 @@ def ssh_g(alias):
return got.get("hostname") or alias, port, got.get("user"), proxied
def proxy_route(alias):
"""How ~/.ssh/config sends ALIAS: (None, None) directly, ("jump", spec) through
ProxyJump, or ("command", text) through a ProxyCommand."""
try:
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 = {}
for line in out.splitlines():
k, _, v = line.partition(" ")
if k in ("proxyjump", "proxycommand") and k not in got:
got[k] = v.strip()
if got.get("proxyjump") not in (None, "", "none"):
return "jump", got["proxyjump"]
if got.get("proxycommand") not in (None, "", "none"):
return "command", got["proxycommand"]
return None, None
# One jump host, as ProxyJump names it: [user@]host[:port], nothing that could start an
# option, split a shell word or carry an ssh % token.
JUMP_RE = re.compile(r"(?:([A-Za-z0-9][A-Za-z0-9._-]{0,63})@)?"
r"(\[[0-9A-Fa-f:.]{2,45}\]|[A-Za-z0-9][A-Za-z0-9.-]{0,252})(?::(\d{1,5}))?")
def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
"""Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}.
@@ -208,6 +238,13 @@ def scrub_failure(text, hosts=()):
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
def failure_detail(error):
"""What an address that answered but failed SSH shows: the short reason when there is one
(the whole message is the banner's headline already), else the message."""
error = error or {}
return error.get("short") or error.get("message") or "SSH failed"
def failure_category(message, raw=""):
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
word, never its text."""
@@ -257,6 +294,43 @@ def ssh_target(host, ip):
return host
def _unlink(path):
try:
os.unlink(path)
except OSError:
pass
def _scanned_key(path, name):
"""The first whole, valid known_hosts line for `name` that a key check wrote, or None.
Only lines ssh has finished (ending in a newline): a half-written key mustn't count."""
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
except (OSError, UnicodeDecodeError):
return None
for line in text.splitlines(True):
if line.endswith("\n"):
entry = frame_devices.key_entry(line.strip(), name)
if entry:
return entry
return None
def proxy_command(literal):
"""A ProxyCommand that runs `literal` (argv, ending with the jump host) with `-W` to the
headset, each word exactly as given. ssh first expands its %-tokens in the line, so every
% of ours is doubled; then it hands the line to a shell (`$SHELL -c` on macOS and Linux)
or, on Windows, starts it as a command line, so every word is quoted for that: a path
with spaces, $(...), backticks or quotes stays one plain word. The forward is
[%h]:%p, so an IPv6 address (with a zone) reads as one host."""
words = [w.replace("%", "%%") for w in literal]
target = "[%h]:%p"
if frame_host.WINDOWS:
return subprocess.list2cmdline([*words[:-1], "-W", target, words[-1]])
return " ".join(shlex.quote(w) for w in [*words[:-1], "-W", target, words[-1]])
def probe_raw(host, port, result):
"""ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
@@ -275,7 +349,8 @@ class Link:
self.session_alias = env_alias # ...and stays selectable after switching away
self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...]
self.control = control # ControlPath, or None where ssh can't share connections
self.apply = apply # apply(alias, host_opts): point every ssh command at the headset
self._apply = apply # apply(alias, host_opts): point every ssh command at the headset
self.applied = (None, []) # what it was last given
self.explain = explain # ssh error text -> plain reason, or None
self.cond = threading.Condition()
self.version = 0
@@ -301,6 +376,7 @@ class Link:
self.routed_device = None
self.last_failure = None # {"stage", "category", "at"}: for report diagnostics, no free text
self.attempt_device = None # the headset the current attempt is for
self.scans = {} # device id -> the host key it showed for "Trust a new key"
# ---- publishing ----
def publish(self, **fields):
@@ -505,13 +581,76 @@ class Link:
return [*mux, *(device.get("frozen") or [])] # what ~/.ssh/config said when it was routed
if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
return ["-o", "HostName=no-address.invalid"]
# StrictHostKeyChecking=yes: whatever ~/.ssh/config says for Host *, every command
# checks the headset's pinned key (only the connector's first handshake may save one).
return [*mux, "-o", "StrictHostKeyChecking=yes", "-o", f"HostName={frame_devices.ssh_host(host)}",
# Whatever ~/.ssh/config says for Host *, every command checks the headset's pinned key:
# StrictHostKeyChecking=yes. Only a headset that never had an identity here (just
# added, never connected) gets accept-new: the first contact saves its key, as Set Up
# Connection and the connector's first handshake do, rather than every command failing
# with "No host key is known" until the connector gets there. Once it had one, a pin
# file that's gone, empty or unreadable fails closed (identity_established): accept-new
# can't tell that from a new headset and would trust whatever answers.
strict = self.strictness(device)
if strict == "accept-new":
try:
frame_devices.pin_dir() # ssh won't create the folder its known_hosts file goes in
except OSError:
pass # ssh then says it couldn't save the key, and goes on
return [*mux, "-o", f"StrictHostKeyChecking={strict}", "-o", f"HostName={frame_devices.ssh_host(host)}",
"-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
"-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
"-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
def apply(self, alias, opts):
self.applied = (alias, list(opts))
self._apply(alias, opts)
def strictness(self, device):
"""yes, or accept-new for a headset that never had an identity. Judged on what the
registry says now, not only the copy handed in (a route frozen at connect, say, from
before its first key was recorded). A key saved since is recorded on the way."""
try:
current = self.reg.get(device["id"])
except frame_devices.DeviceError:
current = {}
if not (frame_devices.identity_established(device) or
(current and frame_devices.identity_established(current))):
return "accept-new"
self.settle_pin(dict(device, **current) if current else device, tighten=False)
return "yes"
def settle_pin(self, device=None, tighten=True):
"""A key ssh saved on a first contact, whatever happened next (logged in or refused,
by the handshake or any command): record it, make the file private, and from then on
route every command strictly. Idempotent and cheap when there's nothing to do."""
device = device or self.routed_device
if not device or device.get("transient") or device.get("none") or not device.get("id"):
return
did = device["id"]
if frame_devices.pinned(did):
fps = [f for _, f in frame_devices.pinned_keys(did)]
if fps and (device.get("identity") != fps or not device.get("identity_established")):
try:
current = self.reg.get(did)
except frame_devices.DeviceError:
return
if current.get("identity") != fps or not current.get("identity_established"):
frame_host.make_private(frame_devices.known_hosts(did)) # ssh wrote it
self.reg.record_identity(did, fps)
if not tighten:
return
with self.route_lock:
alias, opts = self.applied
if alias == device.get("alias") and "StrictHostKeyChecking=accept-new" in opts and \
frame_devices.identity_established(dict(device, **self._current(did))):
self.opts = [("StrictHostKeyChecking=yes" if o == "StrictHostKeyChecking=accept-new" else o)
for o in opts]
self.apply(alias, self.opts)
def _current(self, device_id):
try:
return self.reg.get(device_id)
except frame_devices.DeviceError:
return {}
def public_device(self, d):
return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
@@ -541,6 +680,8 @@ class Link:
if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons:
last_ts = t
self.refresh_network()
if "StrictHostKeyChecking=accept-new" in self.applied[1]:
self.settle_pin() # a command's first contact may have saved the key
phase = self.state["phase"]
if phase == "connected" and not self.alive():
reasons.append("dropped")
@@ -675,10 +816,13 @@ class Link:
return "Starting up"
return "Connecting"
def fail(self, sid, message, raw=""):
def fail(self, sid, message, raw="", **more):
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth), and `short`:
the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
the address that failed)."""
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = {"stage": sid, "message": message, "raw": raw}
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
probes = copy.deepcopy(self.state["probes"])
self.note_failure(sid, message, raw, probes)
@@ -761,7 +905,7 @@ class Link:
self.learn(device, a["host"], net, None)
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(i, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -811,7 +955,7 @@ class Link:
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -867,6 +1011,7 @@ class Link:
if device.get("transient"):
return
self.reg.record_success(device["id"], host, net.get("id"), rtt)
self.reg.record_identity(device["id"], [f for _, f in frame_devices.pinned_keys(device["id"])])
# Set Up Connection may have changed the block while this attempt ran: take that
# in (and reconnect) rather than writing this attempt's older settings over it.
self.watch_config()
@@ -921,7 +1066,17 @@ class Link:
def handshake(self, device, a, found, user):
"""SSH to one address, following ssh -v through stages 3-5.
-> "ok", "next" (try another address) or "stop"."""
-> "ok", "next" (try another address) or "stop". A key ssh saved is recorded and
protected however it ends: a login refused after the key was saved, a timeout, a stop."""
try:
return self._handshake(device, a, found, user)
finally:
if not device.get("transient"):
with self.route_lock:
current = self.attempt_gen == self.gen
self.settle_pin(device, tighten=current)
def _handshake(self, device, a, found, user):
# The IP that answered (with a link-local IPv6 address's zone), so ssh doesn't
# look the name up again and stall on an address that didn't answer.
opts = self.host_opts(device, ssh_target(a["host"], found.get("ip")))
@@ -939,15 +1094,9 @@ class Link:
return "ok"
extra = []
if not device.get("transient"):
if frame_devices.pinned(device["id"]):
extra = ["-o", "StrictHostKeyChecking=yes"]
else:
# First connection since this headset was added: trust what it shows
# (as Set Up Connection does), and pin it from now on. ssh won't create
# the folder its known_hosts file goes in.
extra = ["-o", "StrictHostKeyChecking=accept-new"]
pins = frame_devices.known_hosts(device["id"]).parent
pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
# A headset's first connection trusts what it shows (as Set Up Connection does) and
# pins it; any later one checks the pin strictly (see host_opts).
extra = ["-o", f"StrictHostKeyChecking={self.strictness(device)}"]
if self.control:
# No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
# `extra` first: ssh takes the first value of an option, and it may say accept-new.
@@ -955,27 +1104,70 @@ class Link:
"-o", "ServerAliveCountMax=2", "-N", alias]
else:
argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ConnectTimeout=10", alias, "true"]
lines = queue.Queue()
collecting = [True]
reader_done = threading.Event()
errfile = None
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, **frame_host.DETACHED)
if self.control:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, **frame_host.DETACHED)
else:
# No master (Windows): ssh runs `true` and exits. Its stderr goes to a file read
# as it grows, not a pipe: Windows' OpenSSH can block writing to a redirected
# pipe (see frame_host.run_ssh), and the handshake would never finish.
fd_, errfile = tempfile.mkstemp(prefix="frame-ssh-")
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=fd_, **frame_host.DETACHED)
finally:
os.close(fd_)
except OSError as e:
if errfile:
_unlink(errfile)
self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
return "stop"
self.pending = proc # so stop() can end it mid-handshake
lines = queue.Queue()
collecting = [True]
def read():
for raw in iter(proc.stderr.readline, b""):
if collecting[0]:
lines.put(raw.decode("utf-8", "replace").rstrip())
reader_done.set()
lines.put(None)
proc.stderr.close()
threading.Thread(target=read, daemon=True).start()
def tail():
buf = b""
try:
with open(errfile, "rb") as fh:
while True:
chunk = fh.read()
if not chunk:
if proc.poll() is None:
time.sleep(0.05)
continue
chunk = fh.read() # whatever it wrote just before exiting
if not chunk:
break
buf += chunk
*done, buf = buf.split(b"\n")
for raw in done:
lines.put(raw.decode("utf-8", "replace").rstrip())
except OSError:
pass
if buf:
lines.put(buf.decode("utf-8", "replace").rstrip())
reader_done.set()
lines.put(None)
_unlink(errfile)
threading.Thread(target=read if errfile is None else tail, daemon=True).start()
step, said, authed = "ssh", [], False
deadline = time.monotonic() + HANDSHAKE_TIMEOUT
mismatch = False
shown = None # (type, fingerprint) of the key the headset showed
exited = None # when ssh was first seen to have exited
while True:
left = deadline - time.monotonic()
if self.stopped:
@@ -993,7 +1185,14 @@ class Link:
if authed and self.control and self.check(opts, alias):
break
if proc.poll() is not None and lines.empty():
line = None
# ssh exited: its last words (Permission denied…) may still be on their way.
# The reader's end marker says when they're all in; a pipe that something
# else holds open (a ProxyCommand's child) gets a short grace instead.
exited = exited or time.monotonic()
if reader_done.is_set() and lines.empty() or time.monotonic() - exited > 2:
line = None
else:
continue
else:
continue
if line is None: # ssh exited
@@ -1002,7 +1201,7 @@ class Link:
break
if authed and self.control and self.check(opts, alias):
break
return self.failed(step, said, mismatch, alias)
return self.failed(step, said, mismatch, alias, shown, a["host"], device)
if not line.startswith("debug"):
said.append(line)
m = CONNECTING.search(line)
@@ -1015,12 +1214,15 @@ class Link:
self.stage("identity", "active", "Waiting for the headset's host key")
elif HOSTKEY.search(line):
m = HOSTKEY.search(line)
shown = (m.group(1), m.group(2))
self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…")
elif KNOWN.search(line):
self.stage("identity", "done", "Matches the identity saved for this headset")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
elif ADDED.search(line):
if not device.get("transient"):
self.settle_pin(device, tighten=False) # now, before the login can fail
self.stage("identity", "done", "First connection: saved this headset's identity")
step = "login"
self.stage("login", "active", f"Logging in as {user}")
@@ -1059,18 +1261,36 @@ class Link:
# OpenSSH lower-cases host names (FRAME.LOCAL logs as frame.local).
return not m or m.group(1).lower() in (host.lower(), alias.lower()) or "Authentication succeeded" in line
def failed(self, step, said, mismatch, alias):
def failed(self, step, said, mismatch, alias, shown=None, host=None, device=None):
text = "\n".join(said).strip()
if mismatch:
self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). "
"If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text)
# A key that differs from the saved one, or (strict, pinned) none of the saved key's
# type any more: either way not the identity saved. Never accepted here: the page
# shows the new key's fingerprint and the person decides (Trust a new key).
name = (device or {}).get("name") or alias
lost = bool(device) and not frame_devices.pinned(device["id"]) and \
frame_devices.identity_established(device)
if lost:
message = (f"The SSH identity saved for {name} is missing or can't be read, so Frame Control won't "
"trust whatever answers. Check its key and trust it on the Devices tab.")
else:
message = (f"This address answered with a different SSH identity from the one saved for {name}. "
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
"it on the Devices tab. If not, a different device may be answering here.")
self.fail("identity", message, text,
short="Saved SSH identity missing" if lost else "SSH identity changed",
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
"fingerprint": shown[1] if shown else None, "host": host})
return "next"
# A refused key is the same at every address: stop. (Judged by ssh's own words, not
# the step: a jump host's progress lines look like the headset's.) A forward that
# a jump host couldn't open is about this address only: try the next.
forward = re.search(r"open failed|forwarding failed|Connection refused|Connection closed|timed out", text)
if re.search(r"Permission denied", text) and not forward:
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text)
# Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
"Connection: it pairs this computer with the headset again.", text, auth="denied",
short="This computer's key refused")
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
@@ -1110,9 +1330,11 @@ class Link:
lead = f"Answered in {res['rtt_ms']:g} ms" if res.get("rtt_ms") is not None else "Through the jump host"
rows[i]["ssh"] = "checking"
put()
# "yes" before host_opts (ssh takes an option's first value): a test only looks, so
# it reports a key not saved yet rather than saving it as host_opts' accept-new would.
argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
"-o", "StrictHostKeyChecking=yes",
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))), device["alias"], "true"]
try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
@@ -1140,6 +1362,156 @@ class Link:
put(done=True, finished=now())
self.devices_changed()
# ---- Trust a new key ----
# Every way a key check could do more than read the key: no login of any kind, no agent
# (nor forwarding it), no X11, port or tunnel forwarding, no local or remote command, no
# terminal, no shared connection, no learning keys. Given first, so ssh takes these over
# anything ~/.ssh/config (or host_opts) says. -N: no command, even if a device accepts "none".
SCAN_OPTS = ["BatchMode=yes", "ControlMaster=no", "ControlPath=none", "ConnectTimeout=8",
"StrictHostKeyChecking=accept-new", "UpdateHostKeys=no", "HashKnownHosts=no",
"PreferredAuthentications=none", "PubkeyAuthentication=no", "PasswordAuthentication=no",
"KbdInteractiveAuthentication=no", "HostbasedAuthentication=no", "IdentityAgent=none",
"ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no", "ClearAllForwardings=yes",
"Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none", "RequestTTY=no"]
SCAN_TIMEOUT = 20
def scan_identity(self, device_id):
"""Read the headset's host key without trusting it or logging in. ssh writes it to a
throwaway known_hosts file during the key exchange, before any authentication, and is
stopped as soon as it has (or when it gives up); SCAN_OPTS and -N rule out anything else.
`-F none` too, unless ~/.ssh/config sends the alias through a jump host (then it's
needed for that, and SCAN_OPTS still win). The address that showed the changed key goes
first, then the rest in order. -> {"type", "fingerprint", "host", "saved", "recorded",
"matches", "recorded_match"}; the key is kept (SCAN_TTL) for trust_identity, which pins
exactly it."""
device = self.reg.get(device_id)
if not device["addresses"]:
raise frame_devices.DeviceError("This headset has no addresses to ask")
hosts = [a["host"] for a in device["addresses"]]
with self.cond:
err = self.state.get("error") or {}
mine = (self.state.get("device") or {}).get("id") == device_id
first = (err.get("hostkey") or {}).get("host") if mine else None
if first in hosts:
hosts.remove(first)
hosts.insert(0, first)
route = self.scan_route(device)
reasons = []
for host in hosts:
key, err_text = self._scan_one(device, host, route)
if key:
saved = frame_devices.pinned_keys(device_id)
recorded = [f for f in device.get("identity") or [] if isinstance(f, str)]
scan = {"type": key[0], "blob": key[1], "fingerprint": key[2], "host": host,
"port": device["port"], "user": device["user"], "at": now()}
with self.cond:
self.scans[device_id] = scan
return {"type": key[0], "fingerprint": key[2], "host": host,
"saved": [{"type": t, "fingerprint": f} for t, f in saved], "recorded": recorded,
"matches": any(f == key[2] for _, f in saved), "recorded_match": key[2] in recorded}
text = err_text.strip()
reasons.append(f"{host}: " + (self.explain(text) or (text.splitlines() or ["no answer"])[-1]))
raise frame_devices.DeviceError("Couldn't read the headset's key. " + "; ".join(reasons))
# The jump host's own ssh (a ProxyCommand the scan builds itself): it may log in to the jump
# host as configured (keys, the agent), but it never forwards the agent, X11 or ports, runs
# no local or remote command, has no terminal, shares no connection and goes no further.
HOP_OPTS = ["BatchMode=yes", "ForwardAgent=no", "ForwardX11=no", "ForwardX11Trusted=no",
"ClearAllForwardings=yes", "Tunnel=no", "PermitLocalCommand=no", "RemoteCommand=none",
"RequestTTY=no", "ControlMaster=no", "ControlPath=none", "ProxyCommand=none", "ProxyJump=none"]
def scan_route(self, device):
"""The options that take a key check to the headset: `-F none` and nothing else when
it's reached directly. Through a jump host (ProxyJump), `-F none` and a ProxyCommand
built here, whose ssh gets HOP_OPTS ahead of ~/.ssh/config: config options can't be
overridden for ProxyJump's own ssh, which inherits ForwardAgent, LocalCommand and the
rest. Through a ProxyCommand from ~/.ssh/config, or several jump hosts, it refuses:
those can't be made safe from here."""
kind, spec = proxy_route(device["alias"])
if kind is None:
return ["-F", "none"]
m = JUMP_RE.fullmatch(spec or "") if kind == "jump" else None
if not m or (m.group(3) and not 1 <= int(m.group(3)) <= 65535):
how = "a ProxyCommand" if kind == "command" else "this jump host setting"
raise frame_devices.DeviceError(
f"{device['name']} is reached through {how} in ~/.ssh/config, which Frame Control can't make safe "
"for talking to a device it doesn't trust yet, so it won't read the key that way. Add an address "
"it can reach directly (or through a single ProxyJump host) and check its key again; or compare "
"the key by hand (on the headset, in Konsole: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub)")
user, host, port = m.groups()
# The ssh this server runs, by its full path: the shell that runs a ProxyCommand may set
# up another PATH.
ssh = frame_host.which(self.mux_base[0])
if not ssh:
raise frame_devices.DeviceError(f"Couldn't find {self.mux_base[0]} to reach the jump host with")
hop = [ssh, *(a for o in self.HOP_OPTS for a in ("-o", o)), *(["-l", user] if user else []),
*(["-p", port] if port else []), host.strip("[]")] # ssh takes a bare IPv6 address here
return ["-F", "none", "-o", "ProxyCommand=" + proxy_command(hop)]
def _scan_one(self, device, host, route):
"""-> ((type, blob, fingerprint) or None, what ssh said)."""
name = frame_devices.host_key_alias(device["id"])
path, opt = frame_devices.scan_file(device["id"])
opts = [a for o in [*self.SCAN_OPTS, f"UserKnownHostsFile={opt}"] for a in ("-o", o)]
argv = [self.mux_base[0], *route, *opts, *self.host_opts(device, host), "-N", device["alias"]]
fd_, errfile = tempfile.mkstemp(prefix="frame-scan-") # a file, not a pipe (see frame_host.run_ssh)
key, proc = None, None
try:
try:
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=fd_,
**frame_host.DETACHED)
finally:
os.close(fd_)
deadline = time.monotonic() + self.SCAN_TIMEOUT
while key is None:
ended = proc.poll() is not None
key = _scanned_key(path, name)
if key or ended or self.stopped or time.monotonic() > deadline:
break
time.sleep(0.05)
except OSError as e:
return None, str(e)
finally:
if proc and proc.poll() is None:
proc.kill() # it has the key (or gave up): nothing more to do with that device
if proc:
try:
proc.wait(5)
except subprocess.TimeoutExpired:
pass
try:
with open(errfile, "rb") as fh:
said = fh.read().decode("utf-8", "replace")
except OSError:
said = ""
_unlink(errfile)
_unlink(path)
if key is None and not said.strip() and proc and proc.returncode is not None and proc.returncode < 0:
said = f"Timed out talking to {device['alias']}"
return key, said
def trust_identity(self, device_id, fingerprint):
"""Pin the key scan_identity read, and only if `fingerprint` is the one it showed (what
the person saw and confirmed), replacing whatever was saved; and only while the headset
is still reached as it was then (same port and user, the address still one of its own).
-> the scan."""
with self.cond:
scan = self.scans.get(device_id)
if not scan or now() - scan["at"] > SCAN_TTL:
self.scans.pop(device_id, None)
raise frame_devices.DeviceError("Check the headset's key again first: the one shown has expired")
if not isinstance(fingerprint, str) or fingerprint != scan["fingerprint"]:
raise frame_devices.DeviceError("That isn't the key the headset showed. Check its key again")
self.scans.pop(device_id)
d = self.reg.get(device_id) # still a headset here
if (d["port"], d["user"]) != (scan["port"], scan["user"]) or \
scan["host"] not in [a["host"] for a in d["addresses"]]:
raise frame_devices.DeviceError("The headset's address, port or user changed since its key was read. "
"Check its key again")
frame_devices.pin_key(device_id, scan["type"], scan["blob"])
self.reg.record_identity(device_id, [scan["fingerprint"]])
return scan
# ---- the page's API: /api/devices -------------------------------------------------
@@ -1158,6 +1530,9 @@ def devices_view(link):
view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")}
view["active"] = d["id"] == active["id"]
view["pinned"] = frame_devices.pinned(d["id"])
view["keys"] = [{"type": t, "fingerprint": f} for t, f in frame_devices.pinned_keys(d["id"])]
# It had an identity, but the saved key is gone or unusable: nothing is trusted until checked.
view["identity_lost"] = not view["pinned"] and frame_devices.identity_established(d)
view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
for a in d["addresses"]]
devices.append(view)
@@ -1190,7 +1565,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
is_active = did == active["id"]
moves = action == "use" or (action == "retry" and link.alive()) or (is_active and (
# (a retry while connected would cut the install's connection)
action in ("remove", "address-remove", "forget-identity")
action in ("remove", "address-remove", "identity-trust")
or (action == "update" and login_change(reg, did, body))
or (action == "address-update" and body.get("newHost") not in (None, body.get("host")))))
if moves and busy():
@@ -1270,11 +1645,25 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
threading.Thread(target=link.test, args=(did,), daemon=True).start()
msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
elif action == "forget-identity":
# Dropping the pin used to let the next contact save whatever key answered, unseen.
# A new key is now always shown first: identity-scan, then identity-trust.
reg.get(did)
raise frame_devices.DeviceError("Forgetting a headset's identity is no longer supported: use Check its key "
"on the Devices tab (identity-scan, then identity-trust), which shows the "
"new key before saving it")
elif action == "identity-scan":
d = reg.get(did)
frame_devices.forget_pin(did)
scan = link.scan_identity(did)
msg = (f"{d['name']} shows the key already saved for it" if scan["matches"]
else f"{d['name']} shows a {scan['type']} key, {scan['fingerprint']}")
link.devices_changed()
return dict(devices_view(link), message=msg, scan=scan)
elif action == "identity-trust":
d = reg.get(did)
link.trust_identity(did, body.get("fingerprint"))
if is_active:
link.kick("switch")
msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
link.kick("switch") # reconnect with the key now saved
msg = f"Saved {d['name']}'s new SSH identity; reconnecting"
elif action == "name-network":
reg.name_network(body.get("network"), body.get("name"))
if link.state["network"]:
+12 -1
View File
@@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
"""
import ctypes as C
import glob
import hashlib
import math
import json
import os
@@ -121,7 +122,16 @@ def os_release():
k, _, v = line.partition("=")
out[k] = v.strip('"')
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
"variant": out.get("VARIANT_ID")}
"variant": out.get("VARIANT_ID"), "headset": headset_id()}
def headset_id():
"""A one-way hash of the headset's /etc/machine-id: the same for this headset on every
computer, and the machine-id itself never leaves the headset. None without one."""
mid = read("/etc/machine-id")
if not mid:
return None
return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16]
def volume():
@@ -292,6 +302,7 @@ def status():
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
"thermalAlerts": thermal_alerts(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
+88 -22
View File
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
same project whatever is chosen here.
Events are identified by a random id made on first run, not by the person or
computer, and sent without person profiles or GeoIP. Nothing is sent without a
computer, and sent without person profiles or GeoIP. Opens, installs and updates
carry a country instead, worked out on this computer from its time zone setting
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
$FRAME_CONTROL_TELEMETRY=0.
@@ -43,6 +45,7 @@ from pathlib import Path
from urllib.parse import urlsplit
import frame_host
import frame_timezones
HERE = Path(__file__).resolve().parent
STATE = frame_host.data_dir('telemetry')
@@ -50,22 +53,26 @@ SETTINGS = STATE / 'settings.json'
OUTBOX = STATE / 'outbox.jsonl'
SENT = STATE / 'sent.jsonl'
SENT_KEEP = 200
FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
FLUSH_EVERY = 60
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
# Not faults in Frame Control: the headset asleep, away or not set up yet. The status poll
# Not faults in Frame Control: the headset asleep, away or not set up yet, or ssh and the headset
# not trusting each other (a host key that changed, a key not authorised): the page says what to do
# about each (Devices tab, Set Up Connection). The status poll
# meets these every few seconds, so each is sent at most once per session, whatever the wording,
# but only for an error marked where ssh ran as ssh failing to reach the headset
# (frame_host.link_failure). The message alone isn't evidence: a download's "Connection reset by
# peer", or a file name with ssh's words in it, keeps the usual window.
EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up')
EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
DEFAULT_HOST = 'https://us.i.posthog.com'
LEVELS = ('usage', 'compat', 'diagnostics')
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
# Events the page may send through /api/telemetry, and the properties each may carry.
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
TABS = {'home', 'games', 'android', 'tools'}
TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
_lock = threading.RLock()
_send_lock = threading.Lock() # held while sending; consent changes wait for it
@@ -102,7 +109,7 @@ def blocked():
def _defaults():
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'frames_seen': [], 'compat_sent': []}
@@ -144,12 +151,12 @@ def update_settings(changes):
under way finishes first, so nothing leaves after this returns."""
with _send_lock, _lock:
s = settings()
if 'noticeShown' in changes:
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
if changes.get('noticeShown'):
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
for level in LEVELS:
if level in changes:
s[level] = bool(changes[level])
s['notice_shown'] = True
s['notice_shown'] = True # only the notice itself moves notice_version on
_save(s)
_drop_unwanted(s)
if changes.get('compat'):
@@ -158,10 +165,16 @@ def update_settings(changes):
return state()
def _notice_current(s):
"""Whether the notice this person saw lists everything that's sent now. If not, the page
shows it again, and what it added waits until it has been seen."""
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
def state():
"""What the page shows: the choices, why sending is blocked, and what was sent."""
s = settings()
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
'diagnostics': s['diagnostics'],
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
'sent': list(reversed(_read_lines(SENT)))[:50]}
@@ -264,11 +277,26 @@ CATEGORIES = [
('layer_missing', re.compile(r'OpenXR compatibility layer')),
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name|'
r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
# The Frame-side scripts that talk to the Steam client's UI (frame/android/steam_shortcuts.py), which
# fail with their own words or a Python traceback, its last lines kept. Before frame_unreachable:
# "Connection refused" and "timed out" here are Steam's debug port on the Frame, not ssh.
('steam_not_running', re.compile(r'SharedJSContext not found|'
r'URLError: <urlopen error \[Errno 111\] Connection refused>|'
r'ConnectionRefusedError: \[Errno 111\]')),
('steam_not_responding', re.compile(r'websocket handshake failed|(?:socket\.timeout|TimeoutError): timed out|'
r'(?:\A|\n)EOFError\Z')),
('steam_js_error', re.compile(r'JS error: ')),
('artwork_failed', re.compile(r'incomplete artwork render|artwork renderer did not produce|'
r'render is \d+ bytes of|could not prepare artwork|all five Steam artwork slots')),
# Copying the APK (or its launcher) to the Frame: rsync or scp ran out of time.
('copy_timeout', re.compile(r'to the Frame timed out\Z')),
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed')),
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
# Anything ssh says about its own connection: "ssh: connect to host … port 22: <reason>" (Windows
# says "Unknown error"), a dropped shared connection (mux_client_…, client_loop), and Windows'
# "banner exchange: Connection to UNKNOWN port -1" (its ssh can't name a peer whose connect
@@ -277,6 +305,8 @@ CATEGORIES = [
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
# rsync or scp failed for another reason ("connection unexpectedly closed", or no words at all).
('copy_failed', re.compile(r'\Acopying .+ to the Frame failed: ')),
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
('flatpak', re.compile(r'(?i)flatpak|flathub')),
('cancelled', re.compile(r'(?i)cancel')),
@@ -284,8 +314,16 @@ CATEGORIES = [
]
# Python's own errors for a mistake in Frame Control's code, whatever their message says.
BUG_ERRORS = (KeyError, IndexError, TypeError, AttributeError, NameError, AssertionError,
ZeroDivisionError, RecursionError, UnicodeError, json.JSONDecodeError)
def categorize(message):
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
"""(category, detail): a fixed category name, plus an Android installer code when there is one.
message may be the exception itself: one of BUG_ERRORS is internal_error."""
if isinstance(message, BUG_ERRORS):
return 'internal_error', None
text = str(message or '')
for name, pattern in CATEGORIES:
m = pattern.search(text)
@@ -344,35 +382,59 @@ def page_event(body):
return {'queued': capture(name, props)}
def country():
"""The two-letter country of the computer's time zone setting, or None. Only names in the
tz database's table count, so nothing else in the setting can be sent. The app passes the
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
if not tz:
try:
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
except (OSError, IndexError):
return None
return frame_timezones.COUNTRY.get(tz)
def app_started():
"""Once per server start: first install, an update, and one open a day."""
"""Once per server start: first install, an update, and one open a day, with the country
(left out when sending if the notice this person saw came before it: flush)."""
if blocked():
return
with _lock:
s = settings()
version, today = app_version(), time.strftime('%Y-%m-%d')
cc = country()
where = {'country': cc} if cc else {}
if not s['installed_sent']:
capture('app_installed')
capture('app_installed', where)
s['installed_sent'] = True
elif s['last_version'] and s['last_version'] != version:
capture('app_updated', {'from_version': s['last_version']})
capture('app_updated', {'from_version': s['last_version'], **where})
if s['last_open_day'] != today:
capture('app_opened')
capture('app_opened', where)
s['last_open_day'] = today
s['last_version'] = version
_save(s)
def frame_seen(build, version):
"""The Frame's SteamOS build, once per build (public build numbers)."""
key = f'{build}/{version}'
def frame_seen(build, version, headset=None, saved=None):
"""The Frame's SteamOS build (public build numbers), once per build for each headset.
headset: the anonymous hash from frame_status.headset_id; saved: how many headsets
this copy of the app has set up."""
headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None
key = f'{build}/{version}' + (f'/{headset}' if headset else '')
with _lock:
s = settings()
if not build or key in s['frames_seen']:
return
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
_save(s)
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}
if headset:
p['headset_id'] = headset
if isinstance(saved, int) and saved >= 0:
p['headsets_saved'] = saved
capture('frame_connected', p)
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
@@ -544,10 +606,14 @@ def flush(timeout=20):
if blocked() or not settings()['notice_shown']:
return 0
with _lock:
_drop_unwanted(settings())
s = settings()
_drop_unwanted(s)
batch = _read_lines(OUTBOX)[:100]
if not batch:
return 0
if not _notice_current(s): # what a newer notice added stays here until it's been seen
for e in batch:
e['properties'].pop('country', None)
try:
post(batch, timeout)
except SendError:
+547
View File
@@ -0,0 +1,547 @@
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
Zones that aren't in one country, such as UTC, aren't here."""
COUNTRY = {
'Africa/Abidjan': 'CI',
'Africa/Accra': 'GH',
'Africa/Addis_Ababa': 'ET',
'Africa/Algiers': 'DZ',
'Africa/Asmara': 'ER',
'Africa/Asmera': 'ER',
'Africa/Bamako': 'ML',
'Africa/Bangui': 'CF',
'Africa/Banjul': 'GM',
'Africa/Bissau': 'GW',
'Africa/Blantyre': 'MW',
'Africa/Brazzaville': 'CG',
'Africa/Bujumbura': 'BI',
'Africa/Cairo': 'EG',
'Africa/Casablanca': 'MA',
'Africa/Ceuta': 'ES',
'Africa/Conakry': 'GN',
'Africa/Dakar': 'SN',
'Africa/Dar_es_Salaam': 'TZ',
'Africa/Djibouti': 'DJ',
'Africa/Douala': 'CM',
'Africa/El_Aaiun': 'EH',
'Africa/Freetown': 'SL',
'Africa/Gaborone': 'BW',
'Africa/Harare': 'ZW',
'Africa/Johannesburg': 'ZA',
'Africa/Juba': 'SS',
'Africa/Kampala': 'UG',
'Africa/Khartoum': 'SD',
'Africa/Kigali': 'RW',
'Africa/Kinshasa': 'CD',
'Africa/Lagos': 'NG',
'Africa/Libreville': 'GA',
'Africa/Lome': 'TG',
'Africa/Luanda': 'AO',
'Africa/Lubumbashi': 'CD',
'Africa/Lusaka': 'ZM',
'Africa/Malabo': 'GQ',
'Africa/Maputo': 'MZ',
'Africa/Maseru': 'LS',
'Africa/Mbabane': 'SZ',
'Africa/Mogadishu': 'SO',
'Africa/Monrovia': 'LR',
'Africa/Nairobi': 'KE',
'Africa/Ndjamena': 'TD',
'Africa/Niamey': 'NE',
'Africa/Nouakchott': 'MR',
'Africa/Ouagadougou': 'BF',
'Africa/Porto-Novo': 'BJ',
'Africa/Sao_Tome': 'ST',
'Africa/Timbuktu': 'ML',
'Africa/Tripoli': 'LY',
'Africa/Tunis': 'TN',
'Africa/Windhoek': 'NA',
'America/Adak': 'US',
'America/Anchorage': 'US',
'America/Anguilla': 'AI',
'America/Antigua': 'AG',
'America/Araguaina': 'BR',
'America/Argentina/Buenos_Aires': 'AR',
'America/Argentina/Catamarca': 'AR',
'America/Argentina/ComodRivadavia': 'AR',
'America/Argentina/Cordoba': 'AR',
'America/Argentina/Jujuy': 'AR',
'America/Argentina/La_Rioja': 'AR',
'America/Argentina/Mendoza': 'AR',
'America/Argentina/Rio_Gallegos': 'AR',
'America/Argentina/Salta': 'AR',
'America/Argentina/San_Juan': 'AR',
'America/Argentina/San_Luis': 'AR',
'America/Argentina/Tucuman': 'AR',
'America/Argentina/Ushuaia': 'AR',
'America/Aruba': 'AW',
'America/Asuncion': 'PY',
'America/Atikokan': 'CA',
'America/Atka': 'US',
'America/Bahia': 'BR',
'America/Bahia_Banderas': 'MX',
'America/Barbados': 'BB',
'America/Belem': 'BR',
'America/Belize': 'BZ',
'America/Blanc-Sablon': 'CA',
'America/Boa_Vista': 'BR',
'America/Bogota': 'CO',
'America/Boise': 'US',
'America/Buenos_Aires': 'AR',
'America/Cambridge_Bay': 'CA',
'America/Campo_Grande': 'BR',
'America/Cancun': 'MX',
'America/Caracas': 'VE',
'America/Catamarca': 'AR',
'America/Cayenne': 'GF',
'America/Cayman': 'KY',
'America/Chicago': 'US',
'America/Chihuahua': 'MX',
'America/Ciudad_Juarez': 'MX',
'America/Coral_Harbour': 'CA',
'America/Cordoba': 'AR',
'America/Costa_Rica': 'CR',
'America/Coyhaique': 'CL',
'America/Creston': 'CA',
'America/Cuiaba': 'BR',
'America/Curacao': 'CW',
'America/Danmarkshavn': 'GL',
'America/Dawson': 'CA',
'America/Dawson_Creek': 'CA',
'America/Denver': 'US',
'America/Detroit': 'US',
'America/Dominica': 'DM',
'America/Edmonton': 'CA',
'America/Eirunepe': 'BR',
'America/El_Salvador': 'SV',
'America/Ensenada': 'MX',
'America/Fort_Nelson': 'CA',
'America/Fort_Wayne': 'US',
'America/Fortaleza': 'BR',
'America/Glace_Bay': 'CA',
'America/Godthab': 'GL',
'America/Goose_Bay': 'CA',
'America/Grand_Turk': 'TC',
'America/Grenada': 'GD',
'America/Guadeloupe': 'GP',
'America/Guatemala': 'GT',
'America/Guayaquil': 'EC',
'America/Guyana': 'GY',
'America/Halifax': 'CA',
'America/Havana': 'CU',
'America/Hermosillo': 'MX',
'America/Indiana/Indianapolis': 'US',
'America/Indiana/Knox': 'US',
'America/Indiana/Marengo': 'US',
'America/Indiana/Petersburg': 'US',
'America/Indiana/Tell_City': 'US',
'America/Indiana/Vevay': 'US',
'America/Indiana/Vincennes': 'US',
'America/Indiana/Winamac': 'US',
'America/Indianapolis': 'US',
'America/Inuvik': 'CA',
'America/Iqaluit': 'CA',
'America/Jamaica': 'JM',
'America/Jujuy': 'AR',
'America/Juneau': 'US',
'America/Kentucky/Louisville': 'US',
'America/Kentucky/Monticello': 'US',
'America/Knox_IN': 'US',
'America/Kralendijk': 'BQ',
'America/La_Paz': 'BO',
'America/Lima': 'PE',
'America/Los_Angeles': 'US',
'America/Louisville': 'US',
'America/Lower_Princes': 'SX',
'America/Maceio': 'BR',
'America/Managua': 'NI',
'America/Manaus': 'BR',
'America/Marigot': 'MF',
'America/Martinique': 'MQ',
'America/Matamoros': 'MX',
'America/Mazatlan': 'MX',
'America/Mendoza': 'AR',
'America/Menominee': 'US',
'America/Merida': 'MX',
'America/Metlakatla': 'US',
'America/Mexico_City': 'MX',
'America/Miquelon': 'PM',
'America/Moncton': 'CA',
'America/Monterrey': 'MX',
'America/Montevideo': 'UY',
'America/Montreal': 'CA',
'America/Montserrat': 'MS',
'America/Nassau': 'BS',
'America/New_York': 'US',
'America/Nipigon': 'CA',
'America/Nome': 'US',
'America/Noronha': 'BR',
'America/North_Dakota/Beulah': 'US',
'America/North_Dakota/Center': 'US',
'America/North_Dakota/New_Salem': 'US',
'America/Nuuk': 'GL',
'America/Ojinaga': 'MX',
'America/Panama': 'PA',
'America/Pangnirtung': 'CA',
'America/Paramaribo': 'SR',
'America/Phoenix': 'US',
'America/Port-au-Prince': 'HT',
'America/Port_of_Spain': 'TT',
'America/Porto_Acre': 'BR',
'America/Porto_Velho': 'BR',
'America/Puerto_Rico': 'PR',
'America/Punta_Arenas': 'CL',
'America/Rainy_River': 'CA',
'America/Rankin_Inlet': 'CA',
'America/Recife': 'BR',
'America/Regina': 'CA',
'America/Resolute': 'CA',
'America/Rio_Branco': 'BR',
'America/Rosario': 'AR',
'America/Santa_Isabel': 'MX',
'America/Santarem': 'BR',
'America/Santiago': 'CL',
'America/Santo_Domingo': 'DO',
'America/Sao_Paulo': 'BR',
'America/Scoresbysund': 'GL',
'America/Shiprock': 'US',
'America/Sitka': 'US',
'America/St_Barthelemy': 'BL',
'America/St_Johns': 'CA',
'America/St_Kitts': 'KN',
'America/St_Lucia': 'LC',
'America/St_Thomas': 'VI',
'America/St_Vincent': 'VC',
'America/Swift_Current': 'CA',
'America/Tegucigalpa': 'HN',
'America/Thule': 'GL',
'America/Thunder_Bay': 'CA',
'America/Tijuana': 'MX',
'America/Toronto': 'CA',
'America/Tortola': 'VG',
'America/Vancouver': 'CA',
'America/Virgin': 'VI',
'America/Whitehorse': 'CA',
'America/Winnipeg': 'CA',
'America/Yakutat': 'US',
'America/Yellowknife': 'CA',
'Antarctica/Casey': 'AQ',
'Antarctica/Davis': 'AQ',
'Antarctica/DumontDUrville': 'AQ',
'Antarctica/Macquarie': 'AU',
'Antarctica/Mawson': 'AQ',
'Antarctica/McMurdo': 'AQ',
'Antarctica/Palmer': 'AQ',
'Antarctica/Rothera': 'AQ',
'Antarctica/South_Pole': 'AQ',
'Antarctica/Syowa': 'AQ',
'Antarctica/Troll': 'AQ',
'Antarctica/Vostok': 'AQ',
'Arctic/Longyearbyen': 'SJ',
'Asia/Aden': 'YE',
'Asia/Almaty': 'KZ',
'Asia/Amman': 'JO',
'Asia/Anadyr': 'RU',
'Asia/Aqtau': 'KZ',
'Asia/Aqtobe': 'KZ',
'Asia/Ashgabat': 'TM',
'Asia/Ashkhabad': 'TM',
'Asia/Atyrau': 'KZ',
'Asia/Baghdad': 'IQ',
'Asia/Bahrain': 'BH',
'Asia/Baku': 'AZ',
'Asia/Bangkok': 'TH',
'Asia/Barnaul': 'RU',
'Asia/Beirut': 'LB',
'Asia/Bishkek': 'KG',
'Asia/Brunei': 'BN',
'Asia/Calcutta': 'IN',
'Asia/Chita': 'RU',
'Asia/Choibalsan': 'MN',
'Asia/Chongqing': 'CN',
'Asia/Chungking': 'CN',
'Asia/Colombo': 'LK',
'Asia/Dacca': 'BD',
'Asia/Damascus': 'SY',
'Asia/Dhaka': 'BD',
'Asia/Dili': 'TL',
'Asia/Dubai': 'AE',
'Asia/Dushanbe': 'TJ',
'Asia/Famagusta': 'CY',
'Asia/Gaza': 'PS',
'Asia/Harbin': 'CN',
'Asia/Hebron': 'PS',
'Asia/Ho_Chi_Minh': 'VN',
'Asia/Hong_Kong': 'HK',
'Asia/Hovd': 'MN',
'Asia/Irkutsk': 'RU',
'Asia/Istanbul': 'TR',
'Asia/Jakarta': 'ID',
'Asia/Jayapura': 'ID',
'Asia/Jerusalem': 'IL',
'Asia/Kabul': 'AF',
'Asia/Kamchatka': 'RU',
'Asia/Karachi': 'PK',
'Asia/Kashgar': 'CN',
'Asia/Kathmandu': 'NP',
'Asia/Katmandu': 'NP',
'Asia/Khandyga': 'RU',
'Asia/Kolkata': 'IN',
'Asia/Krasnoyarsk': 'RU',
'Asia/Kuala_Lumpur': 'MY',
'Asia/Kuching': 'MY',
'Asia/Kuwait': 'KW',
'Asia/Macao': 'MO',
'Asia/Macau': 'MO',
'Asia/Magadan': 'RU',
'Asia/Makassar': 'ID',
'Asia/Manila': 'PH',
'Asia/Muscat': 'OM',
'Asia/Nicosia': 'CY',
'Asia/Novokuznetsk': 'RU',
'Asia/Novosibirsk': 'RU',
'Asia/Omsk': 'RU',
'Asia/Oral': 'KZ',
'Asia/Phnom_Penh': 'KH',
'Asia/Pontianak': 'ID',
'Asia/Pyongyang': 'KP',
'Asia/Qatar': 'QA',
'Asia/Qostanay': 'KZ',
'Asia/Qyzylorda': 'KZ',
'Asia/Rangoon': 'MM',
'Asia/Riyadh': 'SA',
'Asia/Saigon': 'VN',
'Asia/Sakhalin': 'RU',
'Asia/Samarkand': 'UZ',
'Asia/Seoul': 'KR',
'Asia/Shanghai': 'CN',
'Asia/Singapore': 'SG',
'Asia/Srednekolymsk': 'RU',
'Asia/Taipei': 'TW',
'Asia/Tashkent': 'UZ',
'Asia/Tbilisi': 'GE',
'Asia/Tehran': 'IR',
'Asia/Tel_Aviv': 'IL',
'Asia/Thimbu': 'BT',
'Asia/Thimphu': 'BT',
'Asia/Tokyo': 'JP',
'Asia/Tomsk': 'RU',
'Asia/Ujung_Pandang': 'ID',
'Asia/Ulaanbaatar': 'MN',
'Asia/Ulan_Bator': 'MN',
'Asia/Urumqi': 'CN',
'Asia/Ust-Nera': 'RU',
'Asia/Vientiane': 'LA',
'Asia/Vladivostok': 'RU',
'Asia/Yakutsk': 'RU',
'Asia/Yangon': 'MM',
'Asia/Yekaterinburg': 'RU',
'Asia/Yerevan': 'AM',
'Atlantic/Azores': 'PT',
'Atlantic/Bermuda': 'BM',
'Atlantic/Canary': 'ES',
'Atlantic/Cape_Verde': 'CV',
'Atlantic/Faeroe': 'FO',
'Atlantic/Faroe': 'FO',
'Atlantic/Jan_Mayen': 'SJ',
'Atlantic/Madeira': 'PT',
'Atlantic/Reykjavik': 'IS',
'Atlantic/South_Georgia': 'GS',
'Atlantic/St_Helena': 'SH',
'Atlantic/Stanley': 'FK',
'Australia/ACT': 'AU',
'Australia/Adelaide': 'AU',
'Australia/Brisbane': 'AU',
'Australia/Broken_Hill': 'AU',
'Australia/Canberra': 'AU',
'Australia/Currie': 'AU',
'Australia/Darwin': 'AU',
'Australia/Eucla': 'AU',
'Australia/Hobart': 'AU',
'Australia/LHI': 'AU',
'Australia/Lindeman': 'AU',
'Australia/Lord_Howe': 'AU',
'Australia/Melbourne': 'AU',
'Australia/NSW': 'AU',
'Australia/North': 'AU',
'Australia/Perth': 'AU',
'Australia/Queensland': 'AU',
'Australia/South': 'AU',
'Australia/Sydney': 'AU',
'Australia/Tasmania': 'AU',
'Australia/Victoria': 'AU',
'Australia/West': 'AU',
'Australia/Yancowinna': 'AU',
'Brazil/Acre': 'BR',
'Brazil/DeNoronha': 'BR',
'Brazil/East': 'BR',
'Brazil/West': 'BR',
'Canada/Atlantic': 'CA',
'Canada/Central': 'CA',
'Canada/Eastern': 'CA',
'Canada/Mountain': 'CA',
'Canada/Newfoundland': 'CA',
'Canada/Pacific': 'CA',
'Canada/Saskatchewan': 'CA',
'Canada/Yukon': 'CA',
'Chile/Continental': 'CL',
'Chile/EasterIsland': 'CL',
'Cuba': 'CU',
'Egypt': 'EG',
'Eire': 'IE',
'Europe/Amsterdam': 'NL',
'Europe/Andorra': 'AD',
'Europe/Astrakhan': 'RU',
'Europe/Athens': 'GR',
'Europe/Belfast': 'GB',
'Europe/Belgrade': 'RS',
'Europe/Berlin': 'DE',
'Europe/Bratislava': 'SK',
'Europe/Brussels': 'BE',
'Europe/Bucharest': 'RO',
'Europe/Budapest': 'HU',
'Europe/Busingen': 'DE',
'Europe/Chisinau': 'MD',
'Europe/Copenhagen': 'DK',
'Europe/Dublin': 'IE',
'Europe/Gibraltar': 'GI',
'Europe/Guernsey': 'GG',
'Europe/Helsinki': 'FI',
'Europe/Isle_of_Man': 'IM',
'Europe/Istanbul': 'TR',
'Europe/Jersey': 'JE',
'Europe/Kaliningrad': 'RU',
'Europe/Kiev': 'UA',
'Europe/Kirov': 'RU',
'Europe/Kyiv': 'UA',
'Europe/Lisbon': 'PT',
'Europe/Ljubljana': 'SI',
'Europe/London': 'GB',
'Europe/Luxembourg': 'LU',
'Europe/Madrid': 'ES',
'Europe/Malta': 'MT',
'Europe/Mariehamn': 'AX',
'Europe/Minsk': 'BY',
'Europe/Monaco': 'MC',
'Europe/Moscow': 'RU',
'Europe/Nicosia': 'CY',
'Europe/Oslo': 'NO',
'Europe/Paris': 'FR',
'Europe/Podgorica': 'ME',
'Europe/Prague': 'CZ',
'Europe/Riga': 'LV',
'Europe/Rome': 'IT',
'Europe/Samara': 'RU',
'Europe/San_Marino': 'SM',
'Europe/Sarajevo': 'BA',
'Europe/Saratov': 'RU',
'Europe/Simferopol': 'UA',
'Europe/Skopje': 'MK',
'Europe/Sofia': 'BG',
'Europe/Stockholm': 'SE',
'Europe/Tallinn': 'EE',
'Europe/Tirane': 'AL',
'Europe/Tiraspol': 'MD',
'Europe/Ulyanovsk': 'RU',
'Europe/Uzhgorod': 'UA',
'Europe/Vaduz': 'LI',
'Europe/Vatican': 'VA',
'Europe/Vienna': 'AT',
'Europe/Vilnius': 'LT',
'Europe/Volgograd': 'RU',
'Europe/Warsaw': 'PL',
'Europe/Zagreb': 'HR',
'Europe/Zaporozhye': 'UA',
'Europe/Zurich': 'CH',
'GB': 'GB',
'GB-Eire': 'GB',
'Hongkong': 'HK',
'Iceland': 'IS',
'Indian/Antananarivo': 'MG',
'Indian/Chagos': 'IO',
'Indian/Christmas': 'CX',
'Indian/Cocos': 'CC',
'Indian/Comoro': 'KM',
'Indian/Kerguelen': 'TF',
'Indian/Mahe': 'SC',
'Indian/Maldives': 'MV',
'Indian/Mauritius': 'MU',
'Indian/Mayotte': 'YT',
'Indian/Reunion': 'RE',
'Iran': 'IR',
'Israel': 'IL',
'Jamaica': 'JM',
'Japan': 'JP',
'Kwajalein': 'MH',
'Libya': 'LY',
'Mexico/BajaNorte': 'MX',
'Mexico/BajaSur': 'MX',
'Mexico/General': 'MX',
'NZ': 'NZ',
'NZ-CHAT': 'NZ',
'Navajo': 'US',
'PRC': 'CN',
'Pacific/Apia': 'WS',
'Pacific/Auckland': 'NZ',
'Pacific/Bougainville': 'PG',
'Pacific/Chatham': 'NZ',
'Pacific/Chuuk': 'FM',
'Pacific/Easter': 'CL',
'Pacific/Efate': 'VU',
'Pacific/Enderbury': 'KI',
'Pacific/Fakaofo': 'TK',
'Pacific/Fiji': 'FJ',
'Pacific/Funafuti': 'TV',
'Pacific/Galapagos': 'EC',
'Pacific/Gambier': 'PF',
'Pacific/Guadalcanal': 'SB',
'Pacific/Guam': 'GU',
'Pacific/Honolulu': 'US',
'Pacific/Johnston': 'UM',
'Pacific/Kanton': 'KI',
'Pacific/Kiritimati': 'KI',
'Pacific/Kosrae': 'FM',
'Pacific/Kwajalein': 'MH',
'Pacific/Majuro': 'MH',
'Pacific/Marquesas': 'PF',
'Pacific/Midway': 'UM',
'Pacific/Nauru': 'NR',
'Pacific/Niue': 'NU',
'Pacific/Norfolk': 'NF',
'Pacific/Noumea': 'NC',
'Pacific/Pago_Pago': 'AS',
'Pacific/Palau': 'PW',
'Pacific/Pitcairn': 'PN',
'Pacific/Pohnpei': 'FM',
'Pacific/Ponape': 'FM',
'Pacific/Port_Moresby': 'PG',
'Pacific/Rarotonga': 'CK',
'Pacific/Saipan': 'MP',
'Pacific/Samoa': 'AS',
'Pacific/Tahiti': 'PF',
'Pacific/Tarawa': 'KI',
'Pacific/Tongatapu': 'TO',
'Pacific/Truk': 'FM',
'Pacific/Wake': 'UM',
'Pacific/Wallis': 'WF',
'Pacific/Yap': 'FM',
'Poland': 'PL',
'Portugal': 'PT',
'ROC': 'TW',
'ROK': 'KR',
'Singapore': 'SG',
'Turkey': 'TR',
'US/Alaska': 'US',
'US/Aleutian': 'US',
'US/Arizona': 'US',
'US/Central': 'US',
'US/East-Indiana': 'US',
'US/Eastern': 'US',
'US/Hawaii': 'US',
'US/Indiana-Starke': 'US',
'US/Michigan': 'US',
'US/Mountain': 'US',
'US/Pacific': 'US',
'US/Samoa': 'AS',
'W-SU': 'RU',
}
+469 -87
View File
@@ -51,6 +51,10 @@
nav a:hover { color: var(--bright); }
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
.spacer { flex: 1; }
/* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
.hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
.hdr-end > :not(.pill, .devsel) { flex: none; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
@@ -60,6 +64,7 @@
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
@@ -74,7 +79,7 @@
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 1150px) { nav kbd { display: none; } }
@media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -87,14 +92,14 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
@@ -132,7 +137,7 @@
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr .s, .dev-problem .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
@@ -449,6 +454,12 @@
backdrop-filter: none; background: rgb(23, 29, 37); }
.brand svg { width: 26px; height: 26px; }
header .chip { height: 30px; padding: 0 10px; }
/* Let connection text yield before battery and header actions. */
.hdr-end { flex: 1; gap: 6px; }
.hdr-end .pill, .hdr-end .devsel { min-width: 0; }
.hdr-end .pill { padding: 0 8px; gap: 6px; }
.hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
#reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
@@ -475,6 +486,9 @@
.and-btns { padding-left: 0; }
.banner { flex-wrap: wrap; }
.banner .s { white-space: normal; }
/* Reserve the message row; connection actions wrap below it. */
#offline { align-items: flex-start; }
#offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
button { height: 38px; }
button.small { height: 32px; }
.actions button { height: 46px; }
@@ -544,6 +558,95 @@
.store-badges { display:flex; flex-wrap:wrap; gap:7px; }
.store-badge { font-size:11px; color:#b7d8ec; background:#6ec9ff14; padding:4px 9px; border-radius:5px; }
.store-badge.works { color:#b8e2ab; background:#70bb5418; }
/* ---- Android: Store and Library ---- */
.store-tabs { border-bottom:0; padding-bottom:0; margin-bottom:26px; }
.store-switch { display:inline-flex; padding:4px; gap:4px; border-radius:12px; background:#0e1723; border:1px solid #ffffff10; }
.store-switch > button { display:inline-flex; align-items:center; gap:9px; height:42px; padding:0 22px; border-radius:9px; font-size:15px; font-weight:600; background:transparent; color:var(--muted); border:0; }
.store-switch > button svg { width:19px; height:19px; }
.store-switch > button.on { background:linear-gradient(180deg,#2a4763,#203a52); color:var(--bright); box-shadow:0 2px 10px #0006, inset 0 1px 0 #ffffff14; }
.store-switch > button:not(.on):hover { color:var(--text); background:#ffffff08; }
#andCount { font-size:12px; font-weight:600; color:#c6e2f5; background:#1a9fff2e; border-radius:10px; padding:1px 8px; }
#andCount:empty { display:none; }
.store-layout { display:grid; grid-template-columns:200px minmax(0,1fr); gap:32px; align-items:start; }
.store-side { position:sticky; top:88px; display:flex; flex-direction:column; gap:18px; }
.store-cats { display:flex; flex-direction:column; gap:2px; }
.store-cats button { display:flex; align-items:center; gap:11px; width:100%; height:38px; padding:0 12px; border-radius:8px; background:transparent; border:0; color:#b5c0cb; font-size:14px; font-weight:500; text-align:left; }
.store-cats button svg { width:18px; height:18px; flex:none; opacity:.85; }
.store-cats button:hover { background:#ffffff09; color:var(--bright); }
.store-cats button[aria-pressed=true] { background:#1a9fff26; color:var(--bright); box-shadow:inset 3px 0 0 var(--blue); }
.store-side-opts { display:flex; flex-direction:column; gap:10px; padding:14px 12px 0; border-top:1px solid #ffffff10; font-size:12px; color:#a8b5c2; }
.store-check { display:flex; align-items:center; gap:8px; cursor:pointer; }
.store-check input { accent-color:var(--blue); width:15px; height:15px; margin:0; }
.store-source-select { display:flex; flex-direction:column; gap:6px; }
.store-source-select select { max-width:none; width:100%; }
.store-main { min-width:0; }
.store-toolbar { margin-bottom:20px; }
@media(max-width:900px) {
.store-layout { display:block; }
.store-side { position:static; margin-bottom:18px; gap:12px; }
.store-cats { flex-direction:row; overflow-x:auto; scrollbar-width:none; gap:6px; }
.store-cats button { width:auto; flex:none; height:34px; border-radius:18px; background:#ffffff07; border:1px solid #ffffff0c; font-size:12px; }
.store-cats button[aria-pressed=true] { box-shadow:none; background:#235779; border-color:#5287a5; }
.store-side-opts { flex-direction:row; flex-wrap:wrap; align-items:center; gap:14px; border:0; padding:0; }
.store-source-select { flex-direction:row; align-items:center; }
}
.store-shelf { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(220px,calc((100% - 54px)/4)); gap:18px; overflow-x:auto; scroll-snap-type:x mandatory; padding:4px 2px 14px; scrollbar-width:thin; }
.store-shelf > * { scroll-snap-align:start; }
.store-row-head .see-all { margin-left:auto; font-size:12px; height:28px; padding:0 12px; border-radius:14px; background:#ffffff0a; color:var(--link); border:0; }
.store-row-head .see-all:hover { background:#ffffff14; color:var(--bright); }
.store-card { display:flex; flex-direction:column; }
.store-card-content { display:flex; flex-direction:column; flex:1; min-width:0; width:100%; }
.store-card-title strong { min-width:0; }
.store-summary { white-space:normal; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; min-height:2.9em; line-height:1.45; }
.store-card-foot { margin-top:auto; padding-top:12px; }
.store-art.shots { display:flex; gap:6px; justify-content:center; padding:10px 10px 0; }
.store-art.shots > img { position:relative; inset:auto; width:auto; height:100%; max-width:31%; object-fit:cover; object-position:top; border-radius:6px 6px 0 0; box-shadow:0 4px 14px #0008; }
.store-art.icon-art > .art-backdrop { position:absolute; inset:-30%; width:160%; height:160%; object-fit:cover; filter:blur(28px) saturate(1.4) brightness(.55); }
.store-art.icon-art > .art-mark { position:absolute; z-index:2; left:50%; top:50%; width:34%; aspect-ratio:1; height:auto; transform:translate(-50%,-50%); border-radius:22%; box-shadow:0 8px 24px #0009; object-fit:cover; }
.store-art.icon-art .art-initial, .store-art.shots .art-initial { display:none; }
.store-cat { color:#a8b5c2; }
.store-fit { font-size:11px; font-weight:600; border-radius:4px; padding:2px 7px; }
.store-fit.works { color:#c3efb2; background:#59bf4029; }
.store-fit.ready { color:#bfe3fa; background:#1a9fff26; }
.store-fit.blocked { color:#f3b2a6; background:#d9412629; }
.store-vr { font-size:10px; font-weight:700; letter-spacing:.8px; color:#0e141b; background:#c2e7ff; border-radius:4px; padding:2px 6px; }
.store-art .store-vr { position:absolute; z-index:3; top:9px; left:9px; box-shadow:0 2px 8px #0007; }
.store-hero .hero-meta { display:flex; gap:8px; align-items:center; }
/* Library */
.lib-head { display:flex; align-items:flex-end; justify-content:space-between; gap:24px; flex-wrap:wrap; margin-bottom:22px; }
.lib-head h1 { font-size:clamp(26px,3vw,34px); letter-spacing:-1px; margin:6px 0 6px; color:var(--bright); font-weight:700; }
.lib-head p { color:#a8b5c2; margin:0; font-size:14px; max-width:620px; }
.lib-tools { display:flex; gap:12px; align-items:center; flex-wrap:wrap; }
.lib-tools input[type=search] { width:220px; border-radius:8px; }
.lib-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:18px; margin-bottom:36px; }
.lib-card { position:relative; display:flex; flex-direction:column; border-radius:10px; background:#1b2938; border:1px solid #ffffff0b; transition:transform .18s,box-shadow .18s; }
.lib-card:hover { transform:translateY(-3px); box-shadow:0 12px 28px #0005; background:#203247; }
.lib-card .store-art { aspect-ratio:16/9; width:100%; border-radius:10px 10px 0 0; }
.lib-card:has(.lib-more[open]) { z-index:5; transform:none; }
.lib-card.running { border-color:#59bf4080; box-shadow:0 0 0 1px #59bf4040; }
.lib-running { position:absolute; z-index:3; top:9px; right:9px; font-size:11px; font-weight:600; color:#d6f5c8; background:#2d5a1fd9; border-radius:10px; padding:2px 9px; display:inline-flex; align-items:center; gap:6px; }
.lib-running::before { content:""; width:7px; height:7px; border-radius:50%; background:#8ef05f; box-shadow:0 0 6px #8ef05f; }
.lib-body { display:flex; gap:11px; align-items:center; padding:13px 14px 4px; min-width:0; }
.lib-body .store-icon { width:40px; height:40px; border-radius:10px; font-size:19px; }
.lib-body strong { display:block; color:var(--bright); font-size:15px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body small { display:block; color:var(--muted); font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-body > div { min-width:0; }
.lib-warn { margin:6px 14px 0; font-size:11px; color:#e8c27a; }
.lib-actions { display:flex; gap:8px; align-items:center; padding:12px 14px 14px; margin-top:auto; }
.lib-actions .play, .lib-actions .stop { flex:1; height:36px; font-size:14px; font-weight:600; border-radius:6px; }
.lib-more { position:relative; }
.lib-more > summary { list-style:none; display:flex; align-items:center; justify-content:center; width:38px; height:36px; border-radius:6px; background:var(--btn); color:var(--text); cursor:pointer; font-size:18px; line-height:1; }
.lib-more > summary::-webkit-details-marker { display:none; }
.lib-more > summary:hover, .lib-more[open] > summary { background:var(--btn-hi); }
.lib-menu { position:absolute; right:0; bottom:calc(100% + 6px); z-index:20; min-width:190px; padding:6px; border-radius:9px; background:#23364a; border:1px solid #ffffff1a; box-shadow:0 14px 34px #000a; display:flex; flex-direction:column; }
.lib-menu button { display:block; width:100%; text-align:left; height:auto; padding:8px 10px; border-radius:6px; background:transparent; font-size:13px; font-weight:400; color:var(--text); border:0; }
.lib-menu button small { display:block; color:var(--muted); font-size:11px; margin-top:2px; }
.lib-menu button:hover { background:#ffffff12; }
.lib-menu button.danger { color:#ff9c8a; }
.lib-empty { grid-column:1/-1; text-align:center; padding:48px 20px; border:1px dashed #ffffff1c; border-radius:12px; color:#a8b5c2; }
.lib-empty h2 { color:var(--bright); text-transform:none; letter-spacing:-.3px; font-size:20px; margin:0 0 8px; }
.lib-section { font-size:13px; text-transform:uppercase; letter-spacing:1.6px; color:var(--muted); margin:0 0 14px; font-weight:700; }
.lib-manage { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr)); gap:18px; align-items:start; }
.store-empty { display:flex; flex-direction:column; align-items:center; text-align:center; padding:58px 20px 70px; }
.store-empty svg { width:110px; height:90px; color:var(--link); margin-bottom:14px; }
.store-empty h2 { font-size:23px; color:var(--bright); margin:8px 0; text-transform:none; letter-spacing:0; }
@@ -624,7 +727,14 @@
.store-hero { height:285px; border-radius:9px; margin-bottom:26px; }
.hero-copy { padding:26px 22px; } .hero-copy strong { font-size:29px; }
.hero-copy > span:not(.store-eyebrow):not(.hero-cta) { font-size:13px; max-width:260px; }
.store-row-head { display:block; } .store-row-head h2 { font-size:18px; } .store-row-head p { margin-top:3px; }
.store-row-head { display:flex; align-items:center; } .store-row-head h2 { font-size:18px; } .store-row-head p { display:none; }
.store-heading .store-eyebrow, .store-heading p { display:none; } .store-heading h1 { font-size:26px; margin:0 0 12px; }
.store-shelf { grid-auto-columns:minmax(200px,72%); gap:12px; }
.store-switch > button { height:38px; padding:0 14px; font-size:14px; }
.lib-head { margin-bottom:16px; } .lib-head p { font-size:13px; } .lib-tools, .lib-tools input[type=search] { width:100%; }
.lib-grid { grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; }
.lib-body { padding:10px 10px 2px; } .lib-body .store-icon { width:30px; height:30px; font-size:14px; } .lib-body strong { font-size:13px; }
.lib-actions { padding:10px; gap:6px; } .lib-actions .play, .lib-actions .stop { height:34px; font-size:13px; }
.store-card-content { padding:10px; } .store-card-title { gap:6px; } .store-card-title strong { font-size:13px; }
.store-card-title .store-icon { width:25px; height:25px; font-size:12px; border-radius:6px; }
.store-card-foot { font-size:10px; margin-top:12px; } .store-summary { font-size:11px; }
@@ -636,6 +746,7 @@
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
</style>
<link rel="stylesheet" href="/control-center.css">
</head>
<body>
<header>
@@ -654,8 +765,9 @@
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
<a href="#control" title="Search controls, settings and live metrics (6)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18M8 3v6M16 9v6M10 15v6"/></svg>Control<kbd>6</kbd></a>
</nav>
<div class="spacer"></div>
<div class="hdr-end">
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
@@ -665,6 +777,7 @@
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
</button>
</div>
</header>
<main>
@@ -677,7 +790,8 @@
</div>
<div class="notice" id="privacyNotice" hidden>
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
worked and the details of errors, which helps fix problems faster.</div>
<button class="small" id="noticeSettings">Privacy settings</button>
@@ -700,10 +814,47 @@
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
<button class="small desk-only" id="offDetails" hidden>Details</button>
<button class="small desk-only" id="offTrust" hidden>Check the new key…</button>
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
<button class="action small" id="offRetry">Retry now</button>
</div>
<div class="page cc" data-page="control" id="controlCenter">
<section class="cc-hero" aria-labelledby="ccTitle">
<div><div class="cc-eyebrow">Your Frame, at a glance</div><h1 id="ccTitle">Control Center</h1><p>Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.</p></div>
<svg class="cc-hero-mark" viewBox="0 0 120 84" fill="none" aria-hidden="true"><path d="M14 28C14 15 106 15 106 28v23c0 18-33 23-39 11h-14c-6 12-39 7-39-11z" stroke="currentColor" stroke-width="1.5"/><path d="M26 32c0-6 25-6 25 0v15c0 11-25 11-25 0zM69 32c0-6 25-6 25 0v15c0 11-25 11-25 0z" stroke="currentColor" stroke-opacity=".4"/><path d="M7 33h7m92 0h7M48 18l4-7h16l4 7" stroke="currentColor" stroke-width="1.5"/><circle cx="60" cy="38" r="2" fill="currentColor"/></svg>
</section>
<section aria-labelledby="ccDeviceName">
<div class="cc-topline"><h2 id="ccDeviceName">Steam Frame</h2><span class="cc-sample" id="ccSampleState" role="status">Connect your Frame to read live metrics</span><button class="small" id="ccRefresh">Refresh metrics</button></div>
<div class="cc-summary">
<div class="cc-metric"><span>Battery</span><strong data-cc-metric="battery">Unavailable</strong><small data-cc-metric="batteryDetail">Connect to read charge and power</small></div>
<div class="cc-metric"><span>Storage free</span><strong data-cc-metric="storage">Unavailable</strong><small data-cc-metric="storageDetail">User storage unavailable</small></div>
<div class="cc-metric"><span>Memory used</span><strong data-cc-metric="memory">Unavailable</strong><small data-cc-metric="memoryDetail">Memory sensor unavailable</small></div>
<div class="cc-metric"><span>Compositor FPS</span><strong data-cc-metric="fps">Unavailable</strong><small data-cc-metric="fpsDetail">Compositor FPS · not game FPS</small></div>
</div>
<details class="cc-device-facts"><summary id="ccBuild">System information unavailable</summary>
<dl class="cc-readings">
<div><dt>Wi-Fi network</dt><dd data-cc-metric="wifi">Unavailable</dd></div><div><dt>Wi-Fi signal</dt><dd data-cc-metric="signal">Unavailable</dd></div>
<div><dt>IP address</dt><dd data-cc-metric="ip">Unavailable</dd></div><div><dt>Uptime</dt><dd data-cc-metric="uptime">Unavailable</dd></div>
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
<div class="cc-services" id="ccServices"></div>
</details>
</section>
<div class="cc-workspace">
<aside class="cc-sidebar" aria-label="Control Center categories"><p>Browse controls</p><div class="cc-categories" id="ccCategories"></div>
<div class="cc-status"><div><i class="dot" id="ccStatusDot"></i><span id="ccStatusLabel">No live sample</span></div><p id="ccStatusNote">Read-only status over SSH. Controls run only when you use them.</p></div>
</aside>
<div>
<div class="cc-search-row"><div class="cc-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></svg><input id="ccSearch" type="search" placeholder="Search settings, controls and guides…" aria-label="Search Control Center" autocomplete="off"><button id="ccClear" type="button" aria-label="Clear search" hidden>×</button></div><span class="cc-results" id="ccResultCount" role="status" aria-live="polite"></span></div>
<div class="cc-cards" id="ccCards"></div>
<div class="cc-empty" id="ccEmpty" hidden><h2>No matching controls</h2><p>Try a different word or browse all categories.</p><button class="small" id="ccResetFilter">Reset search &amp; category</button></div>
</div>
</div>
</div>
<div class="page on" data-page="home">
<div class="grid-top" id="view">
<section class="panel">
@@ -932,24 +1083,33 @@
<div class="page" data-page="android">
<section id="android">
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Discover apps">
<div class="store-toolbar"><div class="store-heading"><div><div class="store-eyebrow">A little more possibility</div><h1>Find your next favorite.</h1><p>Great games and useful apps. All in one place, all free to explore.</p></div></div>
<div class="store-tabs" aria-label="Android sections"><div class="store-switch"><button class="on" data-store-tab="browse" aria-pressed="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>Store</button><button data-store-tab="library" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Library <span id="andCount"></span></button></div><span class="spacer"></span><button id="sourcesOpen" class="small">Sources <span aria-hidden="true">⚙</span></button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
<section id="appStore" aria-label="Android store">
<div class="store-layout">
<div class="store-side" role="navigation" aria-label="Store categories">
<div id="storeCats" class="store-cats"></div>
<div class="store-side-opts">
<label class="store-check"><input type="checkbox" id="storeFits"> Only apps that fit the Frame</label>
<label class="store-check" title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="storePhone"> Show phone-only apps</label>
<label class="store-source-select"><span>Source</span><select id="sourceFilter"><option value="">All sources</option></select></label>
</div>
</div>
<div class="store-main">
<div class="store-toolbar"><div class="store-heading" id="storeHeading"><div class="store-eyebrow">Android apps for your Frame</div><h1>Store</h1><p>VR games, emulators, video and reading apps that work in a panel. Free, from open sources.</p></div>
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">For you</button><button data-filter="vr" aria-pressed="false">VR experiences</button><button data-filter="flat" aria-pressed="false">Flat-screen apps</button><button data-filter="installable" aria-pressed="false">Fits your Frame</button></div><label class="store-source-select"><span class="sr-only">Filter by source</span><select id="sourceFilter"><option value="">All sources</option></select></label></div>
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
<div id="searchCount" class="store-result-count" aria-live="polite"></div>
<div id="searchGrid" aria-busy="false"></div>
</section>
<div class="and-grid" id="androidLibrary" hidden>
<div class="and-col">
<div class="panel">
<div class="shelf-head"><h2>On the Frame</h2></div>
<div class="list" id="andApps"><div class="sub">Loading…</div></div>
<div class="hint">Each app runs in its own Lepton instance and keeps its data. It's in the Steam
library under its own name and opens in its own panel. Test launches it and records
whether it stays up; Report records how it worked for you.</div>
</div>
</div>
</section>
<div class="lib" id="androidLibrary" hidden>
<div class="lib-head"><div><div class="store-eyebrow">On your Frame</div><h1>Library</h1><p id="libSummary">Your Android apps. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.</p></div>
<div class="lib-tools"><div class="store-chips" id="libFilters"><button data-lib="all" aria-pressed="true">All</button><button data-lib="vr" aria-pressed="false">VR</button><button data-lib="flat" aria-pressed="false">Flat</button><button data-lib="running" aria-pressed="false">Running</button></div>
<input id="libQ" type="search" aria-label="Filter your apps" placeholder="Filter your apps"></div></div>
<div class="lib-grid" id="andApps"><div class="sub">Loading…</div></div>
<h2 class="lib-section">Display and reports</h2>
<div class="lib-manage">
<section class="panel disp" id="display">
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
@@ -1140,8 +1300,9 @@
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only.</span></label>
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
compatibility database, so the verdicts get better for everyone.</span></label>
@@ -1358,6 +1519,18 @@
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
</form>
</dialog>
<dialog id="trustDlg" class="dlg" aria-labelledby="trustTitle">
<form method="dialog" id="trustForm">
<h2 id="trustTitle">Check the headset's new key</h2>
<div class="sub" id="trustText"></div>
<dl class="facts" id="trustKeys"></dl>
<div class="sub" id="trustHow" hidden>To check it, on the headset open the desktop from the SteamVR dashboard, start
Konsole and run <code id="trustCmd"></code>. The <code>SHA256:</code> value it prints must match the key above.</div>
<div class="row rep-actions"><span class="sub" id="trustMsg" role="status"></span><span class="spacer"></span>
<button type="button" class="small" id="trustCancel">Cancel</button>
<button type="submit" class="small action" id="trustGo" disabled>This is my headset: trust its new key</button></div>
</form>
</dialog>
<div class="toast" id="toast"></div>
<script>
@@ -1373,7 +1546,7 @@ const HINTS = {
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
};
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
let state = null, stateReceivedAt = null, stateRead = {valid: false, error: null}, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
@@ -1411,6 +1584,7 @@ $("bottombar").onclick = () => {
const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
window.frameControlCenter?.host(h);
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
@@ -1441,6 +1615,13 @@ let devGen = 0;
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
async function api(path, body, device = window.connDevice) {
const gen = devGen;
const checkGeneration = () => {
if (gen !== devGen && !HEADSET_FREE.test(path)) {
const err = new Error("Switched headsets");
err.offline = err.stale = true; // leave the panel for the new headset's answer
throw err;
}
};
// Changes name the headset the page was showing, so the server refuses one meant
// for a headset it has since switched away from.
const dev = device ? { "X-Frame-Device": device } : {};
@@ -1449,15 +1630,12 @@ async function api(path, body, device = window.connDevice) {
let r;
try { r = await fetch(path, opts); }
catch {
checkGeneration(); // a late transport failure belongs to the old headset too
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
}
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
if (gen !== devGen && !HEADSET_FREE.test(path)) {
const err = new Error("Switched headsets");
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
throw err;
}
checkGeneration();
if (!r.ok) {
const err = new Error(data.error || `HTTP ${r.status}`);
err.offline = !!data.offline;
@@ -1496,6 +1674,7 @@ let online = null;
function setOnline(ok, why) {
const was = online;
online = ok;
window.frameControlCenter?.connection(ok);
$("offline").hidden = ok;
if (!ok) {
$("offMsg").textContent = why || "Can't reach the Frame";
@@ -1527,11 +1706,15 @@ function refresh() {
refreshing = (async () => {
try {
state = await api("/api/status");
stateReceivedAt = Date.now();
stateRead = {valid: true, error: null};
render(state);
setConn(true, state.hostname);
setOnline(true);
} catch (e) {
renderPerformance(null);
if (!e.stale) stateRead = {valid: false, error: e};
window.frameControlCenter?.unavailable(e);
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
} finally {
@@ -1541,6 +1724,12 @@ function refresh() {
})();
return refreshing;
}
async function refreshAfterAudio() {
// A request already underway may contain pre-command audio. Preserve normal
// poll deduplication, but drain it before requesting the command's readback.
if (refreshing) await refreshing;
return refresh();
}
// ---- our VR HUD and optional third-party catalogue ----
function renderPerformance(s) {
@@ -1574,7 +1763,7 @@ $("vrUtilities").onclick = async e => {
await loadUtilities();
};
// The Frame owns the clock. Poll independently of status and the selected tab.
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
async function localNotification(message, request = false) {
try {
@@ -1633,7 +1822,12 @@ function renderComfort(s) {
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
if (!comfortBusy) {
// Not while the Frame can't be reached: each poll would be another ssh attempt waiting to
// time out, and the banner already says why. Status checks bring it back (setOnline).
if (online === false) {
const message = "Session status appears when the Frame is connected.";
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
} else if (!comfortBusy) {
comfortBusy = true;
try { renderComfort(await api("/api/comfort", {action: "status"})); }
catch (e) {
@@ -1667,7 +1861,6 @@ $("castBtn").onclick = () => {
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
$("viewer").requestFullscreen?.().catch(() => {});
};
pollComfort();
// ---- battery ----
function battery(b, power) {
@@ -1714,6 +1907,7 @@ function battery(b, power) {
function render(s) {
renderPerformance(s);
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
battery(s.battery, s.power);
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
@@ -3110,7 +3304,22 @@ document.body.addEventListener("click", async e => {
loadOwned();
// ---- Discover apps ----
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
const sourceState = { vr: null, installable: false, source: "", category: "", phone: false, apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
// The store's categories (ui/apk_sources/_categories.py): what a Frame is good for, not F-Droid's phone-centred list.
const STORE_CATS = [
["", "Home", "Android apps for your Frame", "VR games, emulators, video and reading apps that work in a panel. Free, from open sources.", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", "Made for headsets", "OpenXR games and experiences that fill the room, not a panel.", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", "Play in a panel", "Puzzle, strategy, card and action games that play well on a big floating screen.", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6" fill="currentColor"/><circle cx="17" cy="13.6" r=".6" fill="currentColor"/>'],
["emulators", "Emulators", "Classic games, big screen", "RetroArch, Dolphin, PPSSPP and friends. Bring your own games.", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4"/><path d="M8 10.5h3M9.5 9v3"/><circle cx="15" cy="10.5" r=".8" fill="currentColor"/>'],
["watch", "Watch & listen", "Your own cinema", "Video players, streaming front-ends, music and podcasts.", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z" fill="currentColor"/><path d="M8 20h8"/>'],
["read", "Read & learn", "Books, news and more", "E-book and comic readers, news, dictionaries and learning apps.", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", "Make something", "Drawing, photo editing, music and recording.", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", "Get things done", "Notes, tasks, documents, calendars and calculators.", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", "Stay connected", "Browsers, email, messaging and social apps.", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", "Handy utilities", "File managers, passwords, remote access and developer tools.", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const storeCat = id => STORE_CATS.find(c => c[0] === (id || "")) || STORE_CATS[0];
const catIcon = id => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${storeCat(id)[4]}</svg>`;
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
const storeHue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const storeInitial = name => [...String(name || "?")][0].toUpperCase();
@@ -3118,12 +3327,30 @@ const storeText = text => String(text || "").replace(/<[^>]*>/g, " ").replace(/&
const storeOpenSource = o => o.open_source === true || /^(MIT|Apache|[AL]?GPL|BSD|MPL|ISC|CC0|Unlicense)/i.test(o.license || "");
function sourcePage(url) { try { const u = new URL(url); return ["https:", "http:"].includes(u.protocol) ? u.href : ""; } catch { return ""; } }
function storeImage(path, alt, eager=false) { return /^\/source-image\/[\w-]+$/.test(path || "") ? `<img src="${esc(path)}" alt="${esc(alt)}" loading="${eager ? "eager" : "lazy"}" decoding="async">` : ""; }
function storeArt(o, cls="", eager=false, path=null, alt=null) {
const image = path || o.artwork?.banner || o.artwork?.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}"><span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>${storeImage(image, alt || `${o.name} artwork`, eager)}</span>`;
function storeArt(o, cls="", eager=false, path=null, alt=null, extra="") {
// Best artwork first: a banner, else phone-style screenshots side by side, else the icon over its own blur.
const art = o.artwork || {}, label = alt || `${o.name} artwork`, initial = `<span class="art-initial" aria-hidden="true">${esc(storeInitial(o.name))}</span>`;
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
if (!path && !valid(art.banner) && (art.screenshots || []).some(valid)) {
const shots = art.screenshots.filter(valid).slice(0, 3);
return `<span class="store-art shots ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}${shots.map(u => storeImage(u, "", eager)).join("")}${extra}</span>`;
}
if (!path && !valid(art.banner) && valid(art.icon)) {
return `<span class="store-art icon-art ${cls}" style="--hue:${storeHue(o.name)}" role="img" aria-label="${esc(label)}">${initial}<img class="art-backdrop" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async"><img class="art-mark" src="${esc(art.icon)}" alt="" loading="${eager ? "eager" : "lazy"}" decoding="async">${extra}</span>`;
}
const image = path || art.banner || art.screenshots?.[0];
return `<span class="store-art ${cls}" style="--hue:${storeHue(o.name)}">${initial}${storeImage(image, label, eager)}${extra}</span>`;
}
function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue(o.name)}" aria-hidden="true">${esc(storeInitial(o.name))}${storeImage(o.artwork?.icon, "")}</span>`; }
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
document.addEventListener("error", e => {
if (!e.target.matches?.(".store-art img, .store-icon img")) return;
const img = e.target;
if (!img.dataset.retried) { // the proxy fetches from the source on first use, which can time out once
img.dataset.retried = "1"; setTimeout(() => { img.src = img.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return;
}
const art = img.closest(".icon-art, .shots"); img.remove();
if (art && !art.querySelector("img")) art.classList.remove("icon-art", "shots"); // show the initial again
}, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
async function loadSources() {
@@ -3134,32 +3361,50 @@ async function loadSources() {
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
const STORE_FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
function storeFit(o) { const t = o.verdict?.tone; return STORE_FIT[t] ? `<span class="store-fit ${t}" title="${esc(o.verdict.label)}">${STORE_FIT[t]}</span>` : ""; }
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
const i=sourceState.apps.indexOf(app), o=app.offers[0], c=app.category || o.category;
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o,"",false,null,null,o.vr ? '<span class="store-vr">VR</span>' : "")}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span class="store-cat">${esc(o.phone_only ? "Phone app" : c ? storeCat(c)[1] : o.vr === false ? "Flat-screen app" : "Android app")}</span>${storeFit(o) || "<b>Free</b>"}</span></span></button>`;
}
function storeRow(title, subtitle, apps, category) {
return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p>${category !== undefined ? `<button class="see-all" data-cat="${esc(category)}">See all ›</button>` : ""}</div><div class="store-shelf">${apps.slice(0,12).map(storeCard).join("")}</div></section>` : "";
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, clear your filters, or explore another source.</p><button class="action" id="storeReset">Explore all apps</button></div>`;
return `<div class="store-empty"><svg viewBox="0 0 120 100" fill="none" aria-hidden="true"><rect x="18" y="25" width="60" height="55" rx="12" fill="currentColor" opacity=".08"/><rect x="29" y="18" width="60" height="55" rx="12" stroke="currentColor" opacity=".35"/><circle cx="61" cy="43" r="15" stroke="currentColor" stroke-width="3"/><path d="m72 55 17 18M17 12v10m-5-5h10m79-4v10m-5-5h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg><h2>A little further to explore.</h2><p>No apps found this time. Try a different name, another category, or turn on phone-only apps.</p><button class="action" id="storeReset">Back to the store</button></div>`;
}
function renderCats() {
if (!$("storeCats").children.length) // built once, so a keyboard user keeps focus on the category they chose
$("storeCats").innerHTML = STORE_CATS.map(([id, label]) => `<button data-cat="${id}" aria-pressed="false">${catIcon(id)}<span>${esc(label)}</span></button>`).join("");
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Store")}</h1><p>${esc(blurb)}</p>`;
}
function renderStore() {
const apps=sourceState.apps, browsing=!$("sourceQ").value.trim() && sourceState.vr === null && !sourceState.installable && !sourceState.source;
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${$("sourceQ").value.trim() ? ` for “${$("sourceQ").value.trim()}”` : ""}`;
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
renderCats();
$("searchCount").textContent = browsing ? "" : `${apps.length} ${apps.length===1 ? "app" : "apps"}${q ? ` for “${q}”` : ""}${sourceState.category ? ` in ${storeCat(sourceState.category)[1]}` : ""}`;
if (!apps.length) { $("searchGrid").innerHTML=storeEmpty(); return; }
if (!browsing) { $("searchGrid").innerHTML=`<div class="store-grid">${apps.map(storeCard).join("")}</div>`; return; }
const featured=apps.find(a=>a.offers[0].vr && a.offers[0].artwork?.banner && a.offers[0].verdict?.tone !== 'blocked') || apps.find(a=>a.offers[0].artwork?.banner) || apps[0], o=featured.offers[0];
const tested=apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone==='works'));
const vr=tested.length ? tested : apps.filter(a=>a.offers.some(o=>o.vr && o.verdict?.tone !== 'blocked'));
const popular=[...apps].sort((a,b)=>(b.offers[0].popularity||0)-(a.offers[0].popularity||0));
const recent=apps.filter(a=>a.offers.some(o=>o.updated)).sort((a,b)=>String(b.offers[0].updated||'').localeCompare(String(a.offers[0].updated||'')));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="store-eyebrow">Step into something new</span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">Explore ${esc(featured.name)} <span aria-hidden="true">&nbsp; ↗</span></span></span></button>` + storeRow(apps.some(a=>a.offers[0].popularity) ? "Popular apps" : "Explore apps","More ways to make your Frame yours",popular) + storeRow(tested.length ? "VR that works on the Frame" : "Step into VR",tested.length ? "Experiences checked on a Frame" : "A new perspective, a whole new playground",vr) + storeRow("Recently updated","Fresh from their creators",recent);
// Home: one featured VR app, then a shelf per category; each app appears once, blocked ones not at all.
const ok=apps.filter(a=>a.offers[0].verdict?.tone !== 'blocked'), shown=new Set();
const day=Math.floor(Date.now()/864e5), heroes=ok.filter(a=>a.offers[0].vr && a.offers[0].artwork?.banner);
const featured=heroes[day % (heroes.length || 1)] || ok.find(a=>a.offers[0].artwork?.banner) || ok[0];
if (!featured) { $("searchGrid").innerHTML=storeEmpty(); return; } // nothing here runs on the Frame
const o=featured.offers[0];
shown.add(featured);
const take=(list)=>{ const out=list.filter(a=>!shown.has(a)).slice(0,12); out.forEach(a=>shown.add(a)); return out; };
const rows=STORE_CATS.slice(1).map(([id,label,eyebrow])=>storeRow(label,eyebrow,take(ok.filter(a=>a.category===id)),id)).join("");
const recent=take([...ok].filter(a=>a.offers[0].updated && a.offers[0].artwork?.icon).sort((a,b)=>String(b.offers[0].updated).localeCompare(String(a.offers[0].updated))));
$("searchGrid").innerHTML=`<button class="store-hero" data-app="${apps.indexOf(featured)}" aria-label="Explore ${esc(featured.name)}">${storeArt(o,"",true)}<span class="hero-copy"><span class="hero-meta">${o.vr ? '<span class="store-vr">VR</span>' : ""}<span class="store-eyebrow">Featured</span></span><strong>${esc(featured.name)}</strong><span>${esc(storeText(featured.summary))}</span><span class="hero-cta">See ${esc(featured.name)} <span aria-hidden="true">&nbsp;›</span></span></span></button>` + rows + storeRow("Recently updated","Fresh from their creators",recent);
}
function storeChips() { $("searchFilters").querySelectorAll("button").forEach(b=>b.setAttribute("aria-pressed",b.dataset.filter==='all' ? sourceState.vr===null && !sourceState.installable : b.dataset.filter==='installable' ? sourceState.installable : sourceState.vr===(b.dataset.filter==='vr'))); }
function storeChips() { renderCats(); $("storeFits").checked = sourceState.installable; $("storePhone").checked = sourceState.phone; }
function storeCategory(id) { sourceState.category = id || ""; $("sourceQ").value = ""; storeChips(); searchSources(); document.getElementById("android")?.scrollIntoView({block:"start", behavior:"smooth"}); }
async function searchSources(quiet) {
const request=++sourceState.request;
$("searchGrid").setAttribute("aria-busy","true");
if(!quiet)$("searchGrid").innerHTML=`<div class="store-grid" role="status" aria-label="Finding apps">${Array.from({length:8},()=>'<div class="store-skeleton" aria-hidden="true"></div>').join("")}</div>`;
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable)});
const params=new URLSearchParams({q:$("sourceQ").value,source:sourceState.source,installable:String(sourceState.installable),category:sourceState.category,phone:String(sourceState.phone)});
if(sourceState.vr!==null)params.set("vr",String(sourceState.vr));
try {
const result=await api("/api/search?"+params);if(request!==sourceState.request)return;
@@ -3252,8 +3497,10 @@ let storeSearchTimer;
$("sourceQ").oninput=()=>{clearTimeout(storeSearchTimer);storeSearchTimer=setTimeout(searchSources,300);};
$("sourceSearch").onsubmit=e=>{e.preventDefault();clearTimeout(storeSearchTimer);searchSources();};
$("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();}};
$("storeCats").onclick=e=>{const b=e.target.closest('[data-cat]');if(b)storeCategory(b.dataset.cat);};
$("storeFits").onchange=e=>{sourceState.installable=e.target.checked;searchSources();};
$("storePhone").onchange=e=>{sourceState.phone=e.target.checked;searchSources();};
$("searchGrid").onclick=e=>{const c=e.target.closest('[data-cat]');if(c)return storeCategory(c.dataset.cat);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.phone=false;sourceState.category='';sourceState.source='';$('sourceFilter').value='';storeChips();searchSources();}};
$("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();};
@@ -3269,7 +3516,20 @@ async function addSource(body) {
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
function showAndroidTab(tab) {
const browse = tab === 'browse';
$('appStore').hidden = !browse; $('androidLibrary').hidden = browse;
$('repNew').hidden = browse; $('sourcesOpen').hidden = !browse;
document.querySelectorAll('[data-store-tab]').forEach(button => {
const selected = button.dataset.storeTab === tab;
button.classList.toggle('on', selected); button.setAttribute('aria-pressed', String(selected));
});
}
document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick = () => {
showAndroidTab(button.dataset.storeTab);
if (button.dataset.storeTab === 'library') loadAndroid();
});
storeChips();
loadSources().then(()=>searchSources());
// ---- Android apps (own Lepton instance each) + catalogue ----
@@ -3277,27 +3537,55 @@ const VLABEL = { works: "Works on Frame", likely: "Should work", maybe: "Might w
const cat = { apps: null, q: "", cat: "", v: new Set(["works", "likely"]), shown: 0, results: [] };
let androidApps = [];
const libState = { filter: "all", q: "" };
function libCard(a) {
const pkg = esc(a.package), name = a.label || a.package;
const o = { name, artwork: a.store_art || {} }; // the store's artwork for it, when a source lists it
const kind = a.vr && !a.flatscreen ? '<span class="store-vr">VR</span>' : "";
const version = String(a.version || "").replace(/^v/i, "");
const from = a.source && !/\.apk$/i.test(a.source) ? ` · ${a.source}` : "";
const issues = (a.vr_issues || []).filter(n => !/\(good\)\.?$/.test(n)); // frame_apk_vr.py also notes good news
const warn = issues.length ? `<div class="lib-warn" title="${esc(issues.join(" "))}">${esc(issues[0])}</div>` : "";
return `<article class="lib-card${a.running ? " running" : ""}" data-pkg="${pkg}">
${storeArt(o, "", false, null, `${name} artwork`, kind + (a.running ? '<span class="lib-running">Running</span>' : ""))}
<div class="lib-body">${storeIcon(o)}<div><strong title="${esc(name)}">${esc(name)}</strong><small title="${pkg}">${version ? "v" + esc(version) : esc(a.package)}${esc(from)}</small></div></div>${warn}
<div class="lib-actions">
${a.running ? `<button class="stop" data-and="stop" data-pkg="${pkg}">■ Stop</button>`
: `<button class="play" data-and="launch" data-pkg="${pkg}">▶ Play</button>`}
<details class="lib-more"><summary aria-label="More for ${esc(name)}" title="More">⋯</summary><div class="lib-menu">
${a.art_missing ? `<button data-and="refresh-art" data-pkg="${pkg}" data-name="${esc(name)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
<button data-and="probe" data-pkg="${pkg}">Test<small>Launch it and record whether it stays up</small></button>
<button data-and="rate" data-pkg="${pkg}" data-ver="${esc(a.version)}" data-name="${esc(name)}" data-src="${esc(a.source || "")}">Report how it worked<small>Goes to the shared compatibility list</small></button>
<button class="danger" data-and="remove" data-pkg="${pkg}" data-name="${esc(name)}">Remove from the Frame</button>
</div></details>
</div></article>`;
}
function renderLibrary() {
const q = libState.q.trim().toLowerCase(), f = libState.filter;
const shown = androidApps.filter(a => (f === "all" || (f === "vr" ? a.vr && !a.flatscreen : f === "flat" ? !(a.vr && !a.flatscreen) : a.running))
&& (!q || `${a.label} ${a.package}`.toLowerCase().includes(q)));
const vr = androidApps.filter(a => a.vr && !a.flatscreen).length, running = androidApps.filter(a => a.running).length;
$("libSummary").textContent = androidApps.length
? `${androidApps.length} ${androidApps.length === 1 ? "app" : "apps"} on your Frame${vr ? `, ${vr} in VR` : ""}${running ? `; ${running} running now` : ""}. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.`
: "Your Android apps. Each runs in its own Lepton instance, keeps its data, and is in the Steam library under its own name.";
$("libFilters").querySelectorAll("[data-lib]").forEach(b => b.setAttribute("aria-pressed", b.dataset.lib === f));
$("andApps").innerHTML = shown.length ? shown.map(libCard).join("")
: androidApps.length ? `<div class="lib-empty"><h2>Nothing matches</h2><p>Try another filter.</p></div>`
: `<div class="lib-empty"><h2>No Android apps yet</h2><p>Find VR games, emulators and video apps in the store, or drop an .apk on Send to Frame.</p><button class="action" data-store-go="browse">Open the store</button></div>`;
}
$("libFilters").onclick = e => { const b = e.target.closest("[data-lib]"); if (b) { libState.filter = b.dataset.lib; renderLibrary(); } };
$("libQ").oninput = e => { libState.q = e.target.value; renderLibrary(); };
$("andApps").addEventListener("click", e => {
if (e.target.closest("[data-store-go]")) showAndroidTab("browse");
if (e.target.closest(".lib-menu button")) e.target.closest("details").open = false;
});
document.addEventListener("click", e => { document.querySelectorAll(".lib-more[open]").forEach(d => { if (!d.contains(e.target)) d.open = false; }); });
async function loadAndroid() {
try {
androidApps = (await api("/api/android")).apps;
} catch (e) { return failed($("andApps"), e); }
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package);
const icon = c && c.i ? `<img class="and-icon" src="${esc(c.i)}" alt="">` : `<div class="and-icon"></div>`;
return `<div class="item and-item">${icon}
<div class="grow"><div class="t">${esc(a.label)}</div>
<div class="s">${esc(a.package)} · v${esc(String(a.version || "").replace(/^v/i, ""))}</div></div>
${a.running ? `<span class="tag">Running</span>` : ""}
<div class="row and-btns">
${a.running ? `<button class="small" data-and="stop" data-pkg="${esc(a.package)}">Stop</button>`
: `<button class="play small" data-and="launch" data-pkg="${esc(a.package)}">▶</button>`}
${a.art_missing ? `<button class="small" data-and="refresh-art" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}" title="Its Steam library tile has no artwork yet">Add artwork</button>` : ""}
<button class="small" data-and="probe" data-pkg="${esc(a.package)}" title="Launch it and record whether it stays up">Test</button>
<button class="small" data-and="rate" data-pkg="${esc(a.package)}" data-ver="${esc(a.version)}" data-name="${esc(a.label)}" data-src="${esc(a.source || "")}" title="Report how it worked">Report</button>
<button class="small danger" data-and="remove" data-pkg="${esc(a.package)}" data-name="${esc(a.label)}">Remove</button>
</div></div>`;
}).join("") : `<div class="sub">No Android apps yet. Install one from the catalogue, or drop an .apk on Send to Frame.</div>`;
$("andCount").textContent = androidApps.length ? String(androidApps.length) : "";
renderLibrary();
const installed = new Set(androidApps.map(a => a.package));
document.querySelectorAll('#catGrid [data-and="install"]').forEach(b => {
if (installed.has(b.dataset.pkg)) b.outerHTML = `<span class="tag">Installed</span>`;
@@ -3903,8 +4191,8 @@ $("macview").onclick = async e => {
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
const PAGES = ["home", "games", "android", "tools"];
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
let page = "home";
function showPage() {
const id = location.hash.slice(1);
@@ -3914,6 +4202,8 @@ function showPage() {
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
if (id === "display" || id === "androidLibrary") showAndroidTab("library");
else if (id === "appStore") showAndroidTab("browse");
const section = !PAGES.includes(id) && id && $(id);
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
@@ -4482,7 +4772,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => loadShots()); // after status, so app names resolve
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -4511,6 +4801,20 @@ function netLabel(s) {
}
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
// Why the last attempt failed, in a few words: the server's short reason, else the stage that failed.
function failureReason(s) {
const err = s.error || {};
if (err.short) return err.short;
const st = (s.stages || []).find(x => x.id === err.stage);
return st ? `${st.label} failed` : "Not connected";
}
// A sentence or a label as a clause inside another sentence: no closing full stop (the sentence
// adds its own) and a lower-case first letter, unless the first word is an abbreviation ("SSH").
function asClause(text) {
const t = String(text || "").trim().replace(/[.\s]+$/, "");
return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t;
}
// The pill in the header: always there, and says what's happening right now.
function renderPill() {
const s = link.s;
@@ -4522,9 +4826,8 @@ function renderPill() {
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
dot = "off";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
// The reason first, in a few words, so a narrow pill cuts the countdown rather than the reason.
line = failureReason(s) + (s.retry_at ? ` · retry in ${Math.max(0, Math.ceil(s.retry_at - serverNow()))} s` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
@@ -4532,7 +4835,7 @@ function renderPill() {
: "Connecting…";
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
$("conn").title = `${dev}: ${line}. Click for details.`;
$("conn").title = `${dev}${s.phase === "failed" ? " is offline" : ""}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
@@ -4542,16 +4845,20 @@ function renderBanner() {
const s = link.s;
if (!link.live || !s) return;
$("offDetails").hidden = false;
// A changed host key: never accepted by itself. The person checks the new key and decides.
const hk = s.phase === "failed" && s.error && s.error.hostkey && s.device ? s.device.id : null;
$("offTrust").hidden = !hk;
if (hk) $("offTrust").onclick = () => openTrust(hk);
if (s.phase === "failed" && s.error) {
$("offline").hidden = false;
$("offMsg").textContent = s.error.message;
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
const tried = (s.probes || []).map(p => `${p.host}: ${asClause(p.detail)}`).join("; ");
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
(left != null ? `Trying again in ${left} s.` : "");
} else if (s.phase === "connecting" && online === false) {
const st = activeStage(s);
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
$("offDetail").textContent = `Trying again: ${st ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
@@ -4594,8 +4901,11 @@ function onConnection(s) {
if (link.device !== null && devId !== link.device) {
log(`Now using ${s.device.name}`, "ok");
state = null;
stateReceivedAt = null;
stateRead = {valid: false, error: null};
online = null;
devGen++; // answers still on their way are about the other headset
window.frameControlCenter?.reset();
refreshing = null; // and the next status check must be a new one
if (live) toggleLive(false); // the other headset's video or captures
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
@@ -4642,6 +4952,9 @@ function onConnection(s) {
loadDevices();
}
link.phase = s.phase; link.device = devId;
// What the Devices tab offers for the failure (check a new key, pair again) follows it.
const problem = s.phase === "failed" && s.error ? (s.error.hostkey ? "hostkey" : s.error.auth || "") : "";
if (problem !== link.problem) { link.problem = problem; renderDevices(); }
if (page === "devices") renderTests();
}
@@ -4699,7 +5012,7 @@ function setUpActive() {
$("connSetup").onclick = setUpActive;
// ---- Devices tab ----
PAGES.push("devices");
PAGES.push("devices", "control");
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
async function loadDevices() {
if (!link.live) return;
@@ -4726,7 +5039,10 @@ async function useDevice(id) {
if (!res) { // refused (an install is running): show the headset still in use
const current = dv.list.find(x => x.active);
if (current) $("devSel").value = current.id;
return;
}
// The Devices tab shows the headset now in use, not the one it was showing before.
if (dv.sel !== id) { dv.sel = id; dv.found = null; dv.editing = null; renderDevices(); }
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
@@ -4738,7 +5054,8 @@ function deviceStatus(d) {
const s = link.s;
if (d.active && s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
: s.phase === "failed" ? ["off", s.error ? s.error.short || s.error.message : "Offline", s.error && s.error.message]
: ["", "Connecting…"];
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
@@ -4748,10 +5065,10 @@ function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text] = deviceStatus(d);
const [dot, text, full] = deviceStatus(d);
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
<div class="s"${full ? ` title="${esc(full)}"` : ""}>ssh ${esc(d.alias)} · ${esc(text)}</div></div>
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
@@ -4801,8 +5118,15 @@ function renderDetail() {
<button class="small" data-mv="-1" title="Try earlier"${i ? "" : " disabled"}>↑</button>
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
const problem = err && err.hostkey ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
: err && err.auth === "denied" ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
<button class="small action" id="dvPair">Set Up Connection…</button></div>` : "";
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>${problem}
<form class="dev-form" id="dvForm">
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
@@ -4823,10 +5147,12 @@ function renderDetail() {
<button class="small" id="dvTs">Find on Tailscale</button>
<button class="small" id="dvMdns">Find on this network</button>
<span class="spacer"></span>
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
<button class="small" id="dvTrust" title="After SteamOS is reinstalled or reset the headset shows a new identity">Check its key…</button>
<button class="small danger" id="dvRemove">Remove…</button></div>
<div class="found" id="dvFound"></div>
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
<div class="hint">Identity: ${d.pinned ? `saved${(d.keys || []).length ? ` (${esc(d.keys.map(k => k.fingerprint).join(", "))})` : ""}.
A different device answering at one of these addresses is refused.`
: d.identity_lost ? "the saved key is missing or can't be read, so nothing is trusted until you check its key."
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
@@ -4859,7 +5185,9 @@ function renderDetail() {
$("dvTest").onclick = e => devAction(`Test ${d.name}'s addresses`, { action: "test", id: d.id }, e.currentTarget);
$("dvTs").onclick = e => findOn("tailscale", d, e.currentTarget);
$("dvMdns").onclick = e => findOn("mdns", d, e.currentTarget);
$("dvForget").onclick = e => devAction(`Forget ${d.name}'s identity`, { action: "forget-identity", id: d.id }, e.currentTarget);
$("dvTrust").onclick = () => openTrust(d.id);
if ($("dvTrustNow")) $("dvTrustNow").onclick = () => openTrust(d.id);
if ($("dvPair")) $("dvPair").onclick = () => setUpHeadset(d.alias);
$("dvRemove").onclick = () => askRemove(d);
renderFound(d);
renderTests();
@@ -4912,6 +5240,48 @@ function renderFound(d) {
});
}
// Trust a new key: read the key the headset shows now (nothing is saved), show it next to the
// saved one, and save it only when the person says it's their headset. The server pins exactly
// the key it showed here (frame_link.Link.trust_identity), never whatever answers next.
const HOST_KEY_FILE = { "ssh-ed25519": "ed25519", "ssh-rsa": "rsa", "ssh-dss": "dsa" };
async function openTrust(id) {
const d = dv.list.find(x => x.id === id);
const name = d ? d.name : "the headset";
$("trustTitle").textContent = `Check ${name}'s key`;
$("trustText").textContent = "Asking the headset for its key…";
$("trustKeys").innerHTML = ""; $("trustMsg").textContent = "";
$("trustHow").hidden = true; $("trustGo").disabled = true;
$("trustCancel").onclick = () => $("trustDlg").close();
$("trustForm").onsubmit = e => e.preventDefault();
if (!$("trustDlg").open) $("trustDlg").showModal();
let res;
try { res = await api("/api/devices", { action: "identity-scan", id }); }
catch (e) { $("trustText").textContent = e.message; return; }
const scan = res.scan;
const saved = (scan.saved || []).map(k => `${k.type} ${k.fingerprint}`).join("\n")
|| ((scan.recorded || []).length ? `${scan.recorded.join("\n")}\n(recorded; the saved file is missing or unreadable)` : "None");
$("trustKeys").innerHTML = [["Saved key", saved], ["It shows now", `${scan.type} ${scan.fingerprint}`], ["At", scan.host]]
.map(([k, v]) => `<dt>${esc(k)}</dt><dd style="white-space:pre-wrap; word-break:break-all"><code>${esc(v)}</code></dd>`).join("");
if (scan.matches) {
$("trustText").textContent = `${name} shows the key already saved for it, so there's nothing to change. If it still won't connect, try Test now.`;
return;
}
$("trustText").textContent = scan.recorded_match
? `${name} shows the key recorded for it before, but the saved copy is missing or can't be read. Trust it to save it again.`
: (scan.saved || []).length || (scan.recorded || []).length
? `${name} now shows a different SSH key from the one saved. That's expected after SteamOS was reinstalled or reset, or if this is a new headset at the same address. If none of that happened, don't trust it: another device may be answering in its place.`
: `No key is saved for ${name} yet. Trust this one if it's your headset.`;
$("trustCmd").textContent = `ssh-keygen -lf /etc/ssh/ssh_host_${HOST_KEY_FILE[scan.type] || "ecdsa"}_key.pub`;
$("trustHow").hidden = false;
$("trustGo").disabled = false;
$("trustForm").onsubmit = async e => {
e.preventDefault();
const ok = await devAction(`Trust ${name}'s new key`, { action: "identity-trust", id, fingerprint: scan.fingerprint }, $("trustGo"));
if (ok) $("trustDlg").close();
else $("trustMsg").textContent = "Not saved. Close this and check the key again.";
};
}
function askRemove(d) {
$("rmDevTitle").textContent = `Remove ${d.name}?`;
$("rmDevText").textContent = `Frame Control forgets this headset, its ${d.addresses.length} address${d.addresses.length === 1 ? "" : "es"} and its saved identity. Its SSH keys stay.`;
@@ -4950,11 +5320,23 @@ if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
showPage(); // #devices is a page now
api("/api/host").then(h => {
Object.assign(HOST, h);
window.frameControlCenter?.host(h);
if (h.mobile) return;
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
watchConnection();
}).catch(() => watchConnection());
</script>
<script src="/artwork-settings.js"></script>
<script src="/control-center.js"></script>
<script>
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
window.frameControlCenter.host(HOST);
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
if (online === false) window.frameControlCenter.connection(false);
</script>
</body>
</html>
+76 -28
View File
@@ -211,9 +211,14 @@ UNREACHABLE = [
(re.compile(r"port \d+: Connection refused"),
"The Frame refused the connection. Check Developer Mode is still on."),
(re.compile(r"Permission denied \(publickey"),
"The Frame didn't accept this computer's SSH key. Run Set Up Connection again."),
(re.compile(r"Host key verification failed"),
"The Frame's SSH identity changed (after a reinstall, or a different device). Run Set Up Connection again."),
"The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it "
"again (Developer Mode must be on)."),
# Before "Host key verification failed", which ssh prints after each of these.
(re.compile(r"No \S+ host key is known for "),
"The Frame's SSH identity isn't saved yet. Reconnecting saves it; or check and trust it on the Devices tab."),
(re.compile(r"REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed"),
"The Frame's SSH identity has changed: after SteamOS was reinstalled or reset, or a different device "
"answering at its address. Check the new key on the Devices tab before trusting it."),
(re.compile(r"kex_exchange_identification|Connection closed by .* port \d+|Connection reset by .* port \d+"),
"The connection to the Frame dropped. Try again."),
]
@@ -398,11 +403,38 @@ def status(_body):
s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
osr = s.get("os") if isinstance(s, dict) else None
if isinstance(osr, dict):
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"))
# The headset's hash is for usage statistics only; the page and MCP clients don't get it.
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None),
len(LINK.reg.devices()) if LINK else None)
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
return s
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
# `files` that aren't there (none: just check they all are). Private, and each file written
# whole or not at all, so a copy cut short is finished by the next call.
COMFORT_RUN = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
if files:
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
if not all((root / name).is_file() for name in %r):
print('{"comfort": "missing"}')
sys.exit()
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
"""
def comfort(body):
try:
frame_comfort.validate(body)
@@ -410,25 +442,14 @@ def comfort(body):
raise Failure(str(e), 400)
# Content-addressed, user-only helper bundle. Desktop and phone use the same
# on-headset state/lock; no listener, service registration or third-party app.
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
# The page polls this every few seconds, so run the copy already on the headset and
# send the sources only when that version isn't there yet.
files = {name: (HERE / name).read_text() for name in COMFORT_FILES}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
script = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
""" % (files, version, json.dumps(body))
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % ({}, version, COMFORT_FILES, json.dumps(body)), timeout=65))
if out == COMFORT_MISSING:
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % (files, version, COMFORT_FILES, json.dumps(body)),
timeout=65))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
@@ -678,9 +699,14 @@ def set_volume(body):
# Validate everything before touching the headset.
level = None
if "level" in body:
level = float(body["level"])
if not 0 <= level <= 1:
value = body["level"]
if type(value) not in (int, float) or not 0 <= value <= 1:
raise Failure("level must be 0..1", 400)
level = float(value)
if not body or set(body) - {"level", "muted"}:
raise Failure("Provide level or muted", 400)
if "muted" in body and type(body["muted"]) is not bool:
raise Failure("muted must be true or false", 400)
if "muted" in body:
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
if level is not None:
@@ -2153,11 +2179,14 @@ def source_search(query):
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
category, phone = args.get('category', [''])[0], args.get('phone', [''])[0]
if (len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0')
or phone not in ('', 'true', 'false', '1', '0') or len(category) > 40):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
category=category or None, phone=phone in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
@@ -2275,6 +2304,10 @@ def connection_state():
# ---- HTTP ------------------------------------------------------------------
# POST reads the page repeats on a timer, as (path, action).
POLLS = {("/api/comfort", "status")}
def action_of(body):
"""The action a request asked for, for diagnostics: a short word, never user data."""
a = body.get("action") if isinstance(body, dict) else None
@@ -2385,10 +2418,17 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/control-center.js":
self.send_bytes((HERE / 'control-center.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/control-center.css":
self.send_bytes((HERE / 'control-center.css').read_bytes(), 'text/css; charset=utf-8')
elif path == "/assistant":
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
self.send_bytes(page.encode(), "text/html; charset=utf-8")
@@ -2430,7 +2470,12 @@ class Handler(BaseHTTPRequestHandler):
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
try: # store artwork for the library's cards, from saved indexes only
art = apk_search.artwork_for(apps)
except Exception:
art = {}
self.send_json({"apps": [dict(a, store_art=art[a['package']]) if a.get('package') in art else a
for a in apps]})
elif path == "/api/titles":
ensure_master()
titles_list = frame_titles.list_titles()
@@ -2528,7 +2573,10 @@ class Handler(BaseHTTPRequestHandler):
except ClientGone:
raise
except Failure as e:
if e.status >= 500:
# A poll that couldn't reach the headset is the headset asleep or away, which the page
# already shows; like GET /api/status, not an error to report.
polled = (path, action_of(body)) in POLLS and frame_telemetry.link_failed(e)
if e.status >= 500 and not polled:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
self.send_error_json(str(e), e.status, e.apk)
except (ValueError, TypeError) as e:
+489
View File
@@ -0,0 +1,489 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APK Store</title>
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
"The store in the headset"). Served by ui/server.py running on the Frame
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
sources, categories and artwork as the Store in Frame Control (/api/search,
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
keyboard for search. -->
<style>
:root {
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
--good: #8ed36b; --bad: #ef7a64;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden; user-select: none; -webkit-user-select: none; }
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
svg { width: 22px; height: 22px; flex: none; }
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
/* Rail */
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
.rail button:hover { background: #ffffff10; color: var(--bright); }
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
.rail .n:empty { display: none; }
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
/* Main */
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
.bar { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
.search input::placeholder { color: var(--dim); }
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
background: var(--card); border: 1px solid var(--line); }
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
.card .art { flex: 0 0 52%; }
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
.art { position: relative; display: block; overflow: hidden; width: 100%;
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
.art.shots .initial, .art.iconart .initial { display: none; }
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
/* Home */
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
.hero .art { position: absolute; inset: 0; height: 100%; }
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
/* Pager */
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.pager button { min-width: 180px; }
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
.sheet .top { display: flex; gap: 18px; align-items: center; }
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
.sheet .meta { color: var(--dim); font-size: 16px; }
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
.sheet .acts .close { margin-left: auto; }
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
.kbd .row { display: flex; gap: 8px; justify-content: center; }
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
.kbd .wide { min-width: 320px; }
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
.toast.bad { background: #3a1416; border-color: var(--bad); }
</style>
</head>
<body>
<div class="app">
<div class="rail" role="navigation" aria-label="Store">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
<div id="cats"></div>
<div class="sep"></div>
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
</div>
<main>
<div class="bar">
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
</div>
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
<div class="content" id="content"></div>
<div class="pager">
<button id="prev">◀ Previous</button>
<span class="dots" id="page"></span>
<button id="next">Next ▶</button>
</div>
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
<script>
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const PER_PAGE = 8;
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
const CATS = [
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let toastTimer;
function toast(msg, bad = false) {
const t = $("toast");
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
}
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const initial = name => [...String(name || "?")][0].toUpperCase();
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
function art(name, a = {}, extra = "") {
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
return `<span class="art" ${base}>${ini}${extra}</span>`;
}
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
document.addEventListener("error", e => {
const el = e.target;
if (!el.matches?.(".art img, .icon img")) return;
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
const box = el.closest(".shots, .iconart"); el.remove();
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
}, true);
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
// ---- Data ----
async function loadInstalled() {
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
}
async function search(quiet) {
const request = ++st.request;
clearTimeout(st.retry);
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
st.loading = !quiet; if (!quiet) render();
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
try {
const result = await api("/api/search?" + params);
if (request !== st.request) return;
st.apps = result.apps; st.loading = false;
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
if (!quiet) st.page = 0;
render();
} catch (e) {
if (request !== st.request) return;
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
}
}
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
function installedOf(app) {
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
return pkg ? st.installed.get(pkg) : undefined;
}
// ---- Pages ----
function list() {
if (st.view === "installed") {
const q = st.q.toLowerCase();
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
}
return st.home ? st.home.rest : st.apps;
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
}
function render(error) {
const home = st.view === "" && !st.q;
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
st.home = null;
if (home && st.apps.length) {
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
const mixed = [];
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
}
const items = list();
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
}
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
content.innerHTML = html;
}
function turn(by) {
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
st.page += by; render();
}
// ---- Detail sheet ----
let current = null; // {app, installed}
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
current = app; drawDetail(); $("detail").hidden = false;
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
}
}
function drawDetail() {
const app = current; if (!app) return;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
const reasons = (o.fit?.reasons || []).filter(Boolean);
let acts;
const progress = j => {
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
};
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
if (job && !job.done) {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
}
function closeDetail() { current = null; $("detail").hidden = true; }
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
st.jobs.set(key, job); drawDetail(); render();
try {
const { job: id } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
if (!j.done) { drawDetail(); continue; }
if (j.error) throw new Error(j.error);
toast(j.message); job.result = j.result || {};
break;
}
} catch (e) { job.error = e.message; toast(e.message, true); }
finally {
job.done = true;
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
await loadInstalled(); render(); drawDetail();
}
return job;
}
$("sheet").onclick = async e => {
const b = e.target.closest("[data-act]"); if (!b || !current) return;
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
if (act === "close") return closeDetail();
if (act === "install") {
toast(`Installing ${app.name}. The first time takes a minute…`);
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
if (job.result?.package) {
st.installedFrom.set(key, job.result.package);
if (job.result.game_data) st.needsData.add(job.result.package);
}
return render(), drawDetail();
}
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
if (!job.error) st.needsData.delete(m.package);
return render(), drawDetail();
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
try {
const r = await api("/api/android", { action: act, package: pkg });
toast(r.message);
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
if (act === "launch") closeDetail();
} catch (err) { toast(err.message, true); }
await loadInstalled(); render();
if (current === app) drawDetail();
};
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);
$("next").onclick = () => turn(1);
// The thumbstick arrives as a mouse wheel: one notch, one page.
let wheelAt = 0;
document.querySelector("main").addEventListener("wheel", e => {
e.preventDefault();
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
let qTimer;
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
$("kbd").onclick = e => {
const b = e.target.closest("[data-k]"); if (!b) return;
const k = b.dataset.k, q = $("q");
if (k === "done") { $("kbd").hidden = true; return; }
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
q.dispatchEvent(new Event("input"));
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
</script>
</body>
</html>