Compare commits

...
155 Commits
Author SHA1 Message Date
saphidandClaude Opus 5.5 551cc54bbc Release 0.4.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 23:07:51 +11:00
Alex Southwell c3e651cd25 Merge pull request #67 from saphid/fix/contact-email-review-followups
Contact email: withdrawal covers reports, strict consent, review follow-ups to #59
2026-10-05 23:06:11 +11:00
Alex Southwell f0ba42bfba Merge pull request #70 from saphid/fix/windows-ssh-config-acl
Windows: fix ssh config ACL, link-local IPv6, and Set Up Connection under python -I
2026-10-05 23:00:44 +11:00
saphid 99fc15bd79 Merge remote-tracking branch 'origin/main' into tmp/contact67 2026-10-05 22:55:13 +11:00
saphid e63dc43c2f Merge remote-tracking branch 'origin/main' into fix/windows-ssh-config-acl 2026-10-05 22:52:52 +11:00
Alex Southwell 80f433a2d3 Merge pull request #61 from saphid/fix/windows-rdp-report
Remote desktop from Windows: sign in as steamos, and say why when the Frame doesn't answer
2026-10-05 22:52:44 +11:00
saphidandClaude Opus 5.5 07f44f9082 Windows: fix ssh config ACL, link-local IPv6, and setup under python -I
- ~/.ssh/config writes swapped in a temp file that inherited the .ssh folder's
  ACL; Windows' OpenSSH refuses one granting another account (even a deleted
  one) more than read: "Bad owner or permissions". Writes now give the file an
  owner-only ACL (frame_host.make_private), and the server repairs a refused
  config once per run and retries.
- frame_link.probe named a link-local IPv6 zone with if_indextoname, which on
  Windows is "ethernet_32769"; Windows' ssh can't resolve that, so a headset
  found at fe80:: showed as "can't find the Frame". Use the zone number there.
- frame_connect.py imports frame_host (since #60), but the app runs it with
  python -I, which leaves its folder off sys.path: Set Up Connection exited
  with ModuleNotFoundError. Add the folder, as server.py does.

Verified on a Windows 11 VM against OpenSSH_for_Windows 9.5p2: the old write
reproduces the reported error with an orphan SID's Modify ACE; the new write,
repair and server retry all leave a config ssh accepts; ssh to %ethernet_32769
fails to resolve while %5 connects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 22:43:51 +11:00
saphid c305daae15 Merge remote-tracking branch 'origin/main' into tmp/rdp61
# Conflicts:
#	ui/frame_host.py
#	ui/server.py
2026-10-05 22:41:12 +11:00
Alex Southwell a4031db052 Merge pull request #60 from saphid/fix/windows-test-suite
Windows: stop ssh/scp/ssh-keygen hanging when stderr is captured
2026-10-05 22:39:14 +11:00
saphidandClaude Opus 5.5 049d50f43a Merge main into fix/contact-email-review-followups
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:38:54 +10:00
saphidandClaude Opus 5.5 3a95d3b638 privacy.md: a report saves the address first; sending it may wait
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:36:46 +10:00
saphidandClaude Opus 5.5 989962aecc Contact email: a report's rev is its own change; another address starts fresh
- from_report applies its change and reads the id and rev together, so a
  removal made while that change is sending is newer than the report; the
  report's redaction window now starts before the address is saved.
- A report with a different address replaces the saved one with follow-up
  questions only: update notices aren't carried over to an address nobody
  agreed them for, and the form says so before sending.
- Settings refreshes after every report send, whatever the box shows by then.
- privacy.md: a report with follow-up ticked also saves and sends the address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:25:22 +10:00
saphidandClaude Opus 5.5 08d75e3ffb Contact email: follow-up given with a report is kept and removable; match by rev
- Ticking follow-up questions on a report makes that address the contact
  email (follow-up ticked, update choice unchanged), so Settings shows it
  and Remove my email withdraws it like any other.
- Reports carry contact_rev; the inbox takes a report's follow-up
  permission back when a later change from that copy (higher rev) no
  longer agrees, whatever the clocks say.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:08:16 +10:00
saphidandClaude Opus 5.5 01d5c612c0 Contact email: withdrawal covers earlier reports; consent is a real true
- A report with follow-up ticked carries this copy's contact id, and the
  inbox marks its permission withdrawn when a later choice from that copy
  no longer agrees to follow-up questions at that address.
- The one-time prompt never appears in a visit that showed the privacy
  notice, even if the Frame connects just after it's dismissed.
- Saving contact details isn't headset work: it can't hold up switching
  headsets or be refused after a switch.
- Consent flags must be JSON true/false; "false" is no longer consent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:59:23 +10:00
Alex Southwell 28073e212a Merge pull request #65 from saphid/fix/server-stdin-abort
A stop signal no longer crashes the server, and the app restarts it by itself
2026-09-30 22:58:55 +10:00
saphidandClaude Opus 5.5 6abc765e22 App: Try Again also works when the server is up but its page failed to load
The button was accepted only while no server was known. If the server answered
and the page then failed to load, the error page showed with the server still
known, and the button did nothing. It's now accepted from the error page itself
(the window's only data: page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:51:24 +10:00
saphid f73efe414c Merge main into fix/server-stdin-abort 2026-09-30 22:39:14 +10:00
Alex Southwell 704e5d7780 Merge pull request #59 from saphid/feat/contact-email-opt-in
Optional contact email with separate update and follow-up consent
2026-09-30 22:09:42 +10:00
Alex Southwell edbe8d4109 Merge pull request #63 from saphid/screenshot-copy
Screenshots: Copy, right-click menu, and new shots appear on their own
2026-09-30 20:43:22 +10:00
saphidandClaude Opus 5.5 f2c8466ba9 Screenshots: Refresh retries every failed preview, even if a background check lands meanwhile
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:28 +10:00
saphidandClaude Opus 5.5 6cf01729e2 Screenshots: fixes from review
- A right-click menu open when the headset changes closes, so it can't act on the other headset's shot.
- A preview being retried by Refresh is no longer dropped when a background check lands first.
- A late failure from a headset switched away from no longer drops the new headset's preview.
- Tab and Escape close the menu and give focus back to where it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:26:48 +10:00
saphidandClaude Opus 5.5 63c1a9ff55 docs: xrdp sign-in from Windows verified on a real Frame
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:25:49 +10:00
saphidandClaude Opus 5.5 47a29afb4c Screenshots: recheck fixes
- In Control, a right-click on the viewer goes to the Frame only; the copy menu stays out of the way.
- Thumbnails no longer hold up the next check: a save shows as saved straight away, and a
  new shot appears while older previews are still loading.
- Refresh (or a save) during a background check reads again after it, so the answer is fresh.
- A preview that failed is retried on Refresh, not by every background check.
- Copy reports a failure if the app refuses the image, and if the browser can't copy text.
- Windows: Show in File Explorer works when the path has spaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:19:03 +10:00
saphidandClaude Opus 5.5 a0f810c018 App: restart the server by itself when it stops, and a Try Again button on the error page
A server that had been up for a minute starts again without asking. One that
stops sooner shows the error page, now headed "Frame Control stopped", with a
Try Again button (the menu item was the only way, and hard to find).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:17:24 +10:00
saphidandClaude Opus 5.5 e8db571a2c Remote desktop: say which way the Frame didn't answer
Second review: only a refused port 3389 means xrdp is off. A name that
doesn't resolve, a timeout or no route now say so, rather than telling the
person to turn on Developer Mode. All are Unreachable (a 400, no error
diagnostic). The .rdp file name is a digest of the address, since
fe80::1%2 and fe80::1:2 sanitised to the same name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:10:45 +10:00
saphidandClaude Opus 5.5 d9cd40c035 Remote desktop: review fixes
- Write the .rdp file through open(newline=), since Path.write_text(newline=)
  needs Python 3.10 and CI's checks job runs 3.9
- One .rdp file per address, so overlapping launches can't swap headsets
- xrdp not answering is NotListening, a 400 with its message rather than a
  500 filed as an error diagnostic
- /source-image/ lets ClientGone through instead of answering 404 mid-reply

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:06:14 +10:00
saphidandClaude Opus 5.5 865e8dc17f Align continuation lines after the run_ssh rename
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:58:28 +10:00
saphidandGPT-6.1 Sol 34a334fa27 Fix Windows OpenSSH stderr capture in app and tests
Windows OpenSSH 9.5 blocks while writing captured stderr to a pipe, even with stdin disconnected and a connection timeout. Capture stderr in a temporary file for one-shot OpenSSH calls on Windows, preserving subprocess output, text, check, and timeout behavior. Leave POSIX capture unchanged.

Use the shared runner for SSH, scp, key lookup, and streamed app-data transfers. Bound the real ssh-keygen hashing tests and keep their assertions; move the transfer-error mock to the runner seam. Add ten regression tests.

Verified the full suite on Windows 11 with bundled Python 3.12.14: 628 tests, OK (110 existing skips), 42.685s. Verified macOS Python 3.9.6: 628 tests, OK, 67.934s. Independent Codex gpt-6-sol high-reasoning review found no actionable issues. Protected RDP code is unchanged.

Co-Authored-By: GPT-6.1 Sol (Codex) <noreply@openai.com>
2026-09-30 12:52:24 +10:00
saphidandClaude Opus 5.5 3f273ca37a Remote desktop on Windows: say to choose Connect on mstsc's file prompt
Seen on Windows 11: an unsigned .rdp file makes mstsc ask about the
publisher before the certificate warning. Plain '>' in the message, which
a cp1252 console can print.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:07:39 +10:00
saphidandClaude Opus 5.5 72ffec45c2 Remote desktop: mention the Frame's certificate warning
Seen on Windows 11 against a real Frame: mstsc warns about xrdp's own
certificate before xrdp's login box appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:53:01 +10:00
saphidandClaude Opus 5.5 2ca0e6924a Contact email tests: pin the in-gap save and same-second report timing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:38:54 +10:00
saphidandClaude Opus 5.5 cf2db32721 Contact email: don't strand a change saved as a send finishes; time reports exactly
Third review follow-ups:
- A sender that found nothing waiting checks again after letting go of the
  send lock, so a change saved in that moment is sent, not left for a retrier.
- A report is compared with a removal using its full-precision start time, so
  a report sent after the address was removed is logged as sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:35:25 +10:00
saphidandClaude Opus 5.5 3f0f09b138 Contact email: don't block Save on a slow send; redact reports still in flight
Second review follow-ups:
- Saving returns once the choice is stored; a send already under way picks up
  the newest change, or the background retry is woken.
- A problem report still being sent when its address is removed is logged as
  <removed>, checked under the same lock the removal holds.
- The prompt re-checks the privacy notice after fetching its state.
- docs/privacy.md: offline contact changes are sent later by themselves; the
  prompt never follows straight after the privacy notice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:29:17 +10:00
saphidandClaude Opus 5.5 b8ed53f2ff Contact email: newest choice wins by rev, removal wipes the local log
Review follow-ups:
- Each contact_consent event carries a rev that goes up with every change,
  sends are serialized, and `contacts` picks every field from the highest
  rev per copy, so a withdrawal can't lose to an earlier event sent in the
  same second or with a skewed clock.
- Removing the address also replaces it with <removed> in the local
  sent log (earlier contact events and problem reports).
- The prompt is rechecked when the Frame connects, not only at page load.
- No thanks hides the bar only once the dismissal is saved.
- docs/privacy.md: say that the analytics switches don't block a report or
  contact change the person sends deliberately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:22:45 +10:00
saphidandClaude Opus 5.5 19a0d0af18 Ask for an optional contact email, with separate update and follow-up consent
Problem reports arrive with no way to reply. People can now leave an email
address with two separate opt-ins: occasional update notices, and follow-up
questions from the maintainer.

- ui/frame_contact.py keeps the address and choices locally and sends each
  change privately to PostHog as a contact_consent event under its own random
  contact id; removing the address sends a withdrawal without it. Changes made
  offline wait and are retried.
- A one-time, dismissible prompt appears after the Frame first connects; No
  thanks and showing it once are both remembered.
- Privacy & updates gains a Contact email section to add, change or remove it.
- The report form's contact field now goes with a report only when "may
  contact me with follow-up questions" is ticked (contact_followup).
- frame_report.py contacts [updates|followup] lists who agreed to what,
  using the newest event per copy.
- docs/privacy.md says what is collected, why, where and how to remove it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:15:53 +10:00
saphidandClaude Opus 5.5 7308ac09b1 Remote desktop from Windows: sign in as steamos, and say when xrdp isn't there
A Windows user reported "RDP not working". Frame Control ran `mstsc /v:HOST`,
which offers the Windows account; the Frame's xrdp (TLS, no NLA) only accepts
steamos with the Developer Mode password. The app also said "Opened Remote
Desktop" without checking that anything answered on port 3389.

- open_rdp checks port 3389 first and explains how to turn xrdp on
- On Windows, launch mstsc with a .rdp file naming user steamos (CRLF)
- Every platform's message says to sign in as steamos with the Developer Mode password
- The server no longer logs a page closing mid-reply (WinError 10053 on
  Windows) as a 500 with an error diagnostic

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:11:57 +10:00
saphidandClaude Opus 5.5 1a5f089e57 Server: a stop signal no longer crashes it (SIGABRT) while the app holds stdin
The --exit-on-eof watcher read stdin with a buffered read, which holds stdin's
lock. When SIGTERM stopped the server first, Python aborted at exit trying to
take that lock back, and the app showed "The server stopped unexpectedly
(SIGABRT)". It now uses os.read. The startup line is printed inside the try,
so a signal that arrives while it's printed still runs the cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:12:47 +10:00
saphidandClaude Opus 5.5 83d74548cd Screenshots: Copy button, right-click menu, and new shots appear on their own
Each screenshot card and the viewer get a Copy button that puts the image on
the clipboard (natively in the desktop app, as PNG in a browser). Right-click
a screenshot to open, copy, save, show it in Finder, or copy its path or name;
right-click the viewer to copy or save. The shelf re-lists the Frame's
screenshots every 8 s while the window is visible and connected, redraws only
when something changed, and keeps thumbnails it already has. Switching
headsets clears the list and ignores answers still on their way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:08:36 +10:00
Alex Southwell fa6d4fd81b Merge pull request #47 from saphid/linux-vr-streaming
docs: repeat Frame streaming client feasibility under test lock
2026-09-29 12:54:50 +10:00
Alex Southwell 7cc4abaffa Merge pull request #45 from saphid/devices
Several headsets, several addresses each, a Devices tab, and live connection status
2026-09-29 12:53:28 +10:00
saphidandClaude Opus 5.5 02b9413f78 Merge main into devices: several headsets alongside the app store, comfort, panels and media
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:44:30 +10:00
Alex Southwell 1a08258e3d Merge pull request #49 from saphid/theatre-media-device-fixes
Media player: keep playing through headset standby (real-Frame test fixes)
2026-09-29 12:40:35 +10:00
Alex Southwell a84d6b912b Merge pull request #44 from saphid/apk-store-fixes
Android game store: every source in one search, and proper Steam library entries
2026-09-29 12:39:16 +10:00
saphidandClaude Opus 5.5 448720d8d6 Tests: skip the SIGINT launcher case on bash 3.2 (macOS's), which doesn't run the trap during wait
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:35:23 +10:00
saphidandClaude Opus 5.5 1cd56740a6 Media player: keep retrying the theatre surround after a still is shown
Review (GPT-6 Astra, P2): the still-image loop stopped calling show() once
the screen took its first frame, so a surround refused during standby was
never retried and stayed missing for PNG and splat playback until restart.
hold() now keeps draining pending uploads after the screen is shown, until
both are up.

Also: stills and the surround wait out standby without counting as dropped
video frames or tripping the five-minute limit (video only); teardown
errors no longer overwrite a finished status; Stop is ignored once the
outcome is decided.

Tests: PNG and splat where the screen is accepted before the surround
recovers (fail on the old loop); fake-clock coverage of the five-minute
limit and its reset; status keeps filename/metadata layout sources and
explicit layouts stay explicit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:32:12 +10:00
saphidandClaude Opus 5.5 b685a601f7 Mac view: checking the headset and publishing a tunnel happen under one short lock with retarget
Astra review: a switch landing between the check and the assignment could still
install the old headset's tunnel.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:31:47 +10:00
Alex Southwell 31de17aab8 Merge pull request #48 from saphid/frame-mcp-verified
Record real-Frame MCP end-to-end results
2026-09-29 12:27:26 +10:00
saphidandClaude Opus 5.5 ffef4d897a Devices: the assistant's keep-awake and panel tools reach the chosen headset; a Mac view tunnel opened during a switch is dropped
Integration review findings: the scripts they run ssh'd to whatever 'frame' means
in ~/.ssh/config. They now take FRAME_ALIAS and FRAME_SSH_OPTS from the server's route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:16:25 +10:00
saphidandGPT-6 Astra 5aea46afd0 Merge latest origin/main VR utilities into apk-store-fixes
Preserve the complete store, artwork, telemetry, input, media and agent route table alongside the newly landed VR utilities and performance HUD.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-29 12:09:04 +10:00
saphidandGPT-6 Astra 8fca0fe0a2 Merge origin/main into apk-store-fixes, preserving store and headset features
Keep the union of server routes, desktop resources and responsive controls. Preserve OpenXR install defaults and telemetry hooks alongside library artwork. Adapt the resource test to single-file entries and avoid a completed-refresh race in the F-Droid test.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-29 12:07:09 +10:00
Alex Southwell 6d3cde64fe Merge pull request #43 from saphid/vr-utilities
feat: add OpenVR performance HUD and optional VR utilities
2026-09-29 12:05:38 +10:00
saphidandClaude Opus 5.5 094c12c6d8 Keep media playing through headset standby
Real-Frame testing (2026-09-29) found an unworn headset enters standby
within seconds; SetOverlayRaw then returns RequestFailed (23) and the
movie died. The player now drops frames during standby, keeps audio
and pacing, re-sends stills and the theatre surround after waking, and
only errors after five minutes without an accepted frame.

A Stop arriving while the player is already shutting down is ignored,
so a finished video stays 'ended' instead of 'error: Stopped'. The
status now reports the layout's real source (filename/metadata).

Docs record the end-to-end device matrix (API, web UI, CLI).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:54:05 +10:00
saphidandClaude Opus 5.5 c46bf68dd0 Record real-Frame MCP end-to-end results, including approved mutations
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:15:07 +10:00
saphid dc4a64a9c3 docs: repeat streaming client checks under Frame test lock 2026-09-29 11:07:02 +10:00
saphidandClaude Opus 5.5 08fbe730c6 Merge main into devices: switching headset stops the keyboard agent and retargets the Mac view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:37:45 +10:00
saphidandClaude Opus 5.5 1cf353f419 Tests: give Windows time to refuse a closed loopback port
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:34:11 +10:00
saphidandClaude Opus 5.5 c0183ca8b2 Tests: the private ControlPath is per headset too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:22:33 +10:00
saphidandClaude Opus 5.5 bf8a478063 Devices with the Mac view and the MCP adapter: the tunnel follows the headset, a private server runs alongside
The Mac view's tunnel is its own ssh, so it now takes the headset's route (and its
pinned identity, which also checks the USB-C address), and closes when the app
switches headset. The MCP adapter's private server (FRAME_PRIVATE_SSH=1) skips the
one-server lock and can't add, remove or switch headsets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:19:48 +10:00
saphid 215bc357ef Merge remote-tracking branch 'origin/main' into devices 2026-09-29 10:14:51 +10:00
saphidandClaude Opus 5.5 7d6ff7f919 Merge main into devices: MCP, analytics, Mac view alongside several headsets
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:14:50 +10:00
saphidandClaude Opus 5.5 1343900aa1 Devices: placeholder IPv6 in a validation test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:42:15 +10:00
saphidandClaude Opus 5.5 d2a5db7caf Devices: no real tailnet addresses in tests or screenshots
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:42:04 +10:00
saphidandClaude Opus 5.5 a08ba6f65b Docs: screenshots of the Devices tab and the connection pill
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:51:44 +10:00
saphidandClaude Opus 5.5 53c51e1888 Devices: restarting during startup starts afresh; a headset capture keeps its headset through clean-up (review round 33)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:42:19 +10:00
saphidandClaude Opus 5.5 72e4ccf080 App: overlapping restarts and server starts share one (review round 32)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:35:19 +10:00
saphidandClaude Opus 5.5 a9740ad6f2 Devices: the app waits for its old server before starting the new one; clipboard sends keep their headset (review round 31)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:27:52 +10:00
saphidandClaude Opus 5.5 cb6f294299 Devices: one Frame Control server per user
Two servers each connected, reconnected and edited the headsets on their own,
and several review findings were ways one could move the other's install to a
different headset. A lock file in the data folder now refuses a second server
with a plain message; FRAME_CONTROL_DATA_DIR still gives a separate one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:17:43 +10:00
saphidandClaude Opus 5.5 2c8e2fb2a8 SteamGridDB: request PNG only for logos and icons
The live API rejects mimes=image/jpeg on /logos and /icons, so every logo and
icon lookup fell back to generated art. Found with a real key: SuperTux now
gets grid, wide, hero and logo; Beat Saber all five.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:08:08 +10:00
saphidandClaude Opus 5.5 b87be6866b Devices: while an install runs, nothing elsewhere moves it to another headset (review round 29)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:08:04 +10:00
saphidandClaude Opus 5.5 747dbcf72f Devices: route to the saved headset before serving; a headset removed elsewhere mid-install reaches nothing (review round 28)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 08:01:32 +10:00
saphidandClaude Opus 5.5 409e328880 Devices: each headset its own SSH connection, and each server keeps its own headset (review round 27)
ssh's %C hashes only address, user and port, so two headsets reached at one
address shared a ControlMaster and one's commands could run on the other: the
ControlPath now names the headset. Another Frame Control server choosing a
different headset no longer moves this one's commands mid-install.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:52:28 +10:00
saphidandClaude Opus 5.5 c5a614d989 Devices: two servers sharing devices.json can't save over each other's changes (review round 26)
Every change now takes a lock file shared across processes and starts from
what's on disk; reads pick up a newer file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:42:46 +10:00
saphidandClaude Opus 5.5 0f33b4f094 Devices: saving port 22 overrides a port inherited from a later Host entry (review round 25)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:35:07 +10:00
saphidandClaude Opus 5.5 49378b2c80 Devices: fixes from review round 24
- While the connector is taking a queued reconnect off its list, the old
  connection no longer counts as live, so no install starts on it.
- Importing a block without a Port takes the port ssh would really use
  (ssh -F <config> -G), e.g. one a later Host * sets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:13:15 +10:00
saphidandClaude Opus 5.5 22863f2f87 Devices: match the host in ssh's login line case-insensitively (review round 23)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:01:29 +10:00
saphidandClaude Opus 5.5 b779376f5b Devices: fixes from review round 22
- An upload's answer arriving after a switch opens nothing; the APK
  alternatives dialog installs on the headset the APK was checked for.
- A handshake that goes silent (e.g. a jump host's forward hanging) moves on
  to the next address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:53:18 +10:00
saphidandClaude Opus 5.5 8bd8d63546 Devices: fixes from review round 21
- Behind a jump host, a forward it couldn't open moves on to the next address;
  only a refused key stops (judged by ssh's words, not the step).
- Add a headset suggests an alias no Host in ~/.ssh/config already uses.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:44:18 +10:00
saphidandClaude Opus 5.5 42b51afc5a Devices: fixes from review round 20
- A jump host's own "Authenticated to" line no longer counts as the headset's,
  and a master that logs in but doesn't start lets the next address be tried.
- Devices tab changes refresh through the ordered list load, so a late answer
  can't undo a newer selection.
- A probe's time out starts after the name lookup: macOS can take 5 s to look
  up a .local name (found on the real Frame once its USB link went away).
- An attempt's ending is published from a method, not a return in finally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:35:29 +10:00
saphidandClaude Opus 5.5 6597a90059 Devices: fixes from review round 19
- A switch the server refuses no longer drops answers the page is waiting for
  (an install's job id): only an actual change of headset does.
- Test now goes through a jump host when the alias uses one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:25:10 +10:00
saphidandClaude Opus 5.5 c3e3f2629c Devices: fixes from review round 18
- A set-up headset whose alias goes through a jump host (ProxyJump or
  ProxyCommand in ~/.ssh/config) is reached through it, address by address,
  still pinned per headset.
- A refused switch puts the header's switcher back on the headset in use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:17:29 +10:00
saphidandClaude Opus 5.5 d18f1655be e2e: the app icon lives under artwork/ now
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:44 +10:00
saphidandClaude Opus 5.5 90fd2684ba Devices: fixes from review round 17
- A command that fails after a switch doesn't make the connector drop the new
  headset's connection.
- On first import, the app keeps using the `frame` headset even when Set Up
  Connection put another block above it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:36 +10:00
saphidandClaude Opus 5.5 fa05a4b310 Devices: fixes from review round 16
- Terminals, power and a reconnect's probes use the route commands have now
  (a pinned bare-alias destination, a login change still deferred).
- Saving port 22 keeps an explicit Port line where there was one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:00:00 +10:00
saphidandClaude Opus 5.5 f81c98a87b Fix CI: title removal order, Windows fixtures and POSIX-only tests
- Title removal ran the Steam shortcut tidy-up before steamos-delete, which
  finds the Proton prefix through that shortcut, so compatdata was left behind
  (e2e caught it). steamos-delete runs first again; art/collection tidy-up after.
- Test fixtures are byte-exact: never convert line endings (a text-looking
  fixture APK got CRLF on Windows and failed its SHA-256).
- Read index.html/artwork-settings.js as UTF-8 in tests; app-data backup and
  OBB shell tests run only on POSIX (they exercise the Frame-side scripts).
- e2e expects the icon under artwork/ now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:59:44 +10:00
saphidandClaude Opus 5.5 93ebd34f2c Devices: fixes from review round 15
- A bare alias's route is pinned to where ~/.ssh/config sent it when it was
  routed (HostName, Port, User), so editing that file can't move an install.
- Renaming during an install is allowed: only a real user or port change waits.
- The Devices tab follows a network change even while the headset is offline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:51:12 +10:00
saphidandClaude Opus 5.5 cb05395280 Artwork fetch: release the DNS slot if its thread can't start; stricter GIF control blocks
Final review follow-up. A failed Thread.start() leaked a resolver slot (four
failures disabled artwork lookups). GIF graphic-control blocks must have the
fixed 4-byte payload (otherwise dropped) and an image with no pixel data is
rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:48:20 +10:00
saphidandClaude Opus 5.5 34e91988b1 Devices: fixes from review round 14
- Retry now (while connected) and Forget identity wait for running installs.
- Terminal windows get the headset's address by name, so a link-local IPv6
  zone never has to pass through Windows' console.
- Renaming the headset in use shows at once in the header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:40:10 +10:00
saphidandClaude Opus 5.5 3b36feff52 Keep worker notes and proof logs out of the repository
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:38:54 +10:00
saphidandClaude Opus 5.5 1b5f540a66 Bulk fill-only refresh passes fill_only on to each app and title
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:37:42 +10:00
saphidandClaude Opus 5.5 47266afe85 Artwork fetches: TLS handshake within the deadline; cap stuck name lookups
The handshake runs after the watchdog can reach the TLS socket, with the
remaining time as timeout, and the watchdog shuts the socket with the plain
socket method. At most four lookups that outlived their deadline may run; more
fail at once with a clear error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:37:42 +10:00
saphidandClaude Opus 5.5 b9714fda45 Artwork GIFs: parse the blocks and pass on the first frame only, bounded
The screen and first frame must be at most 4096x4096 and the frame inside the
screen; anything malformed or truncated is rejected. Only a minimal
single-frame GIF (header, screen, colour table, graphic control, first
image) reaches the Frame's Chromium, however many frames the source has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:37:42 +10:00
saphidandClaude Opus 5.5 a045f5479f Android launcher: drop process-group reaping; orphan recovery stops only the app's own container
Matching the APK path in command lines could hit an unrelated process, and
the pgid file had a registration race. The launcher keeps the flock (not
inherited by Lepton) and, holding it, stops only lepton-steamlaunch-<instance>,
whose name is this app's alone. A Lepton host process may linger briefly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:37:42 +10:00
saphidandClaude Opus 5.5 5874fe33f6 Devices: fixes from review round 13
- Every command to a set-up headset checks its pinned key
  (StrictHostKeyChecking=yes, whatever ~/.ssh/config says); only the
  connector's first handshake may save one.
- A reconnect during an install keeps the whole route it started with, also
  when a bare alias is set up meanwhile.
- Removing the headset FRAME_ALIAS named doesn't bring it back as a bare alias.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:31:22 +10:00
saphid 2f9ddeea76 Merge branch 'art-sources' into apk-store-fixes 2026-09-28 23:23:44 +10:00
saphid 4589221661 Merge branch 'library-fixes' into apk-store-fixes 2026-09-28 23:23:44 +10:00
saphidandClaude Opus 5.5 7af5916378 Docs: backfill fills only pending entries; launcher reaps a killed launch's group
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:27 +10:00
saphidandClaude Opus 5.5 1b39fc1718 Library backfill only fills art Frame Control couldn't apply; remove serialised with refresh
- Automatic backfill touches only entries marked art_pending at install (a
  devkit title Steam registered later) and fills only slots Steam has no art
  for: no name, exe, VR flag or icon changes, no clearing. Older installs
  without the flag are left alone and refreshed only when the user asks.
- Android remove takes the install lock that install and refresh hold, so a
  refresh in progress can't recreate a removed app; a refresh after removal
  finds it not installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:20 +10:00
saphidandClaude Opus 5.5 2df0f0e32a Tests: put LocalMode's Windows skip back; artwork settings tests run everywhere
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:20 +10:00
saphidandClaude Opus 5.5 d7cb967786 Artwork fetches: the deadline bounds the whole request, trickling servers included
Name resolution runs in a thread within the budget, a watchdog shuts the
socket at the deadline, and the body is read one receive at a time with the
remaining time as timeout. SteamGridDB goes through the same bounded fetch,
without redirects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:20 +10:00
saphidandClaude Opus 5.5 f7bc2a8f68 Android launcher: reap a previous launch's Lepton left by a SIGKILLed launcher
The lock isn't inherited by Lepton, so a launcher killed before Lepton made its
container left an untracked Lepton that a new Play could overlap. The launcher
records its child's process group and, once it holds the lock, ends a recorded
group that is still running this app.apk (never an unrelated reused id).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:20 +10:00
saphidandClaude Opus 5.5 98ef6944c9 Devices: fixes from review round 12
- A reconnect while an install runs keeps the login it started with; a new
  one from ~/.ssh/config applies after.
- Frame > Open SSH goes through the server, so it uses the same headset and
  address as the app and refuses when there's none.
- A bare frame alias in use when a headset is set up stays selectable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:19 +10:00
saphidandClaude Opus 5.5 2e9bc8624b Devices: fixes from review round 11
- A headset set up while a bare alias is in use doesn't take over by itself;
  a login change from ~/.ssh/config waits for running installs.
- Saving a headset writes only the login fields that changed, and only if the
  block still holds the old ones.
- SSH, SFTP, power and remote desktop open with the same headset and address
  as every other command, and refuse when there's no address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:41 +10:00
saphidandClaude Opus 5.5 b5caee5b86 Library art: real gameplay instead of GitHub social cards; accept GIF sources
GitHub's opengraph preview is repo text, stats and an identicon; as a Steam hero
it looked broken. GitHub entries no longer default to it (the store draws its
fallback, Steam gets generated art). Source GIFs (common gameplay captures) are
accepted; the Frame's Chromium draws the first frame. Open Saber Plus uses its
gameplay GIF as banner. Verified on the Frame: hero/wide now show gameplay.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:24 +10:00
saphid 7990553620 Merge branch 'library-fixes' into apk-store-fixes 2026-09-28 23:09:14 +10:00
saphidandClaude Opus 5.5 6c1ece1b62 Docs: library artwork limits, backfill for titles, and Steam's missing devkit_gameid (checked on the Frame)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:08:40 +10:00
saphidandClaude Opus 5.5 fde2f9620a Library artwork backfill: devkit titles in refresh-art; missing art offered and re-applied
- 'Refresh artwork' (settings) and the API's refresh-art --all cover devkit
  titles as well as Android apps; frame_titles.py gains refresh-art ID|--all.
- Apps and titles without complete Steam artwork are flagged (art_missing):
  the app shows 'Add artwork', and the CLIs' list prints the refresh command.
- When the app lists them and Steam answers, Frame Control re-applies their
  art in the background (at most every five minutes), e.g. for a title Steam
  registered after an install made while it wasn't running.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:06:48 +10:00
saphidandClaude Opus 5.5 f0db805d4b Steam library: safe removal and title matching, artwork within Steam's limit
- Remove: collections and artwork clearing are best effort in Steam's JS, and
  the host carries on to delete the files when Steam isn't running (Android
  apps and devkit titles).
- Devkit titles: the shortcut is found by devkit id, the saved id, or an
  executable/start folder inside the title's folder; never by display name.
  Steam's overviews don't carry devkit_gameid (checked on the Frame
  2026-09-28), so 'list' now reads exe/start dir from app details.
- Photo-based grid/wide/hero slots render as JPEG (a noise-heavy 3840x1240
  hero was over 12 MiB as PNG on the Frame); the logo stays transparent PNG.
  Rendering gets 75 s and retries once with generated art. Each slot is
  cleared before it is set, since Steam keeps .png and .jpg side by side.
- Devkit titles keep their own VR flag (vr=None skips SetShortcutIsVR) and
  their Sideloaded collection.
- A failed title install's cleanup can't replace the original error.
- refresh_art for devkit titles (frame_titles.refresh_art).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:06:25 +10:00
saphidandClaude Opus 5.5 baefa105a9 Artwork sources: every optional source falls back, within limits
- Any failure of a source image or SteamGridDB (HTTPException, odd JSON) now
  becomes a warning and generated art, never an aborted install; refresh-art
  --all reports each app and carries on.
- URL artwork goes through apk_sources._images: public addresses only, at most
  three redirects, and one overall deadline for all of an install's fetches.
- PNGs are checked from their header only (any depth or interlace; Steam's
  Chromium decodes them), JPEGs may have trailing padding, and 4K screenshots
  are within limits. The slow pure-Python decoder is gone.
- SteamGridDB title matching keeps letters of every script and never matches
  on an empty name. One warning per source slot, not per candidate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:06:15 +10:00
saphidandClaude Opus 5.5 70897cfd1d Android launcher: stop an orphaned container instead of refusing Play; keep the lock out of Lepton's tree
Once flock is held no launcher owns a running container (a SIGKILLed launcher
left it), so it is stopped and the launch continues. fd 9 is closed for the
Lepton child so it can't keep the lock held.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:06:04 +10:00
saphidandClaude Opus 5.5 060801c674 Artwork settings: send the UI key through api(), so the panel and refresh work
Every /api call needs X-Frame-UI; the panel's own fetch() got 403s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:06:04 +10:00
saphidandClaude Opus 5.5 c69ea6266f Devices: fixes from review round 10
- Removing or moving the active headset's address waits for running installs,
  like switching.
- A volume change still waiting to be sent goes to the headset whose slider
  it was, and a switch cancels it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:03:27 +10:00
saphidandClaude Opus 5.5 829897087a App data: test overlapping restore cleanups; skip the flock test off POSIX
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:56:14 +10:00
saphidandClaude Opus 5.5 a97e8a6183 Store: close second-round races in F-Droid loads, APK reuse and pruning
- The locked publication step also refuses a v1 index once v2 was accepted, so
  an overlapping v1 fallback can't replace a v2 cache at an equal timestamp.
- A cached APK is touched before hashing; if it vanishes, it's downloaded again.
- Only the app prunes (at start and after store downloads), since claims are
  in-process; the CLIs never prune.
- The CLI joins background refreshes on error exits too.
- The Windows lock loop retries only contention errors.
The concurrent-publication test now uses real flock contention.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:56:14 +10:00
saphidandClaude Opus 5.5 43e19d17f6 Devices: fixes from review round 9
- A title waiting its turn to be read stays with the headset it was dropped
  on, and is dropped if the app switches meanwhile.
- Probes still finishing from an earlier attempt can't overwrite the rows of
  a newer one.
- The FRAME_ALIAS the server started with stays on the list after switching
  away, so it can be picked again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:55:14 +10:00
saphidandClaude Opus 5.5 175c39a2b0 Devices: fixes from review round 8
- A batch of dropped files stays with the headset it was dropped on, and
  stops if the app switches.
- Removing or moving the address in use reroutes at once; a headset with no
  addresses reaches nothing rather than whatever ~/.ssh/config says.
- A late answer to an older device-list request is ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:45:34 +10:00
saphidandClaude Opus 5.5 8315c7c3aa Merge vr-library (Steam library art, Play/Stop, refresh-art) into the store
Resolve CLI usage and POST table conflicts. Store installs now hand the
source's own image URLs (icon, banner, screenshots) to frame_android.install
as Steam artwork; before, they passed UI proxy paths (or nothing), so every
store install fell back to generated art.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:45:31 +10:00
saphidandClaude Opus 5.5 51ef5d8283 Devices: fixes from review round 7
- Removing every headset leaves none in use (commands fail at once) instead of
  falling back to the `frame` alias.
- ssh goes to the IP that answered for IPv6 too, with a link-local address's
  interface (verified: frame.local over fe80::…%en9 on the real Frame).
- Find results only show in the panel of the headset they were for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:35:43 +10:00
saphidandClaude Opus 5.5 6c41a341e5 App data: serialise restores of a package with a lock beside its data
Two clients restoring the same package could each swap directories and then
delete the other's pre-restore copy. The swap and retention cleanup now run
under flock on .<package>.restore.lock.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:30:08 +10:00
saphidandClaude Opus 5.5 a7261b1601 Store: pruning rechecks each APK before deleting and spares ones being installed
Deletion re-stats under a lock shared with touch() (F-Droid cache reuse) and
claim()/release() (held by the store around install), so a reused or
installing APK is never removed from an out-of-date scan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:29:39 +10:00
saphidandClaude Opus 5.5 4fd8bb79af Store: publish a search's completion and hand over its queue atomically
A query arriving between the queue handover and the completion event could be
queued with nobody to start it, leaving the source 'loading' forever.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:28:45 +10:00
saphidandClaude Opus 5.5 5ac109c381 F-Droid: inter-process lock for index publication; CLI waits for refreshes
The final timestamp recheck, cache write and state update now run under a file
lock (flock, or msvcrt on Windows), so the CLI and the app can't publish
indexes out of order. The CLI joins background refreshes before exiting so an
expired index doesn't stay expired.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:28:28 +10:00
saphidandClaude Opus 5.5 ba33d2ff40 Devices: fixes from review round 6
- The headset a change is meant for is checked and the work counted in one
  step, so a switch can't slip in between (uploads too).
- A sideloaded title read on one headset can't be installed on another; open
  confirmations close on a switch.
- The only headset can't be removed while its ssh alias stays behind.
- Answers about the previous headset are dropped without touching panels; the
  catalogue's Installed tags are rebuilt for the new headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:24:46 +10:00
saphidandGPT-6 Astra f695f398de Render complete Steam artwork for every sideload and fix VR shortcut identity
Add optional SteamGridDB settings, source-first fallbacks rendered with Steam canvas, backfill commands and shared APK/native library details. Verify live artwork and Open Saber Steam Play/Stop; document SuperTux's clipboard crash.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 22:20:27 +10:00
saphidandClaude Opus 5.5 5000fa4147 App data: skip symlinks into the backup manifest; keep one pre-restore copy
Backups no longer abort on a symlink: it is left out and listed (path and
target) in manifest.json, now written last. A hard link is stored as a copy of
its file. Restore removes older pre-restore copies of the same package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:25 +10:00
saphidandClaude Opus 5.5 41ac28248a Devices: fixes from review round 5
- A switch publishes the new headset at once, so the page clears the old one's
  panels and the lists behind them (games, store, Android apps, screenshots).
- The page names the headset its changes are for (X-Frame-Device); the server
  refuses one meant for a headset it has switched away from (409).
- A rejected address edit changes nothing.
- Test now goes to the IPv4 address that answered, like the connection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:25 +10:00
saphidandClaude Opus 5.5 9b0fedf602 Store: OBB game data becomes a follow-up 'Add game data' step
install_obb needs the app's running instance, which doesn't exist straight after
install, so the store no longer calls it there. The install result says the app
needs its game data; after opening the app once, 'Add game data' copies the
downloaded OBB files (a background job).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:42 +10:00
saphidandClaude Opus 5.5 a9d78679ec Store: add repositories in a background job and show the TOFU fingerprint
Adding a repository downloads and verifies its whole index, so it now runs as a
job (runJob in the UI) and reports 'Trusted on first use: <fingerprint>' when
no pin was given. fdroidrepos:// links pass the server check, as documented.
Jobs report SourceError messages without a 'SourceError:' prefix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:04 +10:00
saphidandClaude Opus 5.5 ddcf3b2ad2 Store: prune download caches (2 GB LRU for APKs, orphaned .part/temp, old listings)
Runs after each APK download and at server start. APKs used in the last hour
are kept; an F-Droid cache hit refreshes the APK's mtime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:12:02 +10:00
saphidandClaude Opus 5.5 029c92bccb F-Droid: serve an expired index as stale while refreshing in the background
Searches no longer wait for (or fail on) a refresh of an expired index; the
store notes which sources show saved listings. A failed refresh keeps the old
index and is retried after 10 minutes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:11:04 +10:00
saphidandClaude Opus 5.5 7c439fdf28 Store: per-host backoff after 403/429 honouring Retry-After
A host that answers 403/429 is left alone until its Retry-After (or GitHub's
rate-limit reset; default 10 minutes). Meanwhile cached data is served, or the
source reports 'limited' with its own name, e.g. 'GitHub is limiting requests;
try again in 10 minutes'. Covers _web reads/downloads and F-Droid fetches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:09:55 +10:00
saphidandClaude Opus 5.5 c68afa6c5d F-Droid: refuse index rollbacks, v1 downgrades after v2, and SHA-1 entry.jar
Each repository's newest accepted index timestamp is stored and older indexes
are refused. index-v1.jar is only a fallback while no v2 index has been
accepted. entry.jar must use SHA-2; the recorded IzzyOnDroid entry.jar is
SHA-256 and still verifies. Tests sign JARs with a throwaway key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:08:26 +10:00
saphidandClaude Opus 5.5 328b7ed211 F-Droid: one load lock per repository; downloads never hold the settings lock
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:06:51 +10:00
saphidandClaude Opus 5.5 3149a6e269 Store: newest query runs after a source's current search; no source calls under the search lock
A search for a different query while a source is busy now queues (newest wins)
instead of being dropped, and warm() uses the browse limit so the first browse
reuses it. set_enabled calls the source before taking search._lock. A
SourceLimited error reports the source as 'limited'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:06:20 +10:00
saphidandClaude Opus 5.5 ea73145fbc Store: unknown VR counts as flat; browse keeps unknown-fit VR first
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:05:50 +10:00
saphidandClaude Opus 5.5 69f790b83a Store: real-source fixes found by running search against live repos
- F-Droid: percent-encode repo file names (a '#' in one screenshot name broke
  the whole main repo); a bad image name drops that image, not the app.
- Search: sources still fetching report 'loading' (UI says so and refreshes
  quietly); indexes warm up at server start; page-only SideQuest is not
  searched and appears as a 'Browse SideQuest' link instead of an error.
- Browse (empty query) ranks VR, artwork and recent updates first; the F-Droid
  archive is off by default (old versions only).
- Throttled sources fall back to their last cached copy; per-host message.
- Curated GitHub list gains Open Saber Plus (MIT) with icon and screenshots.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:02:38 +10:00
saphidandClaude Opus 5.5 9dd57cde4e Devices: connector tests follow ssh to the IPv4 address that answered
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:01:49 +10:00
saphidandClaude Opus 5.5 d383a26746 Devices: fixes from review round 4
- A switch clears every headset-specific list and its buttons at once.
- SSH goes to the IPv4 address that answered the probe, not the name again.
- A rejected headset edit changes nothing.
- The SteamOS/Lepton builds recorded in reports are read again per headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:00:09 +10:00
saphidandClaude Opus 5.5 f10b5fe159 Package ui/apk_sources in the desktop app
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:55:04 +10:00
saphid 7e2228b15e Merge branch 'apk-search' into apk-store 2026-09-28 21:54:36 +10:00
saphid a24d27013c Merge branch 'sidequest' into apk-store 2026-09-28 21:54:36 +10:00
saphid c5b5930582 Merge branch 'more-sources' into apk-store 2026-09-28 21:54:36 +10:00
saphid 70a0bd0d38 Merge branch 'user-repos' into apk-store 2026-09-28 21:54:35 +10:00
saphidandClaude Opus 5.5 318bc3b84f Devices: make the pin folder before ssh saves a first-seen key into it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:49:43 +10:00
saphidandClaude Opus 5.5 cb182dbce5 Devices: fixes from review round 3
- Attempts carry a generation: one overtaken by a switch, a removal or a login
  change routes nothing back to the old headset and can't report connected.
- Removing the active headset or changing its user/port reroutes at once,
  before anything that can fail.
- One known_hosts file per headset (~/.ssh/frame-control-hosts/<id>):
  forgetting one headset's key can't drop another's, whoever else writes.
- learn() checks, under the config lock, that the block is still what the
  attempt started from before writing to it.
- A switch stops live video and drops captures from the previous headset.
- A probe shares its time between the addresses a name resolves to.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:48:39 +10:00
saphidandGPT-6 Astra 7844577be8 Redesign APK discovery as an artwork-led app store
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:47:38 +10:00
saphidandClaude Opus 5.5 18334b5989 Devices: fixes from review round 2
- Switching headsets is serialized with the start of any install; background
  work counts as running from before its thread starts. use() reroutes every
  command at once and makes ensure() wait for the new headset.
- A new user or port reroutes commands even if the attempt then fails.
- ~/.ssh/config edits take a lock file shared with Set Up Connection
  (frame_connect.py and connect.sh, which now also writes atomically).
- A finished attempt no longer writes its older settings over a change Set
  Up Connection made meanwhile.
- Pin edits are locked and swapped atomically.
- A bare alias behind ProxyJump/ProxyCommand is left to ssh to reach.
- The page drops answers about the previous headset after a switch; the
  header switcher takes clicks in the macOS title bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:38:41 +10:00
saphidandClaude Opus 5.5 13eb65603b Devices: fixes from review round 1
- No switching headsets (or changing the active one's user/port, or removing
  it) while installs run: they read the ssh settings step by step.
- Switching reroutes every command to the new headset at once, even if it
  never answers.
- ~/.ssh/config edits are serialized, use unique temp files, and back off if
  another program wrote the file meanwhile.
- stop() ends a handshake in progress and joins the connector.
- Pinned keys are written unhashed (HashKnownHosts=no); hashed ones are still
  found and forgotten via ssh-keygen.
- Set Up Connection changing a headset's user or port updates the registry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:25:41 +10:00
saphidandGPT-6 Astra 08037ab3f5 Expose F-Droid artwork and developer metadata for store entries
Resolve localized v1/v2 artwork, retain six ordered screenshots, clean summaries and refresh older source caches. Add offline metadata and cache regression coverage.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:25:24 +10:00
saphidandGPT-6 Astra 41684e2d90 Add publisher artwork to GitHub APK source entries
Include curated app images and summaries, owner avatars and social banners for topic discovery, and fixture coverage for artwork preservation.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:24:55 +10:00
saphidandGPT-6 Astra c06b3285f2 Add Android Steam library artwork and supervised Lepton sessions
Generate five artwork slots, refresh VR shortcuts and managed collections, and retain a signal-aware launcher around setsid so stopping the wrapper cleans its container. Cover installation, artwork and launch cleanup offline; record the Steam client startup blocker for device verification.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:15:43 +10:00
saphidandClaude Opus 5.5 a954fc83c9 Devices: several headsets, several addresses each, and live connection status
Frame Control can now manage more than one Steam Frame, and reach each at any
of several addresses (LAN IPs per network, its .local name, Tailscale). A
connector in the server tries them all at once, picks the best one that
answers, follows ssh -v through each stage (network, finding, SSH, identity,
login) and streams that to the page. The header shows it live; a new Devices
tab (key 5) manages headsets, addresses and network names.

- ui/frame_devices.py: registry in devices.json, imported from the managed
  ~/.ssh/config blocks; per-headset host key pinning; config block updates.
- ui/frame_network.py: gateway IP+MAC fingerprint, Wi-Fi name, Tailscale.
- ui/frame_link.py: the connector, Test now, Tailscale/mDNS discovery, API.
- server.py: ensure_master delegates to the connector; /api/connection,
  /api/connection/events (SSE), /api/devices.
- Electron: headset switcher and Devices item in the Frame menu.
- frame_connect.py --alias; FRAME_CONTROL_DATA_DIR / FRAME_CONTROL_SSH_DIR
  keep tests off real data.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:13:58 +10:00
saphidandGPT-6 Astra 784a48f218 Add authenticated F-Droid user repositories and management CLI
Verify pinned JAR/CMS signatures, v2 index hashes and APK downloads; support signed v1 fallback and persist TOFU identities. Reuse the catalogue reducer and document repository publishing with offline and live verification evidence.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:06:06 +10:00
saphidandGPT-6 Astra f4dbb1180c Add OBB transfers, private app-data backups and SideQuest source policy
Borrow expansion-file and save-management features with offline verification. Keep SideQuest page-only under its current access terms; document research, integration limits and device acceptance gaps.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:05:59 +10:00
saphidandGPT-6 Astra f1b12a6eb6 Add unified APK source search and source management
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:04:48 +10:00
saphidandGPT-6 Astra 113216cc0e Add curated GitHub APK releases and itch.io VR feed listings
Survey publisher consent and access limits; add cached sources, recorded fixtures and local APK proof.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
2026-09-28 21:03:27 +10:00
128 changed files with 15550 additions and 281 deletions

No files matched your search

+5
View File
@@ -6,3 +6,8 @@
*.json text eol=lf
*.md text eol=lf
*.bat text eol=crlf
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
tests/fixtures/** -text
*.apk binary
*.jar binary
*.obb binary
+108 -22
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -149,7 +149,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -159,51 +159,76 @@ async function startServer() {
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
// Resolves once it has exited (or after 20 s), so a replacement can take the server
// lock: server.py allows one per user.
function endServer(child) {
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
: new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
}
function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
async function restartServer() {
const old = server;
server = null;
url = null;
if (old) endServer(old);
await load();
// Restarts that overlap share one: two could each start a server, and the one
// that lost the lock would leave the app pointing at nothing.
let restarting = null;
function restartServer() {
if (!restarting) {
restarting = (async () => {
const old = server;
server = null;
url = null;
if (old) await endServer(old);
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
await load();
})().finally(() => { restarting = null; });
}
return restarting;
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
if (!url) await startServer();
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
@@ -247,13 +272,66 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
let devices = [];
ipcMain.on("devices:changed", (e, list) => {
if (!fromUi(e) || !Array.isArray(list)) return;
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
// SSH through the server, so it goes to the headset and address the app is using
// (with its own pinned identity), and refuses when there's none.
function openSsh() {
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
const body = JSON.stringify({ what: "terminal" });
const req = http.request(new URL("/api/open", url), {
method: "POST", timeout: 15000,
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
}, (res) => {
let data = "";
res.on("data", (c) => { data += c; });
res.on("end", () => {
if (res.statusCode === 200) return;
let why = `HTTP ${res.statusCode}`;
try { why = JSON.parse(data).error || why; } catch {}
dialog.showErrorBox("Couldn't open SSH", why);
});
});
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(body);
}
function showDevices() {
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
}
ipcMain.handle("comfort:notify", (e, message) => {
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
@@ -425,13 +503,14 @@ async function runInTerminal(argv) {
}
}
async function setUpConnection() {
const alias = `FRAME_ALIAS=${FRAME}`;
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
async function setUpConnection(name = activeAlias()) {
if (!ALIAS_RE.test(name)) return;
const alias = `FRAME_ALIAS=${name}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
// A new console inherits our environment on Windows; Linux terminals may not.
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
}
function buildMenu() {
@@ -446,8 +525,15 @@ function buildMenu() {
{
label: "Frame",
submenu: [
{ label: "Set Up Connection…", click: setUpConnection },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
{ label: "Set Up Connection…", click: () => setUpConnection() },
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
{ type: "separator" },
...(devices.length > 1 ? [{
label: "Headset",
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
}] : []),
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+12 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.0",
"version": "0.4.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -48,9 +48,18 @@
"filter": [
"*.py",
"*.html",
"*.js",
"telemetry.json"
]
},
{
"from": "../ui/apk_sources",
"to": "ui/apk_sources",
"filter": [
"*.py",
"*.json"
]
},
{
"from": "../scripts",
"to": "scripts",
@@ -63,7 +72,8 @@
"to": "frame/android",
"filter": [
"*.sh",
"*.py"
"*.py",
"*.js"
]
},
{
+10 -1
View File
@@ -2,7 +2,8 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can open Set Up Connection when the headset can't be reached.
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
@@ -11,7 +12,15 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
ipcRenderer.removeAllListeners("use-device");
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
},
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
+37
View File
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
browser profile and SSH tunnel and remove the profile and panel log.
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
backend, no API key, no prestarted server) and a human approved or rejected
each change in the approval page in a real Chrome window:
| Tool | Result on the Frame |
|---|---|
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
| `uninstall` | Approved; app and locale removed |
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
A separate Claude Code CLI session, with only this server configured, read
status, `computer_state` and a headset capture, and requested an install. It
received an approval URL and did not execute anything.
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
Against a loopback stub model, a send without consent made zero requests. With
consent it made exactly one, carrying the text and a fresh Frame screenshot.
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
and tunnel.
**Not verified while unworn:** every headset capture was a uniform dark frame,
so SteamVR's rendered view of panels and the game could not be checked; window
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
runtime it pulled in; Flatpak may also remove related extensions when that
runtime is removed by hand.
![Approval page showing the exact install action](img/mcp-approval-install.png)
![The installed Flatpak rendering in its own gamescope panel window](img/mcp-panel-binary.png)
![Assistant in Frame Chromium, after an opted-in request to the local test endpoint](img/assistant-panel.png)
## Computer-use coverage
+144
View File
@@ -0,0 +1,144 @@
# APK repositories
Frame Control supports **F-Droid-format repositories**, including F-Droid,
F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
indexes are authenticated before their apps appear. Search lists builds with
Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
reducer as the existing catalogue. This does not guarantee an app works in Lepton.
## Formats considered
| Format | Users and purpose | Support in this source |
|---|---|---|
| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
Research references (checked 2026-09-28):
- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
[repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
- [Droid-ify](https://github.com/Droid-ify/client) and
[Neo Store](https://github.com/NeoApplications/Neo-Store).
- [Obtainium](https://github.com/ImranR98/Obtainium), its
[configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
and [community app configurations](https://apps.obtainium.imranr.dev/).
- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
The absence of a specification in these materials is not proof that no
historical or private custom-feed format exists.
## Add a repository in Frame Control
From the Frame Control checkout, use its source-management CLI:
```sh
python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
python3 ui/apk_sources/fdroid.py list
python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
python3 ui/apk_sources/fdroid.py remove SOURCE_ID
```
Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
A URL must identify the repository directory, not its website or an index file.
Adding fetches and validates the complete index **before saving** the source.
Without a fingerprint, Frame Control verifies the JAR signature and remembers
its signer: trust on first use (TOFU). This establishes continuity with the
first server response, not independent publisher identity. Obtain the published
fingerprint through a trusted channel when possible; the store's Add a source
form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
separate work. Built-in sources can be disabled but cannot be removed.
Settings and pins live in `frame_host.data_dir('apk-repos.json')`
(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
Authenticated reduced indexes and APKs live under
`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
expired index is still served (marked stale in the store) while it refreshes in
the background; a failed refresh is retried after 10 minutes.
Only the running Frame Control app prunes cached APKs (at start and after store
downloads); the command-line tools never do.
The existing catalogue's unverified index cache is never treated as authenticated.
Rollback protection: each repository's newest accepted signed index timestamp
is kept in `apk-repo-state.json` next to the settings, and an older index is
refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
Do not publish paid app mirrors or bypass store licences. Check distribution
terms before adding someone else's repository; this module does not infer legal
permission from a signature or automatically audit a repository's terms.
Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
and its documented Android/Java dependencies on the publishing machine, then:
```sh
mkdir my-fdroid
cd my-fdroid
fdroid init
# Set repo_url in config.yml to https://example.org/fdroid/repo
# Also set repo_name and repo_description; keep the generated signing key safe.
cp /path/to/your-free-app.apk repo/
fdroid update --create-metadata
# Review the generated metadata (name, summary, licence, source and website).
fdroid update
```
Serve the generated **repo directory** at that HTTPS URL, including APKs,
icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
private signing keystore or configuration passwords. Configure fdroidserver's
`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
public directory with your existing deployment tool. Publish the SHA-256
repository certificate fingerprint displayed by fdroidserver in a link such as
`https://example.org/fdroid/repo?fingerprint=…`.
Keep the repository signing key backed up: changing it breaks existing pins.
For updates, add the new APK, edit metadata as needed, run `fdroid update` and
publish again. Test the published URL with Frame Control's `add`, `search` and
`download` commands. The above publisher setup is documented from fdroidserver;
it was not executed as part of this implementation.
## Verification and limits
The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
recognized. It checks the signer certificate pin, the signature over `.SF`,
the whole-manifest digest, and the manifest's digest of the JSON member.
ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
rejected. Certificates are pinned identities, not validated as Web PKI chains.
HTTPS certificates are separately checked by Python's normal TLS validation.
v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
fingerprint, index hash, TLS and server errors never trigger an unsigned
fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
`verified: true` means the bytes match the signed repository's APK hash; it
is not an independent APK publisher-signature or runtime compatibility verdict.
There is no repository timestamp rollback/expiry policy or automated signing-key
rotation yet. An old correctly signed index can still validate.
Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
the real IzzyOnDroid repository was added with its published pin, searched for
Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
No headset connection or installation was performed.
+103
View File
@@ -0,0 +1,103 @@
# Developer-consented APK sources
Surveyed 2026-09-28. Free access is not proof of redistribution permission or
Frame compatibility. These adapters fetch only public publisher releases or
link to publisher pages. They do not acquire store entitlements, defeat access
checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
|---|---|---|---|
| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
The table distinguishes observed restrictions from unknown permission. An
unverified policy is a reason to defer automation, not a claim that a site is
unlawful. Only the two implemented source kinds are registered by their own
`sources()` functions; the other rows are recommendations, not new UI entries.
## Adapters
`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
[Open Brush](https://github.com/icosa-foundation/open-brush), and
[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
OpenXR project/release evidence, not a blanket claim of headset compatibility.
Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
Open Brush and SuperTux publish the selected builds as prereleases; curated
opt-ins preserve that label in version records. Exact APK filename patterns
avoid downloading desktop archives or alternate non-Quest builds.
[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
provenance was not established in this pass.
Default GitHub search is offline against this small list. Queries
`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
leaves the latter unknown and rejects a requested `version_code` rather than
silently fetching a different build.
`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
preview embedded in the port's README (not a headset capture). The hello_xr
sample has a launcher icon and GitHub social banner; no published screenshot
was found in the inspected repository/README, so its screenshot list is empty.
Uncurated topic results use the owner's avatar and GitHub's repository social
preview. These are repository placeholders, not app screenshots. Itch's recorded
RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
Downloads stream to unique temporary files, require an APK manifest entry,
restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
means the downloaded SHA-256 matches GitHub's published digest. Without such a
digest, the computed SHA-256 is returned with `verified: False`; neither value
claims publisher-signature validation. Installation must inspect the APK as
usual. OBBs, split APKs, paid assets and external release-body download links
are unsupported.
## Evidence and limits
On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
the GitHub adapter, matched its published SHA-256
`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
`python3 ui/frame_android.py info <apk>` exited 0: package
`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
The itch OpenXR RSS was fetched successfully and recorded as a fixture.
Subsequent live adapter search encountered HTTP 429; it is not claimed as a
successful live end-to-end search. Fixture search finds Off Nominal and parses
nine Android entries from the ten-item feed (one has only an HTML platform).
A real itch download and APK inspection were deliberately not performed:
robots restrictions take precedence over that requested proof. No current
policy text is claimed verified where the table records failed access.
Tests use recorded, reduced API/RSS fixtures with network access blocked in
the new test class. They cover selection, prereleases, unknown topic results,
paid/non-Android exclusion, URL restrictions, redirect credential removal,
caching, rate limits, checksum mismatch, non-APK rejection and partial-file
cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
+8
View File
@@ -331,3 +331,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably
don't.
## Expansion files and save backups
SideQuest-inspired CLI helpers install local OBB files into an already-running
app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
+180
View File
@@ -0,0 +1,180 @@
# Headsets, addresses and the connection
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
The code is in three modules, all stdlib-only Python on your computer:
| Module | What it does |
|---|---|
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
## Headsets
Each headset keeps its own SSH alias, as Set Up Connection has always written
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
work for each one.
- **Nothing to migrate by hand.** On first start, the app imports every
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
the block's HostName as its first address. It also copies the host key your
`known_hosts` already trusts for that address into the headset's own
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
When it writes its block, the app picks the headset up by itself. If Set Up
Connection runs again and finds a headset somewhere new, that address is added
at the top of its list.
- **Use this headset** (or the switcher in the header, or the app's
**Frame → Headset** menu) moves the whole app to another headset; every panel
reloads from it. From that moment no command goes to the previous headset, even
if the new one never answers. It waits while an install is running, since an
install reads the SSH settings step by step.
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
the box; either way it isn't imported again unless Set Up Connection changes it.
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
app shows it as not set up and lets ssh's own config decide where it goes.
## Addresses
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
and changeable), an optional label, the networks it has worked on, and when it
last worked with its round-trip time.
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
and ranks them:
1. addresses that worked on the network this computer is on now;
2. mDNS names;
3. Tailscale addresses, if Tailscale is running here;
4. addresses not tried on this network yet;
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
answered is tried. Every success records the network on that address, so next
time on that network it's tried first.
**Test now** probes every address and tries SSH on each one that answers, without
disturbing the connection in use: "SSH works", "answered as a different
headset", "refused this computer's key", or why it didn't answer. **Find on
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
permission. Where the system does share the Wi-Fi name, it's shown, and you can
name any network yourself ("Home Wi-Fi") on the Devices tab.
The app rereads the gateway every 5 seconds and Tailscale's state every
30 seconds. Changing networks reconnects.
## The connection, stage by stage
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
2. **Finding the headset**: each address resolving, trying, answered in N ms,
no answer, refused, or can't be found.
3. **Opening SSH** to the address that answered.
4. **Checking the headset's identity**: the host key must match the one pinned
for this headset.
5. **Logging in** as the headset's user.
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
with the reason in plain words and a countdown to the next try (5, 10, 20,
then every 30 seconds). Retry now skips the wait.
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
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.
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
alias's block in `~/.ssh/config` is also updated to the last address that
worked (and to the user and port you set), so Terminal's `ssh frame` and the
scripts follow. Edits to `~/.ssh/config` take a lock file
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
write over a change someone else made since the app last read the file.
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
is keyed by address, so a different device answering at a remembered IP (a DHCP
lease that moved) would look like a new host there. The app keeps one known_hosts
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.
## One server at a time
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
data folder). Two would each connect, reconnect and edit the headsets on their own, and
one could move the other's install to a different headset. A second one, say
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
## API
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
| Request | Returns |
|---|---|
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the 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` |
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
through a shell.
## The registry file
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
share the format later (it still connects to one host; see
[iphone.md](iphone.md)):
```json
{"version": 1, "active": "f67f8b7e",
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
"identity_files": ["~/.ssh/id_ed25519_frame"],
"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",
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
```
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
## Tests
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
computer for the addresses, and temporary folders for `~/.ssh`
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
never touch yours.
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+29
View File
@@ -0,0 +1,29 @@
Locked real-Frame repeat, 2026-09-29
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
+24 -9
View File
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has four tabs: **Home** (headset view, status, screenshots),
The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports) and **Tools** (sending files and text,
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
anywhere in the window. When the Frame can't be reached, one banner says why in
plain words and the app retries every few seconds, filling everything in once it
answers. Flatpak and Android installs run in the background; the bottom bar
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
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
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
@@ -78,10 +81,20 @@ counts them while they run.
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
- **Devices**: several headsets, each with several addresses (LAN IPs per
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
tries them all at once and learns which worked on which network. Add, edit,
reorder and test addresses, find a headset on Tailscale or on this network,
name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## How it works
@@ -101,7 +114,9 @@ Frame for the keyboard and trackpad.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
the headset through its SSH alias (`frame` for the first one), pointed at the
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
[devices.md](devices.md)). On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

+27
View File
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
All test app files, compatdata, shortcuts and containers were removed afterwards.
SteamVR's original process remained running. No global settings changed.
### Locked repeat, 2026-09-29 (verified)
Acquired `/tmp/frame-test.lock` before installing or launching anything and
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
Reinstalled and launched both original APKs in immersive Lepton instances.
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
unmodified-client test, not the newer installer's automatic compatibility-layer
path. Neither reached a session that could be paired or exercised further.
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
record the failures.
SteamVR's screenshot API returned a 1920×1080 headset capture after each
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
rendering or a working client. The headset was unworn; visibility, controllers,
frame rate and motion-to-photon latency could not be judged. The explicit
OpenXR errors, rather than the dark captures, establish the client blocker.
Both test installs, app data, shader caches, shortcuts, containers and temporary
capture files were removed. The original Steam and SteamVR process IDs were
unchanged. No reboot, power action or global setting change was used. Native
clients remain untested, and no Linux gaming host was available for Valve's
streaming path. The recommendation below is unchanged.
### Relation to the VR APK branch
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
+69 -4
View File
@@ -103,9 +103,18 @@ privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
your analytics events. With that box ticked, the address also becomes your
**Contact email** below with follow-up questions ticked, so you remove it there
like any other. If it's a different address from the one saved there, it
replaces it, and update notices stop until you turn them on again (they were
agreed for the old address); the form says so before you send. The report then also
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
see below), so removing or changing the address later takes back the
follow-up permission given with the report too.
With **Include diagnostics** ticked (the default), the report adds:
@@ -128,11 +137,67 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never in the same visit as the first-run
privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, or when you send a problem report with follow-up questions ticked,
whatever the analytics settings are, because you chose to. With a report, the
address and choices are saved before the report is sent and stay saved if it
fails; like any change, they're sent as soon as PostHog can be reached. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. This also covers problem reports you sent
from this copy with follow-up questions ticked: if your newest choice since the
report (by change number, not the clock) no longer agrees to follow-up
questions at that address, the maintainer's inbox shows the permission as
withdrawn and leaves the address out. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
## Update checks
+142
View File
@@ -0,0 +1,142 @@
# SideQuest and Frame Control
Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop
sideloading/device-management app. Its Quest labels are **not** evidence that a
game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements,
VrApi and Meta services (see [VR APKs](vr-apks.md)).
## Features worth borrowing
Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb),
especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts),
[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts),
and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts).
Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular
bundle `main-4MMXZRXL.js`, inspected locally without browser automation.
No SideQuest implementation code was copied.
| SideQuest feature | Frame Control before this change | Borrow? / effort |
|---|---|---|
| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. |
| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. |
| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. |
| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. |
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
Priority: expansion files, then save backup/restore. Rich discovery and update
notices follow once a permitted metadata source and source/version persistence
are available. This patch deliberately exposes CLI/backend operations, leaving
shared UI, install(), Steam artwork and launch behavior to sibling work.
## SideQuest as a source: page-only
[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits
copying/distributing/disclosing the Service including automated or non-automated
“scraping”; (xi) prohibits content access through means other than those provided
or authorised by the Service; (xii) prohibits bypassing access restrictions.
The terms describe downloading developer-posted games through the Service, but
do not establish permission for this third-party API integration.
[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl
delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission
would not override the terms. The API host's robots request returned HTTP 403;
a request for the first shared website JS chunk also returned 403. No bypass,
account token, cookies, browser session or private endpoint was used.
The homepage publishes `https://api.sidequestvr.com` and
`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and
`getApp(id, null)`; their actual HTTP search/detail routes could not be established
from the retrieved bundle. Do not invent endpoints. The open-source desktop
[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts)
POSTs `{token: ...}` to `/install-from-key`. It consumes
`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`,
`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key
flow, not evidence of an anonymous download API. It is not implemented here.
`ui/apk_sources/sidequest.py` implements the shared interface conservatively:
- `sources()` marks SideQuest `page_only` and explains why.
- `search()` raises a user-readable `SourceError` with the browse URL (zero
limit returns no rows). It does not invent app results or report a false
“no matching games”. The aggregate search UI should surface this source error.
- `details()` accepts a numeric listing id and returns its canonical page link,
`downloadable: False`, empty versions/tags/headsets and the `images` shape
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
unknown facts, including free/VR status, stay `None`.
- `download()` refuses with that page link. Paid/external listings cannot be
downloaded by this adapter either. No downloads means no verification claim.
The JSON fixture records policy evidence, **not a purported live app response**.
No listing metadata, artwork URLs, or OBB download URLs were scraped.
The requested real SideQuest → OpenXR APK → `frame_android.py info` test is
**blocked by the terms**, and was not performed. No alternate source is silently
substituted. A future integration needs SideQuest's permission or an expressly
supported third-party API, plus recorded search/detail/download fixtures,
free/direct-download classification, and size/hash verification. A calculated
local SHA-256 alone must not be called publisher verification.
## OBB files
```sh
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
```
Install the APK first. The named instance must already be running; the helper
never launches an app or uses Lepton Development. It requires standard
`main|patch.<versionCode>.<package>.obb` filenames and nonempty files, validates
the entire batch before transfer, streams each file through SSH into that
instance, checks its SHA-256 **inside Android**, then renames it into
`/sdcard/Android/obb/<package>/`. `verified: True` here means transfer integrity
against the local input, not publisher authentication. Publication is atomic per
file, not for the whole batch; retry after a partial batch failure. Existing OBBs
with different version codes remain. The filename version must match the game;
the current install metadata does not expose its version code for comparison.
Restart the game yourself after the transfer if it cached missing expansion data.
Both read-only SSH attempts to the Frame timed out. Therefore the exact
host-side `/sdcard` mapping and persistence of expansion data were **not verified**.
`compatdata/<instance>/internal/<package>` is documented as `/data/data/<package>`;
it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a
host layout, but device verification across restart/update is still required.
No OBB file was installed on the Frame during this work.
## Private app-data backups
```sh
python3 ui/frame_android.py stop org.example.game
python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz
python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz
```
Keep the instance stopped throughout either operation; do not launch it from
Steam concurrently. The remote guard fails if Podman cannot enumerate containers
or reports that instance running. The helpers use `podman unshare` to read/write
Android's mapped ownership without changing the live data's permissions.
The archive covers **only** `compatdata/<instance>/internal/<package>`, not the
APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot.
It contains a package/instance manifest and regular files/directories. Backups
are private (0600), validated before publication, and never overwrite an existing
backup. Keep them safe: app data can contain credentials and is not encrypted.
Restore checks the package and instance, rejects absolute/traversing/duplicate
paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates
again on the Frame. It extracts into a separate directory, preserves numeric
ownership, ordinary modes and timestamps, then swaps the private-data directory.
Setuid/setgid bits are not restored. The previous directory remains beside it as
`.<package>.before-restore-<timestamp>`; the returned `previous` path identifies
it. This is an additional recovery copy, not an automatic deletion policy.
Locally verified: archive round trip including recovery copy, malformed archive
rejection, transfer command construction and failure handling. Not verified:
real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB
writes or persistence. Backups reject symlinks/special files; an app requiring
those needs a separately designed backup format. These CLI features still need
a real-device acceptance pass before being exposed as a polished UI workflow.
+14
View File
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
Mode password opens a Plasma (X11) desktop within about 6 seconds.
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
fills in `steamos` there on Windows, Remmina and FreeRDP.
- The desktop is a separate login session (Xorg on display `:10`), not the
headset's view. It uses about 1.3 GB of the Frame's memory.
- Closing the client leaves the session running, and the next login
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
gamescope or SteamVR session.
## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
+126
View File
@@ -84,6 +84,132 @@ not being worn, so it did not reach `FOCUSED`).
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
Khronos-style loader and found SteamVR through `/vendor`.
## In the Steam library
Every successful APK install goes through the same mandatory artwork writer:
CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder,
web download and source modules calling `frame_android.install`. Native
Linux/Windows sideloads also use it, preserving their devkit runtime wiring.
A new shortcut is rolled back if artwork fails; failure is never reported as
an installed app with a blank tile.
Artwork preference is **SteamGridDB → source images → generated fallback**.
Set the optional free key in Frame Control's **Library artwork settings**, or
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
settings override the saved key. Without a key there are no provider calls or
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
returned by the settings API or copied to the headset. Exact title matches
(including a trailing “VR” variant) use the highest-scored returned static,
non-NSFW image in each slot. Provider failures use the next source.
Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`,
`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list
`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and
4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's
Chromium decodes them). URLs must resolve to public addresses, follow at most
three redirects and share one deadline per install. Any source that fails,
for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art;
screenshots are the next fallback. Source images are cached for refresh.
All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero,
1280×480 logo and 256×256 icon. Explicit logos retain transparency.
Photo-based portrait, wide and hero slots are JPEG: Steam takes at most
12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero
came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a
landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that
still fails is retried once with generated art. Steam keeps a slot's `.png`
and `.jpg` side by side, so each slot is cleared before it is set.
Generated art uses the APK icon, a dominant-colour gradient, a blurred
backdrop and large foreground icon with shadow. Steam's Chromium canvas and
Motiva Sans render real text consistently regardless of the host OS; no
Pillow, host font installation or bitmap font is needed. The hero has no
title; the generated logo is a transparent title. APKs with no usable icon
get a typographic monogram. The desktop package includes the renderer.
Backfill installed Android apps without reinstalling or stopping them:
```sh
python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus
python3 ui/frame_android.py refresh-art --all
```
Devkit titles installed by Frame Control have the same command,
`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's
refresh covers both. The API is `POST /api/android` with
`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and
`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
background job. Batch results retain per-item errors, and the CLIs exit
nonzero if any failed. Apps and titles without complete artwork show **Add
artwork** and `list` prints the command. Only entries marked `art_pending` at
install (a title Steam registered after an install made while it wasn't
running) are backfilled automatically, when Frame Control lists them with
Steam running (at most every five minutes), and that backfill only fills
slots Steam has no art for: names, icons, flags and any art the user set are
kept. Older installs without the flag are refreshed only on request.
Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
its saved id, or by an executable or start folder inside
`~/devkit-game/<id>/`, read from `appDetailsStore`; never by display name.
That the devkit shortcut's exe/start folder sit inside the title folder is
inferred from `docs/sideloading.md` (`proton waitforexitandrun
"/home/steamos/devkit-game/<id>/<exe>"`), not yet seen in app details.
Devkit titles keep the VR flag Steam gave them.
**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both
Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide,
hero and logo PNGs have the dimensions above; each shortcut points at its
256×256 icon. This Frame client mishandles custom-art type 4 (documented as
Icon), overwriting the wide capsule; the implementation uses custom types
0–3 and **SetShortcutIcon** separately.
Steam accepts display name, executable/start directory, icon, VR flag and
sort-as name. Android apps join **Android**, immersive apps also **Android
VR**; native sideloads join **Sideloaded**. Existing collection members and
unrelated collections are preserved (both games retained **Played**).
Dynamic/read-only collection conflicts produce warnings. The native notes
API supports a managed **Installation details** note (package, version and
source) while preserving other notes. Notes are keyed by sanitized shortcut
name, so Steam itself cannot distinguish equal-name shortcut notes. No
supported shortcut description/store-page, developer/publisher, release
metadata or custom achievement API was found; these are not fabricated.
The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
stopping its own container and child process group. A lock refuses duplicate launches;
a container still running while the lock is free was orphaned by a killed
launcher and is stopped before the new launch. Lepton doesn't inherit the
lock. Orphan recovery only stops the app's own, deterministically named
container; a Lepton host process whose launcher was killed before it created
the container may linger briefly. Removing an app or title still deletes its files when Steam isn't
running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
64-bit game ID string. Frame Control's Stop additionally has a direct-container
fallback. The stable instance ID and compatdata paths remain unchanged.
Lepton normally forwards the instance `SteamAppId` to Android, causing
SteamVR to associate the scene with a different, artwork-less app. The
launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send
the actual shortcut ID to Android while retaining the stable container ID.
**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR
identified `steam.app.3346865537`, and its scene appeared in the headset
capture without the previous blank Resume tile. Steam Stop then removed its
tracked process and stopped the container. An earlier 32-second session was
also tracked until Steam Stop. No global standby or dashboard overrides were
installed; wear detection and other user-opened overlays still apply.
**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during
activity creation because Lepton lacks `ClipboardManager`. Its container
cleaned up on exit after about 17 seconds. Consequently sustained SuperTux
Play/Stop and its VR scene could not be verified. This is an APK/runtime
compatibility failure, separate from library presentation.
Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork
preview and three design passes, `steam-cache-final.log`,
`steam-details-targets.json`, `opensaber-identity-session.log`,
`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is
rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed
out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling
source-search endpoint remain unverified (the public install seam is tested).
## Out of scope
- **Meta entitlement.** Apps that call the Oculus Platform SDK
+28
View File
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
![Our Gaussian-splat stereo preview on the Frame](img/media-splat-proof.png)
**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
the owned player. Our generated test files:
| Case | Result |
|---|---|
| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
| Second Play while one runs | Refused: "Stop the current media…" |
Stop always left the unit inactive, and no player process remained.
**Standby (verified):** an unworn Frame turns its displays off a few seconds
after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
first run's movie died there. The player now drops frames while the headset
is in standby, keeps the audio and its clock going, and resumes the picture
when the headset wakes. The 8 s movie above dropped 40 frames and finished.
Stills and the theatre surround are re-sent after waking. Five minutes
without an accepted frame is reported as an error. Headset-view captures
taken during standby show a flat dark frame, not our screen.
![Media panel in Frame Control while a photo plays on the Frame](img/media-ui-panel.png)
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
in the basic appsink probe and the OpenVR probe. We do not ship that route.
+168
View File
@@ -0,0 +1,168 @@
"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
import contextlib
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
import tarfile
import tempfile
import time
MAX_BYTES = 20 * 1024 ** 3
MAX_FILES = 100000
MAX_MANIFEST = 1024 * 1024
def inspect_archive(path, package, instance):
names, total, manifest = set(), 0, None
with tarfile.open(path, 'r:gz') as archive:
for member in archive:
name = member.name
parts = PurePosixPath(name).parts
if (not parts or name.startswith('/') or '..' in parts or
name != '/'.join(parts) or name in names or '\\' in name):
raise ValueError('unsafe or duplicate archive path')
if name == 'data' and not member.isdir():
raise ValueError('data root must be a directory')
names.add(name)
if len(names) > MAX_FILES or not (member.isdir() or member.isfile()):
raise ValueError('archive has too many files, links or special files')
if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535:
raise ValueError('archive owner outside Android user namespace')
total += member.size
if total > MAX_BYTES:
raise ValueError('archive exceeds 20 GiB')
if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST:
manifest = json.load(archive.extractfile(member))
elif parts[0] != 'data':
raise ValueError('unexpected archive member')
if (not isinstance(manifest, dict) or manifest.get('format') != 1 or
manifest.get('package') != package or manifest.get('instance') != instance or
'data' not in names):
raise ValueError('backup does not match this package and instance')
return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance,
'skipped_links': manifest.get('skipped_link_count', 0)}
def backup(root, package, instance, output):
import io
source = root / package
if source.is_symlink() or not source.is_dir():
raise ValueError('private app data does not exist or is a symlink')
count, total, links, skipped = 0, 0, [], 0
def checked(member):
nonlocal count, total, skipped
if member.issym(): # never followed or restored; listed in the manifest instead
skipped += 1
if len(links) < 1000:
links.append({'path': member.name[:512], 'target': member.linkname[:256]})
return None
if member.islnk(): # a second name for a file already archived: store its content again
member.type, member.linkname = tarfile.REGTYPE, ''
member.size = os.lstat(str(source / member.name[len('data/'):])).st_size
count += 1
total += member.size
if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES:
raise ValueError('private data contains special files or exceeds backup limits')
return member
with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive:
archive.add(str(source), arcname='data', filter=checked)
# Written last so that it can list what was skipped.
manifest = json.dumps({'format': 1, 'package': package, 'instance': instance,
'skipped_links': links, 'skipped_link_count': skipped}).encode()
member = tarfile.TarInfo('manifest.json')
member.size, member.mode = len(manifest), 0o600
archive.addfile(member, io.BytesIO(manifest))
def restore(root, package, instance, input_stream):
source = root / package
if source.is_symlink() or not source.is_dir():
raise ValueError('private app data does not exist or is a symlink')
with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work:
work = Path(work)
archive_path = work / 'backup.tar.gz'
with archive_path.open('wb') as output:
size = 0
while True:
chunk = input_stream.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > MAX_BYTES:
raise ValueError('compressed backup exceeds 20 GiB')
output.write(chunk)
result = inspect_archive(archive_path, package, instance)
stage = work / 'stage'
stage.mkdir(mode=0o700)
with tarfile.open(archive_path, 'r:gz') as archive:
directories = []
for member in archive:
if member.name == 'manifest.json':
continue
target = stage / member.name
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
directories.append((target, member))
else:
target.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as src, target.open('xb') as dst:
shutil.copyfileobj(src, dst, 1024 * 1024)
apply_metadata(target, member)
for target, member in reversed(directories):
apply_metadata(target, member)
with package_lock(root, package): # another restore of this package must not delete our copy
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
source.rename(previous)
try:
(stage / 'data').rename(source)
except BaseException:
previous.rename(source)
raise
# Keep only the newest pre-restore copy of this package's data.
for old in root.glob('.' + package + '.before-restore-*'):
if old != previous and not old.is_symlink():
shutil.rmtree(str(old), ignore_errors=True)
result['previous'] = str(previous)
return result
@contextlib.contextmanager
def package_lock(root, package):
import fcntl
fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd) # releases the lock
def apply_metadata(path, member):
os.chown(str(path), member.uid, member.gid)
os.chmod(str(path), member.mode & 0o777)
os.utime(str(path), (member.mtime, member.mtime))
def main():
action, package, instance = sys.argv[1:]
instance = int(instance)
root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal'
if root.is_symlink() or root.resolve() != root.absolute():
raise ValueError('private-data directory traverses a symlink')
if action == 'backup':
backup(root, package, instance, sys.stdout.buffer)
elif action == 'restore':
print(json.dumps(restore(root, package, instance, sys.stdin.buffer)))
else:
raise ValueError('unknown app-data action')
if __name__ == '__main__':
try:
main()
except (OSError, ValueError, tarfile.TarError) as error:
sys.exit(str(error))
+45 -1
View File
@@ -19,6 +19,25 @@ done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
# Keep the stable Lepton context, but identify the Android VR client as its
# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
if [[ -f "$DIR/shortcut.id" ]]; then
shortcut="$(cat "$DIR/shortcut.id")"
[[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
export LEPTON_ENV_SteamAppId="$shortcut"
fi
exec 9>"$DIR/launch.lock"
flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
CONTAINER="lepton-steamlaunch-$SteamAppId"
# Holding the lock means no launcher owns a running container: it was orphaned
# (this script SIGKILLed), so stop it rather than refuse every later Play. The
# name is this app's alone. A Lepton host process whose launcher was killed
# before it made the container may linger briefly; nothing else is killed.
if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
echo "Stopping orphaned $CONTAINER" >&2
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
fi
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
@@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
# and SSH don't have; give it its own session instead.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
# Keep this shell in Steam's process tree; setsid alone has no container cleanup.
child=""
cleanup() {
trap '' TERM INT HUP
if [[ -n "$child" ]]; then
kill -TERM -- "-$child" 2>/dev/null || true
kill -TERM "$child" 2>/dev/null || true
fi
podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
if [[ -n "$child" ]]; then
kill -KILL -- "-$child" 2>/dev/null || true
kill -KILL "$child" 2>/dev/null || true
wait "$child" 2>/dev/null || true
fi
}
trap cleanup EXIT
trap 'exit 143' TERM
trap 'exit 130' INT
trap 'exit 129' HUP
# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
child=$!
rc=0
wait "$child" || rc=$?
child=""
exit "$rc"
+146
View File
@@ -0,0 +1,146 @@
// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
async function renderLibraryArtwork(input) {
const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
const label = String(input.label || 'Untitled').trim().slice(0,180);
const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
await document.fonts.load(`800 120px ${font}`, label);
const images = {}, warnings = [];
for (const [slot, item] of Object.entries(input.images || {})) {
try {
const img = new Image();
img.src = `data:image/${item[0]};base64,${item[1]}`;
await img.decode();
if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions');
images[slot] = img;
} catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
}
let icon = images.icon;
if (icon) {
// Remove only a near-black matte connected to the outside of an opaque icon.
const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
const c=cut.getContext('2d');c.drawImage(icon,0,0);
const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
const corners=[0,w-1,(h-1)*w,h*w-1];
if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
const seen=new Uint8Array(w*h), queue=corners.slice();
for(let q=0;q<queue.length;q++) {
const i=queue[q];if(seen[i])continue;seen[i]=1;
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
d[i*4+3]=0;
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
}
c.putImageData(pixels,0,0);icon=cut;
}
}
// Quantized, saturated dominant colors avoid white/black icon margins.
let colors = [[48,91,137], [24,36,63]];
if (icon) {
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
const data=s.getImageData(0,0,48,48).data, bins=new Map();
for (let i=0;i<data.length;i+=4) {
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
if (data[i+3]<150 || hi<45 || lo>220 || hi-lo<25) continue;
const key=rgb.map(v=>Math.round(v/32)*32).join(',');
bins.set(key,(bins.get(key)||0)+1);
}
const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
if (ranked.length) {
colors[0]=ranked[0][0].split(',').map(Number);
colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
}
}
// Preserve hue while lifting muted icon colors into a richer background palette.
colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
return c.map(v=>45+(v-low)/range*165);});
const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
function image(ctx,img,x,y,w,h,cover=false) {
const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
const dw=img.width*scale,dh=img.height*scale;
ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
}
function title(ctx,w,h,top,bottom,maxSize) {
let lines=[],size=maxSize;
const maxWidth=w*.84;
for (;size>=18;size-=2) {
ctx.font=`800 ${size}px ${font}`;
lines=[]; let line='';
for (const word of label.split(/\s+/)) {
const next=line?line+' '+word:word;
if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
}
lines.push(line);
if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
}
if(lines.length===2) {
const words=lines[0].split(' ');
if(words.length>1) {
const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
if(ctx.measureText(second).width<=maxWidth &&
Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
}
}
// A long unbroken label is still fitted, including scripts without spaces.
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
ctx.shadowBlur=0; ctx.shadowOffsetY=0;
}
const result={};
for (const [slot,[w,h]] of Object.entries(sizes)) {
const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
const direct=images[slot];
const feature=images.feature_graphic||images.banner;
const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
if (scene) {
if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
else image(ctx,scene,0,0,w,h,true);
} else if (slot==='logo') {
title(ctx,w,h,h*.08,h*.92,150);
} else {
const gradient=ctx.createLinearGradient(0,0,w,h);
gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
if (icon) {
ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
}
const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
const vignette=ctx.createLinearGradient(0,h*.25,0,h);
vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
if (icon) {
ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
ctx.shadowOffsetY=Math.min(w,h)*.022;
// Opaque square icons read as deliberate app tiles, not pasted rectangles.
const [x,y,bw,bh]=box, side=Math.min(bw,bh);
if(slot!=='hero' && icon===images.icon) {
ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
}
image(ctx,icon,...box);ctx.restore();
} else if (slot !== 'hero') {
// A typographic monogram when the APK contains no usable image.
ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
}
if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
// Hero intentionally has no title: Steam overlays the transparent logo.
}
// Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency.
const jpeg=scene && slot!=='logo' && slot!=='icon';
result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]];
}
return {images:result,warnings,font};
}
+229 -9
View File
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
steam_shortcuts.py list -> JSON [{appid, name, exe}]
steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
steam_shortcuts.py stop APPID
steam_shortcuts.py remove APPID
"""
import base64, json, os, socket, struct, sys, urllib.request
import base64, glob, json, os, re, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
@@ -22,10 +24,10 @@ def target_ws():
class WS:
"""Just enough RFC 6455 for one CDP request/response on loopback."""
def __init__(self, url):
def __init__(self, url, timeout=20):
host_port, path = url[len('ws://'):].split('/', 1)
host, port = host_port.split(':')
self.s = socket.create_connection((host, int(port)), timeout=20)
self.s = socket.create_connection((host, int(port)), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
@@ -69,8 +71,8 @@ class WS:
return msg.decode()
def evaluate(js):
ws = WS(target_ws())
def evaluate(js, timeout=20):
ws = WS(target_ws(), timeout)
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
while True:
@@ -83,6 +85,206 @@ def evaluate(js):
return res.get('result', {}).get('value')
# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
def collections_js(appid, wanted=()):
wanted = list(wanted)
return f'''async function syncCollections() {{
const wanted = {json.dumps(wanted)};
if (typeof collectionStore === "undefined" ||
typeof collectionStore.GetUserCollectionsByName !== "function" ||
typeof collectionStore.NewUnsavedCollection !== "function" ||
typeof collectionStore.SaveCollection !== "function")
return ["Steam collections API unavailable"];
const app = {{appid: {appid}}};
const warnings = [];
for (const name of ["Android", "Android VR", "Sideloaded"]) {{
const matches = collectionStore.GetUserCollectionsByName(name);
let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
if (wanted.includes(name)) {{
if (!collection && matches.length) {{
warnings.push(name + " is an existing dynamic or read-only collection");
continue;
}}
if (!collection) {{
collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
}} else {{
collection.AsDragDropCollection().AddApps([app]);
}}
await collectionStore.SaveCollection(collection);
}} else if (collection) {{
collection.AsDragDropCollection().RemoveApps([app]);
await collectionStore.SaveCollection(collection);
}}
}}
return warnings;
}}'''
def notes_js(name, details):
filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
typeof SteamClient.GameNotes.SaveNotes === "function") {{
try {{
const file = {json.dumps(filename)};
const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
const id = "frame-control-library", now = Math.floor(Date.now()/1000);
const old = data.notes.find(n => n.id === id);
const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
time_created: old ? old.time_created : now, time_modified: now}};
data.notes = data.notes.filter(n => n.id !== id).concat([note]);
const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
if (result !== 1) throw Error("save " + result);
}} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
}}'''
MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot
def render(plan):
with open(plan) as f:
source = json.load(f)
images = {}
for slot, path in source['images'].items():
ext = os.path.splitext(path)[1][1:]
with open(path, 'rb') as f:
data = f.read(MAX_ART + 1)
if len(data) > MAX_ART:
raise ValueError('source artwork too large')
images[slot] = [ext, base64.b64encode(data).decode()]
renderer = globals().get('ART_RENDERER')
if renderer is None:
with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
renderer = f.read()
try:
return _render(plan, renderer, source['label'], images)
except (ValueError, OSError, EOFError, SystemExit) as e:
# Generated art from the icon alone always fits; a photo that didn't must not fail the install.
result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'})
result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used')
return result
def _render(plan, renderer, label, images):
# A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that.
result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')',
timeout=75)
if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
raise ValueError('incomplete artwork render')
paths = {}
for slot, (ext, encoded) in result['images'].items():
data = base64.b64decode(encoded, validate=True)
signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext)
if not signature or not data.startswith(signature) or len(data) > MAX_ART:
raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext))
paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext)
with open(paths[slot] + '.tmp', 'wb') as f:
f.write(data)
for slot, path in paths.items():
os.replace(path + '.tmp', path)
for stale in ('png', 'jpg'):
other = os.path.join(os.path.dirname(plan), slot + '.' + stale)
if other != path and os.path.exists(other):
os.remove(other)
return {'paths': paths, 'warnings': list(result.get('warnings', []))}
# Steam's own file for each custom-art type in userdata/*/config/grid/.
GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
def custom_art(appid):
"""The custom-art types this shortcut already has in Steam, for any local user."""
found = set()
for kind, suffix in GRID_FILES.items():
pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
found.add(kind)
return found
def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
"""options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
options = options or {}
category = options.get('category', 'Android')
fill = bool(options.get('fill_only'))
existing = custom_art(appid) if fill else set()
if set(artwork) != set(ASSETS):
raise ValueError('all five Steam artwork slots are required')
images = []
for slot, path in artwork.items():
if slot not in ASSETS:
raise ValueError('unknown artwork slot')
ext = os.path.splitext(path)[1][1:]
if ext not in ('png', 'jpg'):
raise ValueError('artwork must be PNG or JPEG')
with open(path, 'rb') as f:
data = f.read(MAX_ART + 1)
if len(data) > MAX_ART:
raise ValueError('artwork is too large')
# Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
if slot != 'icon' and ASSETS[slot] not in existing:
images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
return evaluate(f'''(async () => {{
const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
const overview = appStore.GetAppOverviewByAppID(id);
if (!fill) {{
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
}}
if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
// null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
if ({json.dumps(vr)} !== null && !fill) {{
if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
else warnings.push("Steam VR shortcut flag API unavailable");
}}
if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
for (const [type, ext, data] of {json.dumps(images)}) {{
// Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win.
if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
}}
}} else throw new Error("Steam artwork API unavailable; installation is incomplete");
{collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))}
try {{ warnings.push(...await syncCollections()); }}
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
{notes_js(name, options.get('details', {}))}
return {{warnings}};
}})()''', timeout=60)
def remove(appid):
# Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal.
return evaluate(f'''(async () => {{
const id = {int(appid)}, warnings = [];
{collections_js(int(appid))}
try {{ warnings.push(...await syncCollections()); }}
catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
for (const type of [0, 1, 2, 3]) {{
try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }}
catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }}
}}
}} else warnings.push("Steam artwork removal API unavailable");
SteamClient.Apps.RemoveShortcut(id);
return {{warnings}};
}})()''')
def main():
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'add':
@@ -97,12 +299,30 @@ def main():
}})()'''
print(evaluate(js))
elif cmd == 'list':
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
.map(a => ({appid: a.appid, name: a.display_name})))()'''
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => {
let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid);
if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => {
let reg;
const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000);
reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => {
clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); });
});
return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid,
exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""};
})))()'''
print(json.dumps(evaluate(js)))
elif cmd == 'render':
print(json.dumps(render(args[0])))
elif cmd == 'configure':
vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone
print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]),
json.loads(args[7]) if len(args) > 7 else None)))
elif cmd == 'stop':
evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
print('stopping')
elif cmd == 'remove':
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
print('removed')
print(json.dumps(remove(int(args[0]))))
else:
sys.exit(__doc__)
+16 -3
View File
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
fi
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
# running app and this script never write over each other's change.
write_config() {
local lockfd="" rc
zmodload zsh/system 2>/dev/null
touch "$CONFIG.frame-control.lock" 2>/dev/null
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
write_config_locked; rc=$?
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
return $rc
}
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
write_config_locked() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
local tmp
local tmp new="$CONFIG.frame-control.$$"
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
@@ -126,7 +138,8 @@ write_config() {
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
+4 -2
View File
@@ -15,11 +15,13 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
HERE=${0:A:h}
cmd=${1:-status}
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
# written the way Steam's settings page does (steamui module exporting the
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
# ("Interactive authentication required") but allows one from a user unit.
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
import json, os, subprocess, sys
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
from frame_steam import Page
+3 -1
View File
@@ -21,6 +21,8 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
# Frame Control passes the headset it has chosen: its address and pinned identity.
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
@@ -109,4 +111,4 @@ EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
+2 -2
View File
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
self.assertEqual(shortcut['name'], 'App label')
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
self.assertEqual(shortcut['start_dir'], APP_DIR)
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'):
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
if cmd == 'ps':
for name, c in sorted(containers.items()):
print(f"{name} {c['port']}")
elif cmd == 'inspect':
if args[-1] not in containers:
sys.exit(1)
print('true')
elif cmd == 'stop':
name = args[-1]
c = containers.get(name)
@@ -0,0 +1,33 @@
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
const zlib = require('zlib');
function crc(data) {
let c=0xffffffff;
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
return (c^0xffffffff)>>>0;
}
function chunk(name,data) {
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
}
function png(w,h) {
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
}
function surface() {
const canvases=[];
const document={fonts:{load:async()=>[]},createElement(tag) {
if(tag!=='canvas')throw Error('unexpected element');
const canvas={width:1,height:1,text:[],draws:0};
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'):
'data:image/png;base64,'+png(canvas.width,canvas.height);
canvases.push(canvas);return canvas;
}};
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
return {document,Image,canvases};
}
module.exports={surface};
@@ -37,7 +37,8 @@ function build(steam) {
},
});
const shortcutOverview = s => ({
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
icon_data: s.icon ? 'fake-icon' : undefined,
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
});
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
@@ -54,7 +55,30 @@ function build(steam) {
}
};
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
// Not yet verified on this Frame build: Steam was unavailable during testing.
steam.collections ||= [];
const collection = value => ({
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
AsDragDropCollection() { return this; },
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
value,
});
return {
...require('./canvas_stub').surface(),
collectionStore: {
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
},
// Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28).
appDetailsStore: {
GetAppDetails(id) {
const s = findShortcut(id);
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
},
},
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
@@ -75,6 +99,17 @@ function build(steam) {
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
async SetCustomArtworkForApp(id, data, ext, type) {
const s = findShortcut(id);
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
},
async ClearCustomArtworkForApp(id, type) {
const s = findShortcut(id);
if (s?.artwork) delete s.artwork[type];
},
// Container fallback in frame_android.stop performs the simulated stop.
TerminateApp(gameid) { steam.last_terminate = gameid; },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
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."""
import json
import os
import signal
import sys
import time
args = sys.argv[1:]
with open(os.environ["FAKESSH_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
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"):
key = args[i]
val = args[i + 1]
if key == "-o":
k, _, v = val.partition("=")
opts[k.lower()] = v
else:
opts[key] = val
i += 2
else:
opts[args[i]] = True
i += 1
alias = args[i] if i < len(args) else ""
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())
if "-G" in opts:
print(f"hostname {alias}\nport 22\nuser tester")
sys.exit(0)
if opts.get("-O") == "check":
sys.exit(0 if os.path.exists(marker) else 255)
if opts.get("-O") == "exit":
if os.path.exists(marker):
os.unlink(marker)
sys.exit(0)
what = hosts.get(host)
if what is None:
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
sys.exit(255)
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
say("debug1: Connection established.")
if what == "slow":
time.sleep(30)
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
if what == "wrong":
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
say("Host key verification failed.")
sys.exit(255)
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
say("debug1: Next authentication method: publickey")
if what == "denied":
say(f"tester@{host}: Permission denied (publickey).")
sys.exit(255)
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
if opts.get("controlmaster") == "yes":
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)
sys.exit(0)
+19
View File
@@ -0,0 +1,19 @@
# Store preview artwork
Recorded public artwork for offline UI verification, fetched 2026-09-28.
`urls.json` records each original URL. No unit test downloads these files.
- Open Brush banner, icon and screenshots: Icosa Foundation's public
`icosa-foundation/openbrush.app` website assets. Artwork remains credited to
its creators; used here to preview the Open Brush listing.
- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid
listings. Corresponding projects use GPL licences; these images represent
those same apps in the store preview.
- Luanti, SuperTuxKart and other social previews: public GitHub-generated
repository preview images. Project names/logos belong to their owners.
`../store.json` contains illustrative listing metadata, including mock package
names, popularity, dates, version/size and compatibility fields. It is not a
catalogue or evidence that a particular release works on the Frame. `_demo.py`
is opt-in and cannot download APKs. `tests/search_preview.py` preloads these
recordings into the image cache and simulates installation without a headset.
Binary file not shown.

After

Width:  |  Height:  |  Size: 489 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
"brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
"brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
"brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp",
"luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti",
"kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
"luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
"pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
"mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
"mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
"pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
"newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
"newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
}
+7
View File
@@ -0,0 +1,7 @@
[
{"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"},
{"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"},
{"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true},
{"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false},
{"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false}
]
+182
View File
@@ -0,0 +1,182 @@
[
{
"id": "brush",
"package": "org.preview.brush",
"name": "Open Brush",
"summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.",
"description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.",
"developer": "Icosa Foundation",
"license": "Apache-2.0",
"free": true,
"downloadable": true,
"version": "2.32.29",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": true,
"updated": "2026-09-27",
"size": 85000000,
"popularity": 100,
"images": {
"banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"screenshots": [
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp"
]
},
"engine": "Unity OpenXR",
"frame_tested": true,
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
"page": "https://openbrush.app"
},
{
"id": "mindustry",
"package": "org.preview.mindustry",
"name": "Mindustry",
"summary": "Build a factory. Defend your world.",
"description": "Build a factory. Defend your world.",
"developer": "Anuken",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.2",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-26",
"size": 85000000,
"popularity": 92,
"images": {
"banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
"screenshots": [
"https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png"
]
},
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png"
},
{
"id": "luanti",
"package": "org.preview.luanti",
"name": "Luanti",
"summary": "A world of blocks. Endless possibilities.",
"description": "A world of blocks. Endless possibilities.",
"developer": "Luanti contributors",
"license": "LGPL-2.1",
"free": true,
"downloadable": true,
"version": "1.3",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-25",
"size": 85000000,
"popularity": 84,
"images": {
"banner": "https://opengraph.githubassets.com/1/luanti-org/luanti",
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
"screenshots": [
"https://opengraph.githubassets.com/1/luanti-org/luanti"
]
},
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png"
},
{
"id": "pod",
"package": "org.preview.pod",
"name": "AntennaPod",
"summary": "Your favorite stories, wherever you listen.",
"description": "Your favorite stories, wherever you listen.",
"developer": "AntennaPod contributors",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.4",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-24",
"size": 85000000,
"popularity": 76,
"images": {
"banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
"screenshots": [
"https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png"
]
},
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png"
},
{
"id": "newpipe",
"package": "org.preview.newpipe",
"name": "NewPipe",
"summary": "Your videos and music, without the distractions.",
"description": "Your videos and music, without the distractions.",
"developer": "Team NewPipe",
"license": "GPL-3.0",
"free": true,
"downloadable": true,
"version": "1.5",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-23",
"size": 85000000,
"popularity": 68,
"images": {
"banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png",
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
"screenshots": [
"https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
]
},
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png"
},
{
"id": "kart",
"package": "org.preview.kart",
"name": "SuperTuxKart",
"summary": "A little friendly competition. A lot of colorful chaos.",
"description": "A little friendly competition. A lot of colorful chaos.",
"developer": "SuperTuxKart Team",
"license": "GPL-3.0",
"free": true,
"downloadable": false,
"version": "1.6",
"version_code": 1,
"min_sdk": 26,
"abis": [
"arm64-v8a"
],
"vr": false,
"updated": "2026-09-22",
"size": 85000000,
"popularity": 60,
"images": {
"banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
"icon": null,
"screenshots": [
"https://opengraph.githubassets.com/1/supertuxkart/stk-code"
]
},
"icon": null,
"page": "https://supertuxkart.net"
}
]
+21
View File
@@ -0,0 +1,21 @@
# F-Droid verification fixtures
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
plain test data, not an installable app. The v2 index includes incompatible
Android-31 and x86-only versions to exercise the shared reducer.
`izzy-entry.jar` was recorded from
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
fingerprint matches the operator's published fingerprint:
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
It exercises an independent production JAR/CMS encoder without network access.
The index it references is not needed by this signature-only fixture test.
`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures
based on the synthetic indexes above. They exercise en-US preference, per-field
locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap,
author names and HTML/multiline summaries. The signed integrity fixtures remain
unchanged; artwork tests feed these JSON files directly through the reducer and
then round-trip the resulting entries through the source cache.
+54
View File
@@ -0,0 +1,54 @@
{
"apps": [
{
"packageName": "org.example.app",
"name": "Example",
"license": "MIT",
"authorName": "Example Developer",
"summary": "Fallback summary",
"localized": {
"de": {
"name": "Beispiel",
"summary": "Deutsch",
"icon": "german.png",
"phoneScreenshots": [
"german.png"
]
},
"en-US": {
"name": "Example",
"summary": "Offline <b>fixture</b> &amp; music.\n One\t line.",
"icon": "icon.png",
"phoneScreenshots": [
"1.png",
"2.png",
"3.png",
"4.png"
]
},
"fr": {
"featureGraphic": "featureGraphic.png",
"sevenInchScreenshots": [
"1.png",
"2.png",
"3.png",
"4.png"
]
}
}
}
],
"packages": {
"org.example.app": [
{
"versionName": "1",
"versionCode": 1,
"apkName": "example1.apk",
"hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
"hashType": "sha256",
"size": 51,
"minSdkVersion": 21
}
]
}
}
+143
View File
@@ -0,0 +1,143 @@
{
"repo": {
"name": {
"en-US": "Fixture"
}
},
"packages": {
"org.example.app": {
"metadata": {
"name": {
"en-US": "Example"
},
"summary": {
"en-US": "<p>Offline <b>fixture</b> &amp; music.</p>\n<p>One\t line.</p><script>hidden()</script>"
},
"license": "MIT",
"authorName": "Example Developer",
"icon": {
"de": {
"name": "/org.example.app/de/icon.png"
},
"en-US": {
"name": "/org.example.app/en-US/icon.png"
}
},
"featureGraphic": {
"fr": {
"name": "/org.example.app/fr/featureGraphic.png"
}
},
"screenshots": {
"phone": {
"de": [
{
"name": "/org.example.app/de/phoneScreenshots/1.png"
}
],
"en-US": [
{
"name": "/org.example.app/en-US/phoneScreenshots/1.png"
},
{
"name": "/org.example.app/en-US/phoneScreenshots/2.png"
},
{
"name": "/org.example.app/en-US/phoneScreenshots/3.png"
},
{
"name": "/org.example.app/en-US/phoneScreenshots/4.png"
}
]
},
"sevenInch": {
"fr": [
{
"name": "/org.example.app/fr/sevenInchScreenshots/1.png"
},
{
"name": "/org.example.app/fr/sevenInchScreenshots/2.png"
},
{
"name": "/org.example.app/fr/sevenInchScreenshots/3.png"
},
{
"name": "/org.example.app/fr/sevenInchScreenshots/4.png"
}
]
}
}
},
"versions": {
"1": {
"manifest": {
"versionName": "1",
"versionCode": 1,
"usesSdk": {
"minSdkVersion": 21
},
"nativecode": []
},
"file": {
"name": "/example1.apk",
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
"size": 51
},
"added": 1700000000000
},
"2": {
"manifest": {
"versionName": "2",
"versionCode": 2,
"usesSdk": {
"minSdkVersion": 30
},
"nativecode": [
"arm64-v8a"
]
},
"file": {
"name": "/example2.apk",
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
"size": 51
},
"added": 1700000000000
},
"3": {
"manifest": {
"versionName": "3",
"versionCode": 3,
"usesSdk": {
"minSdkVersion": 31
},
"nativecode": []
},
"file": {
"name": "/example3.apk",
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
"size": 51
},
"added": 1700000000000
},
"4": {
"manifest": {
"versionName": "4",
"versionCode": 4,
"usesSdk": {
"minSdkVersion": 21
},
"nativecode": [
"x86_64"
]
},
"file": {
"name": "/example4.apk",
"sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
"size": 51
},
"added": 1700000000000
}
}
}
}
}
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
Fixture APK payload, deliberately not installable.
+1
View File
@@ -0,0 +1 @@
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
Binary file not shown.
+17
View File
@@ -0,0 +1,17 @@
# Library fixtures
`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
transparent pixels. `steam-responses.json` includes the Open Saber Plus
shortcut ID/name and home path read from the Frame's existing metadata on
2026-09-28; the `configure` response is synthetic, matching our helper's
contract. Tests never contact the network.
The existing fakeframe CEF shim models artwork and collection methods from
SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
2026-09-28. These methods were not captured from this headset: Steam's client
was unavailable. The Node-based test runs the actual generated JavaScript
against that fixture; it is skipped when Node is absent.
`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
JPEG SOF parsing. `sips` is not used by the product or tests.
+21
View File
@@ -0,0 +1,21 @@
const fs=require('fs'),vm=require('vm'),assert=require('assert');
const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
(async()=>{
const surface=stub.surface(),ctx=vm.createContext(surface);
vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
assert.equal(result.images[slot][0],'png');
const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
}
// A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG.
const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}});
for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg');
for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png');
const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
console.log('five dimensions, textless hero, title logo: OK');
})().catch(e=>{console.error(e);process.exit(1)});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 77 B

+12
View File
@@ -0,0 +1,12 @@
{
"home": "/home/steamos",
"shortcuts": [
{
"appid": 3346865537,
"name": "Open Saber Plus"
}
],
"configure": {
"warnings": []
}
}
+23
View File
@@ -0,0 +1,23 @@
Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or
`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API
values are unchanged. No token, cookies or signed download URL is included.
- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases,
for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and
SgtBilko76/SuperTux-3D (one release).
- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`.
- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`.
- `itch-robots.txt`: `https://itch.io/robots.txt`.
- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`.
The synthetic ZIP in tests is only a transport/integrity fixture, not an
installable APK. Actual APK parsing was verified separately on the downloaded
Khronos Vulkan sample; see docs/apk-sources.md.
Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated
repository API reads. `artwork-check.json` records HTTPS response status,
Content-Type and image magic checks for all curated URLs and three topic
results. All curated URLs returned real images; LWJGL's social preview returned
HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test.
Open Brush screenshots came from the Steam appdetails response for app 1634870,
linked by its README. SuperTux's README links the recorded upstream screenshot.
+120
View File
@@ -0,0 +1,120 @@
[
{
"url": "https://avatars.githubusercontent.com/u/2757344?v=4",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://avatars.githubusercontent.com/u/784805?v=4",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://avatars.githubusercontent.com/u/94376830?v=4",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3",
"error": "HTTP Error 429: Too Many Requests"
},
{
"url": "https://opengraph.githubassets.com/1/bjornbytes/lovr",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
"status": 200,
"content_type": "image/jpeg",
"image": true
},
{
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg",
"status": 200,
"content_type": "image/jpeg",
"image": true
},
{
"url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
"status": 200,
"content_type": "image/jpeg",
"image": true
},
{
"url": "https://www.supertux.org/images/0_7_0/github_preview.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus",
"status": 200,
"content_type": "image/png",
"image": true
},
{
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
"status": 200,
"content_type": "image/gif",
"image": true
},
{
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
"status": 200,
"content_type": "image/gif",
"image": true
},
{
"url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif",
"status": 200,
"content_type": "image/gif",
"image": true
}
]
+88
View File
@@ -0,0 +1,88 @@
[
{
"tag_name": "2.32.29",
"draft": false,
"prerelease": true,
"published_at": "2026-09-26T17:49:28Z",
"assets": [
{
"id": 591143285,
"name": "OpenBrush_Android_2.32.29.apk",
"size": 314602794,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk",
"digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291"
},
{
"id": 591143287,
"name": "OpenBrush_Desktop_2.32.29.zip",
"size": 380735034,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip",
"digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a"
},
{
"id": 591143282,
"name": "OpenBrush_Linux_2.32.29.zip",
"size": 364906490,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip",
"digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16"
},
{
"id": 591143283,
"name": "OpenBrush_Mac_2.32.29.dmg",
"size": 373196471,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg",
"digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c"
},
{
"id": 591143286,
"name": "OpenBrush_Quest_2.32.29.apk",
"size": 314603546,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk",
"digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6"
}
]
},
{
"tag_name": "2.32.28",
"draft": false,
"prerelease": true,
"published_at": "2026-09-26T14:42:27Z",
"assets": [
{
"id": 590837298,
"name": "OpenBrush_Android_2.32.28.apk",
"size": 314603450,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk",
"digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082"
},
{
"id": 590837301,
"name": "OpenBrush_Desktop_2.32.28.zip",
"size": 380738236,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip",
"digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f"
},
{
"id": 590837297,
"name": "OpenBrush_Linux_2.32.28.zip",
"size": 364907046,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip",
"digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06"
},
{
"id": 590837296,
"name": "OpenBrush_Mac_2.32.28.dmg",
"size": 373195966,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg",
"digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d"
},
{
"id": 590837295,
"name": "OpenBrush_Quest_2.32.28.apk",
"size": 314604234,
"browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk",
"digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e"
}
]
}
]
+12
View File
@@ -0,0 +1,12 @@
User-agent: Mediapartners-Google
Disallow:
User-agent: *
Disallow: /*/download/
Disallow: /*/rh/
Disallow: /*/rp/
Disallow: /-/
Sitemap: https://itch.io/sitemap.xml
# vim: set ft=robots:
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" ?><rss version="2.0"><channel><title>Top free games for Android tagged openxr - itch.io</title><link>https://itch.io/games/free/platform-android/tag-openxr</link><item><guid>https://leandrodreamer.itch.io/open-saber</guid><title>Open Saber [Free] [Rhythm] [Windows] [Linux] [Android]</title><plainTitle>Open Saber</plainTitle><imageurl>https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif</imageurl><price>$0.00</price><currency>USD</currency><link>https://leandrodreamer.itch.io/open-saber</link><description><![CDATA[Open Source Rythmic Block Cutting Game :)
<img src="https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif" alt="Open Saber"/>]]></description><pubDate>Thu, 07 Sep 2023 02:11:50 GMT</pubDate><createDate>Thu, 07 Sep 2023 02:11:50 GMT</createDate><updateDate>Wed, 08 Jan 2025 02:24:29 GMT</updateDate><platforms><html>yes</html></platforms></item><item><guid>https://absyo.itch.io/off-nominal</guid><title>Off Nominal [Free] [Puzzle] [Windows] [Linux] [Android]</title><plainTitle>Off Nominal</plainTitle><imageurl>https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://absyo.itch.io/off-nominal</link><description><![CDATA[Adrift in space. Fix the ship. Return home.
<img src="https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png" alt="Off Nominal"/>]]></description><pubDate>Fri, 25 Sep 2026 19:54:48 GMT</pubDate><createDate>Fri, 25 Sep 2026 19:54:48 GMT</createDate><updateDate>Sun, 27 Sep 2026 23:25:20 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://somar-project.itch.io/somar-project</guid><title>Somar-project [Free] [Educational] [Android]</title><plainTitle>Somar-project</plainTitle><imageurl>https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://somar-project.itch.io/somar-project</link><description><![CDATA[Open-Source OpenXR application for raising awareness about negative impacts of underwater noise pollution on marine life
<img src="https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png" alt="Somar-project"/>]]></description><pubDate>Wed, 26 Mar 2025 17:17:58 GMT</pubDate><createDate>Wed, 26 Mar 2025 17:17:58 GMT</createDate><updateDate>Fri, 28 Mar 2025 15:52:59 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://5imon.itch.io/buggenesis</guid><title>Bug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]</title><plainTitle>Bug Genesis VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://5imon.itch.io/buggenesis</link><description><![CDATA[Use the bug generator to populate the planet
<img src="https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg" alt="Bug Genesis VR"/>]]></description><pubDate>Sun, 25 May 2025 20:22:49 GMT</pubDate><createDate>Sun, 25 May 2025 20:22:49 GMT</createDate><updateDate>Sun, 25 May 2025 20:37:39 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://benmclean.itch.io/wolfsharp</guid><title>WolfSharp [Free] [Shooter] [Windows] [Linux] [Android]</title><plainTitle>WolfSharp</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://benmclean.itch.io/wolfsharp</link><description><![CDATA[Wolfenstein 3-D for VR
<img src="https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png" alt="WolfSharp"/>]]></description><pubDate>Sat, 25 Jul 2026 12:16:01 GMT</pubDate><createDate>Sat, 25 Jul 2026 12:16:01 GMT</createDate><updateDate>Sat, 25 Jul 2026 13:45:38 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://mimekunst.itch.io/winter-solitude-vr</guid><title>Winter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]</title><plainTitle>Winter Solitude VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://mimekunst.itch.io/winter-solitude-vr</link><description><![CDATA[Step into the Frozen Abyss: FREE VR Game Experience
<img src="https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png" alt="Winter Solitude VR"/>]]></description><pubDate>Tue, 19 Dec 2023 19:19:59 GMT</pubDate><createDate>Tue, 19 Dec 2023 19:19:59 GMT</createDate><updateDate>Wed, 20 Dec 2023 22:49:23 GMT</updateDate><platforms><windows>yes</windows><osx>yes</osx><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://salmondev.itch.io/evil-miner-vr</guid><title>EVIL MINER VR [Free] [Other] [Windows] [Android]</title><plainTitle>EVIL MINER VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://salmondev.itch.io/evil-miner-vr</link><description><![CDATA[
<img src="https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png" alt="EVIL MINER VR"/>]]></description><pubDate>Fri, 13 Jun 2025 16:48:01 GMT</pubDate><createDate>Fri, 13 Jun 2025 16:48:01 GMT</createDate><updateDate>Sun, 15 Jun 2025 15:19:53 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://robinhuud.itch.io/winter-challenge-north-pole-defense</guid><title>North Pole Defense VR [Free] [Action] [Android]</title><plainTitle>North Pole Defense VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://robinhuud.itch.io/winter-challenge-north-pole-defense</link><description><![CDATA[Defend the north pole against giant snowmen using VR snowballs
<img src="https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png" alt="North Pole Defense VR"/>]]></description><pubDate>Thu, 16 Dec 2021 01:33:33 GMT</pubDate><createDate>Thu, 16 Dec 2021 01:33:33 GMT</createDate><updateDate>Thu, 16 Dec 2021 01:51:29 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://envemos.itch.io/ambly-native-xr</guid><title>Ambly Native XR [Free] [Linux] [Android]</title><plainTitle>Ambly Native XR</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://envemos.itch.io/ambly-native-xr</link><description><![CDATA[OpenXR games for Meta Quest, PICO and Linux.
<img src="https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg" alt="Ambly Native XR"/>]]></description><pubDate>Wed, 22 Jul 2026 18:38:55 GMT</pubDate><createDate>Wed, 22 Jul 2026 18:38:55 GMT</createDate><updateDate>Fri, 07 Aug 2026 16:04:20 GMT</updateDate><platforms><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://andyman404.itch.io/glow-up-garden</guid><title>Glow Up Garden (VR) [Free] [Action] [Windows] [Android]</title><plainTitle>Glow Up Garden (VR)</plainTitle><imageurl>https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://andyman404.itch.io/glow-up-garden</link><description><![CDATA[Speak positive affirmations out loud to grow your garden (voice-driven VR game)
<img src="https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg" alt="Glow Up Garden (VR)"/>]]></description><pubDate>Mon, 03 Jun 2024 20:36:53 GMT</pubDate><createDate>Mon, 03 Jun 2024 20:36:53 GMT</createDate><updateDate>Tue, 04 Jun 2024 04:20:37 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item></channel></rss>
+11
View File
@@ -0,0 +1,11 @@
User-agent: *
Disallow: /embed/
Disallow: /embed-upload/
Disallow: /search
Disallow: /checkout/
Disallow: /game/download/
Disallow: /bundle/download/
Disallow: /register-for-purchase/
Disallow: /email-feedback/
Sitemap: https://itch.io/sitemap.xml
+410
View File
@@ -0,0 +1,410 @@
[
{
"tag_name": "release-1.1.63",
"draft": false,
"prerelease": false,
"published_at": "2026-09-02T21:22:32Z",
"assets": [
{
"id": 541779948,
"name": "apilayer_api_dump-1.1.63.aar",
"size": 5120886,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar",
"digest": "sha256:9cab975cc8df3a99f6530f47a1fbabfa0527109a138f5a3ef5150672a658c61c"
},
{
"id": 541779969,
"name": "apilayer_api_dump-1.1.63.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar.asc",
"digest": "sha256:419ec65d3f526c617176f272d8a481488181ade017cb05ef42205cb2c5a86f05"
},
{
"id": 541779983,
"name": "apilayer_api_dump-1.1.63.pom",
"size": 1462,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom",
"digest": "sha256:9a5b3e470830ae471fe317bc63f43b33bc063458239238fe27e234232c45ff28"
},
{
"id": 541780002,
"name": "apilayer_api_dump-1.1.63.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom.asc",
"digest": "sha256:7cbf9f1af504ca68fc36e0985dadb74d1fbf4d2bbdcde3e00bfe9ea6168fc4a8"
},
{
"id": 541780126,
"name": "apilayer_best_practices_validation-1.1.63.aar",
"size": 484596,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar",
"digest": "sha256:0c56cb3dc0b093b28f4e5490820d3cb3f7b201b48444134f347455d4ee99abd2"
},
{
"id": 541780150,
"name": "apilayer_best_practices_validation-1.1.63.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar.asc",
"digest": "sha256:7eb9ab3efe939c367e4661d5a75836c1d9e0799ab627e99211253546935defa7"
},
{
"id": 541780162,
"name": "apilayer_best_practices_validation-1.1.63.pom",
"size": 1487,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom",
"digest": "sha256:97d410936f5f051ab2a73cdac196c744ac56b2dde6279a5e46e944ed61316147"
},
{
"id": 541780192,
"name": "apilayer_best_practices_validation-1.1.63.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom.asc",
"digest": "sha256:7f9fa0cd33627c4ecc2a4410e53dedadb5731b3cddae59a3c0d4fcd467b3472d"
},
{
"id": 541780025,
"name": "apilayer_core_validation-1.1.63.aar",
"size": 6386964,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar",
"digest": "sha256:9663ce94a5076b6707502cf5503bac456987ccf1f39a3f7c8f350d4521db8647"
},
{
"id": 541780057,
"name": "apilayer_core_validation-1.1.63.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar.asc",
"digest": "sha256:c6e75df848ca6d436fe24f680bde73a9e69972b67eef46e49aba4b77dec366e9"
},
{
"id": 541780090,
"name": "apilayer_core_validation-1.1.63.pom",
"size": 1455,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom",
"digest": "sha256:6aa9337f5e645baf489c05e562cd074dc696bad87db70a0cdd661dffc63b2c89"
},
{
"id": 541780108,
"name": "apilayer_core_validation-1.1.63.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom.asc",
"digest": "sha256:0fe8ded9fdf0660bf28a544ae405b9b58682a8d9648dacedf4e4ceef2f827869"
},
{
"id": 541777706,
"name": "hello_xr-OpenGLES-release-1.1.63.apk",
"size": 9557049,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-OpenGLES-release-1.1.63.apk",
"digest": "sha256:7c96022ac002cb0c72e3cd14c11a817767b7b5dbdf5a1d1c85d0c083b5719056"
},
{
"id": 541777527,
"name": "hello_xr-Vulkan-release-1.1.63.apk",
"size": 9557441,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-Vulkan-release-1.1.63.apk",
"digest": "sha256:f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34"
},
{
"id": 541800362,
"name": "OpenXR-SDK-Source-release-1.1.63.tar.gz",
"size": 4857593,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz",
"digest": "sha256:a3b97a36f11abe256a7ea1668a0a468aac9b738e94bea6b468f0ae31ad537a46"
},
{
"id": 541800378,
"name": "OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
"digest": "sha256:4f97028306ae219f599e9960adbf9bb072e8da2bfbedffd1f0de312516a61f87"
},
{
"id": 541821806,
"name": "OpenXR.Loader.1.1.63.nupkg",
"size": 1916162,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg",
"digest": "sha256:4e5a50a8807ef66f25180ff224e7d8150b594aa8ee4b07590f9ade55a8e98703"
},
{
"id": 541821827,
"name": "OpenXR.Loader.1.1.63.nupkg.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg.asc",
"digest": "sha256:9d66a6e958f7c2d5c4a0a4aef8df90a7beecab13547440c9fa2069979eab7ac7"
},
{
"id": 541779892,
"name": "openxr_loader_for_android-1.1.63-sources.jar",
"size": 1141287,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar",
"digest": "sha256:6f964ad09c4afa3f42f451cada86e61503392b018660b22dd34b61dfbf71a555"
},
{
"id": 541779920,
"name": "openxr_loader_for_android-1.1.63-sources.jar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar.asc",
"digest": "sha256:b98cba20f5c3b202b887307cb19196f4459f895413e55b68823a606f50d045fa"
},
{
"id": 541779720,
"name": "openxr_loader_for_android-1.1.63.aar",
"size": 4170279,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar",
"digest": "sha256:622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73"
},
{
"id": 541779762,
"name": "openxr_loader_for_android-1.1.63.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar.asc",
"digest": "sha256:3bb68b26d7def68b4fe8506bf09f302116290c2de9cf91fdfdba753978bff5ed"
},
{
"id": 541779849,
"name": "openxr_loader_for_android-1.1.63.pom",
"size": 1598,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom",
"digest": "sha256:c98f38fa8acf4cf1bd8bcb40774f9815ae6ed9da94c8a7be2ed9db7815c85404"
},
{
"id": 541779867,
"name": "openxr_loader_for_android-1.1.63.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom.asc",
"digest": "sha256:1c2625f5b889c7ed967c8a6010294ca94bbd96091d879b2fc989c6f40f9a6af1"
},
{
"id": 541793000,
"name": "openxr_loader_macos-1.1.63.zip",
"size": 826298,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip",
"digest": "sha256:b243eebcdfa8683d17ccc8cfbfb9036be94b3f5a22b3eb73c39da0877694a3ab"
},
{
"id": 541793027,
"name": "openxr_loader_macos-1.1.63.zip.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip.asc",
"digest": "sha256:3e95172aabc4bd2537a7125060abbb8efbdd0cee19bdf1bf30cbd9736b7dcbd2"
},
{
"id": 541784717,
"name": "openxr_loader_windows-1.1.63.zip",
"size": 31961521,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip",
"digest": "sha256:01c631aeabbfe0879540f77ef833416c532a20746285b494630160c23588b771"
},
{
"id": 541784760,
"name": "openxr_loader_windows-1.1.63.zip.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip.asc",
"digest": "sha256:e9384e2a94d82c5059d1d83c57d0da585056d95e393255048b0955b0ce69b3fc"
}
]
},
{
"tag_name": "release-1.1.62",
"draft": false,
"prerelease": false,
"published_at": "2026-08-01T01:32:30Z",
"assets": [
{
"id": 500510340,
"name": "apilayer_api_dump-1.1.62.aar",
"size": 4800443,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar",
"digest": "sha256:2a7c2d1bb14d94dfed8c1aaf170a9dda649756477fbcba4a143c76d08a50bed8"
},
{
"id": 500510366,
"name": "apilayer_api_dump-1.1.62.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar.asc",
"digest": "sha256:7ab53e3c1fcaabf49561737fe8ed5df9ad9a5a761615f05e1d5eed2799e85c5f"
},
{
"id": 500510378,
"name": "apilayer_api_dump-1.1.62.pom",
"size": 1462,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom",
"digest": "sha256:fcd4358d7582ce0787b96aacb9840afc086a28499767a6aa7491dbb682bcc921"
},
{
"id": 500510385,
"name": "apilayer_api_dump-1.1.62.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom.asc",
"digest": "sha256:4832ce5c8835d1880ae5adbc535b774d50f8c86f9870650a50fbf3b9993ec5fa"
},
{
"id": 500510464,
"name": "apilayer_best_practices_validation-1.1.62.aar",
"size": 482607,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar",
"digest": "sha256:6d1a369ba367049aff23ae554b284ccc17cb3be8832869de0c1d151228a26502"
},
{
"id": 500510477,
"name": "apilayer_best_practices_validation-1.1.62.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar.asc",
"digest": "sha256:658ecbb7f871e97ed0d659ed55b27ca6ff6cc6e1853699498ee7b1d5583d7313"
},
{
"id": 500510480,
"name": "apilayer_best_practices_validation-1.1.62.pom",
"size": 1487,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom",
"digest": "sha256:571d7c5587075e83ca0a4d2382d87515de614ab8c34416a82a1b9b1a7eb5f9c0"
},
{
"id": 500510489,
"name": "apilayer_best_practices_validation-1.1.62.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom.asc",
"digest": "sha256:342d0ed7080e92399dbc8648df4fe9378086718f1abc73f79f3a52de211ed36e"
},
{
"id": 500510395,
"name": "apilayer_core_validation-1.1.62.aar",
"size": 5910024,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar",
"digest": "sha256:5692ccd5563a0963c4d842614af11d7c280960687a221643a46266ef968c4d70"
},
{
"id": 500510422,
"name": "apilayer_core_validation-1.1.62.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar.asc",
"digest": "sha256:1e39ff48d4cd87231d931515968317c717a6811da84585650f0623c49329ec41"
},
{
"id": 500510432,
"name": "apilayer_core_validation-1.1.62.pom",
"size": 1455,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom",
"digest": "sha256:1917e5cee9b979c9032c7819c386ea62e4498c30ffbbcf0c25578ebcd0c1e441"
},
{
"id": 500510453,
"name": "apilayer_core_validation-1.1.62.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom.asc",
"digest": "sha256:8aed84009daa5e388b7d179ab90837e9537b4f762a1676aab922e03dc633ed18"
},
{
"id": 497398718,
"name": "hello_xr-OpenGLES-release-1.1.62.apk",
"size": 9548745,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-OpenGLES-release-1.1.62.apk",
"digest": "sha256:da5e421795b801684cab50156c572422b73cd98fc8c75aeeb968962b43a2ab46"
},
{
"id": 497398704,
"name": "hello_xr-Vulkan-release-1.1.62.apk",
"size": 9549137,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-Vulkan-release-1.1.62.apk",
"digest": "sha256:a154b2353983f8c0cb1827ddf51d7e80fc105085253fe524502f35c5ddac3284"
},
{
"id": 500514717,
"name": "OpenXR-SDK-Source-release-1.1.62.tar.gz",
"size": 4834887,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz",
"digest": "sha256:977073d7f4c0d1af8ab975f57e4b6ffd1c4e9209be66075812b890576e0e1e5f"
},
{
"id": 500514735,
"name": "OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
"digest": "sha256:3ea4d6e47da6a8b3480931636af3e85eb2e0cddaf582153ee97973a8b05a5214"
},
{
"id": 500514501,
"name": "OpenXR.Loader.1.1.62.nupkg",
"size": 1902954,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg",
"digest": "sha256:6bb16b4dbe3c11f29605def2def5b7d1177784c0403dc9a24bc2e13d7eb82604"
},
{
"id": 500514512,
"name": "OpenXR.Loader.1.1.62.nupkg.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg.asc",
"digest": "sha256:386dfd33e9c2db9c9c37d881b77eec9b74d181fda394b47c473b2bce37fd7005"
},
{
"id": 500510319,
"name": "openxr_loader_for_android-1.1.62-sources.jar",
"size": 1110593,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar",
"digest": "sha256:b83318394b30bb129b069dd854de2b1e21df4376dda1a7fa342aeaff17a71d3d"
},
{
"id": 500510327,
"name": "openxr_loader_for_android-1.1.62-sources.jar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar.asc",
"digest": "sha256:838b5f5a23329eb7f0e13afde7a7d6ae73b439c7cbf6d3ec0af3e65efd7d5bd6"
},
{
"id": 500510263,
"name": "openxr_loader_for_android-1.1.62.aar",
"size": 4158815,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar",
"digest": "sha256:c03c689fed9a48f9394af953660982c998b00f6d2d2d8d150bc3f890c75a7465"
},
{
"id": 500510280,
"name": "openxr_loader_for_android-1.1.62.aar.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar.asc",
"digest": "sha256:883ccab775776c65ee35bf3120553f6a3f0f47de2dd661e074469e98d3caea46"
},
{
"id": 500510292,
"name": "openxr_loader_for_android-1.1.62.pom",
"size": 1598,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom",
"digest": "sha256:9b1047158a416984fd6d60c472da09bb324aec74709f83a1516435917fa05064"
},
{
"id": 500510305,
"name": "openxr_loader_for_android-1.1.62.pom.asc",
"size": 215,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom.asc",
"digest": "sha256:9dd7d3f79ad76a48f709f55d8c205892ae30f70b10a44c4afbd51f50603c4478"
},
{
"id": 500514048,
"name": "openxr_loader_macos-1.1.62.zip",
"size": 817438,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip",
"digest": "sha256:400bf9ab932d04cf8a315fe8e63d5cd9824015b64ad2e5d72b2ffc086c54313c"
},
{
"id": 500514061,
"name": "openxr_loader_macos-1.1.62.zip.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip.asc",
"digest": "sha256:034a3575bbee545926dc59558aa5d62ea9fc2de9e23c426ac640cbb68bd8db88"
},
{
"id": 500513308,
"name": "openxr_loader_windows-1.1.62.zip",
"size": 30975472,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip",
"digest": "sha256:800ec772e2f9448a26ab9f579f4914d984346dd9d0d7c007841abe21d2c8ff2f"
},
{
"id": 500513351,
"name": "openxr_loader_windows-1.1.62.zip.asc",
"size": 870,
"browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip.asc",
"digest": "sha256:8117563bfc5092895e17112366cb954ca78f84964e5c09526dfd69656c1713fd"
}
]
}
]
+28
View File
@@ -0,0 +1,28 @@
{
"items": [
{
"full_name": "LWJGL/lwjgl3",
"name": "lwjgl3",
"description": "LWJGL is a Java library that enables cross-platform access to popular native APIs useful in the development of graphics (OpenGL, Vulkan, bgfx), audio (OpenAL, Opus), parallel computing (OpenCL, CUDA) and XR (OpenVR, LibOVR, OpenXR) applications.",
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/2757344?v=4"
}
},
{
"full_name": "sahibzada-allahyar/YC-Killer",
"name": "YC-Killer",
"description": "A library of enterprise-grade AI agents designed to democratize artificial intelligence and provide free, open-source alternatives to overvalued Y Combinator startups.",
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/94376830?v=4"
}
},
{
"full_name": "bjornbytes/lovr",
"name": "lovr",
"description": "Lua Virtual Reality Framework",
"owner": {
"avatar_url": "https://avatars.githubusercontent.com/u/784805?v=4"
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
[
{
"tag_name": "Beta0.2",
"draft": false,
"prerelease": true,
"published_at": "2026-09-23T14:51:47Z",
"assets": [
{
"id": 583977968,
"name": "SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
"size": 294152462,
"browser_download_url": "https://github.com/SgtBilko76/SuperTux-3D/releases/download/Beta0.2/SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
"digest": "sha256:63287e5d6f1866193e0d4730bf4a2ba87fd2fbdbe6317bd6bd24b96a8ddc9963"
}
]
}
]
+18
View File
@@ -0,0 +1,18 @@
{
"recorded": "2026-09-28",
"robots": {
"url": "https://sidequestvr.com/robots.txt",
"user_agent": "*",
"crawl_delay": 3,
"disallow": ["/search/", "/user/*", "/sideload/*"],
"sitemap": "https://sidequestvr.com/sitemap_index.xml"
},
"api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403},
"terms": {
"url": "https://sidequestvr.com/terms",
"bundle": "https://sidequestvr.com/main-4MMXZRXL.js",
"prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping",
"prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service"
},
"note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction."
}
+58
View File
@@ -0,0 +1,58 @@
"""Local store preview. No device access; installation progress is simulated.
FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py
"""
import json
import os
from pathlib import Path
import sys
import tempfile
import time
from urllib.parse import urlparse
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import server
from apk_sources import _demo, _images, search
class Preview(server.Handler):
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/android':
self.send_json({'apps': []})
return
if path == '/api/android/reports':
self.send_json({'reports': [], 'shared': False})
return
if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'):
self.send_json({'error': 'Headset disconnected', 'offline': True}, 503)
return
super().do_GET()
def do_POST(self):
if not self.local_request():
return
if urlparse(self.path).path == '/api/sources/install':
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
def work(report):
for percent in (12, 28, 43, 67, 89):
report('Downloading', percent)
time.sleep(2)
report('Installing', None)
time.sleep(3)
return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'}
self.send_json(server.start_job('Preview installation', work, progress=True))
return
if urlparse(self.path).path == '/api/sources':
super().do_POST()
return
self.send_json({'error': 'Device access disabled in store preview'}, 403)
if __name__ == '__main__':
if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1':
sys.exit('Set FRAME_APK_SEARCH_DEMO=1')
for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items():
_images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes())
with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp:
search.settings_path = lambda: Path(tmp) / 'enabled.json'
server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever()
+23 -2
View File
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
with mock.patch.object(server.frame_host, 'MUX', True), \
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
# Per headset (its tag), and per process for a private server.
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
class ComputerState(unittest.TestCase):
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
class ScriptsFollowTheHeadset(unittest.TestCase):
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
def test_scripts_get_the_routed_alias_and_options(self):
import shlex
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
agent.run_script(fake, 'keep-awake.sh', ['status'])
env = run.call_args.kwargs['env']
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
# and the script turns that back into the same argv
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
+101
View File
@@ -0,0 +1,101 @@
import http.client
import json
from pathlib import Path
import socket
import sys
import threading
import unittest
from unittest.mock import patch, Mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import _images, search, SourceError
import server
PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes()
class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._cache.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
with self.assertRaisesRegex(SourceError, 'Unknown artwork'):
_images.image('https://example.com/arbitrary.png')
fetch.assert_not_called()
entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private',
'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}}
art = _images.artwork(entry)
self.assertTrue(art['icon'].startswith('/source-image/'))
self.assertIsNone(art['banner'])
self.assertEqual(len(art['screenshots']), 1)
with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch:
self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png'))
_images.image(art['icon'].split('/')[-1])
fetch.assert_called_once_with('https://example.com/icon.png')
def test_rejects_credentials_ports_and_non_http(self):
for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png',
'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']:
self.assertIsNone(_images.register(url), url)
def test_blocks_private_loopback_and_mixed_dns_answers(self):
for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']:
with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \
patch.object(socket, 'create_connection') as connect:
with self.assertRaisesRegex(SourceError, 'Private network'):
_images.fetch('https://example.com/private.png')
connect.assert_not_called()
def test_redirect_to_private_network_is_rejected(self):
response = Mock(status=302)
response.getheader.return_value = 'http://127.0.0.1/secret'
conn = Mock()
conn.getresponse.return_value = response
public = [(2,1,6,'',('93.184.216.34',80))]
private = [(2,1,6,'',('127.0.0.1',80))]
with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \
patch.object(socket, 'create_connection') as connect, \
patch.object(http.client, 'HTTPConnection', return_value=conn):
with self.assertRaisesRegex(SourceError, 'Private network'):
_images.fetch('http://example.com/a.png')
connect.assert_called_once_with(('93.184.216.34',80),timeout=10)
def test_non_images_and_oversized_images_are_rejected(self):
with self.assertRaises(SourceError):
_images.remember('https://example.com/a.svg', b'<svg onload="evil()"/>')
with self.assertRaisesRegex(SourceError, 'too large'):
_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)
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame')
self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame')
self.assertIn('newer Android', search.verdict({'min_sdk':31})['label'])
self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label'])
self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked')
self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works')
def test_image_endpoint_does_not_allow_arbitrary_urls(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler)
threading.Thread(target=httpd.serve_forever,daemon=True).start()
try:
path = _images.register('https://example.com/app.png')
_images.remember('https://example.com/app.png',PNG)
for url, expected in [(path,200),('/source-image/unknown',404)]:
c=http.client.HTTPConnection('127.0.0.1',httpd.server_port)
c.request('GET',url)
r=c.getresponse();data=r.read();c.close()
self.assertEqual(r.status,expected)
if expected==200:
self.assertEqual(data,PNG)
self.assertEqual(r.getheader('Content-Type'),'image/png')
finally:
httpd.shutdown();httpd.server_close()
+235
View File
@@ -0,0 +1,235 @@
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, github, itch, _web
FIX = Path(__file__).parent / 'fixtures' / 'more_sources'
class PublisherSources(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.cache = patch.object(_web, 'cache', return_value=self.tmp.name)
self.cache.start()
self.addCleanup(self.cache.stop)
self.network = patch('urllib.request.OpenerDirector.open', side_effect=AssertionError('network in test'))
self.network.start()
self.addCleanup(self.network.stop)
_web._limited.clear()
self.addCleanup(_web._limited.clear)
self.root = Path(self.tmp.name) / 'caches' / 'apk-sources'
roots = patch.object(_web.frame_host, 'cache_dir', lambda *p: self.root.parent.joinpath(*p))
roots.start()
self.addCleanup(roots.stop)
def test_curated_search_is_offline(self):
self.assertEqual(github.search(github.sources()[0], 'hello')[0]['id'], 'KhronosGroup/OpenXR-SDK-Source')
self.assertEqual(github.search(github.sources()[0], '', 0), [])
def test_curated_artwork_has_recorded_image_evidence(self):
evidence = {r['url']: r for r in json.loads((FIX / 'artwork-check.json').read_text())}
entries = github.search(github.sources()[0], '')
self.assertEqual(len(entries), 4)
for entry in entries:
self.assertTrue(entry['summary'])
images = entry['images']
self.assertEqual(entry['icon'], images['icon'])
for url in [u for u in [images['icon'], images['banner']] if u] + images['screenshots']:
self.assertTrue(url.startswith('https://'))
self.assertEqual(evidence[url]['status'], 200)
self.assertTrue(evidence[url]['image'])
self.assertTrue(entries[1]['images']['screenshots'])
self.assertTrue(entries[2]['images']['screenshots'])
def test_topic_keeps_curated_artwork(self):
curated = github._curated()[1]
repo = {'full_name': curated['repo'], 'name': 'open-brush',
'owner': {'avatar_url': 'https://avatars.githubusercontent.com/u/1'}}
with patch.object(github, '_api', return_value={'items': [repo]}):
entry = github.search(github.sources()[0], 'topic:openxr')[0]
self.assertEqual(entry['images'], curated['images'])
unknown = github.details(github.sources()[0], 'unknown/project')
self.assertEqual(unknown['icon'], 'https://github.com/unknown.png')
self.assertIsNone(unknown['images']['banner'])
def test_real_releases(self):
for key, repo in [('khronos', 'KhronosGroup/OpenXR-SDK-Source'),
('brush', 'icosa-foundation/open-brush'), ('tux', 'SgtBilko76/SuperTux-3D')]:
with patch.object(github, '_api', return_value=json.loads((FIX / (key + '-releases.json')).read_text())):
e = github.details(github.sources()[0], repo)
self.assertTrue(e['downloadable'])
self.assertTrue(e['versions'][0]['name'].endswith('.apk'))
self.assertIsNone(e['version_code'])
search_entry = github.search(github.sources()[0], repo)[0]
self.assertEqual(e['images'], search_entry['images'])
self.assertEqual(e['icon'], e['images']['icon'])
with self.assertRaises(SourceError):
github.download(github.sources()[0], repo, 123)
def test_topic_results_need_approval(self):
data = json.loads((FIX / 'topic.json').read_text())
with patch.object(github, '_api', return_value=data):
entries = github.search(github.sources()[0], 'topic:openxr')
self.assertTrue(entries)
self.assertTrue(any(not e['downloadable'] for e in entries))
for entry, repo in zip(entries, data['items']):
self.assertEqual(entry['icon'], repo['owner']['avatar_url'])
self.assertEqual(entry['images']['icon'], entry['icon'])
self.assertIsNone(entry['images']['banner'])
self.assertEqual(entry['images']['screenshots'], [])
self.assertFalse(github.details(github.sources()[0], 'unknown/project')['downloadable'])
with self.assertRaises(SourceError):
github.search(github.sources()[0], 'topic:piracy')
def test_feed_free_android_only_and_deduplicated(self):
with patch.object(_web, 'read', return_value=(FIX / 'itch-feed.txt').read_bytes()):
entries = itch.search(itch.sources()[0], '')
self.assertEqual(len(entries), 9)
e = itch.details(itch.sources()[0], entries[0]['id'])
self.assertTrue(e['vr'])
self.assertTrue(e['images']['banner'])
for item in entries:
self.assertEqual(item['icon'], item['images']['icon'])
self.assertEqual(item['icon'], item['images']['banner'])
self.assertEqual(item['images']['screenshots'], [])
self.assertFalse(e['downloadable'])
self.assertEqual(itch.search(itch.sources()[0], 'off nominal')[0]['name'], 'Off Nominal')
with self.assertRaises(SourceError):
itch.download(itch.sources()[0], entries[0]['id'])
with self.assertRaises(SourceError):
itch._parse(itch.sources()[0], b'not xml')
def test_paid_and_unsafe_feed(self):
raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'$0.00', b'$1.00')
self.assertEqual(itch._parse(itch.sources()[0], raw), [])
raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'https://absyo.itch.io', b'http://localhost')
self.assertFalse(any(e['name'] == 'Off Nominal' for e in itch._parse(itch.sources()[0], raw)))
def test_download_hash_and_cleanup(self):
b = io.BytesIO()
with zipfile.ZipFile(b, 'w') as z:
z.writestr('AndroidManifest.xml', b'fixture')
raw = b.getvalue()
digest = hashlib.sha256(raw).hexdigest()
with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)):
result = _web.apk('https://github.com/owner/repo/file.apk', github.HOSTS, digest)
self.assertTrue(result['verified'])
self.assertEqual(Path(result['apk']).read_bytes(), raw)
with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)):
self.assertFalse(_web.apk('https://github.com/file.apk', github.HOSTS)['verified'])
for content, expected in [(raw, '0' * 64), (b'html challenge', None)]:
with patch.object(_web, 'open_url', return_value=io.BytesIO(content)), self.assertRaises(SourceError):
_web.apk('https://github.com/file.apk', github.HOSTS, expected)
self.assertFalse(list(Path(self.tmp.name).glob('*.part')))
def test_origin_and_redirect(self):
for url in ['http://github.com/x', 'https://evil.test/x', 'https://user@github.com/x']:
with self.assertRaises(SourceError):
_web.checked_url(url, github.HOSTS)
req = urllib.request.Request('https://api.github.com/x', headers={'Authorization': 'Bearer secret'})
handler = _web.Redirect(('api.github.com', 'github.com'))
redirected = handler.redirect_request(req, None, 302, '', {}, 'https://github.com/x')
self.assertFalse(redirected.has_header('Authorization'))
with self.assertRaises(SourceError):
handler.redirect_request(req, None, 302, '', {}, 'https://evil.test/x')
def test_cache_rate_limit_and_invalid_json(self):
with patch.object(_web, 'open_url', return_value=io.BytesIO(b'index')) as op:
self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index')
self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index')
self.assertEqual(op.call_count, 1)
error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None)
with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \
self.assertRaisesRegex(SourceError, 'FRAME_GITHUB_TOKEN'):
github._api('/x')
with patch.object(_web, 'open_url', side_effect=error), patch.object(_web.time, 'time', return_value=1e12):
self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') # throttled: stale copy
with patch.object(_web, 'read', return_value=b'<html>'), self.assertRaises(SourceError):
github._api('/x')
def test_prune_caps_apks_by_age_and_removes_orphans(self):
now = 1e9
self.root.mkdir(parents=True)
def make(folder, name, size, age):
path = Path(folder) / name
path.mkdir() if size is None else path.write_bytes(b'x' * size)
os.utime(str(path), (now - age, now - age))
return path
pub = self.tmp.name
oldest = make(self.root, 'a.apk', 40, 9000)
old = make(pub, 'b.apk', 40, 8000)
kept = make(self.root, 'c.apk', 40, 7200)
recent = make(pub, 'd.apk', 40, 60) # just downloaded: never pruned
orphan, busy = make(self.root, 'x.part', 5, 90000), make(pub, 'y.part', 5, 60)
listing, fresh = make(pub, 'l.data', 5, 8 * 86400), make(pub, 'm.data', 5, 3600)
tmpdir = make(self.root, 'tmpabc', None, 90000)
with patch.object(_web, 'APK_CAP', 100), patch.object(_web.time, 'time', return_value=now):
_web.prune()
self.assertEqual([p.exists() for p in (oldest, old, kept, recent)], [False, False, True, True])
self.assertEqual([p.exists() for p in (orphan, busy, listing, fresh, tmpdir)], [False, True, False, True, False])
def test_prune_rechecks_before_deleting_and_spares_apks_in_use(self):
self.root.mkdir(parents=True)
old = time.time() - 7200
reused, claimed, stale = self.root / 'a.apk', self.root / 'b.apk', self.root / 'c.apk'
for path in (reused, claimed, stale):
path.write_bytes(b'x' * 40)
_web.claim(claimed)
self.addCleanup(_web.release, claimed)
for path in (reused, claimed, stale):
os.utime(str(path), (old, old))
real = os.listdir
def listdir(folder):
if folder == self.tmp.name: # scanning the second folder: a download reuses a.apk meanwhile
self.assertTrue(_web.touch(reused))
return real(folder)
with patch.object(_web, 'APK_CAP', 10), patch.object(_web.os, 'listdir', listdir):
_web.prune()
self.assertEqual([p.exists() for p in (reused, claimed, stale)], [True, True, False])
_web.release(claimed)
with patch.object(_web, 'APK_CAP', 10):
_web.prune()
self.assertFalse(claimed.exists())
self.assertFalse(_web.touch(stale)) # a pruned APK is reported gone, so it's downloaded again
def test_backoff_honours_retry_after_per_host(self):
from apk_sources import SourceLimited
from email.utils import formatdate
now = [1e9]
clock = patch.object(_web.time, 'time', side_effect=lambda: now[0])
clock.start()
self.addCleanup(clock.stop)
error = urllib.error.HTTPError('https://itch.io/a', 429, 'slow down', {'Retry-After': '120'}, None)
with patch.object(_web, 'open_url', side_effect=error) as op:
with self.assertRaisesRegex(SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'):
itch.search(itch.sources()[0], '')
with self.assertRaises(SourceLimited) as caught: # other URLs on the host wait too
_web.read('https://itch.io/b', ('itch.io',), name='itch.io')
self.assertEqual(op.call_count, 1)
self.assertAlmostEqual(caught.exception.retry_after, 120)
with self.assertRaises(SourceLimited):
_web.apk('https://itch.io/c.apk', ('itch.io',))
self.assertEqual(op.call_count, 1)
with patch.object(_web, 'open_url', return_value=io.BytesIO(b'fresh')):
self.assertEqual(_web.read('https://api.github.com/x', ('api.github.com',)), b'fresh') # other hosts unaffected
now[0] += 121
with patch.object(_web, 'open_url', return_value=io.BytesIO(b'feed')):
self.assertEqual(_web.read('https://itch.io/b', ('itch.io',)), b'feed')
cases = [({}, 600), ({'Retry-After': formatdate(now[0] + 300, usegmt=True)}, 300),
({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(now[0]) + 60)}, 60)]
for headers, expected in cases:
with self.subTest(headers=headers):
_web._limited.clear()
self.assertAlmostEqual(_web.throttle('https://h.test/x', headers), expected, delta=1)
self.assertAlmostEqual(_web.wait_time('https://h.test/y'), expected, delta=1)
error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None)
with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \
self.assertRaisesRegex(SourceLimited, '^GitHub is limiting requests; try again in 10 minutes .*TOKEN'):
github._api('/y')
if __name__ == '__main__':
unittest.main()
+350
View File
@@ -0,0 +1,350 @@
import http.client
import json
from pathlib import Path
import sys
import tempfile
import threading
import time
import types
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import search, SourceError
import server
ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text())
def fake(source_id='one', fn=None):
return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id,
enabled=True, trust='official', builtin=True)],
search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]),
details=lambda s, i: ENTRIES[0],
download=Mock(return_value={'apk': '/fake.apk', 'obb': []}))
class SettingsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
p.start()
self.addCleanup(p.stop)
for state in (search._running, search._pending, search._status, search._game_data):
state.clear()
from apk_sources import _web
self.claims = []
for name in ('claim', 'release'): # fake downloads aren't real files
p = patch.object(_web, name, side_effect=lambda path, name=name: self.claims.append((name, path)))
p.start()
self.addCleanup(p.stop)
class SearchTests(SettingsTest):
def test_group_does_not_merge_distinct_or_unknown_packages(self):
result = search.group(ENTRIES)
self.assertEqual(len(result), 3)
self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2])
same_name = dict(ENTRIES[0], package=None)
self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2)
def test_rank_exact_and_installable_and_verified(self):
result = search.group(ENTRIES, 'Open Brush')
self.assertEqual(result[0]['package'], 'org.brush')
self.assertEqual(result[0]['offers'][0]['source'], 'one')
self.assertEqual(result[1]['package'], 'org.other')
self.assertEqual(len(search.group(ENTRIES, vr=False)), 1)
self.assertEqual(len(search.group(ENTRIES, installable=True)), 1)
def test_unknown_vr_counts_as_flat(self):
entries = [dict(ENTRIES[3], id='a', name='Flat', vr=False), dict(ENTRIES[3], id='b', name='Unknown', vr=None),
dict(ENTRIES[3], id='c', name='Headset', vr=True)]
self.assertEqual(sorted(a['name'] for a in search.group(entries, vr=False)), ['Flat', 'Unknown'])
self.assertEqual([a['name'] for a in search.group(entries, vr=True)], ['Headset'])
def test_browse_puts_unknown_fit_vr_first_and_blocked_last(self):
entries = [dict(source='s', id='flat', name='Flat', package='a.flat', vr=False, min_sdk=21, abis=[]),
dict(source='s', id='vr', name='Headset', package='a.vr', vr=True),
dict(source='s', id='bad', name='Blocked', package='a.bad', vr=True, min_sdk=34, abis=[])]
self.assertEqual([a['name'] for a in search.group(entries)], ['Headset', 'Flat', 'Blocked'])
def test_fit_unknown_and_native_free_and_vr_hints(self):
self.assertIsNone(search.fit({})['installable'])
self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable'])
self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable'])
self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0])
def test_timeout_and_failure_leave_other_results(self):
release = threading.Event()
calls = []
def slow(s, q, limit=50):
calls.append(q)
release.wait(2)
return []
mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))]
try:
with patch.object(search, 'modules', return_value=(mods, [])):
started = time.monotonic()
result = search.search(timeout=.03)
self.assertLess(time.monotonic() - started, .3)
self.assertTrue(result['apps'])
self.assertEqual([s['status'] for s in result['sources']], ['ok', 'loading', 'error'])
self.assertEqual(search.search('other', timeout=.03)['sources'][1]['status'], 'loading')
search.search('newest', timeout=.03)
self.assertEqual(calls, [''])
queued = search._pending['slow']
release.set()
self.assertTrue(queued['event'].wait(2))
self.assertEqual(queued['entries'], [])
self.assertEqual(calls, ['', 'newest']) # 'other' was superseded, never run
finally:
release.set()
def test_query_arriving_as_a_search_finishes_is_not_stranded(self):
mod = fake()
source = mod.sources()[0]
arrived = []
class Event(threading.Event):
def set(self):
if not arrived: # a request lands just as the first search completes
arrived.append(None)
t = threading.Thread(target=lambda: arrived.append(search._launch(mod, source, 'second', 50)))
t.start()
t.join(.3) # blocks on search._lock if completion is published atomically
super().set()
with patch.object(search, 'threading', types.SimpleNamespace(Event=Event, Thread=threading.Thread)):
search._launch(mod, source, 'first', 50)
for _ in range(200):
if len(arrived) == 2:
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
def set_enabled(source_id, enabled):
t = threading.Thread(target=lambda: free.append(search._lock.acquire(timeout=1) and not search._lock.release()))
t.start()
t.join()
mod = fake()
mod.set_enabled = set_enabled
with patch.object(search, 'modules', return_value=([mod], [])):
search.set_enabled('one', False)
self.assertEqual(free, [True])
def test_stale_source_status(self):
mod = fake()
mod.stale = lambda source: True
with patch.object(search, 'modules', return_value=([mod], [])):
status = search.search(timeout=1)['sources'][0]
self.assertEqual((status['status'], status['stale']), ('ok', True))
def test_limited_source_status(self):
from apk_sources import SourceLimited
mods = [fake('busy', Mock(side_effect=SourceLimited('busy is limiting requests', 60)))]
with patch.object(search, 'modules', return_value=(mods, [])):
status = search.search(timeout=1)['sources'][0]
self.assertEqual((status['status'], status['error']), ('limited', 'busy is limiting requests'))
def test_disable_persists_and_prevents_queries_and_installs(self):
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])):
search.set_enabled('one', False)
self.assertFalse(search.sources()[0]['enabled'])
self.assertEqual(search.search()['apps'], [])
with self.assertRaisesRegex(SourceError, 'disabled'):
search.install('one', 'brush')
def test_install_passes_metadata_artwork_and_obb(self):
mod = fake()
mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png')
def install(apk, name=None, icon_png=None, source=None, artwork=None):
self.assertEqual((apk, name, icon_png, source, artwork),
('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'}))
return {'package': 'org.brush'}
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install_obb', create=True) as obb:
# An actual function exposes the future signature for inspection.
with patch.object(server.frame_android, 'install', install):
result = search.install('one', 'brush', 1)
# The app's instance isn't running right after install, so game data is a follow-up step.
obb.assert_not_called()
self.assertTrue(result['game_data'])
self.assertIn('Add game data', result['message'])
obb.return_value = {'package': 'org.brush', 'obb': []}
self.assertEqual(search.add_game_data('org.brush')['message'], 'Game data added')
obb.assert_called_once_with('org.brush', ['main.obb'])
with self.assertRaisesRegex(SourceError, 'install it again'):
search.add_game_data('org.brush')
mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1)
def test_install_uses_source_image_urls_as_steam_artwork(self):
mod = fake()
plain = mod.details
mod.details = lambda source, entry_id: dict(plain(source, entry_id), images={
'icon': 'https://img.example/icon.png', 'banner': 'https://img.example/banner.png',
'screenshots': ['https://img.example/1.png', None]})
seen = {}
def install(apk, name=None, icon_png=None, source=None, artwork=None):
seen['artwork'] = artwork
return {'package': 'org.brush'}
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install', install):
search.install('one', 'brush')
self.assertEqual(seen['artwork'], {'icon': 'https://img.example/icon.png',
'banner': 'https://img.example/banner.png',
'screenshots': ['https://img.example/1.png']})
def test_discovery_and_demo_are_opt_in(self):
module = fake()
with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \
patch.object(search.importlib, 'import_module', return_value=module), \
patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
self.assertEqual(search.modules(), ([module], []))
with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \
patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
self.assertEqual(search.modules(), ([], []))
def test_newest_compatible_then_official_offer(self):
first = dict(ENTRIES[0], verified=False, trust='community')
newer = dict(first, source='new', version_code=2, updated='2026-01-01')
official = dict(newer, source='official', trust='official')
incompatible = dict(newer, source='blocked', verified=True, min_sdk=40)
offers = search.group([first, incompatible, newer, official])[0]['offers']
self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked'])
def test_missing_obb_support_stops_before_install(self):
mod = fake()
mod.download.return_value['obb'] = ['main.obb']
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install') as install, \
patch.dict(server.frame_android.__dict__):
server.frame_android.__dict__.pop('install_obb', None)
with self.assertRaisesRegex(SourceError, 'OBB'):
search.install('one', 'brush')
install.assert_not_called()
def test_downloaded_apk_is_protected_from_pruning_while_installing(self):
mod = fake()
def install(apk, **kwargs):
self.assertEqual(self.claims, [('claim', '/fake.apk')])
raise server.frame_android.FrameError('adb failed')
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(server.frame_android, 'install', install):
with self.assertRaises(server.frame_android.FrameError):
search.install('one', 'brush')
self.assertEqual(self.claims, [('claim', '/fake.apk'), ('release', '/fake.apk')])
def test_listing_cannot_download(self):
mod = fake()
mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False)
with patch.object(search, 'modules', return_value=([mod], [])):
with self.assertRaisesRegex(SourceError, 'developer page'):
search.install('one', 'brush')
mod.download.assert_not_called()
class EndpointTests(SettingsTest):
def setUp(self):
super().setUp()
self.mod = fake()
p = patch.object(search, 'modules', return_value=([self.mod], []))
p.start()
self.addCleanup(p.stop)
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def request(self, method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
result = r.status, json.loads(r.read())
c.close()
return result
def test_http_search_and_validation(self):
self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one')
self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps'])
self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400)
self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400)
for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}):
self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400)
def test_http_install_background_job(self):
with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install:
status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'})
self.assertEqual(status, 200)
for _ in range(100):
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
if job['done']:
break
time.sleep(.01)
self.assertTrue(job['done'])
self.assertIsNone(job['error'])
install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one')
def test_details_endpoint_and_real_install_stages(self):
code, entry = self.request('GET', '/api/sources/details?source=one&id=brush')
self.assertEqual(code, 200)
self.assertEqual(entry['name'], 'Open Brush')
self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame')
self.assertIn('artwork', entry)
self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400)
stages = []
with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}):
search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent)))
self.assertEqual(stages, [('Downloading',None),('Installing',None)])
def test_http_repository_management(self):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200)
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
self.assertEqual(code, 400)
self.assertIn('not available', reply['error'])
mod = fake('fdroid')
added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True}
mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock()
with patch.object(search, 'modules', return_value=([mod], [])):
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
self.assertEqual(code, 200)
job = self.wait(reply['job'])
self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32)
self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32)
mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None)
link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32
mod.add_repo.return_value = dict(added, trust_on_first_use=False)
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['message'], 'Added repo.example')
mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None)
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')
def test_http_add_game_data_job(self):
search._game_data['org.brush'] = ['/cache/main.1.org.brush.obb']
self.addCleanup(search._game_data.clear)
with patch.object(server.frame_android, 'install_obb', create=True,
side_effect=server.frame_android.FrameError('start this app instance before installing OBB data')):
job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job'])
self.assertEqual(job['error'], 'start this app instance before installing OBB data')
with patch.object(server.frame_android, 'install_obb', create=True, return_value={'package': 'org.brush'}) as obb:
job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job'])
self.assertEqual(job['message'], 'Game data added')
obb.assert_called_once_with('org.brush', ['/cache/main.1.org.brush.obb'])
def wait(self, job_id):
for _ in range(200):
job = self.request('GET', '/api/job?id=' + job_id)[1]
if job['done']:
return job
time.sleep(.01)
self.fail('job did not finish')
+412
View File
@@ -0,0 +1,412 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_only_a_real_true_counts_as_consent(self):
for wrong in ("false", "true", 1, 0, [], {}):
with self.assertRaisesRegex(ValueError, "true or false"):
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
with self.assertRaisesRegex(ValueError, "true or false"):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
self.assertEqual((fc.load()["email"], self.got), ("", []))
fc.save({"email": "me@example.com", "updates": True}) # left out is no
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def reports(self):
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = self.reports()
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
consent = [e for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
self.assertEqual(self.reports()[1]["contact_rev"], 1)
fc.save({"email": ""}) # Remove my email
last = self.events()[-1]
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
("withdraw", "", 2))
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>", "<removed>"])
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
"""Update notices were agreed for the old address, not the new one (the form says so)."""
fc.save({"email": "old@example.com", "updates": True})
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
self.assertEqual(self.reports()[0]["contact_rev"], 2)
fc.save({"email": "new@example.com", "updates": True, "followup": False})
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
"""Removed while the report's own consent is on its way: the report keeps that consent's
rev (so the removal is newer) and is logged without the address."""
post, removed = tm.post, []
def slow_post(events, **kw):
post(events, **kw)
if not removed and events[0]["event"] == "contact_consent":
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
with mock.patch.object(tm, "post", side_effect=slow_post):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
report = self.reports()[0]
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
self.assertEqual(logged, ["<removed>"])
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
"0.4.0", "Windows", "", "", followup, cid, rev]
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
reports = [self.report_row(), # removed later
self.report_row(cid="other"), # another copy, still agrees
self.report_row(rev=3), # sent after the removal
self.report_row(cid="moved"), # address changed later
self.report_row(cid="news-only"), # follow-up unticked later
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
self.report_row(cid="", followup=True), # no contact id: left alone
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports],
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
def test_the_change_number_decides_not_the_clock(self):
"""The clock went back between the report and the removal: the removal still counts."""
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
fc.save({"email": ""})
report = self.reports()[0]
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
for e in self.events() if e["event"] == "contact_consent"]
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
fr.mark_withdrawn([row], consents)
self.assertEqual(row[10], "withdrawn")
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
reports = [self.report_row(), ["short"]]
consents = [["copy", "", False, 2]]
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
mock.patch("builtins.print") as out:
fr.main()
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("follow-up permission since withdrawn", printed)
self.assertNotIn("me@example.com", printed)
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
fr.inbox()
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
def test_saving_is_not_headset_work(self):
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
import io
import server
seen = []
for path in ("/api/contact", "/api/contact/prompt"):
h = server.Handler.__new__(server.Handler)
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
h.path, h.rfile = path, io.BytesIO(body)
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
h.local_request = lambda: True
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
h.do_POST()
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
+403
View File
@@ -0,0 +1,403 @@
"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
host keys and input checks. Everything works in temporary folders.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
# >>> steam-frame (frame) >>>
Host frame
HostName frame.tail1234.ts.net
User steamos
IdentityFile ~/.ssh/id_ed25519_frame
IdentityFile ~/.ssh/id_rsa_frame_devkit
IdentitiesOnly yes
ServerAliveInterval 30
Host *
# <<< steam-frame (frame) <<<
# >>> steam-frame (frame-2) >>>
Host frame-2
HostName 192.168.1.60
Port 2222
User deck
IdentityFile ~/.ssh/id_ed25519_frame
Host *
# <<< steam-frame (frame-2) <<<
Host *
ServerAliveInterval 60
"""
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
class Base(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.ssh = self.dir / "ssh"
self.ssh.mkdir()
(self.ssh / "config").write_text(CONFIG)
old = os.environ.get("FRAME_CONTROL_SSH_DIR")
os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
self.reg = fd.Registry(self.dir / "devices.json")
class Validation(unittest.TestCase):
def test_hosts(self):
for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"):
self.assertEqual(fd.check_host(good), good)
for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
"frame%en0", "x" * 300, None, 5, "frame/../x"):
with self.assertRaises(fd.DeviceError, msg=repr(bad)):
fd.check_host(bad)
def test_names(self):
self.assertEqual(fd.check_alias("frame-2"), "frame-2")
for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
with self.assertRaises(fd.DeviceError):
fd.check_alias(bad)
with self.assertRaises(fd.DeviceError):
fd.check_user(bad)
for bad in ("0", "65536", "x", None, "22; id"):
with self.assertRaises(fd.DeviceError):
fd.check_port(bad)
self.assertEqual(fd.check_port("2222"), 2222)
with self.assertRaises(fd.DeviceError):
fd.check_text("line\nbreak", "label")
with self.assertRaises(fd.DeviceError):
fd.check_kind("wifi")
def test_ipv6_zone_is_escaped_for_ssh(self):
self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
class Migration(Base):
def test_blocks_are_parsed(self):
blocks = fd.parse_blocks(CONFIG)
self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
def test_existing_headsets_are_imported_once(self):
self.assertTrue(self.reg.sync_from_config(seed=False))
devices = self.reg.devices()
self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
frame, second = devices
self.assertEqual(frame["name"], "Steam Frame")
self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
self.assertEqual((second["user"], second["port"]), ("deck", 2222))
self.assertEqual(self.reg.active(), frame["id"])
self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
# It's all on disk, in the documented shape.
data = json.loads((self.dir / "devices.json").read_text())
self.assertEqual(data["version"], 1)
self.assertEqual(len(data["devices"]), 2)
self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
def test_first_import_keeps_using_frame(self):
# Set Up Connection puts each new block first; the app used `frame` before.
blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
(self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
+ "# >>> steam-frame (frame) >>>" + first + tail)
self.reg.sync_from_config(seed=False)
self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
@unittest.skipUnless(shutil.which("ssh"), "needs ssh")
def test_a_port_inherited_from_another_host_entry_is_kept(self):
(self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
self.reg.sync_from_config(seed=False)
self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
# Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
self.assertTrue(fd.rewrite_block("frame", port=22))
self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
def test_setup_finding_a_new_address_adds_it(self):
self.reg.sync_from_config(seed=False)
(self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
self.assertTrue(self.reg.sync_from_config(seed=False))
hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
def test_setup_changing_the_login_updates_the_headset(self):
self.reg.sync_from_config(seed=False)
(self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
self.assertTrue(self.reg.sync_from_config(seed=False))
d = self.reg.by_alias("frame")
self.assertEqual((d["user"], d["port"]), ("deck", 2200))
def test_removed_headset_stays_removed_until_setup_changes_it(self):
self.reg.sync_from_config(seed=False)
second = self.reg.by_alias("frame-2")
self.reg.remove_device(second["id"])
self.reg.sync_from_config(seed=False)
self.assertIsNone(self.reg.by_alias("frame-2"))
self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
self.reg.sync_from_config(seed=False)
self.assertIsNotNone(self.reg.by_alias("frame-2"))
def test_corrupt_registry_is_ignored(self):
(self.dir / "bad.json").write_text("{not json")
self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
(self.dir / "evil.json").write_text(json.dumps({"devices": [
{"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
{"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
devices = fd.Registry(self.dir / "evil.json").devices()
self.assertEqual([d["alias"] for d in devices], ["ok"])
self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
class SharedFile(Base):
"""The desktop app and a standalone server can share devices.json."""
def test_one_server_never_saves_over_anothers_change(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
d = self.reg.by_alias("frame")
self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
self.assertIn("100.101.1.2", hosts)
self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
self.reg.sync_from_config(seed=False)
other = fd.Registry(self.dir / "devices.json")
mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
self.reg.set_active(mine)
other.set_active(theirs)
self.assertEqual(self.reg.active(), mine) # after a refresh
self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
self.assertEqual(self.reg.active(), mine)
self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
class ConfigRewrite(Base):
def test_hostname_user_and_port_change_only_inside_the_block(self):
cfg = self.ssh / "config"
self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
text = cfg.read_text()
self.assertIn(" HostName 192.168.1.237\n", text)
self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
block = fd.parse_blocks(cfg.read_text())[0]
self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly
self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
self.assertIn(" Port 22\n", cfg.read_text())
self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
if os.name != "nt":
self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
def test_concurrent_edits_all_land(self):
import threading
def edit(alias, prefix):
for n in range(15):
fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
for t in threads:
t.start()
for t in threads:
t.join()
blocks = fd.parse_blocks((self.ssh / "config").read_text())
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
def test_learning_skips_a_block_someone_changed(self):
# The connector learned an address, but Set Up Connection moved the block meanwhile.
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "old.example", "user": None, "port": None}))
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
def test_zone_is_escaped_and_read_back(self):
fd.rewrite_block("frame", hostname="fe80::1%en0")
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
def test_missing_block_is_left_alone(self):
self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
self.assertFalse(fd.remove_block("frame-9"))
self.assertTrue(fd.remove_block("frame-2"))
self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
class Pins(Base):
def test_seed_copies_the_trusted_key_under_the_device_alias(self):
(self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
self.assertFalse(fd.pinned("d1"))
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
self.assertTrue(fd.pinned("d1"))
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
self.assertTrue(fd.forget_pin("d1"))
self.assertFalse(fd.pinned("d1"))
self.assertFalse(fd.forget_pin("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"])
fd.seed_pin("db", ["b.local"])
fd.forget_pin("da")
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
def test_hashed_and_non_default_port_entries(self):
kh = self.ssh / "known_hosts"
kh.write_text(f"[frame.local]:2222 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
def test_hashed_pins_are_found_and_forgotten(self):
target = fd.known_hosts("d4")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(f"frame-control-d4 {KEY}\n")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
self.assertFalse(fd.pinned("d4"))
def test_known_hosts_option_uses_the_override(self):
self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
os.environ.pop("FRAME_CONTROL_SSH_DIR")
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
class Registry(Base):
def test_address_editing(self):
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
self.assertEqual(a["kind"], "mdns")
self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
with self.assertRaises(fd.DeviceError):
self.reg.add_address(d["id"], "frame-3.local") # already there
with self.assertRaises(fd.DeviceError):
self.reg.add_address(d["id"], "frame-3.local; id")
self.reg.move_address(d["id"], "100.100.1.1", -1)
self.reg.move_address(d["id"], "100.100.1.1", -1)
self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
self.reg.update_address(d["id"], "192.168.1.40", label="Home")
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
with self.assertRaises(fd.DeviceError):
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
moved = self.reg.get(d["id"])["addresses"][1]
self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
self.reg.remove_address(d["id"], "192.168.1.41")
self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
with self.assertRaises(fd.DeviceError):
self.reg.remove_address(d["id"], "nope")
def test_devices(self):
a = self.reg.add_device("frame")
b = self.reg.add_device("frame-2", name="Office")
self.assertEqual(self.reg.active(), a["id"])
with self.assertRaises(fd.DeviceError):
self.reg.add_device("frame")
self.reg.set_active(b["id"])
self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
with self.assertRaises(fd.DeviceError):
self.reg.update_device(b["id"], user="bad user")
with self.assertRaises(fd.DeviceError):
self.reg.update_device(b["id"], user="steam", port="bad")
self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
self.reg.remove_device(b["id"])
self.assertEqual(self.reg.active(), a["id"])
self.assertFalse(self.reg.emptied())
self.reg.remove_device(a["id"])
self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
self.reg.add_device("frame-4")
self.assertFalse(self.reg.emptied())
with self.assertRaises(fd.DeviceError):
self.reg.get(b["id"])
def test_networks_get_names(self):
net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
self.reg.record_network(net)
self.reg.name_network("n-1", "Home Wi-Fi")
self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
self.assertEqual(self.reg.network_name(None), "No network")
with self.assertRaises(fd.DeviceError):
self.reg.name_network("n-unknown", "x")
class Order(unittest.TestCase):
def addr(self, host, kind, networks=()):
return {"host": host, "kind": kind, "networks": list(networks)}
def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
self.addr("192.168.1.237", "lan", ["n-home"])]
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
# Tailscale off: its addresses go last.
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
self.assertEqual(order[-1], "100.64.1.2")
# On an unknown network nothing has worked yet; the user's order breaks ties.
ranked = fd.order_addresses(addrs, None, True)
self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
self.assertEqual(ranked[0][1], "mDNS name")
if __name__ == "__main__":
unittest.main()
class RoutingLongLivedSsh(unittest.TestCase):
"""The keyboard agent and the Mac view keep their own ssh open: switching headset
must end or retarget them, or input would go on reaching the old headset."""
def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
import server
from unittest import mock
before = (server.FRAME, list(server.HOST_OPTS))
self.addCleanup(lambda: server.route(*before))
with mock.patch.object(server._input, "stop") as stop, \
mock.patch.object(server.macview, "retarget") as retarget:
server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
stop.assert_not_called()
server.route("frame-2", ["-o", "HostName=192.0.2.2"])
stop.assert_called_once()
retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
+537
View File
@@ -0,0 +1,537 @@
"""Offline authenticated repository fixtures; no tests contact a server."""
import io
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
import urllib.error
import zipfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, SourceLimited, _web, fdroid
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
URL = 'https://example.org/repo/'
_KEY = []
def signed_jar(member, content, digest='sha256'):
"""A JAR signed like fdroidserver's (no CMS signed attributes) with a throwaway test key."""
import base64, hashlib
from frame_apk_sign import certificate, der, integer, sequence, signing_key
if not _KEY:
with tempfile.TemporaryDirectory() as tmp:
_KEY.append(signing_key(Path(tmp) / 'key.json'))
key = _KEY[0]
label = 'SHA1' if digest == 'sha1' else 'SHA-256'
b64 = lambda data: base64.b64encode(hashlib.new(digest, data).digest()).decode()
manifest = ('Manifest-Version: 1.0\r\n\r\nName: %s\r\n%s-Digest: %s\r\n\r\n' % (member, label, b64(content))).encode()
sf = ('Signature-Version: 1.0\r\n%s-Digest-Manifest: %s\r\n\r\n' % (label, b64(manifest))).encode()
oid, prefix = next((bytes.fromhex(o), bytes.fromhex(p)) for o, (d, p) in fdroid._DIGESTS.items() if d == digest)
alg = sequence(der(6, oid), der(5, b''))
size = (key['n'].bit_length() + 7) // 8
value = prefix + hashlib.new(digest, sf).digest()
padded = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
signature = pow(int.from_bytes(padded, 'big'), key['d'], key['n']).to_bytes(size, 'big')
cert = certificate(key)
issuer = fdroid._der_parts(fdroid._der_parts(fdroid._der_parts(cert)[0][1])[0][1])[3][2]
signer = sequence(integer(1), sequence(issuer, integer(1)), alg,
sequence(der(6, bytes.fromhex('2a864886f70d010101')), der(5, b'')), der(4, signature))
signed = sequence(integer(1), der(0x31, alg), sequence(der(6, bytes.fromhex('2a864886f70d010701'))),
der(0xa0, cert), der(0x31, signer))
block = sequence(der(6, bytes.fromhex('2a864886f70d010702')), der(0xa0, signed))
stream = io.BytesIO()
with zipfile.ZipFile(stream, 'w') as z:
for name, data in (('META-INF/MANIFEST.MF', manifest), ('META-INF/TEST.SF', sf),
('META-INF/TEST.RSA', block), (member, content)):
z.writestr(name, data)
return stream.getvalue(), fdroid.hashlib.sha256(cert).hexdigest()
def entry_jar(timestamp, digest='sha256'):
entry = json.loads(zipfile.ZipFile(FIXTURES / 'entry.jar').read('entry.json'))
return signed_jar('entry.json', json.dumps(dict(entry, timestamp=timestamp)).encode(), digest)
class Repositories(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
mock = patch.object(fdroid.frame_host, name, value)
mock.start()
self.addCleanup(mock.stop)
net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden'))
net.start()
self.addCleanup(net.stop)
mock = self.fetch_patch = patch.object(fdroid, '_fetch', side_effect=self.fetch)
self.fetch_mock = mock.start()
self.addCleanup(mock.stop)
self.v1 = False
self.corrupt = None
self.files = {}
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing):
state.clear()
self.addCleanup(state.clear)
def fetch(self, url, path, maximum):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
if name == self.corrupt:
payload += b'tampered'
Path(path).write_bytes(payload)
def add(self):
return fdroid.add_repo(URL, PIN)
def test_add_search_details_download_cache(self):
source = self.add()
self.assertEqual(source['fingerprint'], PIN)
self.assertFalse(source['trust_on_first_use'])
result = fdroid.search(source, 'example offline')
self.assertEqual(len(result), 1)
self.assertNotIn('versions', result[0])
self.assertEqual(result[0]['version_code'], 2)
self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1])
downloaded = fdroid.download(source, 'org.example.app', 1)
self.assertTrue(downloaded['verified'])
self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
count = self.fetch_mock.call_count
os.utime(downloaded['apk'], (1, 1))
fdroid.download(source, 'org.example.app', 1)
self.assertEqual(self.fetch_mock.call_count, count)
self.assertGreater(Path(downloaded['apk']).stat().st_mtime, 1) # reuse counts as recent use
def test_wrong_pin_is_not_saved(self):
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.add_repo(URL, '0' * 64)
self.assertEqual(fdroid.user_repos(), [])
def test_tampered_index_is_not_saved(self):
self.corrupt = 'index-v2.json'
with self.assertRaisesRegex(SourceError, 'SHA-256'):
self.add()
self.assertEqual(fdroid.user_repos(), [])
def test_tampered_apk_is_not_cached(self):
source = self.add()
self.corrupt = 'example2.apk'
with self.assertRaisesRegex(SourceError, 'APK SHA-256'):
fdroid.download(source, 'org.example.app')
self.assertEqual(list(self.root.rglob('*.apk')), [])
self.assertEqual(list(self.root.rglob('*.part')), [])
def test_v1_fallback(self):
self.v1 = True
source = self.add()
self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1)
self.assertTrue(fdroid.download(source, 'org.example.app')['verified'])
def test_bad_v2_never_downgrades(self):
self.corrupt = 'index-v2.json'
with self.assertRaises(SourceError):
self.add()
self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
def test_transient_error_never_downgrades(self):
self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
with self.assertRaises(SourceError):
self.add()
self.assertEqual(self.fetch_mock.call_count, 1)
def test_tofu_preserves_pin_and_settings(self):
source = fdroid.add_repo('fdroidrepos://example.org/repo')
self.assertTrue(source['trust_on_first_use'])
self.assertEqual(source['fingerprint'], PIN)
fdroid.add_repo(URL)
self.assertEqual(len(fdroid.user_repos()), 1)
with self.assertRaisesRegex(SourceError, 'different pinned'):
fdroid.add_repo(URL, '0' * 64)
fdroid.set_enabled(source['id'], False)
self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), [])
with self.assertRaisesRegex(SourceError, 'disabled'):
fdroid.download(fdroid.user_repos()[0], 'org.example.app')
fdroid.set_enabled('fdroid', False)
self.assertFalse(fdroid.sources()[0]['enabled'])
fdroid.remove_repo(source['id'])
self.assertEqual(fdroid.user_repos(), [])
with self.assertRaises(SourceError):
fdroid.remove_repo('fdroid')
def test_urls(self):
self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN))
for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']:
with self.subTest(url=url), self.assertRaises(SourceError):
fdroid._url(url)
with self.assertRaisesRegex(SourceError, 'conflicting'):
fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64)
self.assertEqual(fdroid._child(URL, '/app/en-US/phoneScreenshots/#0 a.png'),
URL + 'app/en-US/phoneScreenshots/%230%20a.png') # real F-Droid screenshot name
for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']:
with self.subTest(name=name), self.assertRaises(SourceError):
fdroid._child(URL, name)
def test_recorded_real_signature(self):
content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN, strong=True)
self.assertEqual(fingerprint, fdroid.IZZY_PIN)
self.assertIn('index', json.loads(content))
def test_tampering_each_signature_layer(self):
for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']:
stream = io.BytesIO()
with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst:
for item in src.infolist():
data = src.read(item.filename)
if item.filename == member:
data = data[:-1] + bytes([data[-1] ^ 1])
dst.writestr(item.filename, data)
with self.subTest(member=member), self.assertRaises(SourceError):
fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN)
def test_duplicate_jar_member_rejected(self):
stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes())
import warnings
with warnings.catch_warnings():
warnings.simplefilter('ignore', UserWarning)
with zipfile.ZipFile(stream, 'a') as z:
z.writestr('entry.json', '{}')
stream.seek(0)
with self.assertRaisesRegex(SourceError, 'duplicate'):
fdroid._jar(stream, 'entry.json', PIN)
def test_corrupt_cache_refetches_verified_index(self):
source = self.add()
fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{')
self.assertEqual(len(fdroid.search(source, 'example')), 1)
self.assertEqual(self.fetch_mock.call_count, 4)
def test_artwork_v1_and_v2_survives_source_cache(self):
source = self.add()
for version in ('v1', 'v2'):
with self.subTest(version=version):
raw = FIXTURES / ('artwork-' + version + '.json')
if version == 'v1':
normalized = self.root / 'normalized.json'
fdroid._v1(raw.read_bytes(), normalized)
raw = normalized
apps = fdroid._reduce(raw, source)
cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
fdroid._write(cache, {'version': fdroid.CACHE_VERSION, 'url': URL,
'fingerprint': PIN, 'apps': apps})
before = self.fetch_mock.call_count
result = fdroid.search(source, 'example offline')[0]
self.assertEqual(result['developer'], 'Example Developer')
self.assertEqual(result['summary'], 'Offline fixture & music. One line.')
self.assertEqual(result['icon'], URL + 'org.example.app/en-US/icon.png')
self.assertEqual(result['images'], {
'icon': result['icon'],
'banner': URL + 'org.example.app/fr/featureGraphic.png',
'screenshots': [URL + 'org.example.app/en-US/phoneScreenshots/' + str(i) + '.png' for i in range(1, 5)] +
[URL + 'org.example.app/fr/sevenInchScreenshots/' + str(i) + '.png' for i in range(1, 3)]})
self.assertEqual(fdroid.details(source, result['id'])['images'], result['images'])
self.assertEqual(self.fetch_mock.call_count, before)
def test_missing_artwork_is_not_invented(self):
result = fdroid.search(self.add(), 'example')[0]
self.assertEqual(result['images'], {'icon': None, 'banner': None, 'screenshots': []})
self.assertIsNone(result['icon'])
self.assertIsNone(result['developer'])
def test_v1_legacy_icon_and_tablet_fallback(self):
index = json.loads((FIXTURES / 'artwork-v1.json').read_text())
app = index['apps'][0]
app['localized'] = {'fr': {'sevenInchScreenshots': ['tablet.png']}}
app['icon'] = 'legacy.1.png'
raw = self.root / 'legacy.json'
fdroid._v1(json.dumps(index).encode(), raw)
result = fdroid._reduce(raw, {'id': 'test', 'url': URL})['org.example.app']
self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
def test_v2_legacy_screenshot_keys_and_limit(self):
meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
images = fdroid._images(meta, URL)
self.assertEqual(images['screenshots'], [URL + 'phone/' + str(i) + '.png' for i in range(6)])
meta.pop('phoneScreenshots')
self.assertEqual(fdroid._images(meta, URL)['screenshots'], [URL + 'tablet.png'])
def test_old_cache_refreshes_for_artwork(self):
source = self.add()
path = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
saved = json.loads(path.read_text())
saved.pop('version')
for app in saved['apps'].values():
app.pop('images')
fdroid._write(path, saved)
self.assertIn('images', fdroid.search(source, 'example')[0])
self.assertEqual(self.fetch_mock.call_count, 4)
def test_slow_download_blocks_neither_settings_nor_other_repos(self):
import threading
source = self.add()
other = dict(source, id='other-repo')
started, release = threading.Event(), threading.Event()
def fetch(url, path, maximum):
if threading.current_thread().name == 'slow':
started.set()
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = fetch
slow = threading.Thread(target=fdroid._load, args=(other, True), name='slow')
slow.start()
try:
self.assertTrue(started.wait(2))
results = []
# The settings lock is free and another repo still loads while this one downloads.
check = threading.Thread(target=lambda: results.append(
(fdroid.set_enabled('fdroid', False), len(fdroid._load(source, force=True)[0]))))
check.start()
check.join(2)
self.assertEqual(results, [(None, 1)])
finally:
release.set()
slow.join()
def test_rollback_to_older_index_is_refused(self):
self.files['entry.jar'], pin = entry_jar(2000)
source = fdroid.add_repo(URL)
self.assertEqual(source['fingerprint'], pin)
self.files['entry.jar'], _ = entry_jar(1000)
with self.assertRaisesRegex(SourceError, 'older'):
fdroid._load(source, force=True)
for timestamp in (2000, 3000): # unchanged and newer indexes are fine
self.files['entry.jar'], _ = entry_jar(timestamp)
self.assertEqual(len(fdroid._load(source, force=True)[0]), 1)
self.files['entry.jar'], _ = entry_jar(2000)
with self.assertRaisesRegex(SourceError, 'older'):
fdroid._load(source, force=True)
fdroid.remove_repo(source['id']) # a deliberate re-add starts over
self.assertEqual(fdroid.add_repo(URL)['fingerprint'], pin)
def test_concurrent_processes_cannot_publish_an_older_index_last(self):
# Threads with their own in-memory locks, as separate processes would have; each
# _state_file_lock() opens its own file description, so flock contends for real.
import threading
self.files['entry.jar'], _ = entry_jar(50)
source = fdroid.add_repo(URL)
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
def fetch(url, path, maximum):
name = threading.current_thread().name
if name in jars and url.endswith('entry.jar'):
Path(path).write_bytes(jars[name])
else:
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = fetch
inside, go, order = threading.Event(), threading.Event(), []
real_write = fdroid._write
def write(path, value):
if path.name.endswith('.json') and 'apps' in value and threading.current_thread().name == 'older':
inside.set() # the older load has passed its locked recheck; hold it there
go.wait(5)
order.append(threading.current_thread().name)
real_write(path, value)
errors = {}
def load():
try:
fdroid._load(source, force=True)
except SourceError as e:
errors[threading.current_thread().name] = str(e)
with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()), \
patch.object(fdroid, '_write', write):
older = threading.Thread(target=load, name='older')
older.start()
self.assertTrue(inside.wait(5))
newer = threading.Thread(target=load, name='newer')
newer.start()
newer.join(.5)
self.assertTrue(newer.is_alive()) # blocked on the file lock, not publishing
go.set()
older.join(5)
newer.join(5)
self.assertEqual(errors, {})
self.assertEqual(order, ['older', 'older', 'newer', 'newer']) # cache+state, one load at a time
self.assertEqual(fdroid._state(source)['timestamp'], 200)
def test_overlapping_v1_load_cannot_replace_accepted_v2(self):
import threading
v1 = json.loads(zipfile.ZipFile(FIXTURES / 'index-v1.jar').read('index-v1.json'))
v1['repo'] = {'timestamp': 100}
self.files['index-v1.jar'], pin = signed_jar('index-v1.json', json.dumps(v1).encode())
self.files['entry.jar'], _ = entry_jar(100) # the same timestamp as the v1 index
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
self.v1 = True
inner = []
def fetch(url, path, maximum):
if url.endswith('index-v1.jar') and not inner:
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
self.v1 = False
t = threading.Thread(target=lambda: inner.append(fdroid._load(source, force=True)))
with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()):
t.start()
t.join(5)
self.v1 = True
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = fetch
with self.assertRaisesRegex(SourceError, 'v2'):
fdroid._load(source, force=True)
self.assertEqual(inner[1][1], pin)
self.assertTrue(fdroid._state(source)['v2'])
self.v1 = False
count = self.fetch_mock.call_count
apps, _ = fdroid._load(dict(source, fingerprint=pin))
self.assertEqual((apps['org.example.app']['version_code'], self.fetch_mock.call_count), (2, count)) # v2 cache stayed
def test_apk_removed_during_cache_check_is_downloaded_again(self):
source = self.add()
first = fdroid.download(source, 'org.example.app', 1)
count = self.fetch_mock.call_count
real = fdroid._sha256
def removed_first(path):
if str(path) == first['apk'] and not hashed:
hashed.append(1)
os.remove(first['apk']) # deleted between the existence check and the open
return real(path)
hashed = []
with patch.object(fdroid, '_sha256', removed_first):
again = fdroid.download(source, 'org.example.app', 1)
self.assertEqual(self.fetch_mock.call_count, count + 1)
self.assertEqual(Path(again['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
def test_cached_apk_is_touched_before_hashing(self):
source = self.add()
first = fdroid.download(source, 'org.example.app', 1)
os.utime(first['apk'], (1, 1))
count = self.fetch_mock.call_count
real = fdroid._sha256
def prune_first(path):
if str(path) == first['apk']:
with patch.object(_web, 'APK_CAP', 0):
_web.prune() # a pruner running now sees a just-used APK
return real(path)
with patch.object(fdroid, '_sha256', prune_first):
fdroid.download(source, 'org.example.app', 1)
self.assertEqual(self.fetch_mock.call_count, count)
self.assertTrue(Path(first['apk']).exists())
def test_cli_search_waits_for_background_refresh(self):
source = self.add()
self.expire(source)
before = self.fetch_mock.call_count
out = io.StringIO()
with patch.object(sys, 'argv', ['fdroid.py', 'search', source['id'], 'example']), \
patch('sys.stdout', out):
fdroid.main()
self.assertEqual(json.loads(out.getvalue())[0]['id'], 'org.example.app')
self.assertEqual(self.fetch_mock.call_count, before + 2) # the refresh finished before exit
self.assertFalse(fdroid.stale(source))
self.assertNotIn(source['id'], fdroid._refreshing)
def test_cli_error_still_waits_for_background_refresh(self):
import threading
source = self.add()
self.expire(source)
release = threading.Event()
def slow(url, path, maximum):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
threading.Timer(.3, release.set).start()
with patch.object(sys, 'argv', ['fdroid.py', 'download', source['id'], 'org.missing']), \
patch('sys.stderr', io.StringIO()) as err, self.assertRaises(SystemExit):
fdroid.main()
self.assertIn('no Lepton-compatible version', err.getvalue())
self.assertTrue(release.is_set())
self.assertEqual(fdroid._refreshing, {}) # joined before exiting
self.assertFalse(fdroid.stale(source))
def test_no_v1_fallback_once_v2_accepted(self):
source = self.add()
self.v1 = True
with self.assertRaisesRegex(SourceError, 'v2'):
fdroid._load(source, force=True)
self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
def test_v1_then_v2_upgrade_is_allowed(self):
self.v1 = True
source = self.add()
self.v1 = False
self.assertEqual(fdroid._load(source, force=True)[0]['org.example.app']['version_code'], 2)
def test_sha1_only_entry_jar_rejected(self):
self.files['entry.jar'], _ = entry_jar(1, 'sha1')
with self.assertRaisesRegex(SourceError, 'SHA-1'):
fdroid.add_repo(URL)
self.assertEqual(fdroid.user_repos(), [])
stream = io.BytesIO(self.files['entry.jar'])
self.assertIn(b'index', fdroid._jar(stream, 'entry.json', None)[0]) # index-v1.jar may still use SHA-1
def test_rate_limited_host_backs_off(self):
source = dict(self.add(), name='My repo')
self.fetch_patch.stop()
error = urllib.error.HTTPError(URL, 429, 'slow down', {'Retry-After': '300'}, None)
with patch.object(fdroid.urllib.request, 'build_opener') as opener:
opener.return_value.open.side_effect = error
with self.assertRaisesRegex(SourceLimited, '^My repo is limiting requests; try again in 5 minutes$'):
fdroid._load(source, force=True)
with self.assertRaisesRegex(SourceLimited, 'My repo'):
fdroid.download(source, 'org.example.app', 1)
self.assertEqual(opener.return_value.open.call_count, 1)
self.fetch_mock = self.fetch_patch.start()
def expire(self, source):
cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
old = cache.stat().st_mtime - fdroid.MAX_AGE - 1
fdroid.os.utime(str(cache), (old, old))
def test_expired_index_served_stale_while_refreshing(self):
source = self.add()
self.expire(source)
before = self.fetch_mock.call_count
release = __import__('threading').Event()
def slow(url, path, maximum):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
self.assertEqual(len(fdroid.search(source, 'example')), 1) # immediately, from the old index
self.assertTrue(fdroid.stale(source))
refresh = fdroid._refreshing[source['id']]
fdroid.search(source, 'example')
self.assertIs(fdroid._refreshing.get(source['id']), refresh) # one refresh at a time
release.set()
refresh.join(5)
self.assertEqual(self.fetch_mock.call_count, before + 2)
self.assertFalse(fdroid.stale(source))
def test_failed_refresh_keeps_serving_stale_index(self):
source = self.add()
self.expire(source)
self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
self.assertEqual(len(fdroid.search(source, 'example')), 1)
# A fast failed refresh may already have removed itself from the registry.
refresh = fdroid._refreshing.get(source['id'])
if refresh is not None:
refresh.join(5)
calls = self.fetch_mock.call_count
self.assertEqual(fdroid.details(source, 'org.example.app')['version_code'], 2)
self.assertTrue(fdroid.stale(source))
self.assertNotIn(source['id'], fdroid._refreshing) # failed refresh waits before retrying
self.assertEqual(self.fetch_mock.call_count, calls)
def test_cached_index_does_not_cross_pins(self):
source = self.add()
source['fingerprint'] = '0' * 64
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.search(source, '')
if __name__ == '__main__':
unittest.main()
+293
View File
@@ -0,0 +1,293 @@
import io
import json
import os
from pathlib import Path
import runpy
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_android_data as data
REMOTE = runpy.run_path(str(data.REMOTE))
PKG = 'org.example.game'
META = {'package': PKG, 'instance': 2800000001}
class ObbTests(unittest.TestCase):
def test_invalid_files_never_contact_frame(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(android, 'ssh') as ssh:
for name in ('game.obb', 'main.1.org.other.game.obb', 'main.x.' + PKG + '.obb'):
path = Path(tmp) / name
path.write_bytes(b'content')
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [path])
with self.assertRaises(android.FrameError):
data.install_obb('../game', [])
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [])
ssh.assert_not_called()
def test_streams_to_correct_instance_and_checks_hash_before_rename(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('main.7.' + PKG + '.obb')
path.write_bytes(b'expansion payload')
calls = []
def stream(command, src=None, dst=None):
calls.append(command)
self.assertEqual(src.read(), b'expansion payload')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001\n'), \
patch.object(data, '_stream', side_effect=stream):
result = data.install_obb(PKG, [path])
self.assertTrue(result['verified'])
self.assertIn('podman exec -i lepton-steamlaunch-2800000001', calls[0])
self.assertIn('/sdcard/Android/obb/' + PKG, calls[0])
self.assertLess(calls[0].index('sha256sum'), calls[0].index('; mv'))
self.assertIn(result['obb'][0]['sha256'], calls[0])
def test_stopped_instance_and_failed_transfer(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('patch.7.' + PKG + '.obb')
path.write_bytes(b'patch')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value=''), patch.object(data, '_stream') as stream:
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
@unittest.skipUnless(os.name == 'posix' and shutil.which("sh") and shutil.which("shasum"),
"the OBB script runs on the Frame (Linux shell)")
def test_android_shell_publish_and_hash_failure(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / ('main.7.' + PKG + '.obb')
source.write_bytes(b'good expansion')
output = Path(tmp) / 'sdcard/Android/obb' / PKG / source.name
tools_dir = Path(tmp) / 'bin'
tools_dir.mkdir()
checksum = tools_dir / 'sha256sum'
checksum.write_text('#!/bin/sh\nexec shasum -a 256 "$@"\n')
checksum.chmod(0o700)
corrupt = False
def stream(command, src=None, dst=None):
script = shlex.split(command)[-1].replace('/sdcard/', tmp + '/sdcard/')
if corrupt:
source.write_bytes(b'corrupt expansion')
result = subprocess.run(['sh', '-c', script], stdin=src, capture_output=True,
env=dict(os.environ, PATH=str(tools_dir) + ':' + os.environ['PATH']))
if result.returncode:
raise android.FrameError('checksum failed')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001'), \
patch.object(data, '_stream', side_effect=stream):
data.install_obb(PKG, [source])
self.assertEqual(output.read_bytes(), b'good expansion')
corrupt = True
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [source])
self.assertEqual(output.read_bytes(), b'good expansion')
self.assertEqual(list(output.parent.glob('*.part')), [])
@unittest.skipUnless(os.name == 'posix', 'app-data backups run on the Frame (Linux ownership and modes)')
class BackupTests(unittest.TestCase):
def test_roundtrip_and_retains_previous_data(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / PKG
(source / 'files').mkdir(parents=True)
(source / 'files/save').write_bytes(b'original save')
archive = io.BytesIO()
REMOTE['backup'](root, PKG, META['instance'], archive)
(source / 'files/save').write_bytes(b'new save')
archive.seek(0)
# Current user's uid/gid in this local test; no elevated execution.
result = REMOTE['restore'](root, PKG, META['instance'], archive)
self.assertEqual((source / 'files/save').read_bytes(), b'original save')
self.assertEqual((Path(result['previous']) / 'files/save').read_bytes(), b'new save')
def test_symlinks_skipped_and_recorded_hardlinks_copied(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / PKG
(source / 'files').mkdir(parents=True)
(source / 'files/save').write_bytes(b'save')
os.link(str(source / 'files/save'), str(source / 'files/save-link'))
os.symlink('/data/app/lib', str(source / 'lib'))
os.symlink('save', str(source / 'files/alias'))
archive = root / 'backup.tar.gz'
with archive.open('wb') as output:
REMOTE['backup'](root, PKG, META['instance'], output)
result = REMOTE['inspect_archive'](archive, PKG, META['instance'])
self.assertEqual(result['skipped_links'], 2)
with tarfile.open(archive) as tar:
manifest = json.load(tar.extractfile('manifest.json'))
self.assertEqual(tar.extractfile('data/files/save-link').read(), b'save')
self.assertEqual(sorted((l['path'], l['target']) for l in manifest['skipped_links']),
[('data/files/alias', 'save'), ('data/lib', '/data/app/lib')])
with archive.open('rb') as src:
REMOTE['restore'](root, PKG, META['instance'], src)
self.assertFalse((source / 'lib').exists() or (source / 'lib').is_symlink())
self.assertEqual((source / 'files/save-link').read_bytes(), b'save')
@unittest.skipUnless(os.name == 'posix', 'restores run on the Frame (Linux flock)')
def test_overlapping_restores_keep_a_recovery_copy(self):
import threading
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'backup')
archive = io.BytesIO()
REMOTE['backup'](root, PKG, META['instance'], archive)
(root / PKG / 'save').write_bytes(b'current')
REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue())) # an old copy to clean up
restore = REMOTE['restore']
real_rmtree, inside, go = shutil.rmtree, threading.Event(), threading.Event()
def rmtree(path, **kwargs):
if threading.current_thread().name == 'first':
inside.set() # swapped, now cleaning up; hold it here
go.wait(5)
real_rmtree(path, **kwargs)
results = {}
def run():
results[threading.current_thread().name] = restore(
root, PKG, META['instance'], io.BytesIO(archive.getvalue()))['previous']
with patch.dict(restore.__globals__, {'shutil': type('S', (), {'rmtree': staticmethod(rmtree),
'copyfileobj': shutil.copyfileobj})}):
first = threading.Thread(target=run, name='first')
first.start()
self.assertTrue(inside.wait(5))
second = threading.Thread(target=run, name='second')
second.start()
second.join(.5)
self.assertTrue(second.is_alive()) # can't swap or clean up during the first's cleanup
go.set()
first.join(5)
second.join(5)
copies = sorted(root.glob('.' + PKG + '.before-restore-*'))
self.assertEqual(copies, [Path(results['second'])]) # the second restore's recovery copy survives
self.assertEqual((copies[0] / 'save').read_bytes(), b'backup')
def test_restore_keeps_only_latest_previous_copy(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'one')
other = root / '.org.example.gameplus.before-restore-1' # another package's copy is left alone
other.mkdir()
archive = io.BytesIO()
REMOTE['backup'](root, PKG, META['instance'], archive)
previous = []
for _ in range(3):
archive.seek(0)
previous.append(REMOTE['restore'](root, PKG, META['instance'], archive)['previous'])
self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(previous[-1])])
self.assertTrue(other.exists())
def make_archive(self, path, members, package=PKG):
with tarfile.open(path, 'w:gz') as archive:
payload = json.dumps({'format': 1, 'package': package, 'instance': META['instance']}).encode()
member = tarfile.TarInfo('manifest.json')
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
root = tarfile.TarInfo('data')
root.type = tarfile.DIRTYPE
archive.addfile(root)
for name, kind in members:
member = tarfile.TarInfo(name)
member.type = kind
member.linkname = '/tmp/escape'
archive.addfile(member)
def test_rejects_wrong_package_traversal_links_devices_duplicates(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
cases = [('../escape', tarfile.REGTYPE), ('/absolute', tarfile.REGTYPE),
('data/link', tarfile.SYMTYPE), ('data/link', tarfile.LNKTYPE),
('data/device', tarfile.CHRTYPE), ('data', tarfile.DIRTYPE),
('other/file', tarfile.REGTYPE), ('data/../escape', tarfile.REGTYPE)]
for member in cases:
self.make_archive(path, [member])
with self.assertRaises(ValueError, msg=str(member)):
REMOTE['inspect_archive'](path, PKG, META['instance'])
self.make_archive(path, [], package='org.other.game')
with self.assertRaisesRegex(ValueError, 'does not match'):
REMOTE['inspect_archive'](path, PKG, META['instance'])
def test_failed_backup_leaves_no_archive_and_existing_is_preserved(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'backup.tar.gz'
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(data, '_stream', side_effect=android.FrameError('offline')):
with self.assertRaises(android.FrameError):
data.backup_data(PKG, path)
self.assertEqual(list(Path(tmp).iterdir()), [])
path.write_bytes(b'keep')
with self.assertRaisesRegex(android.FrameError, 'already exists'):
data.backup_data(PKG, path)
self.assertEqual(path.read_bytes(), b'keep')
def test_guard_does_not_hide_podman_failure(self):
command = data._data_command('backup', META)
self.assertIn('|| exit 1', command)
self.assertIn('stop the app', command)
self.assertIn('podman unshare python3', command)
self.assertNotIn('|| true', command)
def test_bad_restore_is_rejected_before_transfer(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
self.make_archive(path, [('../escape', tarfile.REGTYPE)])
with patch.object(android, '_meta_or_fail', return_value=META), patch.object(data, '_stream') as stream:
with self.assertRaises(android.FrameError):
data.restore_data(PKG, path)
stream.assert_not_called()
def test_successful_backup_is_private_and_inspectable(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'checkpoint')
destination = root / 'backup.tar.gz'
def stream(command, src=None, dst=None):
REMOTE['backup'](root, PKG, META['instance'], dst)
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(data, '_stream', side_effect=stream):
result = data.backup_data(PKG, destination)
self.assertEqual(destination.stat().st_mode & 0o777, 0o600)
self.assertEqual(result['sha256'], data._sha256(destination))
self.assertEqual(result['files'], 2)
def test_rejected_restore_keeps_existing_data(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'keep')
archive = root / 'bad.tar.gz'
self.make_archive(archive, [('data/link', tarfile.SYMTYPE)])
with archive.open('rb') as source, self.assertRaises(ValueError):
REMOTE['restore'](root, PKG, META['instance'], source)
self.assertEqual((root / PKG / 'save').read_bytes(), b'keep')
self.assertFalse(list(root.glob('.frame-restore-*')))
self.assertFalse(list(root.glob('.*.before-restore-*')))
def test_archive_root_must_be_a_directory(self):
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / 'bad.tar.gz'
with tarfile.open(archive, 'w:gz') as target:
target.addfile(tarfile.TarInfo('data'))
with self.assertRaisesRegex(ValueError, 'directory'):
REMOTE['inspect_archive'](archive, PKG, META['instance'])
+644
View File
@@ -0,0 +1,644 @@
"""Offline artwork, Steam API and launcher supervision regressions."""
import importlib.util
import json
import os
from pathlib import Path
import signal
import struct
import subprocess
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_artwork as art
spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py')
shortcuts = importlib.util.module_from_spec(spec)
spec.loader.exec_module(shortcuts)
@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
class LauncherTests(unittest.TestCase):
def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False):
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
app = d / 'Applications/Android/org.test.app'
app.mkdir(parents=True)
(app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes())
(app / 'app.apk').touch()
(app / 'instance.id').write_text('2800000001')
(app / 'shortcut.id').write_text('3346865537')
bin_dir = d / 'bin'
bin_dir.mkdir()
lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton'
lepton.parent.mkdir(parents=True)
def script(path, body):
path.write_text('#!' + sys.executable + '\n' + body)
path.chmod(0o755)
script(lepton, 'import os,time\nfrom pathlib import Path\n'
'assert os.environ["SteamAppId"] == "2800000001"\n'
'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n'
'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n'
'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n'
+ ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n'))
script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n')
script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS
script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n'
'p=Path(os.environ["HOME"],"podman-calls")\n'
'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n'
'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n')
env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']}
if blocked:
env['TEST_' + blocked] = '1'
if orphan:
env['TEST_RUNNING'] = '1'
saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
saved.parent.mkdir(parents=True)
saved.write_text('saved game')
proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
if blocked:
proc.communicate(timeout=5)
self.assertEqual(proc.returncode, 1)
self.assertFalse((d / 'started').exists())
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
self.assertNotIn('stop ', calls)
return
deadline = time.monotonic() + 5
while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
time.sleep(.02)
self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
if terminate:
self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
proc.send_signal(sig)
_, err = proc.communicate(timeout=5)
calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode())
self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1)
self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
self.assertEqual(saved.read_text(), 'saved game')
self.assertTrue((app / 'app.apk').exists())
finally:
if proc.poll() is None:
proc.kill()
proc.communicate()
if (d / 'started').exists():
try:
os.kill(int((d / 'started').read_text()), signal.SIGKILL)
except ProcessLookupError:
pass
def test_steam_stop_cleans_container(self):
self.exercise(True)
def test_hangup_and_interrupt_cleanup(self):
# macOS's stock bash 3.2 doesn't run a SIGINT trap while blocked in `wait`;
# the Frame's bash (5.x) does, and that's where the launcher runs.
major = subprocess.run(['bash', '-c', 'echo ${BASH_VERSINFO[0]}'], capture_output=True, text=True).stdout.strip()
sigs = (signal.SIGHUP, signal.SIGINT) if major.isdigit() and int(major) >= 4 else (signal.SIGHUP,)
for sig in sigs:
with self.subTest(sig=sig):
self.exercise(True, sig)
def test_duplicate_launch_leaves_existing_session_alone(self):
self.exercise(False, blocked='LOCKED')
def test_orphaned_container_is_stopped_and_play_proceeds(self):
# Container running but the lock free: its launcher was SIGKILLed.
self.exercise(False, orphan=True)
def test_normal_exit_cleans_container_and_keeps_exit_code(self):
self.exercise(False)
FIXTURES = ROOT / 'tests/fixtures/library'
class ArtworkTests(unittest.TestCase):
def test_source_inputs_and_url(self):
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
with patch('frame_steamgriddb.lookup', return_value=({}, [])), \
patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch:
images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'})
self.assertEqual(images['banner'], ('png', data))
self.assertEqual(images['icon'], ('png', data))
self.assertEqual(warnings, [])
self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png')
self.assertIsNotNone(fetch.call_args.kwargs['deadline'])
def test_provider_precedence_and_bad_source_fallback(self):
data = (FIXTURES / 'icon.png').read_bytes()
jpg = (FIXTURES / 'icon.jpg').read_bytes()
with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])):
images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]})
self.assertEqual(images['hero'], ('jpg', jpg))
self.assertEqual(images['icon'], ('png', data))
self.assertEqual(images['screenshot'], ('png', data))
self.assertNotIn('wide', images)
self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate
def test_any_source_failure_falls_back_to_generated_art(self):
import http.client
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')):
with self.subTest(error=type(error).__name__), \
patch.object(_images, 'fetch', side_effect=error), \
patch('frame_steamgriddb.lookup', side_effect=error):
images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'})
self.assertEqual(set(images), {'icon'})
self.assertEqual(len(warnings), 2)
def test_url_fetch_refuses_private_hosts_and_honours_deadline(self):
from apk_sources import _images, SourceError
local = [(2, 1, 6, '', ('127.0.0.1', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=local), \
self.assertRaisesRegex(SourceError, 'Private'):
art.fetch('https://example.org/icon.png')
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
patch.object(_images.socket, 'create_connection') as connect, \
self.assertRaisesRegex(SourceError, 'too long'):
art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1)
connect.assert_not_called()
with self.assertRaises(SourceError):
art.fetch('file:///etc/passwd')
def trickle(self, head, seconds):
# A server that answers one byte every 20 ms, over a socketpair standing in for the network.
import socket
import threading
from apk_sources import _images
client, server = socket.socketpair()
def serve():
try:
server.recv(65536)
for byte in head + b'x' * 1000:
server.sendall(bytes([byte]))
time.sleep(0.02)
except OSError:
pass
finally:
server.close()
threading.Thread(target=serve, daemon=True).start()
public = [(2, 1, 6, '', ('93.184.216.34', 80))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
patch.object(_images.socket, 'create_connection', return_value=client):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('http://example.org/a.png', deadline=start + seconds)
return time.monotonic() - start
def test_deadline_bounds_trickling_headers_and_body(self):
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4)
self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish
def test_deadline_covers_a_stalled_tls_handshake(self):
import socket
from apk_sources import _images
client, server = socket.socketpair()
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
try:
with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
patch.object(_images.socket, 'create_connection', return_value=client):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.25) # server never answers
self.assertLess(time.monotonic() - start, 0.45)
finally:
server.close()
def test_timed_out_lookups_are_capped(self):
import threading
from apk_sources import _images
gate = threading.Event()
try:
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
errors = []
for _ in range(6):
try:
_images.get('https://example.org/a.png', deadline=time.monotonic() + 0.05)
except _images.SourceError as e:
errors.append(str(e))
self.assertEqual(sum('too long' in e for e in errors), 4)
self.assertEqual(sum('Too many' in e for e in errors), 2)
finally:
gate.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
time.sleep(0.01)
_images._resolvers.release() # the stuck lookups finished and gave their slots back
def test_resolver_slot_released_when_thread_cannot_start(self):
from apk_sources import _images
with patch.object(_images.threading.Thread, 'start', side_effect=RuntimeError("can't start new thread")):
for _ in range(6):
with self.assertRaises(RuntimeError):
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
for _ in range(4): # every slot came back
self.assertTrue(_images._resolvers.acquire(blocking=False))
for _ in range(4):
_images._resolvers.release()
def test_deadline_covers_name_resolution(self):
import threading
from apk_sources import _images
gate = threading.Event()
with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
start = time.monotonic()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=start + 0.1)
self.assertLess(time.monotonic() - start, 0.4)
gate.set()
with self.assertRaisesRegex(_images.SourceError, 'too long'):
_images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
import frame_steamgriddb as sgdb
from apk_sources import _images
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(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)
def test_supplied_jpeg(self):
data = (FIXTURES / 'icon.jpg').read_bytes()
self.assertEqual(art.image_type(data), 'jpg')
self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI
with self.assertRaises(ValueError):
art.image_type(data[:30])
FRAME = b'\x21\xf9\x04\x01\x00\x00\x00\x00' + b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'
def gif(self, frames=1, screen=(1, 1), frame=None):
head = b'GIF89a' + struct.pack('<HHBBB', *screen, 0x80, 0, 0) + b'\xff\xff\xff\x00\x00\x00'
return head + (frame or self.FRAME) * frames + b'\x3b'
def test_gif_first_frame_is_bounded_and_re_emitted(self):
one = self.gif()
self.assertEqual(art.image_type(one), 'gif')
self.assertEqual(art.gif_frame(one), one) # already minimal: unchanged
self.assertEqual(art.fetch(self.gif(frames=3)), ('gif', one)) # animation: first frame only
start = time.monotonic()
self.assertEqual(art.gif_frame(self.gif(frames=500000)), one) # ~10 MB of frames, never parsed
self.assertLess(time.monotonic() - start, 1)
big = b'\x2c' + struct.pack('<HHHHB', 0, 0, 8192, 8192, 0) + b'\x02\x02\x44\x01\x00'
for bomb in (self.gif(frame=big), self.gif(screen=(8192, 8192), frame=big), self.gif(screen=(5000, 10)),
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 1, 0, 1, 1, 0) + b'\x02\x02\x44\x01\x00'),
b'GIF89a' + struct.pack('<HHBBB', 1, 1, 0, 0, 0) + self.FRAME + b'\x3b', # no colour table
self.gif(frame=b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x0c\x02\x44\x01\x00'),
self.gif(frame=b'\x99')):
with self.subTest(bomb=bomb[:40]), self.assertRaises(ValueError):
art.image_type(bomb)
for cut in range(len(one) - 1): # every truncation before the image's last block
with self.subTest(cut=cut), self.assertRaises(ValueError):
art.gif_frame(one[:cut])
def test_gif_control_block_and_empty_image(self):
image = self.FRAME[8:] # the image without its graphic control block
head = b'GIF89a' + struct.pack('<HHBBB', 1, 1, 0x80, 0, 0) + b'\xff\xff\xff\x00\x00\x00'
good = b'\x21\xf9\x04\x00\x00\x00\x00\x00'
self.assertEqual(art.gif_frame(head + good + image + b'\x3b'), head + good + image + b'\x3b')
bad = b'\x21\xf9\x02\x00\x00\x00' # wrong payload size: dropped, not passed on
self.assertEqual(art.gif_frame(head + bad + image + b'\x3b'), head + image + b'\x3b')
empty = b'\x2c' + struct.pack('<HHHHB', 0, 0, 1, 1, 0) + b'\x02\x00'
with self.assertRaises(ValueError):
art.gif_frame(head + empty + b'\x3b')
def test_png_variants_left_to_chromium_and_limits(self):
def png(w, h, depth, color, interlace):
return art.PNG + art.chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \
art.chunk(b'IEND', b'')
self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png')
self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png')
for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'),
(png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')):
with self.subTest(message=message), self.assertRaisesRegex(ValueError, message):
art.image_type(bad)
broken = bytearray(png(10, 10, 8, 6, 0))
broken[20] ^= 1
with self.assertRaisesRegex(ValueError, 'checksum'):
art.image_type(bytes(broken))
with self.assertRaises(ValueError):
art.image_type(art.PNG + b'junk')
def test_bad_artwork_arguments(self):
for value in ({'bad': b'bad'}, ['hero']):
with self.subTest(value=value), self.assertRaises(ValueError):
art.prepare('Game', artwork=value)
def test_godot_project_icon(self):
import io
import zipfile
data = (FIXTURES / 'icon.png').read_bytes()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w') as archive:
archive.writestr('assets/icon.png', data)
with zipfile.ZipFile(buffer) as archive:
self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data)
class InstallTests(unittest.TestCase):
def setUp(self):
self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text())
self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None,
'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24}
self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS}
self.existing = {'package': 'org.test.vr', 'instance': 2800000001,
'shortcut': 3346865537, 'label': 'Old name'}
def install(self, existing, tool=None):
def shortcut(*args, **kwargs):
if args[0] == 'add':
return '3346865537'
if args[0] == 'render':
return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}})
if args[0] == 'list':
return json.dumps(self.responses['shortcuts'])
return json.dumps(self.responses['configure'])
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=existing), \
patch.object(android, '_copy') as copy, \
patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \
patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \
patch.object(android, '_write_meta') as meta:
result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test')
return result, ssh, api, meta
def test_existing_shortcut_refreshes_name_vr_and_every_slot(self):
result, ssh, api, meta = self.install(self.existing)
calls = [c.args for c in api.call_args_list]
self.assertNotIn('add', [c[0] for c in calls])
configure = next(c for c in calls if c[0] == 'configure')
self.assertEqual(configure[1:3], ('3346865537', 'New name'))
self.assertEqual(configure[6], '1')
self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS))
self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png'))
self.assertEqual(result['shortcut'], self.existing['shortcut'])
self.assertEqual(result['label'], 'New name')
self.assertEqual(result['library_warnings'], [])
self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5)
meta.assert_called_once()
def test_first_install_adds_shortcut(self):
_, _, api, _ = self.install(None)
self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure'])
def test_artwork_forwarded_through_patch(self):
artwork = {'hero': b'provided'}
with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \
patch.object(android, 'xr_compat_files', return_value={}), \
patch.object(android, 'patch', return_value={'patched': ['launcher']}), \
patch.object(android, '_install', return_value={}) as install:
android.install('x.apk', artwork=artwork)
self.assertIs(install.call_args.args[-1], artwork)
def test_failed_new_install_removes_shortcut(self):
def tool(*args, **kwargs):
if args[0] == 'add':
return '3346865537'
if args[0] == 'render':
return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}})
if args[0] == 'configure':
raise android.FrameError('write failed')
return '{}'
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=None), \
patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \
patch.object(android, 'shortcut_tool', side_effect=tool) as api:
with self.assertRaisesRegex(android.FrameError, 'write failed'):
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
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_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'), \
patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \
patch.object(android, 'ssh') as ssh:
android.remove('org.test.vr', keep_data=True)
api.assert_called_once_with('remove', '3346865537')
ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr')
api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?')
ssh.reset_mock()
result = android.remove('org.test.vr')
ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr '
'.local/share/Steam/steamapps/compatdata/2800000001 '
'.local/share/Steam/steamapps/shadercache/2800000001')
self.assertIn('Steam client running', result['library_warnings'][0])
def test_remove_waits_for_refresh_and_is_never_undone(self):
import threading
state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}}
in_refresh, release, added = threading.Event(), threading.Event(), []
def meta(pkg):
if not state['meta']:
raise android.FrameError(pkg + ' is not installed')
return dict(state['meta'])
def ssh(cmd, input=None, **kw):
if cmd.startswith('rm -rf Applications/Android/org.test.vr'):
state['meta'] = None
return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos'
def tool(*args, **kw):
if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']])
if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}'
if args[0] == 'add': added.append(args); return '99'
if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}})
return '{"warnings": []}'
def prepare(*args, **kw):
in_refresh.set(); release.wait(5)
return self.images, []
with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \
patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \
patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \
patch.object(android.frame_artwork, 'prepare', side_effect=prepare):
refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True})
refresh.start()
self.assertTrue(in_refresh.wait(5))
remove = threading.Thread(target=android.remove, args=('org.test.vr',))
remove.start()
remove.join(0.3)
self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing')
release.set(); refresh.join(5); remove.join(5)
self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set())
with self.assertRaisesRegex(android.FrameError, 'not installed'):
android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal
self.assertEqual(added, [])
def test_stop_requests_steam_and_has_container_fallback(self):
with patch.object(android, '_meta_or_fail', return_value=self.existing), \
patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
patch.object(android, 'ssh') as ssh:
android.stop('org.test.vr')
api.assert_called_once_with('stop', '3346865537')
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
js = evaluate.call_args.args[0]
self.assertIn('SetShortcutIsVR(id, true)', js)
self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js)
self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js)
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)'))
self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4})
self.assertIn('NewUnsavedCollection(name, undefined, [app])', js)
def test_remove_clears_every_slot_before_shortcut(self):
with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate:
shortcuts.remove(42)
js = evaluate.call_args.args[0]
self.assertIn('[0, 1, 2, 3]', js)
self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)'))
self.assertIn('const wanted = []', js)
self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs
def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self):
slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
js = evaluate.call_args.args[0]
self.assertIn('if (null !== null && !fill)', js)
self.assertIn('const wanted = ["Sideloaded"]', js)
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
json.dumps(slots), '{}']), \
patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'):
shortcuts.main()
self.assertIsNone(configure.call_args.args[5])
def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self):
import base64
png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode()
jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode()
huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode()
calls = []
def evaluate(js, timeout=20):
calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout))
hero = ['jpg', huge] if len(calls) == 1 else ['png', png]
return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero,
'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []}
with tempfile.TemporaryDirectory() as tmp:
for name in ('icon.png', 'hero.jpg'):
Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes())
Path(tmp, 'hero.png').write_bytes(b'stale')
plan = Path(tmp, 'input.json')
plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')),
'hero': str(Path(tmp, 'source-hero.jpg'))}}))
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
result = shortcuts.render(str(plan))
self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'})
self.assertEqual(set(calls[1][0]['images']), {'icon'})
self.assertEqual([c[1] for c in calls], [75, 75])
self.assertTrue(result['paths']['grid'].endswith('grid.jpg'))
self.assertTrue(result['paths']['hero'].endswith('hero.png'))
self.assertIn('generated art used', result['warnings'][0])
self.assertFalse(Path(tmp, 'hero.jpg').exists())
self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes())
def test_stop_uses_exact_64_bit_game_id_string(self):
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \
patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'):
shortcuts.main()
self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)')
class SteamContextTests(unittest.TestCase):
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
def test_collection_lifecycle_and_native_artwork_calls(self):
steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {},
'collections': [{'name': 'Android', 'apps': [999]}]}
def evaluate(expression, timeout=20):
nonlocal steam
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
'steam': steam}) + '\n',
text=True, capture_output=True, timeout=10, check=True)
reply = json.loads(proc.stdout)
self.assertNotIn('exceptionDetails', reply['result'])
steam = reply['steam']
return reply['result']['result'].get('value')
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
self.assertEqual(result['warnings'], [])
self.assertTrue(steam['shortcuts'][0]['vr'])
self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'})
self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]},
{'name': 'Android VR', 'apps': [42]}])
shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed')
self.assertEqual(steam['collections'][1]['apps'], [])
with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out:
shortcuts.main()
self.assertEqual(json.loads(out.call_args.args[0]),
[{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}])
shortcuts.remove(42)
self.assertEqual(steam['shortcuts'], [])
self.assertEqual(steam['collections'][0]['apps'], [999])
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
def test_fill_only_keeps_customised_name_icon_and_art(self):
custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}}
steam = {'apps': [], 'compat_tools': {}, 'collections': [],
'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]}
def evaluate(expression, timeout=20):
nonlocal steam
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
'steam': steam}) + '\n',
text=True, capture_output=True, timeout=10, check=True)
reply = json.loads(proc.stdout)
self.assertNotIn('exceptionDetails', reply['result'])
steam = reply['steam']
return reply['result']['result'].get('value')
with tempfile.TemporaryDirectory() as home:
grid = Path(home, '.local/share/Steam/userdata/1/config/grid')
grid.mkdir(parents=True)
(grid / '42p.png').write_bytes(b'mine')
(grid / '42_hero.jpg').write_bytes(b'mine')
with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate):
self.assertEqual(shortcuts.custom_art(42), {0, 1})
shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False,
{slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS},
{'category': 'Sideloaded', 'fill_only': True})
s = steam['shortcuts'][0]
self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True))
self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom)
self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'})
@unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
def test_remove_without_collections_or_artwork_api_still_removes(self):
steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"',
'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}}
def evaluate(expression, timeout=20):
nonlocal steam
expression = ('delete globalThis.collectionStore;'
'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression)
proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
'steam': steam}) + '\n',
text=True, capture_output=True, timeout=10, check=True)
reply = json.loads(proc.stdout)
self.assertNotIn('exceptionDetails', reply['result'])
steam = reply['steam']
return reply['result']['result'].get('value')
with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
result = shortcuts.remove(42)
self.assertEqual(steam['shortcuts'], [])
self.assertEqual(len(result['warnings']), 5)
if __name__ == '__main__':
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
+368
View File
@@ -0,0 +1,368 @@
"""Every public sideload entry point reaches the mandatory five-slot writer."""
import contextlib
import io
import json
import shutil
import subprocess
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_artwork as artwork
import frame_catalog as catalog
import frame_apk_versions as versions
import frame_titles as titles
import frame_steamgriddb as sgdb
import server
import frame_webinstall as webinstall
class EntryPoints(unittest.TestCase):
def setUp(self):
self.stack = contextlib.ExitStack()
self.addCleanup(self.stack.close)
self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1,
'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None}
self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info))
self.stack.enter_context(patch.object(android, 'read_meta', return_value=None))
self.stack.enter_context(patch.object(android, '_copy'))
self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos'))
self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, [])))
self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam))
def steam(self, *args, **kwargs):
if args[0] == 'add': return '3346865537'
if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}})
if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}])
return '{"warnings":[]}'
def assert_art(self):
calls = [c.args for c in self.api.call_args_list]
self.assertEqual(sum(c[0] == 'render' for c in calls), 1)
configs = [c for c in calls if c[0] == 'configure']
self.assertEqual(len(configs), 1)
self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS))
def test_cli_install(self):
with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'):
android.main()
self.assert_art()
def test_file_upload(self):
class Request:
headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'}
rfile = io.BytesIO(b'apk')
with patch.object(server, 'ensure_master'):
result = server.Handler.upload(Request())
self.assertEqual(result['app']['package'], self.info['package'])
self.assert_art()
def test_catalogue_install(self):
with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'), \
patch.object(catalog, 'fetch_icon', return_value=None):
catalog.install(self.info['package'])
self.assert_art()
def test_version_finder_install(self):
record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'}
with patch.object(versions, '_versions', return_value=([record], [])), \
patch.object(catalog, 'fetch_apk', return_value='game.apk'):
versions.install(self.info['package'], record['url'])
self.assert_art()
def test_web_download_install(self):
result = webinstall.dispatch('game.apk', source='https://example.org/game.apk')
self.assertEqual(result['kind'], 'apk')
self.assert_art()
def test_refresh_api_covers_android_apps_and_titles(self):
with patch.object(server, 'ensure_master'), \
patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
patch.object(android, 'refresh_art', return_value=[]) as refresh, \
patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh:
self.assertEqual(server.android({'action':'refresh-art', 'all':True}),
{'apps':[], 'titles':[{'id':'G','error':'x'}]})
refresh.assert_called_once_with(); title_refresh.assert_called_once_with()
server.titles({'action':'refresh-art', 'id':'G'})
title_refresh.assert_called_with('G')
def test_backfill_fills_only_entries_pending_since_install(self):
import threading
ran = threading.Event()
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh:
apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True},
{'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending
with contextlib.redirect_stderr(io.StringIO()):
self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}]))
self.assertTrue(ran.wait(5))
self.assertFalse(server.backfill_art(apps=apps)) # throttled
self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list))
title_refresh.assert_called_once_with('G', fill_only=True)
def test_bulk_fill_only_refresh_keeps_names_and_art(self):
meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
with patch.object(android, 'list_apps', return_value=[{'package':self.info['package']}]), \
patch.object(android, '_meta_or_fail', return_value=meta), \
patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
patch.object(android, '_write_meta'):
android.refresh_art(fill_only=True)
options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8])
self.assertTrue(options['fill_only'])
self.api.reset_mock()
with patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'Game','frame_control':True}]), \
patch.object(titles, '_check_id', side_effect=lambda gid: gid), \
patch.object(titles, '_library_shortcut', return_value=7), \
patch.object(titles, 'ssh', side_effect=lambda cmd, **kw: '{"name":"Game"}' if cmd.startswith('cat devkit')
else '{"artwork":{},"icon":""}' if cmd == 'python3 -' else '/home/steamos'):
titles.refresh_art(fill_only=True)
options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8])
self.assertTrue(options['fill_only'])
def test_upgrade_leaves_legacy_customised_titles_alone(self):
# A title installed before art_pending existed, whose art the user has customised in Steam.
legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'],
'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}]
with patch.object(titles, 'ssh', return_value=json.dumps(legacy)):
listed = titles.list_titles()
self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False))
with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh:
self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed))
refresh.assert_not_called(); app_refresh.assert_not_called()
self.api.assert_not_called()
def test_art_missing_flags(self):
self.assertTrue(android.art_missing({'artwork': {}}))
self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}}))
self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}}))
def test_source_search_shared_installer_contract(self):
# Source workers hand their download and optional images to this public seam.
android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'})
self.assert_art()
def test_native_title_install(self):
with tempfile.TemporaryDirectory() as root:
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
'runtime':'proton-experimental','source':'example.zip'}
def ssh(cmd, **kwargs):
if 'test -d' in cmd: return ''
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True):
result = titles._install(plan, lambda *args: None)
self.assertEqual(result['shortcut'], 3346865537)
self.assert_art()
config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')
self.assertEqual(json.loads(config[8])['category'], 'Sideloaded')
self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring.
self.assertEqual(config[6], '') # nor the VR flag the title declares
self.assertEqual(set(result['artwork']), set(artwork.SLOTS))
def test_native_renamed_shortcut_uses_saved_identity(self):
self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]'
with patch.object(titles, 'ssh', return_value='{"shortcut":42}'):
self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42)
def test_native_shortcut_never_matched_by_name_alone(self):
d = '/home/steamos/devkit-game/Game'
shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'},
{'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}]
self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts)
with patch.object(titles, 'ssh', return_value=''):
self.assertIsNone(titles._library_shortcut('Game', d))
shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''})
self.assertEqual(titles._library_shortcut('Game', d), 3)
shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d})
with self.assertRaisesRegex(android.FrameError, 'ambiguous'):
titles._library_shortcut('Game', d)
def test_native_cleanup_failure_keeps_original_error(self):
with tempfile.TemporaryDirectory() as root:
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
'runtime':'proton-experimental','source':'example.zip'}
def ssh(cmd, **kwargs):
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
def steam(*args, **kwargs):
if args[0] == 'remove': raise android.FrameError('Steam went away')
return self.steam(*args)
self.api.side_effect = steam
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
with self.assertRaisesRegex(android.FrameError, 'render failed'):
titles._install(plan, lambda *args: None)
def test_native_remove_survives_steam_being_down(self):
cmds = []
def ssh(cmd, **kwargs):
cmds.append(cmd)
return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else ''
self.api.side_effect = android.FrameError('SharedJSContext not found')
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'):
titles.remove('Game')
self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds))
def test_native_remove_deletes_before_tidying_the_shortcut(self):
# steamos-delete finds the Proton prefix through the shortcut, so the shortcut must still exist.
order = []
def ssh(cmd, **kwargs):
if 'steamos-delete' in cmd:
order.append('delete')
return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else ''
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_library_shortcut', return_value=42), \
patch.object(titles.frame_android, 'shortcut_tool', side_effect=lambda *a: order.append(a)):
titles.remove('Game')
self.assertEqual(order, ['delete', ('remove', '42')])
def test_native_refresh_art_backfills_registered_title(self):
meta = {'id':'Game','name':'My Game','source':'game.zip'}
writes = []
def ssh(cmd, input=None, **kwargs):
if 'test -d' in cmd: return 'yes'
if 'HOME' in cmd: return '/home/steamos'
if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta)
if cmd == 'python3 -':
self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input)
return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''})
if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input))
return ''
shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}]
self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args)
with patch.object(titles, 'ssh', side_effect=ssh), \
patch.object(artwork, 'prepare', return_value=({}, [])) as prepare:
result = titles.refresh_art('Game')
self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'})
self.assertEqual(result['shortcut'], 7)
self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS))
self.assert_art()
shortcuts.clear()
with patch.object(titles, 'ssh', side_effect=ssh), \
patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True},
{'id':'Valve','name':'V','frame_control':False}]):
results = titles.refresh_art()
self.assertEqual(len(results), 1)
self.assertIn("hasn't registered", results[0]['error'])
def test_native_failure_removes_new_blank_shortcut(self):
with tempfile.TemporaryDirectory() as root:
plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
'runtime':'proton-experimental','source':'example.zip'}
def ssh(cmd, **kwargs):
if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
return ''
with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
with self.assertRaisesRegex(android.FrameError, 'render failed'):
titles._install(plan, lambda *args: None)
self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list])
def test_refresh_does_not_reinstall_or_stop(self):
meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
with patch.object(android, '_meta_or_fail', return_value=meta), \
patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy:
android.refresh_art(self.info['package'])
stop.assert_not_called(); copy.assert_not_called(); self.assert_art()
def test_all_refresh_reports_partial_failures(self):
import http.client
with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \
patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'),
AttributeError('odd')]):
result=android.refresh_art()
self.assertEqual(len(result),2)
self.assertTrue(all('error' in a for a in result))
def test_render_failure_cannot_report_success(self):
self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}'
with self.assertRaisesRegex(android.FrameError,'every slot'):
android.install('game.apk')
class Renderer(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture')
def test_canvas_slots_and_title_placement(self):
subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')],
cwd=str(ROOT), check=True, capture_output=True, timeout=30)
def test_desktop_packages_include_renderer_and_settings(self):
config = json.loads((ROOT / 'app/package.json').read_text())
# Single-file resources (licenses/notices) do not need a filter.
resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']}
self.assertIn('*.js', resources['../ui'])
self.assertIn('*.js', resources['../frame/android'])
class SteamGridDB(unittest.TestCase):
def test_no_key_is_silent_and_offline(self):
with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get:
self.assertEqual(sgdb.lookup('Game'),({},[]))
get.assert_not_called()
def test_exact_match_and_top_votes_per_slot(self):
calls=[]
def get(path,key,deadline=None):
calls.append(path)
if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}]
dims=(600,900) if '600x900' in path else (920,430)
return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]},
{'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}]
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get):
images,warnings=sgdb.lookup('Game VR')
self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[])
self.assertTrue(all(url.endswith('/top.png') for url in images.values()))
self.assertTrue(all('/game/2?' in p for p in calls[1:]))
# SteamGridDB rejects JPEG in logo/icon queries (the live API returned an error for SuperTux).
for p in calls[1:]:
jpeg = 'image%2Fjpeg' in p
self.assertEqual(jpeg, p.startswith(('/grids/', '/heroes/')), p)
def test_unicode_titles_match_exactly_and_symbols_never_match_all(self):
self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr')
with patch.object(sgdb,'api_key',return_value='test-key'), \
patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get:
self.assertEqual(sgdb.lookup('★★★'),({},[]))
get.assert_not_called()
self.assertEqual(sgdb.lookup('ビートセイバー'),({},[]))
with patch.object(sgdb,'api_key',return_value='test-key'), \
patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")):
self.assertEqual(sgdb.lookup('Game')[0],{})
def test_wrong_title_and_failed_lookup_fall_back(self):
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]):
self.assertEqual(sgdb.lookup('Game'),({},[]))
with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')):
result=sgdb.lookup('Game')
self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{})
def test_settings_never_return_key(self):
with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \
patch.dict(sgdb.os.environ,{},clear=True):
result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'})
self.assertTrue(result['steamgriddb_configured'])
self.assertNotIn('secret-fixture',json.dumps(result))
self.assertEqual(sgdb.api_key(),'secret-fixture')
if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600)
sgdb.save_settings({'steamgriddb_api_key':''})
self.assertFalse(sgdb.settings()['steamgriddb_configured'])
if __name__=='__main__':unittest.main()
+695
View File
@@ -0,0 +1,695 @@
"""frame_link: finding a headset among its addresses and following each stage of
connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
Also the server's /api/connection, its event stream, and /api/devices.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
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_devices as fd # noqa: E402
import frame_link as fl # noqa: E402
import frame_network as fn # noqa: E402
FAKESSH = ROOT / "tests" / "fakessh"
NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
"ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
def explain(msg):
"""A cut-down server.unreachable, so this needs no server import."""
if "Could not resolve" in msg:
return "Can't find the Frame on the network."
if "refused" in msg:
return "The Frame refused the connection."
if "timed out" in msg.lower():
return "The Frame isn't answering."
if "Permission denied" in msg:
return "The Frame didn't accept this computer's SSH key."
return None
class Probe(unittest.TestCase):
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
srv.bind(("127.0.0.1", 0))
srv.listen(4)
port = srv.getsockname()[1]
seen = []
res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
self.assertEqual(res["state"], "answered")
self.assertEqual(res["ip"], "127.0.0.1")
self.assertIsInstance(res["rtt_ms"], float)
self.assertEqual(seen, ["resolving", "trying"])
# Closed now. Windows retries a refused connect for about 2 s before saying so.
self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
def test_failed_probes_read_like_ssh(self):
# So the server's UNREACHABLE table words them like any other ssh failure.
self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
class Pick(unittest.TestCase):
def pick(self, results, tried=()):
return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
def test_best_ranked_answer_wins(self):
now = time.monotonic()
ok = lambda t=now: {"state": "answered", "t": t}
no = {"state": "timeout", "t": now}
self.assertEqual(self.pick([no, ok(), ok()]), 1)
self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
self.assertIsNone(self.pick([no, no]))
# A worse-ranked answer waits PREFER for a better one still trying, then goes.
t0 = time.monotonic()
self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
def test_gives_up_on_slow_lookups_at_the_deadline(self):
t0 = time.monotonic()
results = [None]
self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
self.assertLess(time.monotonic() - t0, 2)
self.assertEqual(results[0]["state"], "timeout")
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class Connecting(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
(self.dir / "ssh").mkdir()
self.log = self.dir / "calls.jsonl"
env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
"FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
patcher = mock.patch.dict(os.environ, env)
patcher.start()
self.addCleanup(patcher.stop)
for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
p = mock.patch.object(fn, name, value)
p.start()
self.addCleanup(p.stop)
self.srv = socket.socket()
self.srv.bind(("127.0.0.1", 0))
self.srv.listen(16)
self.addCleanup(self.srv.close)
self.port = self.srv.getsockname()[1]
self.reg = fd.Registry(self.dir / "devices.json")
self.routes = []
self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
explain=explain)
self.addCleanup(self.link.stop)
def test_start_routes_to_the_saved_headset_before_serving(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(b["id"])
with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
self.link.start()
self.assertEqual(self.routes[0][0], "frame-2")
self.assertIn("HostName=192.0.2.2", self.routes[0][1])
self.assertNotEqual(a["id"], b["id"])
def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
self.reg.add_device("frame-2", hosts=["192.0.2.2"])
self.reg.set_active(a["id"])
other = fd.Registry(self.dir / "devices.json") # another Frame Control server
other.remove_device(a["id"])
self.link.work = lambda: 1
self.assertTrue(self.link.active_device().get("none"))
self.link.work = lambda: 0
self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
def test_nothing_elsewhere_moves_a_running_install(self):
"""A bare alias in use, then another server sets up and picks a headset."""
self.link.override = None
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
started = self.routes[-1]
other = fd.Registry(self.dir / "devices.json")
other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.routes[-1][0], started[0])
self.assertTrue(self.link.deferred)
def listen6(self):
"""A "different device": the same port on IPv6 loopback."""
try:
six = socket.socket(socket.AF_INET6)
self.addCleanup(six.close)
six.bind(("::1", self.port))
six.listen(4)
except OSError:
self.skipTest("no IPv6 loopback")
def pin(self, device_id):
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
def hosts(self, mapping):
# An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
if "localhost" in mapping:
mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping)
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
def calls(self):
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
def device(self, *hosts):
d = self.reg.add_device("frame-t", port=self.port, hosts=[])
for h in hosts:
self.reg.add_address(d["id"], h, kind="lan")
return d
def test_falls_through_to_the_address_that_is_really_the_headset(self):
# Tried in this order: a name that doesn't resolve, a different device, the headset.
self.listen6()
d = self.device("nothing.invalid", "::1", "127.0.0.1")
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["host"], "127.0.0.1")
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
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"])
# 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")
self.assertIn("HostName=127.0.0.1", opts)
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
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.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"]}
self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
self.assertEqual(learned["::1"]["networks"], [])
self.assertTrue(self.link.alive())
self.link.close_master()
self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
def test_stages_are_published_as_they_happen(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
steps, versions = [], []
real = self.link.stage
def stage(sid, state, detail=None):
real(sid, state, detail)
steps.append((sid, state))
versions.append(self.link.snapshot()["version"])
self.link.stage = stage
before = self.link.snapshot()["version"]
self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
self.link.connect(["start"])
started = [sid for sid, state in steps if state == "active"]
self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
def test_nothing_answers(self):
self.device("nothing.invalid", "also-nothing.invalid")
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "failed")
self.assertEqual(s["error"]["stage"], "find")
self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
self.assertGreater(s["retry_at"], time.time())
self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
def test_refused_key_stops_at_login(self):
self.device("localhost")
self.hosts({"localhost": "denied"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
self.assertIn("SSH key", s["error"]["message"])
def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost")
self.pin(d["id"])
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
self.assertIn("StrictHostKeyChecking=yes", master)
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
def test_no_headset_after_removing_them_all(self):
d = self.device("localhost")
self.reg.remove_device(d["id"])
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
self.assertIn("No headset", s["error"]["message"])
self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
def test_a_headset_without_addresses_reaches_nothing(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
self.link.connect(["switch"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
self.assertIn("no addresses", s["error"]["message"])
def test_switching_back_to_the_frame_alias_the_server_started_with(self):
self.link.override = self.link.session_alias = "frame-bare"
other = self.device("localhost")
ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
self.assertEqual(ids, ["alias-frame-bare", other["id"]])
fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
self.assertEqual(self.link.active_device()["alias"], "frame-bare")
self.assertEqual(self.routes[-1][0], "frame-bare")
def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
d = self.device("localhost")
self.link.override = self.link.session_alias = "frame-t"
fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
def test_setup_changing_the_login_waits_for_installs(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
self.link.watch_config() # the block's login is recorded
routes = len(self.routes)
cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
running = [1]
self.link.work = lambda: running[0]
self.link.config_mtime = None
self.link.watch_config()
self.assertEqual(len(self.routes), routes) # an install is running: not yet
self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
self.assertIn("User=steamos", self.routes[-1][1])
running[0] = 0
self.link.watch_config()
self.assertIn("User=deck", self.routes[-1][1])
def test_a_rename_leaves_the_login_in_the_config_alone(self):
d = self.device("localhost")
cfg = self.dir / "ssh" / "config"
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertIn("User deck", cfg.read_text())
out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
self.assertIn("left as it is", out["message"])
def test_a_late_failure_from_the_last_headset_is_ignored(self):
self.link.state["phase"] = "connected"
self.link.gen = 3
self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
self.assertEqual(self.link.kicks, [])
self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
self.assertEqual(len(self.link.kicks), 1)
def test_a_jump_hosts_login_isnt_the_headsets(self):
opts = ["-o", "HostName=10.0.0.5"]
self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
["-o", "HostName=FRAME.LOCAL"], "frame"))
def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
"channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
for i, _ in fl.STAGES]
self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
"stop")
def test_a_reconnect_being_started_isnt_a_live_connection(self):
self.link.state["phase"] = "connected"
self.assertTrue(self.link.alive())
self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
def test_a_bare_alias_lets_ssh_config_decide(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertTrue(s["device"]["transient"])
# Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
alias, opts = self.routes[-1]
self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
"-o", "User=tester"]))
self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
def test_each_headset_has_its_own_shared_connection(self):
"""ssh's %C hashes only address, user and port: two headsets at one address
(one moved) must still never share a ControlMaster."""
a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
self.assertNotEqual(pa, pb)
self.assertIn("aaaa1111", pa)
long_alias = fl.Link.bare("x" * 64)
path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
self.link.override = "frame-bare"
self.hosts({"localhost": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
self.link.connect(["start"])
pinned = self.routes[-1][1]
# ~/.ssh/config now sends the alias elsewhere, but an install is running.
self.link.work = lambda: 1
with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
self.link.close_master()
self.link.connect(["dropped"])
self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
self.assertEqual(self.link.snapshot()["phase"], "connected")
self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
self.link.override = "frame-jump"
self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
self.link.connect(["start"])
s = self.link.snapshot()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["why"], "through a jump host")
def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
self.reg.update_device(d["id"], port=1) # nothing listens there
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
self.assertIn("Port=1", self.routes[-1][1])
def test_test_now_checks_every_address_without_touching_the_connection(self):
self.listen6()
d = self.device("::1", "127.0.0.1", "nothing.invalid")
self.pin(d["id"])
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
self.link.test(d["id"])
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
self.assertEqual(rows["::1"]["ssh"], "wrong")
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(self.routes, [])
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
self.link.use(other["id"])
self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
self.link.connect(["switch"])
self.assertEqual(self.link.snapshot()["phase"], "failed")
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-other")
self.assertIn("HostName=nothing.invalid", opts)
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
other = self.reg.add_device("frame-other", port=self.port)
self.reg.add_address(other["id"], "nothing.invalid")
pick = fl.Link.pick
def switch_then_pick(*args):
if not getattr(self, "switched", False):
self.switched = True
self.link.use(other["id"]) # the user switches while A is being found
return pick(*args)
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
self.link.connect(["start"])
self.assertEqual(self.routes[-1][0], "frame-other")
self.assertEqual(self.link.snapshot()["phase"], "connecting")
self.assertFalse(self.link.alive())
self.assertIsNone(self.link.master)
def test_no_switching_while_something_is_installing(self):
d = self.device("localhost")
other = self.reg.add_device("frame-other")
for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
{"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"]}):
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.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_no_reconnecting_under_a_running_install(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
def test_terminals_get_the_address_by_name(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
alias, opts = self.link.named_route()
self.assertEqual(alias, "frame-t")
self.assertIn("HostName=localhost", opts)
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
def test_renaming_during_an_install_is_fine(self):
d = self.device("localhost")
# The page sends the user and port along with the name, unchanged.
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
"port": str(self.port)}, None, busy=lambda: 1)
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
def test_a_rename_shows_at_once(self):
d = self.device("localhost")
self.hosts({"localhost": "ok"})
self.link.connect(["start"])
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
self.device("localhost")
self.hosts({"localhost": "slow"})
t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
t.start()
for _ in range(100):
if self.link.pending:
break
time.sleep(0.05)
proc = self.link.pending
self.assertIsNotNone(proc)
self.link.stop()
t.join(10)
self.assertFalse(t.is_alive())
self.assertIsNotNone(proc.poll())
self.assertIsNone(self.link.master)
def test_test_now_goes_through_a_jump_host(self):
d = self.device("10.99.99.99")
self.pin(d["id"])
self.hosts({"10.99.99.99": "ok"})
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
self.link.test(d["id"])
row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
self.assertEqual(row["ssh"], "ok", row)
self.assertIn("jump host", row["detail"])
def test_devices_api_checks_everything(self):
d = self.device("localhost")
bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
{"action": "address-add", "id": d["id"], "host": "a\nHost *"},
{"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
{"action": "update", "id": d["id"], "user": "root; id"},
{"action": "update", "id": d["id"], "port": 0},
{"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
{"action": "setup", "alias": "-F/etc/passwd"},
{"action": "setup", "alias": "frame-9", "host": "$(id)"},
{"action": "use", "id": "nope"},
{"action": "explode"}]
for body in bad:
with self.assertRaises(fd.DeviceError, msg=body):
fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
# Removing every headset leaves none in use, rather than falling back to the `frame` alias.
spare = self.reg.add_device("frame-spare")
fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
# The only headset, whose ssh alias would stay: not without removing that too.
(self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
"Host *\n# <<< steam-frame (frame-t) <<<\n")
with self.assertRaises(fd.DeviceError):
fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
opened = []
out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
self.assertEqual(opened, [("frame-9", "192.168.1.50")])
self.assertIn("frame-9", out["message"])
self.assertEqual(out["active"], d["id"])
self.assertEqual(fl.next_alias(self.link), "frame")
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
self.assertEqual(fl.next_alias(self.link), "frame-2")
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
class ServerConnection(unittest.TestCase):
"""The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
@classmethod
def setUpClass(cls):
cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
ssh_dir = cls.dir / "ssh"
ssh_dir.mkdir()
cls.srv = socket.socket() # the "headset's" port 22
cls.srv.bind(("127.0.0.1", 0))
cls.srv.listen(16)
(ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
f" Port {cls.srv.getsockname()[1]}\n"
" User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
"FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
"FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}),
"PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
env.pop("FRAME_ALIAS", None)
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
stdout=subprocess.PIPE, stderr=cls.log, text=True)
cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
@classmethod
def tearDownClass(cls):
cls.proc.terminate()
cls.proc.wait(timeout=15)
cls.proc.stdout.close()
cls.log.close()
cls.srv.close()
shutil.rmtree(cls.dir, ignore_errors=True)
def request(self, method, path, body=None, key="1"):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
headers={"X-Frame-UI": key, "Content-Type": "application/json"})
r = conn.getresponse()
data = json.loads(r.read() or b"{}")
conn.close()
return r.status, data
def wait_connected(self):
for _ in range(100):
status, s = self.request("GET", "/api/connection")
if s.get("phase") in ("connected", "failed"):
return s
time.sleep(0.1)
self.fail(f"never connected: {s}")
def test_imports_the_headset_and_connects_through_its_port(self):
s = self.wait_connected()
self.assertEqual(s["phase"], "connected", s["error"])
self.assertEqual(s["via"]["host"], "localhost")
self.assertEqual(s["device"]["alias"], "frame")
self.assertEqual(s["device"]["name"], "Steam Frame")
self.assertEqual(s["probes"][0]["host"], "localhost")
status, devices = self.request("GET", "/api/devices")
self.assertEqual(status, 200)
self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
self.assertEqual(devices["nextAlias"], "frame-2")
def test_events_stream_the_state(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
r = conn.getresponse()
self.assertEqual(r.status, 200)
self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
line = r.fp.readline()
self.assertTrue(line.startswith(b"data: "), line)
self.assertIn("stages", json.loads(line[6:]))
conn.close()
def test_changes_meant_for_another_headset_are_refused(self):
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
r = conn.getresponse()
self.assertEqual(r.status, 409)
self.assertIn("switched headsets", json.loads(r.read())["error"])
conn.close()
def test_guards_and_validation(self):
self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
import base64
import http.client
import json
import io
import os
import shutil
import socket
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
self.assertEqual(h, 1080)
self.assertAlmostEqual(w / h, 0.5, places=2)
def test_the_tunnel_follows_the_headset(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
class Tunnel:
ended = False
def poll(self): return None
def terminate(self): Tunnel.ended = True
mv.tunnel, mv.remote_port = Tunnel(), 47999
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
self.assertFalse(Tunnel.ended)
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
self.assertTrue(Tunnel.ended)
self.assertIsNone(mv.tunnel)
self.assertEqual(mv.frame, "frame-2")
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
mv.port = 47000
ended = []
class Proc:
def poll(self): return None
def terminate(self): ended.append(self)
def wait(self): return 0
stderr = io.StringIO("")
def probe(port):
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
return True
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
self.assertFalse(mv._open_tunnel([], [47001]))
self.assertIsNone(mv.tunnel)
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
+160
View File
@@ -1,6 +1,9 @@
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
import argparse
import io
import json
import os
import signal
from pathlib import Path
import struct
import sys
@@ -16,6 +19,13 @@ import frame_splat as splat
import server
def stop_now():
"""What systemd's SIGTERM does to the player, without signalling the test process."""
handler = signal.getsignal(signal.SIGTERM)
if callable(handler):
handler(signal.SIGTERM, None)
class Media(unittest.TestCase):
def test_layout_evidence_and_override(self):
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
@@ -56,6 +66,156 @@ class Media(unittest.TestCase):
self.assertNotIn('-re', cmd)
self.assertIn('-frames:v', cmd)
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
fail=None, layout='auto'):
"""Run the player against a fake OpenVR; returns (status, pixels calls).
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
the n-th upload busy; every status written is kept in self.writes."""
calls = []
self.writes = []
write_status = player.write_status
def record(path, **values):
self.writes.append(values)
write_status(path, **values)
class FakeOverlay:
def create(self, *a, **k):
return len(calls)
def call(self, *a):
pass
def pixels(self, handle, data, w, h):
calls.append((handle, w, h))
if on_pixels:
on_pixels(len(calls))
if len(calls) <= busy or (fail and fail(handle, len(calls))):
raise player.OverlayBusy('standby')
def close(self):
# A Stop landing during cleanup must be ignored, not become an error.
# Call the installed handler directly: a real SIGTERM kills Windows.
stop_now()
frame = bytes(4*2*4)
proc = unittest.mock.MagicMock()
proc.stdout = io.BytesIO(frame*4)
proc.wait.return_value = 0
proc.poll.return_value = 0
old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
with tempfile.TemporaryDirectory() as d, \
patch.object(player, 'Overlay', FakeOverlay), \
patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
patch.object(player.subprocess, 'Popen', return_value=proc), \
patch.object(player, 'write_status', side_effect=record), \
patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
patch.object(player.time, 'sleep', side_effect=sleeps):
path = Path(d)/name
path.write_bytes(b'x')
status = Path(d)/'status.json'
try:
player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
finally:
signal.signal(signal.SIGTERM, old[0])
signal.signal(signal.SIGINT, old[1])
return json.loads(status.read_text()), calls
def test_video_survives_standby_and_stop_after_end_stays_ended(self):
# Verified 2026-09-29: an unworn Frame enters standby within seconds and
# SetOverlayRaw then returns RequestFailed (23) until it wakes.
result, calls = self.play_with('clip_SBS.mp4', busy=3)
self.assertEqual((result['state'], result['frames']), ('ended', 4))
# Two video frames were dropped; the surround (handle 0) waited and was re-sent.
self.assertEqual(result['dropped'], 2)
self.assertIn((0, 1, 1), calls[3:])
def test_stop_mid_video_reports_stopped(self):
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
self.assertEqual(result['state'], 'stopped')
def test_video_errors_when_steamvr_never_takes_frames(self):
with patch.object(player, 'BUSY_LIMIT', -1), \
self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
self.play_with('clip_SBS.mp4', busy=99)
def test_still_waits_out_standby_without_a_limit(self):
# Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
ticks = iter(range(10))
def sleep(_):
if next(ticks) == 8:
stop_now()
with patch.object(player, 'BUSY_LIMIT', -1):
result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
info={'codec_name': 'png', 'width': 4, 'height': 2})
self.assertEqual(result['state'], 'stopped')
screen = [c for c in calls if c[0] == 1]
self.assertGreater(len(screen), 1) # retried through standby
self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
def still_with_late_surround(self, name, **kw):
# The screen takes its first frame while the surround is still refused
# (its first upload, the drain right after the screen, and one retry).
ticks = iter(range(10))
def sleep(_):
if next(ticks) == 5:
stop_now()
surround_tries = []
def fail(handle, n):
if handle == 0:
surround_tries.append(n)
return len(surround_tries) <= 3
return False
result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
self.assertEqual(result['state'], 'stopped')
screen = [c for c in calls if c[0] == 1]
surround = [c for c in calls if c[0] == 0]
self.assertEqual(len(screen), 1) # shown once, not re-sent every second
self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
self.assertEqual(calls[-1][0], 0)
return calls
def test_photo_surround_recovers_after_screen_is_shown(self):
self.still_with_late_surround('photo_SBS.png',
info={'codec_name': 'png', 'width': 4, 'height': 2})
def test_splat_surround_recovers_after_screen_is_shown(self):
self.still_with_late_surround('scene.splat')
def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
self.assertEqual(player.BUSY_LIMIT, 300)
def run(times, busy):
# Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
clock = [1000.0]
def on_pixels(n):
clock[0] = 1000.0 + times.get(n, times[max(times)])
with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
fail=lambda h, n: h == 1 and n in busy)
# Busy for 299 s, then a frame lands: no error.
result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
self.assertEqual((result['state'], result['dropped']), ('ended', 2))
# An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
self.assertEqual((result['state'], result['dropped']), ('ended', 3))
# 301 s in a row without an accepted frame is an error.
with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
def test_status_reports_where_the_layout_came_from(self):
video = {'codec_name': 'h264', 'width': 4, 'height': 2}
for name, layout, tags, expect in [
('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
with self.subTest(name=name, layout=layout):
self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
playing = self.writes[0]
self.assertEqual(playing['state'], 'playing')
self.assertEqual((playing['layout'], playing['source']), expect)
def test_fake_frame_library_and_traversal(self):
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
identity = 'a'*32+'/space and quote\'.png'
+147
View File
@@ -0,0 +1,147 @@
"""frame_network's parsers, with what macOS, Linux and Windows print.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_network as fn # noqa: E402
MAC_ROUTE = """ route to: default
destination: default
mask: default
gateway: 192.168.1.1
interface: en0
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
"""
MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
MAC_SUMMARY = """<dictionary> {
BSSID : <redacted>
ConnectionID : 1
InterfaceType : WiFi
LinkStatusActive : TRUE
NetworkID : <redacted>
SSID : <redacted>
Security : WPA2_PSK
}"""
MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : <redacted>\n Security", "SSID : Home Net\n Security")
MAC_SUMMARY_WIRED = "<dictionary> {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
"""
LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
WIN_ROUTE = """===========================================================================
Interface List
12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
===========================================================================
IPv4 Route Table
===========================================================================
Active Routes:
Network Destination Netmask Gateway Interface Metric
0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
===========================================================================
Persistent Routes:
None
"""
WIN_ARP = """
Interface: 192.168.1.50 --- 0xc
Internet Address Physical Address Type
192.168.1.1 b4-fb-e4-b5-67-55 dynamic
"""
NETSH = """
There is 1 interface on the system:
Name : Wi-Fi
Description : Intel(R) Wi-Fi 6 AX201 160MHz
State : connected
SSID : Office 5G
BSSID : 12:34:56:78:9a:bc
Network type : Infrastructure
"""
NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
TAILSCALE = json.dumps({
"BackendState": "Running",
"CurrentTailnet": {"Name": "example.github"},
"Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
"Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
"nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
"TailscaleIPs": ["100.77.1.2"]}},
})
class Parsers(unittest.TestCase):
def test_macos(self):
self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
def test_linux(self):
self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
self.assertEqual(fn.parse_route_linux(""), (None, None))
self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
def test_windows(self):
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
self.assertIsNone(fn.parse_netsh(NETSH_OFF))
def test_mac_addresses(self):
self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
self.assertIsNone(fn.norm_mac(bad), bad)
def test_network_id_is_stable_and_needs_both_parts(self):
a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
self.assertTrue(a.startswith("n-"))
self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
self.assertIsNone(fn.network_id("192.168.1.1", None))
self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
def test_tailscale(self):
ts = fn.parse_tailscale(TAILSCALE)
self.assertTrue(ts["up"])
self.assertEqual(ts["ip"], "100.101.102.103")
self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
self.assertEqual(ts["tailnet"], "example.github")
frame = ts["peers"][0]
self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
("frame", "frame.tail1234.ts.net", "linux", True))
self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
def test_address_kinds(self):
cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
"100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale",
"192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
"frame.example.com": "manual", "8.8.8.8": "manual"}
for host, kind in cases.items():
self.assertEqual(fn.guess_kind(host), kind, host)
if __name__ == "__main__":
unittest.main()
+161
View File
@@ -0,0 +1,161 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
+111 -1
View File
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
"FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
@@ -109,6 +111,8 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
("/api/open", {"what": "shot"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
@@ -119,6 +123,10 @@ class ServerGuards(unittest.TestCase):
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -238,6 +246,108 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.post("/api/nope", {})[0], 404)
class OneServer(unittest.TestCase):
"""Two servers for one user would each connect and edit headsets on their own."""
def start(self, env):
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
return proc
def test_a_second_server_is_refused_until_the_first_exits(self):
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
first = self.start(env)
self.assertIn("Frame Control on", first.stdout.readline())
second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
capture_output=True, text=True, timeout=60)
self.assertEqual(second.returncode, 1)
self.assertIn("already running", second.stderr)
first.terminate()
first.wait(10)
self.assertIn("Frame Control on", self.start(env).stdout.readline())
def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
"""The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
data = tempfile.mkdtemp(prefix="frame-one-server-")
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
"FRAME_CONTROL_SERVER_WAIT": "1"}
self.assertIn("Frame Control on", self.start(env).stdout.readline())
private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
line = private.stdout.readline()
self.assertIn("Frame Control on", line)
port = int(line.split("http://127.0.0.1:")[1].split()[0])
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
def test_settings_need_and_accept_the_ui_key(self):
with tempfile.TemporaryDirectory() as home:
port = free_port()
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
"HOME": home, "APPDATA": home, "XDG_DATA_HOME": home}
for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"):
env.pop(name, None)
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
def request(method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
headers=headers or {})
r = conn.getresponse()
payload = r.read()
conn.close()
return r.status, payload
for _ in range(100):
try:
if request("GET", "/")[0] == 200:
break
except OSError:
time.sleep(0.05)
key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True))
self.assertNotIn(b"abc_1", payload)
status, payload = request("GET", "/api/settings/artwork", headers=key)
self.assertTrue(json.loads(payload)["steamgriddb_configured"])
self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400)
finally:
proc.terminate()
proc.wait(timeout=10)
def test_panel_script_uses_the_keyed_api_helper(self):
script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8")
self.assertNotIn("fetch(", script)
self.assertIn("api('/api/settings/artwork'", script)
page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8")
self.assertLess(page.index("async function api("), page.index('<script src="/artwork-settings.js">'))
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
"""FRAME_LOCAL=1, as the iPhone app starts the server on the Frame: its own key
+35
View File
@@ -0,0 +1,35 @@
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, sidequest
class SideQuestTests(unittest.TestCase):
def test_policy_is_recorded_and_source_is_page_only(self):
fixture = json.loads((Path(__file__).parent / 'fixtures/sidequest-policy.json').read_text())
self.assertIn('/search/', fixture['robots']['disallow'])
self.assertIn('scraping', fixture['terms']['prohibited_activities_i'])
self.assertTrue(sidequest.sources()[0]['page_only'])
@patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
def test_unknown_listing_never_claims_free_or_downloadable(self, _urlopen):
source = sidequest.sources()[0]
entry = sidequest.details(source, '123')
self.assertEqual(entry['page'], 'https://sidequestvr.com/app/123')
self.assertFalse(entry['downloadable'])
self.assertIsNone(entry['free'])
self.assertIsNone(entry['vr'])
self.assertEqual(entry['images']['screenshots'], [])
with self.assertRaisesRegex(SourceError, 'page-only'):
sidequest.download(source, '123')
self.assertEqual(sidequest.search(source, 'open saber'), [])
self.assertEqual(sidequest.search(source, 'open saber', 0), [])
def test_invalid_ids(self):
for value in ('../123', '1?paid=false', '1', '', '1/2', '1' * 13):
with self.assertRaises(SourceError):
sidequest.details(sidequest.sources()[0], value)
+7 -4
View File
@@ -391,14 +391,16 @@ class ReportProblem(Base):
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com"})
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com", "contactFollowup": True})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -421,8 +423,9 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
"0.4.0", "macOS", "", "", None, None, None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
+162
View File
@@ -0,0 +1,162 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -43,3 +43,11 @@ Rules
class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
class SourceLimited(SourceError):
"""The source's host asked us to slow down; retry_after is in seconds."""
def __init__(self, message, retry_after=None):
super().__init__(message)
self.retry_after = retry_after
+34
View File
@@ -0,0 +1,34 @@
"""Opt-in local store fixtures: FRAME_APK_SEARCH_DEMO=1. Never downloads."""
import json
from pathlib import Path
from apk_sources import SourceError
KIND = 'demo'
FIXTURES = Path(__file__).resolve().parents[2] / 'tests' / 'fixtures' / 'apk-search'
def sources():
return [{'id': 'demo-' + key, 'kind': KIND, 'name': name, 'enabled': True,
'builtin': True, 'trust': trust, 'url': 'https://example.invalid'}
for key, name, trust in [('github', 'GitHub', 'official'), ('fdroid', 'F-Droid', 'community'),
('sidequest', 'SideQuest', 'official'), ('itch', 'itch.io', 'community')]]
def search(source, query, limit=50):
if source['id'] == 'demo-itch':
raise SourceError('Source temporarily unavailable')
entries = json.loads((FIXTURES / 'store.json').read_text())
if source['id'] == 'demo-sidequest':
entries = [e for e in entries if e['vr']]
if source['id'] == 'demo-fdroid':
entries = [e for e in entries if not e['vr']]
return [dict(e, verified=source['id'] in ('demo-github', 'demo-fdroid')) for e in entries
if query.lower() in (e['name'] + ' ' + e['summary']).lower()][:limit]
def details(source, entry_id):
return next(e for e in search(source, '') if e['id'] == entry_id)
def download(source, entry_id, version_code=None):
raise SourceError('Preview sources cannot download or install apps')
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more