Compare commits

..
Author SHA1 Message Date
SAPHID c5521a917f fix(ui): reconcile PR 97 with current main 2026-10-18 00:37:46 +00:00
Alex Southwell f9496d67da Merge pull request #56 from saphid/ui-4-store
Make the Android store look and read like the rest of the app
2026-10-18 11:31:01 +11:00
Alex Southwell 7eae209b1c Merge pull request #55 from saphid/ui-3-home
Lead Home with what's happening, and give settings a page
2026-10-18 11:30:16 +11:00
Alex Southwell 28bc0506a6 Merge pull request #54 from saphid/ui-2-connection
Change the headset's addresses from the connection pill
2026-10-18 11:29:47 +11:00
Alex Southwell 0a4f008d03 Merge pull request #53 from saphid/ui-1-layout
Fit the window at every size and show the battery once
2026-10-18 11:26:14 +11:00
Alex Southwell 16b37bc667 Merge pull request #50 from saphid/flat2vr-mods
VR mods: UEVR install, play in VR and remove from Frame Control
2026-10-18 11:04:17 +11:00
SAPHID a8e5d7cf61 merge: preserve repository logins alongside store refinements 2026-10-18 00:01:10 +00:00
SAPHID 5cdfee4f62 merge: integrate verified connection base into home and settings 2026-10-18 00:00:08 +00:00
SAPHID dd80ca7b74 test(connection): align pill expectations with connection redesign 2026-10-17 23:59:41 +00:00
SAPHID d546568a11 fix(store): preserve resolver cleanup and test lookup budgets deterministically 2026-10-17 23:58:05 +00:00
Alex Southwell ae8cc1b4a6 Merge pull request #58 from saphid/fix/flat2vr-mods-windows
UEVR helper tests: import and skip cleanly on Windows (fixes PR #50 CI)
2026-10-18 10:54:26 +11:00
SAPHID 75acf4a3e9 merge: resolve #56 conflicts with ui-3-home 2026-10-17 22:36:26 +00:00
SAPHID 629a2fafb6 merge: resolve #55 conflicts with ui-2-connection 2026-10-17 22:34:58 +00:00
SAPHID b0d848dc5c merge: resolve #54 conflicts with ui-1-layout 2026-10-17 22:30:09 +00:00
SAPHID 57a9d8c717 chore: resolve conflicts with main 2026-10-17 22:10:19 +00:00
SAPHID 63017e1969 chore: resolve conflicts with main 2026-10-17 22:10:04 +00:00
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

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

From review round 5.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
saphid 87884f382b Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:26:07 +10:00
saphid f98f3e728b Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:21:07 +10:00
saphidandClaude Opus 5.5 90034540ae Keep the toast's fade-in, and join the test's threads on failure
From the review: opening the toast popover with .show already set skipped the
fade-in; it now opens hidden and then shows. And the superseded-test test now
joins its threads in finally, so a failure can't leave them running into
cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:21:07 +10:00
saphid cc9af9eba4 Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:08:06 +10:00
saphid bc0e0fc043 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 5b4efc87b3 Show toasts above open dialogs, and harden the superseded-test test
From the review of the Reconnect change:

- A toast raised while a dialog was open (Reconnect refused during an install,
  say) sat under the dialog's backdrop, dimmed. The toast is now a manual
  popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
  and didn't check its threads finished. It now waits on events with time
  limits, releases everything in finally, and also checks the first test
  finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
  address wins still depends on ranking and timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 c2c1cf0168 Check every JavaScript script type, and keep the name check without acorn
From the review of the parser change: scripts typed text/ecmascript,
application/x-javascript and the other JavaScript MIME types run as classic
scripts too, so they're checked now. And the duplicate-name check (V8 only)
no longer skips with the var check when a Node lacks acorn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:43 +10:00
saphid 01ba796881 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 b9f96866e5 Replace Use now with a plain Reconnect, and ignore superseded tests
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.

The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.

The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:12 +10:00
saphid 3fb876ad5a Merge branch 'ui-3-home' into ui-4-store 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 9bd6890fe8 Find var declarations with a parser, and check only classic inline scripts
The fix review showed the var regex flagged the word in comments and strings and
missed var/*c*/x and var {x}. The test now uses the acorn parser Node ships
(--expose-internals; it skips if a Node ever drops it) to find real var
declarations, and reads the page with html.parser so it checks inline classic
scripts only: no src, and no module or JSON data type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:29:12 +10:00
saphidandClaude Opus 5.5 0d181b676e Retry from the hero after a failed install, and keep var out of the page
From the fix review:

- The hero's Install skipped installing whenever a job existed, including one
  that had failed, so it only opened the details. It now tries again after a
  failure, and still only opens the details while installing or once installed.
- Declaring a var twice is legal, so the strict-mode check couldn't see it. The
  page's one var (a block-local in the capture code) is now let, and the test
  rejects var outright (CSS var() in strings is fine). It also checks every
  inline script, whatever its attributes.
- Clear filters still set the removed source filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:04:52 +10:00
saphid 8de0292568 Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 22:03:10 +10:00
saphidandClaude Opus 5.5 8e8c24f370 Don't break startup at Games links, and reload titles in the iPhone app too
The fix review found the Games reload added to showPage() read `link`, which
the page declares later: opening Frame Control at #games, #library,
#sideloaded or #getgames threw at startup and skipped the rest of the setup.
And the iPhone app never watches the connection, so `link.s` stayed null and
titles still never reloaded there.

The reload now runs only when navigating to Games (the hashchange), where the
first load is already done, and doesn't depend on connection state; a failed
load shows its error in the list as Refresh did. tests/test_page_startup.py runs
the real showPage() at every page and section link with everything declared
later still uninitialised, and fails on the old line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:02:53 +10:00
saphid 79dda60bde Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:00:27 +10:00
saphidandClaude Opus 5.5 0bb2f9eb03 Offer Use now from the finished test's own order
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.

The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:54:16 +10:00
saphid 30509da71f Merge branch 'ui-3-home' into ui-4-store 2026-10-01 21:32:42 +10:00
saphid d4b34e1bad Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:32:42 +10:00
saphid 22548bc42f Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 21:32:41 +10:00
saphidandClaude Opus 5.5 497b5d9ae6 Don't launch from the hero's Install, and check page names with V8
From the independent review of this PR:

- After installing the featured app, the hero's Install on the Frame took the
  installed branch and launched it on the headset. It now opens the details,
  whose button says Open in Steam, without acting.
- The duplicate-name test matched declarations at column zero only, so an
  indented second loadPanels (the bug it was written for) passed, and so did
  classes, destructuring and later declarators. It now compiles both inline
  scripts as one strict-mode block, where V8 rejects any name declared twice
  in the shared scope, with fixtures for each case and for same-named helpers
  in different functions (which are fine).
- A card's "Won't run" overstated the check's "Might not work"; it now says
  May not run.
- Removed styles and the source-filter state that nothing used any more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:21:34 +10:00
saphid 04995c066f Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 21:10:07 +10:00
saphidandClaude Opus 5.5 0bceb5c928 Reload titles on Games, show Home uploads' progress, reset the comfort form
From the independent review of this PR:

- Phones lost the Refresh button, and nothing else reloaded sideloaded titles,
  so one added or removed from another computer never showed. Opening Games
  now reloads them.
- Send files... on Home uploaded with its progress bar in the hidden Tools
  page, and phones have no Activity bar. Choosing files from Home now opens
  the file panel on Tools.
- A session that finished while the page was open left its settings (1 / 0 / 0
  after a test) in the form. The form goes back to its defaults when the
  session ends. The test now covers that, and its mock uses the page's real
  break default (20 minutes, not 30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:09:58 +10:00
saphidandClaude Opus 5.5 22bb9c6469 Offer Use now only where a reconnect goes, and keep newer edits and headsets apart
From the second independent review of this PR:

- Use now sends a plain reconnect, which picks the first-ranked address that
  answers, but it was offered on every tested address listed above the one in
  use. The devices list now carries each address's rank on the current network
  (frame_devices.order_addresses), and only the address a reconnect would pick
  gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
  closed the second editor and lost what was typed. Each edit now has its own
  session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
  previous headset's status before it was cleared, so Add could save its IP to
  the new headset. The dialog now renders after the old status is cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:03:13 +10:00
saphid fab04f0558 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:03:13 +10:00
saphid 0520082586 Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 36dd5a05f2 Make the offered home address the one that gets used, and keep edits safe
An interrupted review pass pointed at four problems in the pill's dialog, each
confirmed against the Frame:

- The offer to add the Frame's LAN address appended it after the Tailscale name,
  which then kept winning on that network, so nothing changed. The offer now adds
  it first in the list (address-add takes first: true); away from home the
  Tailscale name still leads. A tested address that ranks above the one in use
  gets a Use now button that reconnects through it.
- A half-typed edit was thrown away when the connection state changed after
  focus left the input, and when the server refused the save. The edit row now
  stays until it is saved or cancelled.
- Pressing Enter twice sent the update twice.
- The offer's button could act on the previously selected headset.

Keyboard focus also stays on the same button of the same address when the rows
are rebuilt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 21:28:38 +10:00
saphid 002c040223 Merge branch 'ui-3-home' into ui-4-store 2026-09-30 20:58:23 +10:00
saphidandClaude Opus 5.5 4b1bb1557d Make room in the header for the settings button
With one more button in the header the pill lost its route at a few widths
(970-1020, 1160-1180 and 1350-1410 px). The header's spacing now tightens below
1500 px, the shortcut hints go at 1200, and the compact header starts at 1030,
so the pill reads "Connected · Tailscale" in full at every width above that,
including the 1400 px default window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:58:23 +10:00
saphid b151ae691e Merge branch 'ui-2-connection' into ui-3-home
# Conflicts:
#	ui/index.html
2026-09-30 20:40:03 +10:00
saphidandClaude Opus 5.5 36db6b1ac6 Never cut the pill's status word short
At 1260 to 1400 px wide the header was a few pixels too tight and the pill read
"Connected · Tails…"; on a phone it read "Connected · T…". The status word now
always shows whole: when the route doesn't fit beside it, the route drops out
instead of being clipped. The wordmark gives way a little earlier so the route
shows at every desktop width above 960 px, and the smallest phones lose the
logo rather than squeeze the pill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:43 +10:00
saphid 7ea999d5b4 Merge branch 'ui-1-layout' into ui-2-connection 2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 d6fb5e917a UEVR helper tests: import and skip cleanly on Windows
frame_mods runs on the Frame, but the server tests import it on every
desktop OS; the module-level `import fcntl` failed test discovery on
Windows. Import it where the lock is taken, as frame_panels and frame_vr
do. Skip the Frame-side helper tests on Windows, as test_input does for the
input agent: they rely on /proc, flock and POSIX prefix paths. The server
routes and the usage-error check still run there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:28:57 +10:00
saphidandClaude Opus 5.5 9e25ae9025 Make the Android store look and read like the rest of the app
Apps without a banner or screenshot show their icon on a blurred wash of
itself, or a VR or app glyph, rather than a giant letter. Every card says
whether it will run on the Frame (works, should install, not checked yet,
won't run), in place of "Free" on every card.

The featured app isn't repeated in the rows below, and no row repeats
another. Its buttons stay in the app: Install on the Frame opens the details
with the install under way, and Details opens them. It's chosen from apps
that can run, with real artwork, VR first.

The stray "Browse SideQuest" link goes; the Sources dialog lists SideQuest
with a link to its website instead of a switch that did nothing. The "All
sources" drop-down goes too, as the Sources dialog already chooses them.
The copy is plain and British ("Apps and games for the Frame", no "Find
your next favorite"), and Games and Android point at each other.

Two functions were each declared twice across the page's scripts, so the
later one silently replaced the earlier everywhere: the store's details
dialog opened empty (the Devices tab's renderDetail ran instead), and Home's
Desktop view never found the headset's panels (the panel switcher's loader
ran instead). They're renamed, and a test
fails on any top-level name declared twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +10:00
saphidandClaude Opus 5.5 ed4a0ad5cd Lead Home with what's happening, and give settings a page
Home answers "what's my headset doing, and what do I want to do now". A
Right now card says whether SteamVR is running and holds the volume and the
next things people do: cast to this screen, play a video or photo, show a
Mac window, send files, type on the Frame. With nothing captured, the
headset view is a short strip rather than a big black box; the keyboard and
trackpad fold to one line until turned on; VR performance folds away.
Screenshots and Family and comfort follow.

Tools is grouped: in the headset (Mac in the headset, the media player, the
panel switcher), then sending files, Linux apps, remote and power. Privacy
and updates, library artwork and the assistant move to a Settings page,
opened by a gear in the header (key 6), with About and licences.

Family and comfort no longer fills the form with a finished session's
settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0,
and its recent events carry their times.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:57 +10:00
saphidandClaude Opus 5.5 cb0a90a9c3 Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.

The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.

On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:39:52 +10:00
saphid daf30515b1 Merge main into flat2vr-mods
# Conflicts:
#	README.md
#	ui/index.html
#	ui/server.py
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 2f056dbcff UEVR: address the second review; recheck on the Frame
frame_inject.py declares every Win32 signature, checks each result, frees
its remote buffers and closes handles. Uninstall only accepts our folder and
UEVR's own per-game settings folders, and keeps the receipt if anything can't
be removed. The injector's reply is parsed defensively. Rechecked on a real
Frame: install, three cold starts injected (33-35 s), clean uninstall.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:04:12 +10:00
saphidandClaude Opus 5.5 e3176a2de8 UEVR: inject with Frame Control's own script instead of UEVR's GUI injector
UEVR's .NET injector, driven with XSendEvent, injected in only 6 of 8 cold
starts on the Frame: in bad sessions every new injector ignored input or never
showed a window. start() now runs frame_inject.py under python.org's
embeddable Python (pinned SHA-256) in the game's Wine session, repeating the
frontend's own Inject steps: nullify VR plugins, load openvr_api.dll, set
Frontend_RequestedRuntime, load UEVRBackend.dll. Verified 9/9 cold starts in
33-35 s and a clean UI install/play/remove cycle. No .NET download any more
(47 MB installed instead of about 190 MB).

Also from the SWE-2 Max review: one mod action at a time (flock), a download
size cap, errors always reported as JSON, a receipt can't name another app's
prefix, 0 frames counts as an answer, and a longer server timeout for start.

Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:53:28 +10:00
saphidandClaude Opus 5.5 416f3181cc Add a UEVR mod manager: install, play in VR and remove from Frame Control
Installed Unreal Engine games get a "VR mod" button. ui/frame_mods.py runs on
the Frame: it downloads the pinned official UEVR 1.05 release and Microsoft's
.NET 6 runtimes (hash-checked, safely unpacked) into the game's Proton prefix
with a receipt; starts the game, runs the injector in the game's own Wine
session and presses OpenVR + Inject with XSendEvent (gamescope keeps real
input on the game); confirms UEVRBackend.dll is loaded and SteamVR counts
frames; and removes exactly what it added.

Verified on a real Frame 2026-09-29 with Gravitas, through the UI: install,
four injections (three from a cold start), remove back to the pre-install
prefix listing. The headset was unworn, so the VR image, tracking and controls
are not verified. Part of #26.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:52:02 +10:00
47 changed files with 3612 additions and 369 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+2 -2
View File
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
@@ -219,7 +219,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
+1 -1
View File
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
To put the same page in the headset, with the HTTP server still running:
```sh
+57
View File
@@ -183,6 +183,63 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
+14 -3
View File
@@ -3,8 +3,9 @@
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.
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
@@ -57,7 +58,7 @@ and ranks them:
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
Your order (on the Devices tab, or in the pill's dialog) 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
@@ -72,6 +73,16 @@ 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`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
+122
View File
@@ -0,0 +1,122 @@
# UEVR from Frame Control, 2026-09-29
**Verified observations**, with limits below. Same device and software as
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
Frame's AEST clock. Headset tests ran under the shared
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
12:44–12:52 and 13:01–13:03. Battery 45 % →
93 %, on charge throughout.
**Not observed:** the VR image, head tracking and controller input. SteamVR
reported `activity_level` 3 (user interaction timeout; the headset was not
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
capture was a uniform dark frame. Frame submits prove the app is presenting
to SteamVR, not that the image is correct.
## Manual injection (11:13–11:28)
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
started with the game process's Wine variables, and its WPF window drew
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
(64-bit, PCD3D_SM5))`, a Wine-side process id.
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
was not restored on the next start.
- XTest clicks landed on the game: `XQueryPointer` reported child
`0x2000001` (the game) at the injector's coordinates, and
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
- `XSendEvent` button events to the injector window worked. After OpenVR and
Inject, `/proc/<game>/maps` contained
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
Proton's `vrclient_x64.dll`. UEVR's log:
```text
[11:17:32.948] Hooking D3D11
[11:17:33.020] Hooked DirectX 11
[11:17:34.263] Framework initialized
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
[11:17:39.791] UGameViewportClient::Draw called for the first time.
[11:17:39.792] is stereo enabled called!
```
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
A second session's injector exited within about 20 seconds. The third,
started fresh, injected again (`frame_submits` 1412). After the game was
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
files were removed.
## Through Frame Control (11:33–11:49)
Local `ui/server.py` against the `frame` alias. First the API, then the
page's **VR mod** dialog, driven with a headless Chromium:
| Step | Result |
|---|---|
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
| `uninstall` while running | Refused: "the game is running; quit it first" |
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
## The GUI injector wasn't reliable (12:02–12:40)
After review fixes, cold starts through the API with UEVR's own injector:
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
relaunched the injector didn't rescue a bad session. In both failures of
the last batch, the first injector ignored the clicks and the next two
never showed a window within 60 s.
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
with OpenVR still unselected. The injector's log had no error.
Lock released at 12:40 before redesigning.
## Frame Control's own injector (12:44–12:52)
`start` now runs `frame_inject.py` under python.org's embeddable Python
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
the game's Wine session. The old install was removed and the new one
installed without the headset lock, since neither launches anything. The
prefix listing after removal matched the pre-install state; the new install
is 47 MB.
| Step | Result |
|---|---|
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
Battery 98–100 % on charge. Lock released at 12:52.
## After the second review (13:01–13:03)
`frame_inject.py` now declares every Win32 signature, checks each result,
frees its remote buffers and closes its handles; uninstall accepts only our
folder and UEVR's own settings folders. Rechecked: install; three cold starts
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
through the API, plus the UI run.
## Left on the Frame
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
installed; nothing else from this work is left.
- No `UEVRInjector.exe` or game processes were left running. Steam and
SteamVR were running at the end.
- The test lock was released at 13:03.
+1 -1
View File
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
## Casting
**Cast headset view** starts the existing headset Live view and requests full
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
screen where supported. Show that screen to people in the room, or use the
computer/phone's own screen mirroring. It creates no new stream transport,
public URL or LAN server. iPhone uses the inline viewer if full screen is not
+17 -11
View File
@@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has six tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (sending files and text,
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
and **Control** (the searchable Control Center, metrics, supported settings and
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
The window has six tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
player and the panel switcher; then sending files and text, Flatpaks, remote and
power), **Devices** (your headsets and their addresses), and **Control** (the
searchable Control Center, metrics, supported settings and headset guidance).
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
privacy and updates, library artwork and the assistant. The battery chip opens
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
build. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. 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.
@@ -133,7 +139,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal **On your Frame** before
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
@@ -239,5 +245,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
require a separate user approval; power also retains its password prompt. The
assistant uses a user-chosen endpoint and sends nothing until the user opts in
for a message. Screenshot context is separately opt-in. Model replies cannot
operate the headset. Tools → Open assistant opens the page; the linked guide
operate the headset. Settings → Open assistant settings opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
+1
View File
@@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+2 -2
View File
@@ -114,8 +114,8 @@ don't.
## Family and comfort
The shared Home card sets session limits, breaks and check-ins, and offers
**Cast headset view**. **Enable / test notifications** requests iOS notification
The shared Home card sets session limits, breaks and check-ins; **Cast to this
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
permission and sends a local test. These are local notifications, not APNs push;
iOS background suspension can interrupt phone alerts. The headset timer still
runs. See [the behavior and verification limits](family-comfort.md).
+85 -15
View File
@@ -1,11 +1,14 @@
# Flat-to-VR mods and Beat Saber songs
**Status: feasibility work, not an installer.** Frame Control does not yet
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
stays open: neither UEVR injection nor Beat Saber custom-song playback has
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
has not been inspected. There is no Mods button until the underlying
install, playback and removal have been checked.
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
songs are blocked.** Installed games have a **VR mod** button. It installs
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
the VR image, head tracking and controls haven't been seen yet. Beat Saber
custom songs need an owned Beat Saber on the Frame, which there isn't
([below](#beat-saber-songs-first)).
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
The mod manager must be Frame Control's own implementation. Mods and songs
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
## Per-game support
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
the observation stated, not a promise that the game is playable. “Documented”
means an upstream source describes it; “inferred” means it still needs a test.
| Game / build | Mod or content | Evidence and support status | Next check |
|---|---|---|---|
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
visible output and failures. It also records a SteamVR restart during the
shared session, which prevents attributing the failed UEVR attempt to FEX.
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
visible output, injection and failures.
## UEVR from Frame Control
![The VR mod dialog while UEVR runs in Gravitas](img/uevr-dialog.png)
**Get games → an installed game → VR mod.** The dialog says whether the game
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
like `frame_steam.py`.
- **Which games.** Unreal Engine games, recognised by their
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
prefix, so it must have been played once.
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
Each is checked against a pinned SHA-256; Python's comes from python.org's
release-file API. Archives are unpacked into a staging folder, rejecting
entries that escape it, symlinks and oversized contents, then moved into
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
what to remove, including UEVR's own settings folders only if they didn't
exist before. Install and remove refuse while the game is running, and only
one mod action runs at a time.
- **Play in VR.** Launches the game through Steam if it isn't running and
waits for its window. It then runs a short script under that Python, with
the game's Proton, prefix and Wine settings read from the game process, so
both share one Wine session. The script repeats what UEVR's own injector does
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
then checks that `UEVRBackend.dll` is mapped into the game and reads
SteamVR's frame count for the app (`vrcmd --stats`).
- **Remove.** Deletes what the receipt lists, then the download cache once
no game uses it. Verified: the prefix's file listing matched its
pre-install state afterwards.
Why not UEVR's own injector (verified 2026-09-29):
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
of downloads) in the prefix.
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
clicks, raising or refocusing the injector window don't change that, so in
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
events straight to its window does work.
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
saved OpenVR choice.
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
new injector ignored input or never showed its window, even after
relaunching. Its process list reads each process's main window title, a
cross-process window message; a window that isn't answering would block its
UI thread (inferred from the frontend's source, not proven). Without a
`HOME` it also died in .NET at startup.
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
Neither stopped the injection.
## Beat Saber: songs first
@@ -62,7 +125,10 @@ or dependency for Frame Control.
## Requirements for our manager
These are **planned**, not implemented or verified:
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
fake-library tests (`tests/test_mods.py`), apart from checking ownership
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
same, and none of it is built for them:
1. Resolve the selected Steam game's real library, installed build, executable
architecture and Proton prefix. Confirm ownership through Steam; a directory
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
including the SHA-512 hashes used for the test runtimes.
including the SHA-512 hashes used for the injector tests.
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
embeddable Python's `sha256_sum`.
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
including per-game installation and the need to preserve original DLLs.
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
+1 -1
View File
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
separate from Frame Control. Public reports describe some Proton support;
Windows-only does not by itself prove a Frame app cannot run. Local status
and sources are in [VR utilities](vr-utilities.md).
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
headset creates its own gamescope panel using built-in tools. It needs no
third-party overlay app. [Metrics and verification](vr-utilities.md).
+2 -2
View File
@@ -132,8 +132,8 @@ runs.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
**Report a problem** is the speech-bubble button in the header, also in the
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
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
+1
View File
@@ -89,6 +89,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+2
View File
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
- `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.
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
with a link to its website instead of an on/off switch.
- `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;
+3 -3
View File
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
Steam Frame rating, installs them on the Frame, and searches the Steam store.
This page covers how it works underneath, so you can do the same from a shell.
For flat-to-VR mods and Beat Saber custom songs, see the
[per-game feasibility table](mods.md). Mod support is separate from Steam's
Frame rating; there is no mod installer yet.
Installed Unreal Engine games also have a **VR mod** button that installs,
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
per-game table. Mod support is separate from Steam's Frame rating.
## How it works
+1 -1
View File
@@ -94,7 +94,7 @@ 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
Set the optional free key in Frame Control's **Settings → Library artwork**, 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
+1 -1
View File
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
## Our performance HUD
On **Home → VR comfort and performance**, the app shows a timestamped sample
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
+1 -1
View File
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
## Use it
In **Tools → Media in the headset**, send a file, choose its layout and press
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
**Stop** removes both. Refresh reads the library and the player's state.
The screen follows your head; it isn't a saved world-space panel.
+1 -1
View File
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env zsh
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
# panel on the Steam Frame, so you can browse and install apps from inside.
# See docs/apks.md → "The store in the headset".
#
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
# needed after `open`.
#
# Usage:
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
# scripts/store-on-frame.sh close # close the panel and stop the server
# scripts/store-on-frame.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
ROOT=${HERE:h}
PORT=47812 # the Frame's 127.0.0.1 only
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
REMOTE=.local/share/frame-control/store-server
cmd=${1:-}
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
# server on the Frame has exactly the files the desktop app ships.
deploy() {
local tmp version
tmp=$(mktemp -d)
trap "rm -rf ${(q)tmp}" EXIT
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
print -u2 "==> Copying Frame Control ($version) to the Frame"
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
fi
# Older versions, unless a server is still running from one.
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
print -r -- "$version"
}
# Runs on the Frame. Args: dir port appid key
OPEN=$(cat <<'EOF'
set -u
dir=$HOME/$1 port=$2 appid=$3 key=$4
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
if [ -x "$HOME/chromium-xr/chrome" ]; then
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
else
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
exit 3
fi
# A fresh server each time, so it runs this version with this key.
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
# Match the browser by its profile flag and the server by its script path: this
# launcher's own command line holds the bundle path, so a looser pattern kills it.
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
sleep 0.5
cd "$dir" || exit 1
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
>/tmp/frame-store-server.log 2>&1 </dev/null &
echo $! > /tmp/frame-store-server.pid
up=
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
sleep 0.25
done
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
--disable-features=Translate,MediaRouter --window-size=1280,800 \
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
# Tag the window so it's its own panel (docs/panels.md).
for _ in $(seq 1 60); do
sleep 0.5
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "Chromium started, but its window didn't appear within 30 s:" >&2
tail -n 15 /tmp/frame-store.log >&2
exit 1
EOF
)
case "$cmd" in
open)
version=$(deploy | tail -n 1)
key=$(openssl rand -hex 16)
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
;;
close)
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
;;
status)
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
;;
*) sed -n '2,15p' "$0"; exit 2 ;;
esac
+5 -4
View File
@@ -443,20 +443,20 @@ test('every linked workflow selects its real page and section, preserving shortc
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
let activePage,scrolled;
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const nodes=['home','games','android','tools','devices','control','settings'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
if(!sections.has(id))sections.set(id,{hidden:false,classList:{toggle(){}},scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},scrollToY(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\n'+html.match(/^PAGES\.push\([^\n]+/m)[0],context);
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
if(!href.startsWith('#')||href==='#connection')continue;
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
@@ -468,4 +468,5 @@ test('every linked workflow selects its real page and section, preserving shortc
}
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
assert.equal(vm.runInContext('PAGES[5]',context),'control');
assert.equal(vm.runInContext('PAGES[6]',context),'settings');
});
+21 -5
View File
@@ -19,8 +19,8 @@ function grab(name) {
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
const fns = new Function("$", "sourceState", "document", "console", "api", "runJob", "loadAndroid", "act", "window", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); },
async (title, key, work) => work(), () => {}, async (title, work) => work(),
{ open() { fail("an in-app installation opened an external page"); } });
return { els, sourceState, calls, ...fns };
}
@@ -68,7 +70,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Broken", offers: [] }];
await p.openStoreApp(0);
await p.openStoreApp(0, true);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
if (p.calls.length) fail("asked for details of a listing that couldn't render");
}
@@ -90,4 +92,18 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
}
// The featured Install starts a new or failed job, while a job already running or
// installed opens its details without another install or a launch command.
for (const [job, expected] of [[null, 1], [{ done: false }, 0],
[{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
await p.openStoreApp(0, true);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
const installs = p.calls.filter(([path]) => path === "/api/sources/install");
if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
}
console.log("ok");
+9 -3
View File
@@ -19,7 +19,9 @@ class ArtworkTests(unittest.TestCase):
def setUp(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
_images._cache.clear()
_images._dns.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
@@ -71,9 +73,13 @@ class ArtworkTests(unittest.TestCase):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
with patch.object(_images, 'MAX_TOKENS', 50):
first = _images.register('https://example.com/0.png')
for i in range(1, 60):
_images.register('https://example.com/%d.png' % i)
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+1 -1
View File
@@ -122,7 +122,7 @@ class SearchTests(SettingsTest):
break
time.sleep(.01)
self.assertTrue(arrived[1]['event'].wait(2))
self.assertEqual(arrived[1]['query'], ('second', 50))
self.assertEqual(arrived[1]['query'][:2], ('second', 50))
def test_set_enabled_does_not_hold_search_lock_in_source(self):
free = []
+42
View File
@@ -56,6 +56,48 @@ const log = ()=>{}, toast = ()=>{};
assert.equal($('sessionMinutes').disabled,false);
assert.equal($('sessionCancel').disabled,true);
})().catch(e=>{console.error(e);process.exitCode=1;});
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_a_finished_session_leaves_the_defaults(self):
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
setup = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
// The form's own defaults, as in the page's HTML.
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
return elements.get(id);
};
const window = {frameApp:{notify:async()=>{}}};
const log = ()=>{}, toast = ()=>{};
'''
checks = r'''
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
assert.equal($('sessionMinutes').value, '30');
assert.equal($('breakMinutes').value, '20');
assert.equal($('batteryAlert').checked, true);
assert.match($('comfortEvents').textContent, /Session ended/);
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
assert.equal($('heatAlert').checked, false);
// The page stays open while that session ends: the next one starts from the defaults again.
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
assert.equal($('sessionMinutes').value, '30');
assert.equal($('breakMinutes').value, '20');
assert.equal($('heatAlert').checked, true);
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
$('sessionMinutes').value = '50'; // what's typed for the next session
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+14
View File
@@ -63,6 +63,20 @@ class Coverage(unittest.TestCase):
self.assertIn('*.js', ui['filter'])
self.assertIn('*.css', ui['filter'])
def test_bundle_runs_the_server_on_its_own(self):
# The iPhone app and scripts/store-on-frame.sh start ui/server.py from this bundle alone.
import io, tarfile, tempfile
spec = importlib.util.spec_from_file_location('frame_bundle_run', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(bundle.build()), mode='r:gz') as tar:
tar.extractall(tmp)
result = subprocess.run([sys.executable, '-I', '-B', '-c',
'import sys; sys.path.insert(0, "ui"); import server, apk_sources.search; '
'apk_sources.search.modules()[1] and sys.exit("source modules failed: %r" % apk_sources.search.modules()[1])'],
cwd=tmp, capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stderr[-2000:])
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
# Run the real aggregator with every external probe stubbed.
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
+14
View File
@@ -323,6 +323,20 @@ class Pins(Base):
class Registry(Base):
def test_an_address_added_first_wins_on_its_own_network(self):
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
# worked here before. The LAN address has to go ahead of it to be used at home.
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
addrs = self.reg.get(d["id"])["addresses"]
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
self.assertEqual(at_home[0], "192.168.1.40")
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
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")
+3
View File
@@ -141,6 +141,9 @@ def wait_for_idle_resolvers(timeout=10):
class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
from apk_sources import _images
_images._dns.clear() # each test answers lookups its own way
self.addCleanup(_images._dns.clear)
def test_source_inputs_and_url(self):
from apk_sources import _images
+41
View File
@@ -897,6 +897,47 @@ class Connecting(unittest.TestCase):
self.assertEqual(self.routes, [])
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
d = self.device("nothing.invalid")
entered = {1: threading.Event(), 2: threading.Event()}
release = {1: threading.Event(), 2: threading.Event()}
calls = []
def probe(host, port, update=None):
calls.append(host)
n = len(calls)
entered[n].set()
release[n].wait(10)
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
with mock.patch.object(fl, "probe", probe):
try:
tests[0].start()
self.assertTrue(entered[1].wait(5), "the first test never probed")
tests[1].start()
self.assertTrue(entered[2].wait(5), "the second test never probed")
# The first finishes while the second is still probing: it mustn't show its
# rows or mark the second done.
release[1].set()
tests[0].join(10)
self.assertFalse(tests[0].is_alive())
running = self.link.snapshot()["tests"][d["id"]]
self.assertFalse(running["done"])
self.assertEqual(running["rows"][0]["detail"], "Waiting")
release[2].set()
tests[1].join(10)
self.assertFalse(tests[1].is_alive())
finally:
for e in release.values():
e.set()
for t in tests:
if t.ident: # started
t.join(10)
result = self.link.snapshot()["tests"][d["id"]]
self.assertTrue(result["done"])
self.assertEqual(result["rows"][0]["detail"], "test 2")
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
self.device("localhost")
self.hosts({"localhost": "ok"})
+7 -6
View File
@@ -56,18 +56,19 @@ class LinkUI(unittest.TestCase):
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_the_pill_leads_with_the_reason(self):
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
# the reason comes first, in a few words, and only the countdown gets cut.
def test_the_pill_keeps_the_failure_detail_in_its_tooltip(self):
# The connection redesign keeps the state and retry countdown in the pill,
# with the full failure explanation in its tooltip and details dialog.
self.run_page(r'''
link.s = changed;
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
assert.ok($('conn').title.includes(changed.error.message));
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
assert.match($('conn').innerHTML, /<span>Checking the headset&#39;s identity failed<\/span>/);
assert.match($('conn').innerHTML, /<span>Offline<\/span>/);
assert.ok($('conn').title.includes('Something.'));
''')
def test_the_banner_never_doubles_a_full_stop(self):
+337
View File
@@ -0,0 +1,337 @@
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
Run: python3 -m unittest discover -s tests
"""
import hashlib
import io
import itertools
import json
import stat
import subprocess
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_mods # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
APPID = 1067310
def zip_bytes(files, links=()):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
for name, data in files.items():
z.writestr(name, data)
for name in links:
info = zipfile.ZipInfo(name)
info.external_attr = (stat.S_IFLNK | 0o777) << 16
z.writestr(info, "/etc/passwd")
return buf.getvalue()
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
class FakeLibrary(unittest.TestCase):
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
home = Path(tmp.name)
self.steam = home / ".local/share/Steam"
apps = self.steam / "steamapps"
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
self.prefix = apps / f"compatdata/{APPID}/pfx"
for sub in ("Roaming", "Local"):
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
(apps / f"appmanifest_{APPID}.acf").write_text(
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
("RECEIPTS", home / ".local/share/frame-control/mods")):
p = mock.patch.object(frame_mods, name, value)
p.start()
self.addCleanup(p.stop)
self.running = None
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
p.start()
self.addCleanup(p.stop)
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
"openvr_api.dll": b"MZ"})}
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
self.pin(self.downloads)
def pin(self, downloads):
"""Serve these bytes and pin their real hashes in place of the published ones."""
for name in ("UEVR", "PYTHON"):
pinned = getattr(frame_mods, name)
p = mock.patch.object(frame_mods, name,
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
p.start()
self.addCleanup(p.stop)
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
self.urlopen = p.start()
self.addCleanup(p.stop)
def managed(self):
return self.prefix / "drive_c/frame-control"
class Status(FakeLibrary):
def test_unreal_game_is_eligible_and_not_installed(self):
st = frame_mods.status(APPID)
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
(True, "Drop-Win64-Shipping.exe", False, False, True))
def test_game_without_unreal_binary_is_refused(self):
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
self.assertFalse(frame_mods.status(APPID)["eligible"])
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
frame_mods.install(APPID)
self.urlopen.assert_not_called()
def test_uninstalled_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.status(620)
class InstallUninstall(FakeLibrary):
def test_install_then_uninstall_leaves_prefix_as_found(self):
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
res = frame_mods.install(APPID)
self.assertTrue(res["installed"])
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
self.assertFalse(list(self.managed().glob(".staging-*")))
# What UEVR writes on its first injection.
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
roaming.mkdir(parents=True)
(roaming / "log.txt").write_text("x")
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
frame_mods.uninstall(APPID)
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
self.assertEqual(after, before)
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_existing_uevr_settings_survive_uninstall(self):
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
profile.mkdir(parents=True)
(profile / "config.txt").write_text("mine")
frame_mods.install(APPID)
frame_mods.uninstall(APPID)
self.assertEqual((profile / "config.txt").read_text(), "mine")
def test_second_install_is_a_no_op(self):
frame_mods.install(APPID)
calls = self.urlopen.call_count
self.assertIn("already installed", frame_mods.install(APPID)["message"])
self.assertEqual(self.urlopen.call_count, calls)
def test_refuses_while_game_runs(self):
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.install(APPID)
self.running = None
frame_mods.install(APPID)
self.running = 4242
with self.assertRaisesRegex(frame_mods.Fail, "running"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
def test_needs_a_prefix(self):
import shutil
shutil.rmtree(self.prefix.parent)
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
frame_mods.install(APPID)
def test_receipt_cannot_point_outside_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
victim = self.steam / "steamapps/common/Gravitas"
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(victim.is_dir())
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
users = self.prefix / "drive_c/users"
r["remove"].append(str(users))
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
frame_mods.uninstall(APPID)
self.assertTrue(users.is_dir())
def test_failed_removal_keeps_the_receipt(self):
frame_mods.install(APPID)
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
frame_mods.uninstall(APPID)
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
frame_mods.uninstall(APPID)
self.assertFalse(self.managed().exists())
def test_receipt_cannot_name_another_prefix(self):
frame_mods.install(APPID)
r = frame_mods.read_receipt(APPID)
r["prefix"] = "/"
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
frame_mods.uninstall(APPID)
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
class Downloads(FakeLibrary):
def test_hash_mismatch_deletes_and_installs_nothing(self):
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
frame_mods.install(APPID)
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
self.assertFalse(self.managed().exists())
self.assertIsNone(frame_mods.read_receipt(APPID))
def test_archive_escaping_its_folder_is_refused(self):
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
self.pin(downloads)
with self.subTest(), self.assertRaises(frame_mods.Fail):
frame_mods.install(APPID)
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
import shutil
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
def test_oversized_archive_is_refused(self):
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
frame_mods.install(APPID)
def test_oversized_download_is_stopped(self):
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
frame_mods.install(APPID)
self.assertFalse(list(frame_mods.CACHE.glob("*")))
class Actions(FakeLibrary):
def test_one_change_at_a_time(self):
import fcntl
lock = Path(tempfile.mkdtemp()) / "mods.lock"
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
fcntl.flock(held, fcntl.LOCK_EX)
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
frame_mods.main(["install", str(APPID)])
self.assertFalse(frame_mods.status(APPID)["installed"])
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
def test_zero_frames_is_an_answer(self):
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
self.assertIsNone(frame_mods.frame_submits(APPID))
class Start(FakeLibrary):
def environ(self, text):
"""The game's /proc/<pid>/environ; every other file reads normally."""
real = frame_mods.Path.read_text
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
text if str(path).startswith("/proc/") else real(path, *a, **k))
def test_needs_install_first(self):
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
frame_mods.start(APPID)
def run_start(self, reply, loaded=(False, True, True, True)):
frame_mods.install(APPID)
self.running = 4242
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
with self.environ(env), \
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
mock.patch.object(frame_mods.time, "sleep"):
return frame_mods.start(APPID), run
def test_injects_with_the_games_wine_settings(self):
res, run = self.run_start('noise\n{"pid": 320}\n')
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
self.assertEqual(env["WINEPREFIX"], "/x")
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
def test_reports_the_injection_error(self):
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
def test_success_is_checked_in_the_game(self):
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
def test_inject_script_is_valid_python(self):
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
class Helper(unittest.TestCase):
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
def test_unexpected_errors_are_still_json(self):
# A receipt from another version, without "exe": a KeyError, not a traceback.
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
self.assertEqual(out.returncode, 1, out.stderr)
self.assertIn("KeyError", json.loads(out.stdout)["error"])
def test_bad_usage_prints_json_error(self):
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
capture_output=True, text=True, timeout=30)
self.assertEqual(out.returncode, 1, args)
self.assertIn("error", json.loads(out.stdout), args)
class ModRoutes(test_server.ServerGuards):
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
def test_mod_input_validation(self):
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
status, payload = self.post("/api/mods", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_needs_custom_header(self):
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
def test_runs_the_mods_helper_with_its_timeout(self):
import server
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
setattr(ModRoutes, name, None)
+141
View File
@@ -0,0 +1,141 @@
"""The page's inline scripts share one global scope, so a second top-level function or
variable with a name already used replaces the first everywhere, silently."""
import html.parser
import json
import pathlib
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
# Compiles the scripts as one strict-mode block. There, functions are block scoped like let
# and const, so V8 itself rejects a name declared twice in the shared scope (however it's
# indented or declared: function, class, let, const, destructuring), while helpers with the
# same name inside different functions stay legal. var is the exception (declaring one twice
# is allowed), so the page doesn't use var at all: Node's own copy of the acorn parser finds
# real var declarations, not the word in comments, strings or CSS var().
DUPLICATES = r'''
const vm = require('vm');
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
try { new vm.Script('"use strict"; {\n' + scripts.join('\n;\n') + '\n}'); console.log('ok'); }
catch (e) { console.log(e.message); }
'''
VARS = r'''
const scripts = JSON.parse(require('fs').readFileSync(0, 'utf8'));
let acorn, walk;
try {
acorn = require('internal/deps/acorn/acorn/dist/acorn');
walk = require('internal/deps/acorn/acorn-walk/dist/walk');
} catch { console.log('no parser'); process.exit(0); }
for (const code of scripts) {
let found = null;
try {
walk.simple(acorn.parse(code, { ecmaVersion: 'latest' }), {
VariableDeclaration(n) { if (n.kind === 'var' && !found) found = code.slice(n.start, n.end).slice(0, 40); },
});
} catch (e) { console.log(e.message); process.exit(0); }
if (found) { console.log('declares with var: ' + found); process.exit(0); }
}
console.log('ok');
'''
def node(script, scripts, *flags):
r = subprocess.run(['node', *flags, '-e', script], input=json.dumps(scripts), capture_output=True, text=True)
return r.stdout.strip() or r.stderr.strip()
def check(scripts):
"""'ok', or V8's message for the first name declared twice."""
return node(DUPLICATES, scripts)
def check_vars(scripts):
"""'ok', or the first var declaration. Skips where Node doesn't include acorn."""
out = node(VARS, scripts, '--expose-internals')
if out == 'no parser':
raise unittest.SkipTest("this Node doesn't include acorn")
return out
class ClassicScripts(html.parser.HTMLParser):
"""The page's inline classic scripts: no src, and no type other than a JavaScript one (a
module has its own scope, and JSON data isn't code). The types a browser runs as script:
https://mimesniff.spec.whatwg.org/#javascript-mime-type"""
JS = {'', 'application/ecmascript', 'application/javascript', 'application/x-ecmascript',
'application/x-javascript', 'text/ecmascript', 'text/javascript', 'text/javascript1.0',
'text/javascript1.1', 'text/javascript1.2', 'text/javascript1.3', 'text/javascript1.4',
'text/javascript1.5', 'text/jscript', 'text/livescript', 'text/x-ecmascript', 'text/x-javascript'}
def __init__(self):
super().__init__()
self.scripts, self.current = [], None
def handle_starttag(self, tag, attrs):
if tag == 'script':
a = dict(attrs)
inline = 'src' not in a and (a.get('type') or '').strip().lower() in self.JS
self.current = [] if inline else None
def handle_data(self, data):
if self.current is not None:
self.current.append(data)
def handle_endtag(self, tag):
if tag == 'script' and self.current is not None:
self.scripts.append(''.join(self.current))
if tag == 'script':
self.current = None
def classic_scripts(page):
p = ClassicScripts()
p.feed(page)
return p.scripts
@unittest.skipUnless(shutil.which('node'), 'Node parses the page scripts')
class PageScripts(unittest.TestCase):
def test_no_top_level_name_is_declared_twice(self):
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
self.assertGreaterEqual(len(scripts), 2)
self.assertEqual(check(scripts), 'ok')
def test_the_page_declares_nothing_with_var(self):
scripts = classic_scripts((ROOT / 'ui/index.html').read_text(encoding='utf-8'))
self.assertEqual(check_vars(scripts), 'ok')
def test_the_check_finds_what_it_should(self):
twice = {
'indented function': ['function loadPanels() {}', ' async function loadPanels() {}'],
'class': ['class Panel {}', 'class Panel {}'],
'destructured': ['const { a, b } = {};', 'let [b] = [];'],
'later declarator': ['let x = 1;', 'const y = 2, x = 3;'],
'function and const': ['function f() {}', 'const f = 1;'],
}
for what, scripts in twice.items():
with self.subTest(what):
self.assertIn('already been declared', check(scripts))
helpers = ['function a() { function help() {} }', 'function b() { const help = 1; }']
self.assertEqual(check(helpers), 'ok')
def test_the_var_check_finds_what_it_should(self):
# var: legal to declare twice, so not allowed at all; however it's written.
for code in ['var x = 1;', 'var/*c*/x = 1;', 'var {x} = {x: 1};', 'function f() { var y; }']:
with self.subTest(code):
self.assertIn('declares with var', check_vars([code]))
# The word var elsewhere is fine.
text = ['// var x\nconst a = "var y", b = `var ${a}`, c = "color: var(--blue)", d = {}.var;']
self.assertEqual(check_vars(text), 'ok')
def test_only_inline_classic_scripts_are_checked(self):
page = ('<script>let a;</script><script type="module">export const m = 1;</script>'
'<script type="application/json">{"b": 1}</script><script src = "x.js"></script>'
'<script data-src="y" type="text/javascript">let c;</script>'
'<script type="text/ecmascript">let d;</script><script type=" Application/X-JavaScript ">let e;</script>')
self.assertEqual(classic_scripts(page), ['let a;', 'let c;', 'let d;', 'let e;'])
if __name__ == '__main__':
unittest.main()
+55
View File
@@ -0,0 +1,55 @@
"""The page calls showPage() while its first script is still running, before names declared
later (in that script or the second one) exist. Touching one of them there throws, and the
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
import json
import pathlib
import re
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab']
RUN = r'''
const vm = require('vm');
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const failures = [];
for (const hash of hashes) {
const context = vm.createContext({
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
});
try { vm.runInContext(code, context); }
catch (e) { failures.push(`${hash}: ${e.message}`); }
}
console.log(JSON.stringify(failures));
'''
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
class PageStartup(unittest.TestCase):
def test_opening_any_page_or_section_at_startup_runs(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
tables = first[first.index('const PAGES ='):first.index('let page =')]
start = first.index('function showPage(')
show = first[start:first.index('\n}\n', start) + 3]
# Everything declared after the startup call is still uninitialised when it runs.
call = re.search(r'^showPage\(\);', first, re.M).end()
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
self.assertIn('link', later) # the name that broke #games
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
for n in STUBS if n != '$')
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#control', '#nowhere']
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(json.loads(r.stdout), [])
if __name__ == '__main__':
unittest.main()
+6
View File
@@ -88,6 +88,12 @@ class ServerGuards(unittest.TestCase):
self.assertIn(mime, headers['Content-Type'])
self.assertIn(marker, payload)
def test_store_page_served_with_anti_framing_headers(self):
status, headers, payload = self.request("GET", "/store")
self.assertEqual(status, 200)
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+360
View File
@@ -0,0 +1,360 @@
"""The store's Frame categories (ui/apk_sources/_categories.py): which apps go where, which are
phone-only and hidden, and how the browse, search and library artwork use them. Offline."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, _categories, _images, fdroid, search # noqa: E402
import test_fdroid_sources as fixtures # noqa: E402 (a module, so its tests don't run twice)
FIXTURES, PIN, URL = fixtures.FIXTURES, fixtures.PIN, fixtures.URL
def kind(**entry):
return _categories.classify(dict({'name': 'App', 'summary': ''}, **entry))
class Classify(unittest.TestCase):
def test_vr_first_whatever_else_it_is(self):
self.assertEqual(kind(vr=True, categories=['Phone & SMS']), {'category': 'vr', 'phone_only': False})
def test_fine_categories_decide(self):
self.assertEqual(kind(categories=['Games', 'Puzzle Game'])['category'], 'games')
self.assertEqual(kind(categories=['Games', 'Emulator'])['category'], 'emulators')
# A chat app is in Multimedia too; the finer Social Network wins.
self.assertEqual(kind(categories=['Multimedia', 'Internet', 'Social Network'])['category'], 'web')
self.assertEqual(kind(categories=['Reading', 'Ebook Reader'])['category'], 'read')
self.assertEqual(kind(categories=['Multimedia', 'Podcast'])['category'], 'watch')
def test_broad_categories_alone(self):
self.assertEqual(kind(categories=['Multimedia']), {'category': 'watch', 'phone_only': False})
self.assertEqual(kind(categories=['Writing']), {'category': 'work', 'phone_only': False})
def test_phone_only(self):
for categories in (['System', 'Launcher'], ['Internet', 'VPN & Proxy'], ['Theming', 'Wallpaper'],
['Sports & Health'], ['Connectivity'], ['Time', 'Alarm Clock'], ['Habit Tracker']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(name='[ROOT] Night Light', categories=['Development'])['phone_only'])
self.assertTrue(kind(name='5G Proxy Client', summary='Routes traffic')['phone_only'])
def test_games_and_media_keep_phone_words(self):
# 'Battery' in a game's name or a widget in a music player doesn't hide a game or a player.
self.assertFalse(kind(name='Widget Wars', categories=['Strategy Game'])['phone_only'])
self.assertFalse(kind(name='Music', summary='with a home screen widget', categories=['Local Media Player'])['phone_only'])
def test_emulators_by_name_without_categories(self):
self.assertEqual(kind(name='RetroArch', summary='Frontend for emulators')['category'], 'emulators')
def test_unknown_is_neither_hidden_nor_guessed(self):
self.assertEqual(kind(name='Thing'), {'category': None, 'phone_only': False})
self.assertEqual(kind(categories=['Something New']), {'category': None, 'phone_only': False})
def test_matches(self):
phone = {'category': 'tools', 'phone_only': True}
self.assertFalse(_categories.matches(phone))
self.assertTrue(_categories.matches(phone, phone=True))
self.assertTrue(_categories.matches({'category': 'games', 'phone_only': False}, 'games'))
self.assertFalse(_categories.matches({'category': 'watch', 'phone_only': False}, 'games'))
def test_every_store_category_has_a_label(self):
self.assertEqual({c for c, _ in _categories.CATEGORIES}, _categories.IDS)
targets = set(_categories.FINE) | {c for c in _categories.BROAD.values() if c}
self.assertLessEqual(targets, _categories.IDS)
self.assertLessEqual(set(_categories.PRIORITY), _categories.IDS)
class FDroidCategories(unittest.TestCase):
setUp, fetch, add = fixtures.Repositories.setUp, fixtures.Repositories.fetch, fixtures.Repositories.add
def index(self, version, categories):
raw = json.loads((FIXTURES / ('artwork-' + version + '.json')).read_text())
if version == 'v2':
raw['packages']['org.example.app']['metadata']['categories'] = categories
else:
raw['apps'][0]['categories'] = categories
path = self.root / ('index-' + version + '.json')
path.write_text(json.dumps(raw))
if version == 'v1':
fdroid._v1(path.read_bytes(), path)
return path
def test_categories_survive_v1_and_v2(self):
source = self.add()
for version in ('v1', 'v2'):
with self.subTest(version=version):
apps = fdroid._reduce(self.index(version, ['Games', 'Puzzle Game', 7]), source)
self.assertEqual(apps['org.example.app']['categories'], ['Games', 'Puzzle Game'])
def save(self, source, apps):
fdroid._write(fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json'),
{'version': fdroid.CACHE_VERSION, 'url': URL, 'fingerprint': PIN, 'apps': apps})
def app(self, pkg, name, updated, categories=(), banner=None):
return {'source': 'x', 'id': pkg, 'package': pkg, 'name': name, 'summary': '', 'updated': updated,
'categories': list(categories), 'images': {'icon': None, 'banner': banner, 'screenshots': []}}
def test_browse_shows_artwork_then_newest_and_filters_before_the_limit(self):
source = self.add()
self.save(source, {a['id']: a for a in (
self.app('a.old', 'Aaa', '2020-01-01', ['Puzzle Game']),
self.app('b.new', 'Bbb', '2026-01-01', ['Puzzle Game']),
self.app('c.art', 'Ccc', '2019-01-01', ['Puzzle Game'], banner=URL + 'b.png'),
self.app('d.sms', 'Ddd', '2026-02-01', ['Phone & SMS']))})
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=10)], ['c.art', 'd.sms', 'b.new', 'a.old'])
keep = lambda e: _categories.matches(dict(e, **_categories.classify(e)))
self.assertEqual([a['id'] for a in fdroid.search(source, '', limit=1, keep=keep)], ['c.art'])
self.assertEqual([a['id'] for a in fdroid.search(source, 'a', limit=10)], ['a.old', 'c.art']) # a query sorts by name
def test_lookup_reads_the_saved_index_only(self):
source = self.add()
self.assertEqual(fdroid.lookup(dict(source, id='never-loaded'), ['org.example.app']), {})
self.save(source, {'a.b': self.app('a.b', 'A', None)})
before = self.fetch_mock.call_count
self.assertEqual(list(fdroid.lookup(source, ['a.b', 'missing'])), ['a.b'])
self.assertEqual(self.fetch_mock.call_count, before)
self.assertEqual(fdroid.lookup(dict(source, enabled=False), ['a.b']), {})
def fake_source(entries, kind_='fake', lookup=None):
mod = types.SimpleNamespace(KIND=kind_)
mod.sources = lambda: [{'id': kind_, 'kind': kind_, 'name': kind_.title(), 'url': 'https://example.org/',
'builtin': True, 'enabled': True, 'trust': 'community'}]
mod.search = lambda source, query, limit=50: [dict(e) for e in entries][:limit]
mod.details = lambda source, entry_id: next(dict(e) for e in entries if e['id'] == entry_id)
if lookup is not None:
mod.lookup = lookup
return mod
ENTRY = {'source': 'fake', 'package': None, 'summary': '', 'free': True, 'downloadable': True,
'min_sdk': 24, 'abis': ['arm64-v8a'], 'vr': None}
class Search(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
for state in (search._running, search._pending, search._status):
state.clear()
self.addCleanup(state.clear)
def run_search(self, entries, **kwargs):
with patch.object(search, 'modules', return_value=([fake_source(entries)], [])):
return search.search(**kwargs)
def test_category_and_phone_filters(self):
entries = [dict(ENTRY, id='g', name='Game', categories=['Puzzle Game']),
dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS']),
dict(ENTRY, id='v', name='Video', categories=['Local Media Player'])]
result = self.run_search(entries)
self.assertEqual(sorted(a['name'] for a in result['apps']), ['Game', 'Video'])
self.assertEqual({a['name']: a['category'] for a in result['apps']}, {'Game': 'games', 'Video': 'watch'})
self.assertIn({'id': 'emulators', 'label': 'Emulators'}, result['categories'])
self.assertEqual([a['name'] for a in self.run_search(entries, category='games')['apps']], ['Game'])
self.assertEqual(len(self.run_search(entries, phone=True)['apps']), 3)
with self.assertRaisesRegex(SourceError, 'Unknown category'):
self.run_search(entries, category='phones')
def test_listing_offers_three_screenshots_details_all(self):
shots = ['https://example.org/%d.png' % i for i in range(8)]
entry = dict(ENTRY, id='g', name='Game', images={'icon': None, 'banner': None, 'screenshots': shots})
listed = self.run_search([entry])['apps'][0]['offers'][0]
self.assertEqual(len(listed['artwork']['screenshots']), 3)
self.assertEqual(len(search.decorate(entry)['artwork']['screenshots']), 8)
def test_browse_asks_sources_for_more_than_a_search(self):
seen = []
mod = fake_source([])
mod.search = lambda source, query, limit=50: seen.append((query, limit)) or []
with patch.object(search, 'modules', return_value=([mod], [])):
search.search('')
search.search('chess')
self.assertEqual(seen, [('', search.BROWSE_LIMIT), ('chess', 50)])
class LibraryArtwork(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(tmp.name) / 'sources.json')
p.start()
self.addCleanup(p.stop)
def test_by_package_from_saved_indexes_then_by_name_for_curated_apps(self):
icon = {'images': {'icon': 'https://example.org/icon.png', 'banner': None, 'screenshots': []}}
repo = fake_source([], 'fdroid', lookup=lambda source, packages: {
p: dict(icon, name='x') for p in packages if p == 'org.antennapod'})
github = fake_source([dict(icon, id='icosa/open-brush', name='Open Brush')], 'github')
bare = fake_source([dict(icon, id='x', name='Elsewhere', package='org.elsewhere')], 'itch')
apps = [{'package': 'org.antennapod', 'label': 'AntennaPod'}, {'package': 'com.Icosa.OpenBrush', 'label': 'Open Brush'},
{'package': 'org.elsewhere', 'label': 'Elsewhere'}, {'package': 'org.none', 'label': 'None'}]
with patch.object(search, 'modules', return_value=([repo, github, bare], [])):
found = search.artwork_for(apps)
self.assertEqual(sorted(found), ['com.Icosa.OpenBrush', 'org.antennapod'])
self.assertTrue(found['org.antennapod']['icon'].startswith('/source-image/'))
def test_a_failing_source_is_skipped(self):
def broken(source, packages):
raise SourceError('no')
with patch.object(search, 'modules', return_value=([fake_source([], 'fdroid', lookup=broken)], [])):
self.assertEqual(search.artwork_for([{'package': 'a.b', 'label': 'A'}]), {})
class ImageHandles(unittest.TestCase):
def test_same_url_same_handle_without_scanning(self):
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
a = _images.register('https://example.org/a.png')
self.assertEqual(_images.register('https://example.org/a.png'), a)
self.assertNotEqual(_images.register('https://example.org/b.png'), a)
class Route(unittest.TestCase):
def test_search_filters_are_checked(self):
import server
with patch.object(server.apk_search, 'search', return_value={'apps': []}) as run:
server.source_search('q=&category=games&phone=1')
self.assertEqual(run.call_args.kwargs['category'], 'games')
self.assertIs(run.call_args.kwargs['phone'], True)
server.source_search('q=chess')
self.assertEqual((run.call_args.kwargs['category'], run.call_args.kwargs['phone']), (None, False))
for bad in ('phone=maybe', 'category=' + 'x' * 41):
with self.subTest(bad=bad), self.assertRaises(server.Failure):
server.source_search(bad)
class ReviewCases(unittest.TestCase):
"""Real index entries (October 2026) that the first rules got wrong."""
def test_useful_apps_with_a_phone_side_stay(self):
for categories, category in ((['Connectivity', 'Email', 'Internet', 'Writing'], 'web'), # Thunderbird
(['Connectivity', 'Internet', 'Multimedia', 'Podcast'], 'watch'), # FocusPodcast
(['Contact', 'Email'], 'web'),
(['Cloud Storage & File Sync', 'File Manager', 'System'], 'tools')):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': category, 'phone_only': False})
def test_phone_side_alone_or_with_a_broad_category_is_hidden(self):
for categories in (['Internet', 'VPN & Proxy'], ['App Manager', 'Development', 'System'], ['Navigation']):
with self.subTest(categories=categories):
self.assertTrue(kind(categories=categories)['phone_only'])
self.assertTrue(kind(categories=['Launcher', 'Games'])['phone_only']) # system ones always
def test_no_root_needed_is_not_a_root_tool(self):
for summary in ('Linux on Android, no root required', 'Linux Without Root Access', 'SSH server without root',
'Works on non-rooted phones', 'Rootless and simple'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Development'])['phone_only'])
self.assertTrue(kind(summary='Needs root to tune the kernel', categories=['Development'])['phone_only'])
def test_izzyondroid_names(self):
self.assertEqual(kind(categories=['Office'])['category'], 'work')
self.assertTrue(kind(categories=['Xposed'])['phone_only'])
class ReviewCasesTwo(unittest.TestCase):
"""Round two of real entries (October 2026)."""
def test_messengers_with_phone_or_push_stay(self):
for categories in (['Internet', 'Messaging', 'Phone & SMS'], ['Internet', 'Messaging', 'Push'],
['Connectivity', 'Messaging', 'Phone & SMS', 'Voice & Video Chat']):
with self.subTest(categories=categories):
self.assertEqual(kind(categories=categories), {'category': 'web', 'phone_only': False})
def test_broad_only_sources_can_escape_a_phone_side(self):
self.assertEqual(kind(categories=['System', 'Development']), {'category': 'tools', 'phone_only': False})
self.assertEqual(kind(categories=['Connectivity', 'Internet']), {'category': 'web', 'phone_only': False})
self.assertTrue(kind(categories=['VPN & Proxy', 'Internet'])['phone_only']) # it has finer ones, none useful
def test_summary_features_dont_make_a_phone_app(self):
self.assertFalse(kind(name='OSS Weather', summary='Forecasts with home screen widgets', categories=['Weather'])['phone_only'])
self.assertFalse(kind(name='Digital Calculator', summary='Hardware keyboard support', categories=['Calculator', 'System'])['phone_only'])
self.assertTrue(kind(name='Clock Widget', categories=['Clock'])['phone_only']) # but the name says what it is
def test_saying_root_isnt_needed(self):
for summary in ('VNC server app that does not require root privileges', "Backups that don't need root access",
"It doesn't need root", 'Linux without root', 'Works on non-rooted phones', 'Rootless'):
with self.subTest(summary=summary):
self.assertFalse(kind(summary=summary, categories=['Remote Access'])['phone_only'])
for summary in ('Requires root', 'Needs root access to change the kernel', 'For rooted devices'):
with self.subTest(summary=summary):
self.assertTrue(kind(summary=summary, categories=['Remote Access'])['phone_only'])
def test_hardware_the_frame_lacks(self):
self.assertTrue(kind(name='IR Blaster Remote', categories=['Multimedia', 'Remote Controller', 'System'])['phone_only'])
self.assertTrue(kind(name='Wear Sync for Breezy', summary='Weather on your Wear OS watch', categories=['Connectivity', 'Weather'])['phone_only'])
class SearchFindsPhoneApps(unittest.TestCase):
setUp, run_search = Search.setUp, Search.run_search
def test_a_search_finds_what_browsing_hides(self):
entries = [dict(ENTRY, id='s', name='Texts', categories=['Phone & SMS'])]
self.assertEqual(self.run_search(entries)['apps'], [])
found = self.run_search(entries, query='texts')['apps']
self.assertEqual([(a['name'], a['offers'][0]['phone_only']) for a in found], [('Texts', True)])
class ArtworkLookups(unittest.TestCase):
"""A page of cards asks for dozens of images at once; they mustn't fail for want of a lookup slot."""
def setUp(self):
_images._dns.clear()
self.addCleanup(_images._dns.clear)
def test_public_answers_are_kept_private_ones_are_not(self):
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=public) as lookup:
for _ in range(3):
self.assertEqual(_images._resolve('example.org', 443, 5), public)
self.assertEqual(lookup.call_count, 1)
private = [(2, 1, 6, '', ('10.0.0.5', 443))]
with patch.object(_images.socket, 'getaddrinfo', return_value=private) as lookup:
_images._resolve('inside.example', 443, 5)
_images._resolve('inside.example', 443, 5)
self.assertEqual(lookup.call_count, 2)
def test_waiting_for_a_slot_counts_against_the_budget(self):
# Sixty milliseconds pass while a slot is busy. A still-running lookup
# gets only the remaining forty, regardless of the test host's load.
with patch.object(_images, '_resolvers') as slots, \
patch.object(_images.time, 'monotonic', side_effect=[100, 100.06]), \
patch.object(_images.threading, 'Thread') as worker:
slots.acquire.return_value = True
with self.assertRaises(SourceError):
_images._resolve('slow.example', 443, 0.1)
slots.acquire.assert_called_once_with(timeout=0.1)
self.assertAlmostEqual(worker.return_value.join.call_args.args[0], 0.04)
def test_many_at_once_wait_for_a_slot(self):
import threading, time
public = [(2, 1, 6, '', ('93.184.216.34', 443))]
def slow(*a, **k):
time.sleep(0.05)
return public
errors = []
def one(i):
try:
_images._resolve('host%d.example' % i, 443, 5)
except SourceError as e:
errors.append(e)
with patch.object(_images.socket, 'getaddrinfo', side_effect=slow):
threads = [threading.Thread(target=one, args=(i,)) for i in range(12)]
[t.start() for t in threads]
[t.join() for t in threads]
self.assertEqual(errors, [])
if __name__ == '__main__':
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Store categories that make sense on a Steam Frame, and which apps only make sense on a phone.
Sources describe apps in their own terms (F-Droid's categories, a VR flag). The store groups
them for a headset instead: VR first, then games and the flat apps worth having in a panel
(video, reading, making things). Apps built around a phone's hardware or system (calls and
SMS, launchers, widgets, keyboards, fitness, root tools) are marked phone_only; the store
hides them unless asked, since Lepton can run them but they do nothing useful on the Frame.
"""
import re
# (id, label), in the order the store shows them.
CATEGORIES = (
('vr', 'VR'),
('games', 'Games'),
('emulators', 'Emulators'),
('watch', 'Watch & listen'),
('read', 'Read & learn'),
('create', 'Create'),
('work', 'Work'),
('web', 'Web & chat'),
('tools', 'Tools'),
)
IDS = {c for c, _ in CATEGORIES}
# F-Droid's category names -> a store category; None = only useful on a phone.
# F-Droid has broad categories ('Multimedia') and, since 2025, finer ones ('Podcast'); an app
# usually has both. A finer one decides, since 'Social Network' + 'Multimedia' is a chat app.
BROAD = {
'Games': 'games', 'Multimedia': 'watch', 'Reading': 'read', 'Science & Education': 'read',
'Graphics': 'create', 'Writing': 'work', 'Money': 'work', 'Time': 'work', 'Internet': 'web',
'Development': 'tools', 'Security': 'tools',
'Office': 'work', 'Food': 'read', # IzzyOnDroid's own names
'Connectivity': None, 'Navigation': None, 'Phone & SMS': None, 'Sports & Health': None,
'System': None, 'Theming': None,
}
FINE = {
'emulators': ('Emulator',),
'games': ('Action Game', 'Board Game', 'Card Game', 'Casual Game', 'Dice', 'Educational Game', 'Party Game',
'Platformer Game', 'Puzzle Game', 'Role-Playing Game', 'Shooter Game', 'Sport Game',
'Strategy Game', 'Visual Novel', 'Word Game'),
'watch': ('Local Media Player', 'Online Media Player', 'Podcast', 'Radio', 'Cast', 'Lyrics', 'Gallery',
'Ambient Sound'),
'read': ('Ebook Reader', 'Audiobook', 'News', 'Translation & Dictionary', 'Religion', 'Recipe Manager'),
'create': ('Draw', 'Music Practice Tool', 'Recorder'),
'web': ('Browser', 'Email', 'Messaging', 'Social Network', 'Forum', 'Voice & Video Chat', 'AI Chat',
'Bookmark'),
'work': ('Note', 'Task', 'Text Editor', 'Calendar & Agenda', 'Calculator', 'Unit Convertor',
'Finance Manager', 'Time Tracker', 'Schedule', 'Inventory'),
'tools': ('Code & Forge', 'Password & 2FA', 'File Manager', 'File Encryption & Vault', 'Text Encryption',
'Remote Access', 'Remote Controller', 'Cloud Storage & File Sync', 'Network Analyzer',
'File Transfer', 'Text to Speech', 'Speech Recognizer', 'Download', 'Game Helper', 'Weather'),
}
FINE_TARGET = {name: target for target, names in FINE.items() for name in names}
# Only make sense as part of a phone's own system: any one of these makes an app phone-only.
PHONE_SYSTEM = {
'Launcher', 'Keyboard & IME', 'Wallpaper', 'Icon Pack', 'Flashlight', 'Battery', 'Notification', 'Volume',
'Theming', 'Emergency Action', 'Xposed', 'Automation',
}
# Phone things that are often a side of a useful app (Thunderbird is in Contact, FluffyChat in
# Phone & SMS, Nextcloud in Connectivity, a file manager in System): phone-only unless a useful
# category says otherwise. A finer one when the source has them; a broad one when it only has
# broad ones (IzzyOnDroid often does).
PHONE_SIDE = {
'Phone & SMS', 'Push', 'Contact', 'Camera', 'OCR', 'Location Tracker & Sharer', 'Navigation',
'Public Transport', 'Workout', 'Diet', 'Medication', 'Health Manager', 'Alarm Clock', 'Pass Wallet',
'Wallet', 'VPN & Proxy', 'DNS & Hosts', 'Firewall', 'App Manager', 'App Store & Updater', 'System',
'Connectivity',
}
PHONE_CATEGORIES = PHONE_SYSTEM | PHONE_SIDE
# Fine categories that aren't useful on a headset either, but don't make an app phone-only on their own.
PHONE_LEANING = {'Clock', 'Timer', 'Stopwatch', 'Habit Tracker', 'Shopping List', 'Market & Price',
'Meditation', 'Mental Health', 'Sports & Health'}
# When an app has several, the one that best describes it on a headset wins.
PRIORITY = ('emulators', 'games', 'watch', 'read', 'create', 'web', 'work', 'tools')
EMULATOR = re.compile(r'\bemulat(or|ion|es)\b|\b(retroarch|ppsspp|dolphin|dosbox|scummvm|mame|mupen64|melonds|citra|duckstation|flycast)\b', re.I)
# What an app is, from its name: these are phone parts, whatever the categories say.
PHONE_NAME = re.compile(r'\b(launcher|home ?screen|widgets?|keyboard|wallpapers?|ringtones?|dialer|sms|mms|'
r'caller id|lock ?screen|status ?bar|step counter|pedometer|5g|4g|lte|hotspot|tethering|'
r'magisk|xposed|battery)\b|\[root\]', re.I)
# "Doesn't need root" isn't a root tool; taken out before HARDWARE looks for "needs root".
NOT_ROOT = re.compile(r"\b(no|non|without|not|never|doesn.?t (need|require)|does not (need|require)|don.?t (need|require))[- ]+(the )?root(ed|ing)?\b"
r"|\broot(less|[- ]free| (is )?not (required|needed))\b", re.I)
# Needs hardware or a device the Frame doesn't have, from the name or summary.
HARDWARE = re.compile(r'\b(infra-?red|ir (blaster|remote|transmitter)|wear ?os|smart ?watch(es)?|nfc|sim card|'
r'(requires?|needs?|for) (a )?root(ed)?( (access|device|phone))?)\b', re.I)
def classify(entry):
"""{'category': one of IDS or None, 'phone_only': bool} for a store entry."""
if entry.get('vr') is True:
return {'category': 'vr', 'phone_only': False}
name, summary = str(entry.get('name') or ''), str(entry.get('summary') or '')
names = [c for c in entry.get('categories') or () if isinstance(c, str)]
fine = {FINE_TARGET[c] for c in names if c in FINE_TARGET}
broad = {BROAD[c] for c in names if BROAD.get(c)}
category = next((c for c in PRIORITY if c in fine), None) or next((c for c in PRIORITY if c in broad), None)
if category is None and EMULATOR.search(name + ' ' + summary):
category = 'emulators'
only_broad = all(c in BROAD for c in names) # this source has no finer categories for it
useful = fine or (broad if only_broad else set())
phone = any(c in PHONE_SYSTEM for c in names) or (not useful and any(c in PHONE_SIDE for c in names))
if names and category is None and all(c in PHONE_CATEGORIES or c in PHONE_LEANING or BROAD.get(c, 1) is None
for c in names):
phone = True
if category not in ('games', 'emulators', 'watch', 'read', 'create'):
phone = phone or bool(PHONE_NAME.search(name)) or bool(HARDWARE.search(NOT_ROOT.sub(' ', name + ' ' + summary)))
return {'category': category, 'phone_only': phone}
def matches(entry, category=None, phone=False):
"""Whether a classified entry belongs in a browse for this category ('' or None = any). A search
shows phone-only apps too (search passes phone=True), so a wrong guess never hides an app."""
if entry.get('phone_only') and not phone:
return False
return not category or entry.get('category') == category
+38 -11
View File
@@ -12,7 +12,9 @@ from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError, _credentials
_lock = threading.Lock()
_urls = OrderedDict()
_urls = OrderedDict() # token -> URL, oldest first
_tokens = {} # URL -> token
MAX_TOKENS = 16384
_cache = OrderedDict()
MAX_IMAGE = 8 * 1024 * 1024
MAX_CACHE = 64 * 1024 * 1024
@@ -31,24 +33,24 @@ def register(url):
if not isinstance(url, str) or not valid_url(url):
return None
with _lock:
for token, known in _urls.items():
if known == url:
_urls.move_to_end(token)
return '/source-image/' + token
token = _tokens.get(url)
if token in _urls:
_urls.move_to_end(token)
return '/source-image/' + token
token = secrets.token_urlsafe(24)
_urls[token] = url
while len(_urls) > 4096:
_urls.popitem(last=False)
_urls[token], _tokens[url] = url, token
while len(_urls) > MAX_TOKENS: # a store browse lists a few thousand images
_tokens.pop(_urls.popitem(last=False)[1], None)
return '/source-image/' + token
def artwork(entry):
def artwork(entry, shots=12):
images = entry.get('images') or {}
if not isinstance(images, dict):
images = {}
return {'icon': register(images.get('icon') or entry.get('icon')),
'banner': register(images.get('banner')),
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:12]) if path]}
'screenshots': [path for path in (register(u) for u in (images.get('screenshots') or [])[:min(shots, 12)]) if path]}
def image_type(data):
@@ -71,12 +73,37 @@ def fetch(url, redirects=3, deadline=None, limit=MAX_IMAGE):
# Lookups that outlast their deadline keep running; cap them so they can't pile up.
_resolvers = threading.BoundedSemaphore(4)
# A page of store cards asks for dozens of images from the same few hosts at once: their public
# addresses are kept for a few minutes, so most requests don't need a lookup (or a slot) at all.
_dns = {} # (host, port) -> (addresses, expires)
DNS_TTL = 300
def _resolve(host, port, timeout):
with _lock:
known = _dns.get((host, port))
if known and known[1] > time.monotonic():
return known[0]
found = _lookup(host, port, timeout)
try:
public = bool(found) and all(ipaddress.ip_address(a[4][0]).is_global for a in found)
except (TypeError, ValueError, IndexError):
public = False
if public: # private answers are refused by the caller, and never kept
with _lock:
_dns[(host, port)] = (found, time.monotonic() + DNS_TTL)
while len(_dns) > 256:
_dns.pop(next(iter(_dns)))
return found
def _lookup(host, port, timeout):
# getaddrinfo has no timeout of its own; a thread keeps a slow resolver inside the budget.
if not _resolvers.acquire(blocking=False):
# A request waits for a free slot within its own budget rather than failing at once.
started = time.monotonic()
if not _resolvers.acquire(timeout=max(0, min(timeout, 10))):
raise SourceError('Too many slow artwork name lookups are still running; try again shortly')
timeout -= time.monotonic() - started # the wait for a slot comes out of the same budget
found = {}
def run():
+29 -4
View File
@@ -26,7 +26,7 @@ from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
KIND = 'fdroid'
CACHE_VERSION = 2
CACHE_VERSION = 3 # 3: categories
TMP_PREFIX = '.download-' # in the cache folder, then the server's PID, so server.sweep_tmp can clear a stopped run's
_LOCK = threading.RLock() # settings only; never held while downloading
_load_locks = {}
@@ -474,6 +474,7 @@ def _reduce(path, source):
name=_text(meta.get('name')) or pkg, summary=_summary(meta.get('summary')),
icon=images['icon'], images=images, developer=_text(meta.get('authorName')) or None,
page=meta.get('webSite') or source['url'], vr=None, free=True,
categories=[c for c in meta.get('categories') or [] if isinstance(c, str)][:8],
license=meta.get('license'), downloadable=bool(latest.get('sha256')),
versions=versions, **{k: latest[k] for k in ('version', 'version_code', 'min_sdk', 'abis', 'size', 'updated')})
return result
@@ -492,6 +493,7 @@ def _v1(content, path):
localized = app.get('localized', {})
meta = {k: _text({locale: fields[k] for locale, fields in localized.items() if fields.get(k)}) or app.get(k)
for k in ('name', 'summary', 'license', 'webSite', 'authorName')}
meta['categories'] = app.get('categories') or []
for field in ('icon', 'featureGraphic', 'phoneScreenshots', 'sevenInchScreenshots'):
images = {}
for locale, fields in localized.items():
@@ -684,16 +686,39 @@ def set_enabled(source_id, enabled):
_write(_storage(), settings)
def search(source, query, limit=50):
def _showcase(a):
"""Browse order without a query: apps with real artwork first, then the most recently updated."""
images = a.get('images') or {}
return (not images.get('banner'), not images.get('screenshots'), not images.get('icon'),
''.join(chr(0x10ffff - ord(c)) for c in str(a.get('updated') or '')))
def search(source, query, limit=50, keep=None):
"""keep: optional filter applied before the limit (the store's category browse)."""
if not source.get('enabled', True):
return []
apps, _ = _load(source)
words = query.casefold().split()
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)]
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
found = [a for a in apps.values() if all(w in (a['id'] + ' ' + a['name'] + ' ' + a['summary']).casefold() for w in words)
and (keep is None or keep(a))]
if words:
found.sort(key=lambda a: (a['id'].casefold() != query.casefold(), a['name'].casefold()))
else:
found.sort(key=_showcase)
return [{k: v for k, v in a.items() if k != 'versions'} for a in found[:max(0, limit)]]
def lookup(source, packages):
"""Entries for these packages from the saved index only (never downloads); missing ones are left out."""
if not source.get('enabled', True):
return {}
found = _cached(source, frame_host.cache_dir('apk-sources', source['id'] + '.json'))
if not found:
return {}
apps = found[0]
return {p: {k: v for k, v in apps[p].items() if k != 'versions'} for p in packages if p in apps}
def details(source, entry_id):
if not source.get('enabled', True):
raise SourceError('repository is disabled')
+58 -14
View File
@@ -15,7 +15,7 @@ import time
import unicodedata
import frame_host
from apk_sources import SourceError, SourceLimited
from apk_sources import SourceError, SourceLimited, _categories as categories
_lock = threading.RLock()
_running = {}
@@ -23,6 +23,7 @@ _pending = {} # source id -> newest query waiting for the running one
_game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
def modules():
@@ -166,9 +167,11 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry):
def decorate(entry, shots=12):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry))
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
**categories.classify(entry))
def details(source_id, entry_id):
@@ -186,10 +189,12 @@ def offer_rank(entry):
entry.get('trust') == 'official')
def group(entries, query='', vr=None, installable=False):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
for entry in entries:
entry = decorate(entry)
entry = decorate(entry, shots=3)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
continue
if installable and entry['fit']['installable'] is not True:
@@ -203,7 +208,7 @@ def group(entries, query='', vr=None, installable=False):
offers.sort(key=offer_rank, reverse=True)
best = offers[0]
result.append({'name': best.get('name'), 'package': best.get('package'),
'summary': best.get('summary'), 'offers': offers})
'summary': best.get('summary'), 'category': best.get('category'), 'offers': offers})
q = normalise(query)
def browse(a): # a headset store: VR first, then apps with artwork, newest first
@@ -223,10 +228,10 @@ def group(entries, query='', vr=None, installable=False):
return result
def _launch(module, source, query, limit):
def _launch(module, source, query, limit, category=None, phone=False):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
task = {'event': threading.Event(), 'query': (query, limit), 'started': time.monotonic(),
task = {'event': threading.Event(), 'query': (query, limit, category or None, bool(phone)), 'started': time.monotonic(),
'job': (module, source)}
with _lock:
old = _running.get(key)
@@ -245,13 +250,17 @@ def _launch(module, source, query, limit):
def _start(key, task):
module, source = task['job']
query, limit = task['query']
query, limit, category, phone = task['query']
kwargs = {}
if (category or not phone) and 'keep' in inspect.signature(module.search).parameters:
# Large sources filter before their limit, so a category browse isn't cut to the first few hundred.
kwargs['keep'] = lambda e: categories.matches(dict(e, **categories.classify(e)), category, phone)
def run():
queued = None
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
for e in module.search(source, query, limit=limit, **kwargs) if e.get('free') is True]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -267,14 +276,20 @@ def _start(key, task):
threading.Thread(target=run, daemon=True).start()
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50):
def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, limit=50, category=None, phone=False):
items, errors = registry()
if source and source not in [s['id'] for _, s in items]:
raise SourceError('Unknown source')
if category and category not in categories.IDS:
raise SourceError('Unknown category')
if not query.strip():
limit = max(limit, BROWSE_LIMIT)
else:
phone = True # the phone-only guess hides apps from browsing, never from a search for them
chosen = [(m, s) for m, s in items if s['enabled'] and (not source or s['id'] == source)]
# Page-only sources (SideQuest) can't be searched; offer a link to browse them instead.
elsewhere = [{'name': s['name'], 'url': s['url']} for m, s in chosen if s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit)) for m, s in chosen if not s.get('page_only')]
tasks = [(s, _launch(m, s, query, limit, category, phone)) for m, s in chosen if not s.get('page_only')]
entries, statuses = [], list(errors)
for s, task in tasks:
status = {'id': s['id'], 'name': s['name']}
@@ -289,7 +304,8 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
statuses.append(status)
with _lock:
_status[s['id']] = {k: v for k, v in status.items() if k not in ('id', 'name')}
return {'apps': group(entries, query, vr, installable), 'sources': statuses, 'elsewhere': elsewhere}
return {'apps': group(entries, query, vr, installable, category, phone), 'sources': statuses, 'elsewhere': elsewhere,
'categories': [{'id': c, 'label': label} for c, label in categories.CATEGORIES]}
def warm():
@@ -299,7 +315,35 @@ def warm():
items, _ = registry()
for m, s in items:
if s['enabled'] and not s.get('page_only'):
_launch(m, s, '', 50) # same as the first browse, so that search reuses it
_launch(m, s, '', BROWSE_LIMIT) # same as the first browse, so that search reuses it
def artwork_for(apps):
"""Store artwork for installed apps ({'package', 'label'} each), from saved indexes only:
by package where a source has one, else by name (curated GitHub apps). {package: artwork}."""
from apk_sources import _images
items, _ = registry()
wanted = {a['package']: normalise(a.get('label')) for a in apps if a.get('package')}
found = {}
for module, source in items:
if not source['enabled'] or source.get('page_only'):
continue
try:
if hasattr(module, 'lookup'):
hits = module.lookup(source, [p for p in wanted if p not in found])
elif module.KIND == 'github': # a small local list; its entries have names, not packages
names = {n: p for p, n in wanted.items() if n and p not in found}
hits = {names[normalise(e['name'])]: e for e in module.search(source, '', limit=100)
if normalise(e.get('name')) in names}
else:
continue
except (SourceError, OSError, ValueError, KeyError, TypeError):
continue
for package, entry in hits.items():
art = _images.artwork(entry)
if art['icon'] or art['banner'] or art['screenshots']:
found.setdefault(package, art)
return found
def install(source_id, entry_id, version_code=None, progress=None):
+4 -2
View File
@@ -740,7 +740,9 @@ class Registry:
return a
raise DeviceError(f"{host} isn't one of this headset's addresses")
def add_address(self, device_id, host, kind=None, label=""):
def add_address(self, device_id, host, kind=None, label="", first=False):
"""Add an address at the end of the list, or at the front (first=True), where the
user's order makes it win over the others that work on the same network."""
with self._changing():
d = self._find(device_id)
a = new_address(host, kind, label)
@@ -748,7 +750,7 @@ class Registry:
raise DeviceError(f"{a['host']} is already on the list")
if len(d["addresses"]) >= 32:
raise DeviceError("That's enough addresses for one headset")
d["addresses"].append(a)
d["addresses"].insert(0 if first else len(d["addresses"]), a)
self.save()
return copy.deepcopy(a)
+7 -1
View File
@@ -356,6 +356,7 @@ class Link:
self.version = 0
self.stopped = False
self.kicks = [] # reasons someone asked for a (re)connect
self.test_gen = {} # device id -> its newest test of the addresses (see test())
self.busy = False # the loop is handling kicks
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
@@ -1306,9 +1307,13 @@ class Link:
started = now()
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
with self.cond:
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
def put(**fields):
with self.cond:
if gen != self.test_gen[device_id]:
return # a newer test has started: its results are the ones to show
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
self.version += 1
self.cond.notify_all()
@@ -1617,7 +1622,8 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
elif action == "address-add":
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
first=body.get("first") is True)
if is_active and link.state["phase"] == "failed":
link.kick("retry")
msg = f"Added {a['host']}"
+532
View File
@@ -0,0 +1,532 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
python3 - install APPID # download the pinned official release into the game's prefix
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
or ignored input in about one session in four, and gamescope keeps real input
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
the game's Wine session, doing what the injector does on Inject.
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
from python.org, each checked against a pinned hash. Files live in
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
records what to remove.
"""
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
HOME = Path.home()
STEAM = HOME / ".local/share/Steam"
CACHE = HOME / ".cache/frame-control/mods"
RECEIPTS = HOME / ".local/share/frame-control/mods"
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
UEVR = {"version": "1.05", "dir": "uevr-1.05",
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
# release-file API (sha256_sum).
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
# The game's environment a second program needs to join its Wine session. Not
# WINESERVERSOCKET and friends: those are inherited file descriptors.
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
# Runs under Windows Python in the game's Wine session: the steps of UEVR
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
INJECT_PY = r"""
import ctypes, json, os, sys
from ctypes import wintypes as W
exe, uevr, profile = sys.argv[1:4]
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
for name, res, args in (
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
("CloseHandle", W.BOOL, [H]),
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
("FreeLibrary", W.BOOL, [W.HMODULE]),
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
fn = getattr(k32, name)
fn.restype, fn.argtypes = res, args
INVALID = ctypes.c_void_p(-1).value
class Entry(ctypes.Structure): # PROCESSENTRY32W
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
("flags", W.DWORD), ("name", W.WCHAR * 260)]
class Module(ctypes.Structure): # MODULEENTRY32W
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
def fail(msg):
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
def snapshot(flags, pid, entry, first, next_, match):
snap = k32.CreateToolhelp32Snapshot(flags, pid)
if not snap or snap == INVALID:
fail("couldn't list processes")
try:
entry.size = ctypes.sizeof(entry)
ok = first(snap, ctypes.byref(entry))
while ok:
if match(entry):
return entry
ok = next_(snap, ctypes.byref(entry))
finally:
k32.CloseHandle(snap)
def remote_call(proc, fn, arg, wait_ms):
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
if not t:
fail("CreateRemoteThread failed")
try:
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
finally:
k32.CloseHandle(t)
def inject(proc, pid, name):
path = os.path.join(uevr, name)
data = ctypes.create_unicode_buffer(path)
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
if not mem:
fail(f"couldn't allocate in {exe}")
try:
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
fail(f"couldn't write into {exe}")
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
if not remote_call(proc, load, mem, 30000):
fail(f"loading {name} into {exe} didn't finish in 30 s")
finally:
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
lambda m: m.path.lower() == path.lower())
if not m:
fail(f"{name} didn't load into {exe}")
return path, m.base
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
lambda e: e.name.lower() == exe.lower())
if not e:
fail(f"{exe} isn't running")
pid = e.pid
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
if not proc:
fail(f"couldn't open {exe}")
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
local = k32.LoadLibraryW(path)
fn = local and k32.GetProcAddress(local, b"nullify")
if not fn:
fail("couldn't find UEVRPluginNullifier.dll's nullify")
offset = fn - local # same DLL, same layout in both processes
k32.FreeLibrary(local)
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
nullified = remote_call(proc, base + offset, None, 2000)
inject(proc, pid, "openvr_api.dll")
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
os.makedirs(profile, exist_ok=True)
cfg = os.path.join(profile, "config.txt")
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
open(cfg, "w").write("\n".join(lines) + "\n")
inject(proc, pid, "UEVRBackend.dll")
k32.CloseHandle(proc)
print(json.dumps({"pid": pid, "nullified": nullified}))
"""
class Fail(Exception):
pass
def libraries():
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
libs = [STEAM]
try:
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
except OSError:
pass
seen, out = set(), []
for lib in libs:
key = os.path.realpath(lib)
if key not in seen:
seen.add(key)
out.append(lib)
return out
def game(appid):
"""Where the game is installed and its Proton prefix, from its app manifest."""
for lib in libraries():
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
try:
text = manifest.read_text(errors="replace")
except OSError:
continue
m = re.search(r'"installdir"\s+"([^"]+)"', text)
if not m:
continue
name = re.search(r'"name"\s+"([^"]*)"', text)
return {"name": name.group(1) if name else str(appid),
"dir": lib / "steamapps/common" / m.group(1),
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
raise Fail(f"app {appid} isn't installed on this Frame")
def shipping_exe(game_dir):
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
return max(found, key=lambda p: p.stat().st_size) if found else None
def game_pid(exe_name):
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
want = "\\" + exe_name.lower()
for d in Path("/proc").iterdir():
if not d.name.isdigit():
continue
try:
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
except OSError:
continue
if cmd.endswith(want):
return int(d.name)
return None
def loaded(pid, name):
try:
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
except OSError:
return False
def receipt_path(appid):
return RECEIPTS / f"{appid}-uevr.json"
def read_receipt(appid):
try:
return json.loads(receipt_path(appid).read_text())
except (OSError, ValueError):
return None
def managed_dir(g):
return g["prefix"] / "drive_c" / MANAGED
def user_dirs(g, exe):
"""What UEVR itself writes into the prefix when it first runs."""
user = g["prefix"] / "drive_c/users/steamuser/AppData"
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
"injector": user / "Local/praydog"} # the injector's own settings
def status(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
r = read_receipt(appid)
pid = game_pid(exe.name) if exe else None
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
"eligible": bool(exe), "exe": exe.name if exe else None,
"prefix": g["prefix"].is_dir(), "installed": bool(r),
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
def fetch(url, digest, algo):
"""Download into the cache once, and check its hash every time it's used."""
CACHE.mkdir(parents=True, exist_ok=True)
path = CACHE / url.rsplit("/", 1)[1]
if not path.exists():
part = path.with_suffix(path.suffix + ".part")
try:
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
for block in iter(lambda: r.read(1 << 20), b""):
if f.tell() + len(block) > MAX_DOWNLOAD:
raise Fail(f"{url} is bigger than expected; stopped the download")
f.write(block)
except (OSError, Fail) as e:
part.unlink(missing_ok=True)
raise Fail(f"download failed: {url}: {e}")
part.rename(path)
h = hashlib.new(algo)
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
if h.hexdigest() != digest:
path.unlink()
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
return path
def unpack(archive, dest, budget):
"""Extract a ZIP into dest, refusing anything that would land outside it."""
root = os.path.realpath(dest)
with zipfile.ZipFile(archive) as z:
for info in z.infolist():
target = os.path.realpath(os.path.join(dest, info.filename))
if target != root and not target.startswith(root + os.sep):
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
budget -= info.file_size
if budget < 0:
raise Fail(f"{archive.name} unpacks to more than expected")
z.extractall(dest)
return budget
def install(appid):
g = game(appid)
exe = shipping_exe(g["dir"])
if not exe:
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
if not g["prefix"].is_dir():
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
if game_pid(exe.name):
raise Fail(f"{g['name']} is running; quit it first")
if read_receipt(appid):
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
base = managed_dir(g)
dirs = user_dirs(g, exe)
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
stage = base / f".staging-{os.getpid()}"
shutil.rmtree(stage, ignore_errors=True)
try:
budget = MAX_UNPACKED
for archive, sub in archives:
budget = unpack(archive, stage / sub, budget)
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
if not need.is_file():
raise Fail(f"the download has no {need.name}")
if game_pid(exe.name): # it may have started during the download
raise Fail(f"{g['name']} started; quit it first")
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
for sub in (UEVR["dir"], PYTHON["dir"]):
shutil.rmtree(base / sub, ignore_errors=True)
(stage / sub).rename(base / sub)
finally:
shutil.rmtree(stage, ignore_errors=True)
if str(base) in fresh:
try:
base.rmdir() # only succeeds if the install failed and left it empty
except OSError:
pass
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
"installed": int(time.time()), "prefix": str(g["prefix"]),
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
"remove_if_empty": fresh,
# UEVR creates these on first injection. Remove them on uninstall
# only if they weren't there before, so earlier settings survive.
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
"sources": [UEVR["url"], PYTHON["url"]]}
RECEIPTS.mkdir(parents=True, exist_ok=True)
tmp = receipt_path(appid).with_suffix(".tmp")
tmp.write_text(json.dumps(receipt, indent=1))
tmp.rename(receipt_path(appid))
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
def x11_windows(display):
"""(id, name, width) for each named top-level window, via xwininfo."""
try:
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
capture_output=True, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return []
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
def frame_submits(appid):
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
try:
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
except (OSError, ValueError, subprocess.TimeoutExpired):
return None
if not isinstance(stats, list):
return None
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
def wait(what, seconds, check, step=2):
end = time.time() + seconds
while time.time() < end:
v = check()
if v is not None and v is not False: # 0 frames is still an answer
return v
time.sleep(step)
raise Fail(f"timed out waiting for {what}")
def start(appid):
g = game(appid)
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for {g['name']}")
exe = r["exe"]
pid = game_pid(exe)
if pid and loaded(pid, "UEVRBackend.dll"):
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
if not pid:
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
try:
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
except OSError:
raise Fail(f"{exe} exited") from None
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
display = game_env.get("DISPLAY")
if not display or "WINEPREFIX" not in game_env:
raise Fail(f"{exe} doesn't look like a Proton game process")
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
# own helper windows ("Steam", "Default IME", …) are small.
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
time.sleep(10)
# Our own environment (HOME and the rest) under the game's Wine settings.
env = {**os.environ, **game_env}
base = f"C:\\{MANAGED}"
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
try:
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
timeout=120, stdin=subprocess.DEVNULL,
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
except subprocess.TimeoutExpired:
raise Fail("the injection step didn't finish in 2 minutes") from None
reply = None
for line in reversed(out.stdout.splitlines()):
try:
reply = json.loads(line)
break
except ValueError:
continue
if not isinstance(reply, dict):
reply = None
if not reply or "error" in reply:
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
try:
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
except Fail:
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
submits = None
try:
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
except Fail:
pass
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
else "; SteamVR hasn't reported frames from it yet"),
"frame_submits": submits, **status(appid)}
def uninstall(appid):
r = read_receipt(appid)
if not r:
raise Fail(f"UEVR isn't installed for app {appid}")
if game_pid(r["exe"]):
raise Fail("the game is running; quit it first")
prefix = os.path.realpath(r["prefix"])
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
# Only ever our folder and UEVR's own settings folders for this game, even
# if the receipt says otherwise.
managed = os.path.join(prefix, "drive_c", MANAGED)
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
for t in targets:
real = os.path.realpath(t)
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
for t in targets:
shutil.rmtree(t, ignore_errors=True)
left = [t for t in targets if os.path.lexists(t)]
if left: # keep the receipt, so Remove can be tried again
raise Fail("couldn't remove " + ", ".join(left))
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
for t in r.get("remove_if_empty", []):
if os.path.realpath(t) in parents:
try:
os.rmdir(t)
except OSError:
pass # something else lives there now
receipt_path(appid).unlink()
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
shutil.rmtree(CACHE, ignore_errors=True)
try:
RECEIPTS.rmdir()
except OSError:
pass
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
def main(argv):
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
raise Fail("usage: status|install|start|uninstall APPID")
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
if action is status:
return status(int(argv[1]))
import fcntl # only on the Frame; the server and its tests also import this module on Windows
# One change at a time, or an uninstall could delete what an install is moving in.
LOCK.parent.mkdir(parents=True, exist_ok=True)
with open(LOCK, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
return action(int(argv[1]))
if __name__ == "__main__":
try:
print(json.dumps(main(sys.argv[1:])))
except Fail as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # still one JSON object, for the server to show
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
+817 -265
View File
File diff suppressed because it is too large. Load diff
+34 -8
View File
@@ -709,13 +709,14 @@ def launch(body):
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40):
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
def steam_frame(*args, timeout=40, script="frame_steam.py"):
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
stdin=(HERE / script).read_text(), timeout=timeout)
except Failure as e:
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
# The helper prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
try:
@@ -766,6 +767,20 @@ def vr(body):
return result
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
def mods(body):
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
appid, action = str(body.get("appid", "")), body.get("action")
if not APPID.match(appid):
raise Failure("bad appid", 400)
if action not in MOD_TIMEOUT:
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
def steam_search(query):
q = parse_qs(query)
cc = (q.get("cc") or [""])[0].upper()
@@ -2414,11 +2429,14 @@ def source_search(query):
q = args.get('q', [''])[0]
vr = args.get('vr', [''])[0]
installable = args.get('installable', [''])[0]
if len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0'):
category, phone = args.get('category', [''])[0], args.get('phone', [''])[0]
if (len(q) > 500 or vr not in ('', 'true', 'false', '1', '0') or installable not in ('', 'true', 'false', '1', '0')
or phone not in ('', 'true', 'false', '1', '0') or len(category) > 40):
raise Failure('Invalid search filters', 400)
try:
return apk_search.search(q, vr=None if not vr else vr in ('true', '1'),
source=args.get('source', [None])[0], installable=installable in ('true', '1'))
source=args.get('source', [None])[0], installable=installable in ('true', '1'),
category=category or None, phone=phone in ('true', '1'))
except SourceError as e:
raise Failure(str(e), 400)
@@ -2489,7 +2507,7 @@ POST = {
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
@@ -2662,6 +2680,9 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
@@ -2711,7 +2732,12 @@ class Handler(BaseHTTPRequestHandler):
ensure_master()
apps = frame_android.list_apps()
backfill_art(apps=apps)
self.send_json({"apps": apps})
try: # store artwork for the library's cards, from saved indexes only
art = apk_search.artwork_for(apps)
except Exception:
art = {}
self.send_json({"apps": [dict(a, store_art=art[a['package']]) if a.get('package') in art else a
for a in apps]})
elif path == "/api/quest":
self.send_json(quest_status(url.query))
elif path == "/api/meta":
+489
View File
@@ -0,0 +1,489 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>APK Store</title>
<!-- Frame Control's Android store as a panel in the headset (docs/apks.md →
"The store in the headset"). Served by ui/server.py running on the Frame
(FRAME_LOCAL=1), opened by scripts/store-on-frame.sh at 1280×800. The same
sources, categories and artwork as the Store in Frame Control (/api/search,
ui/apk_sources/_categories.py), drawn for the laser: big targets, pages
instead of drag-scrolling (the panel gets mouse clicks, so a drag would
select text), the thumbstick (a mouse wheel) turns pages, and an on-screen
keyboard for search. -->
<style>
:root {
--bg: #0f1722; --rail: #131d2a; --card: #1b2938; --card-hi: #23364a; --line: #ffffff14;
--text: #e3e8ee; --bright: #fff; --dim: #93a4ba; --blue: #1a9fff; --link: #66c0f4;
--good: #8ed36b; --bad: #ef7a64;
--action: linear-gradient(to right, #47bfff 5%, #1a44c2 95%);
--play: linear-gradient(to right, #75b022 5%, #588a1b 95%);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; background: radial-gradient(ellipse at 70% -20%, #1d3a5a 0%, transparent 60%), var(--bg);
color: var(--text); font: 19px/1.35 "Motiva Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
overflow: hidden; user-select: none; -webkit-user-select: none; }
button { font: inherit; color: inherit; background: #ffffff0d; border: 1px solid var(--line); border-radius: 12px;
min-height: 52px; padding: 0 20px; cursor: pointer; }
button:hover { background: #ffffff1c; }
button:disabled { opacity: .45; cursor: default; }
button.primary { background: var(--action); border: 0; font-weight: 600; color: #fff; }
button.play { background: var(--play); border: 0; font-weight: 600; color: #fff; }
button.danger { color: var(--bad); }
svg { width: 22px; height: 22px; flex: none; }
.app { display: grid; grid-template-columns: 236px 1fr; height: 100%; }
/* Rail */
.rail { background: #0c131ccc; border-right: 1px solid var(--line); padding: 18px 12px; display: flex; flex-direction: column; gap: 3px; }
.rail h1 { font-size: 22px; margin: 2px 10px 12px; display: flex; align-items: center; gap: 10px; color: var(--bright); }
.rail h1 svg { width: 28px; height: 28px; color: var(--blue); }
.rail button { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 47px; text-align: left; border: 0;
background: transparent; color: #b5c0cb; font-size: 17px; font-weight: 500; padding: 0 12px; border-radius: 10px; }
.rail button:hover { background: #ffffff10; color: var(--bright); }
.rail button[aria-pressed="true"] { background: #1a9fff2e; color: var(--bright); box-shadow: inset 4px 0 0 var(--blue); }
.rail .sep { height: 1px; background: var(--line); margin: 6px 8px; }
.rail .n { margin-left: auto; font-size: 13px; color: #c6e2f5; background: #1a9fff2e; border-radius: 10px; padding: 1px 8px; }
.rail .n:empty { display: none; }
.rail label { margin-top: auto; display: flex; align-items: center; gap: 10px; padding: 10px 12px; font-size: 15px; color: var(--dim); cursor: pointer; }
.rail input { width: 22px; height: 22px; accent-color: var(--blue); margin: 0; }
/* Main */
main { display: flex; flex-direction: column; min-width: 0; padding: 18px 22px; gap: 12px; }
.bar { display: flex; gap: 12px; align-items: center; }
.search { flex: 1; display: flex; align-items: center; gap: 12px; background: #0c1520; border: 1px solid var(--line);
border-radius: 12px; min-height: 54px; padding: 0 18px; color: var(--dim); }
.search input { flex: 1; background: none; border: 0; color: var(--text); font: inherit; outline: none; min-width: 0; }
.search input::placeholder { color: var(--dim); }
.heading { display: flex; align-items: baseline; gap: 14px; min-height: 30px; }
.heading b { color: var(--bright); font-size: 25px; letter-spacing: -.4px; }
.heading span { color: var(--dim); font-size: 16px; }
.heading .note { margin-left: auto; font-size: 14px; color: var(--dim); max-width: 520px; text-align: right; }
.content { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); gap: 14px; }
.grid.one-row { grid-template-rows: minmax(0, 1fr); }
.card { display: flex; flex-direction: column; padding: 0; overflow: hidden; text-align: left; min-height: 0; border-radius: 14px;
background: var(--card); border: 1px solid var(--line); }
.card:hover { background: var(--card-hi); border-color: #66c0f480; }
.card .art { flex: 0 0 52%; }
.card .body { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 12px; min-height: 0; flex: 1; width: 100%; }
.card .title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.card .title strong { font-size: 17px; color: var(--bright); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.card .sum { color: var(--dim); font-size: 14px; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card .foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); }
/* Artwork: a banner, else screenshots side by side, else the icon over its own blur, else an initial. */
.art { position: relative; display: block; overflow: hidden; width: 100%;
background: radial-gradient(ellipse at 75% 10%, hsl(var(--hue) 50% 40%), transparent 70%), linear-gradient(140deg, hsl(var(--hue) 35% 18%), #101827); }
.art > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.art .initial { position: absolute; inset: 0; display: grid; place-items: center; font-size: 64px; font-weight: 700; color: #ffffff45; }
.art.shots { display: flex; gap: 6px; justify-content: center; padding: 8px 8px 0; }
.art.shots > img { position: relative; inset: auto; width: auto; max-width: 31%; height: 100%; object-position: top; border-radius: 6px 6px 0 0; box-shadow: 0 4px 14px #0008; }
.art.iconart > .backdrop { position: absolute; inset: -30%; width: 160%; height: 160%; object-fit: cover; filter: blur(28px) saturate(1.4) brightness(.55); }
.art.iconart > .mark { position: absolute; left: 50%; top: 50%; width: 36%; height: auto; aspect-ratio: 1; transform: translate(-50%, -50%); border-radius: 22%; box-shadow: 0 8px 24px #0009; object-fit: cover; }
.art.shots .initial, .art.iconart .initial { display: none; }
.art .vr { position: absolute; z-index: 2; top: 8px; left: 8px; }
.icon { position: relative; display: inline-grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; overflow: hidden; flex: none;
background: hsl(var(--hue) 35% 28%); color: #fff; font-size: 18px; font-weight: 600; }
.icon img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.vr { font-size: 12px; font-weight: 700; letter-spacing: .8px; color: #0e141b; background: #c2e7ff; border-radius: 5px; padding: 2px 7px; }
.fit { font-size: 12px; font-weight: 600; border-radius: 5px; padding: 2px 8px; white-space: nowrap; }
.fit.works { color: #c3efb2; background: #59bf4029; } .fit.ready { color: #bfe3fa; background: #1a9fff26; } .fit.blocked { color: #f3b2a6; background: #d9412629; }
.tag { font-size: 12px; font-weight: 600; color: #d6f5c8; background: #2d5a1f; border-radius: 10px; padding: 2px 9px; white-space: nowrap; }
/* Home */
.hero { position: relative; flex: 0 0 42%; padding: 0; border: 0; border-radius: 16px; overflow: hidden; text-align: left; isolation: isolate; }
.hero .art { position: absolute; inset: 0; height: 100%; }
.hero::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(90deg, #081421f0 0%, #08142180 50%, #08142100 85%); }
.hero .copy { position: relative; z-index: 2; padding: 26px 30px; max-width: 560px; display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.hero .eyebrow { display: flex; gap: 10px; align-items: center; font-size: 13px; font-weight: 700; letter-spacing: 1.6px; text-transform: uppercase; color: #c2e7ff; }
.hero strong { font-size: 38px; color: #fff; line-height: 1.05; letter-spacing: -.8px; }
.hero .blurb { color: #e0e8ef; font-size: 17px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.hero .cta { margin-top: 4px; background: #ffffff22; border: 1px solid #ffffff44; border-radius: 10px; padding: 9px 18px; font-size: 16px; color: #fff; }
/* Pager */
.pager { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.pager button { min-width: 180px; }
.dots { display: flex; gap: 8px; align-items: center; color: var(--dim); font-size: 15px; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; display: grid; place-items: center; align-content: center; gap: 14px; color: var(--dim); text-align: center; padding: 30px; }
.empty h2 { margin: 0; color: var(--bright); font-size: 24px; }
.skeleton { border-radius: 14px; background: linear-gradient(100deg, #1b2938 30%, #24364a 50%, #1b2938 70%); background-size: 300% 100%; animation: shimmer 1.4s infinite; }
@keyframes shimmer { to { background-position: -150% 0; } }
/* Detail sheet and keyboard sit over everything. */
.overlay { position: fixed; inset: 0; background: #000b; display: grid; place-items: center; z-index: 10; }
.sheet { background: #16212e; border: 1px solid var(--line); border-radius: 22px; width: 1000px; max-height: 740px; overflow: hidden;
display: flex; flex-direction: column; box-shadow: 0 30px 80px #000c; }
.sheet > .art { flex: 0 0 250px; }
.sheet .gallery { flex: 0 0 250px; display: flex; gap: 10px; padding: 14px 14px 0; background: #0c131c; justify-content: center; overflow: hidden; }
.sheet .gallery img { height: 100%; width: auto; max-width: 32%; object-fit: cover; object-position: top; border-radius: 10px 10px 0 0; }
.sheet .inner { padding: 20px 28px 22px; display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; }
.sheet .top { display: flex; gap: 18px; align-items: center; }
.sheet .top .icon { width: 72px; height: 72px; border-radius: 16px; font-size: 32px; }
.sheet h2 { margin: 0; font-size: 30px; color: var(--bright); line-height: 1.1; }
.sheet .meta { color: var(--dim); font-size: 16px; }
.sheet .text { font-size: 17px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.sheet .why { background: #ffffff0a; border-radius: 12px; padding: 10px 16px; font-size: 15px; color: #c5d0db; }
.sheet .why ul { margin: 4px 0 0; padding-left: 22px; }
.sheet .acts { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 8px; align-items: center; }
.sheet .acts .close { margin-left: auto; }
.progress { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 6px; font-size: 15px; color: var(--dim); }
.progress .track { height: 8px; border-radius: 4px; background: #ffffff14; overflow: hidden; }
.progress .fill { height: 100%; background: var(--blue); width: 30%; transition: width .3s; }
.progress.busy .fill { animation: busy 1.2s ease-in-out infinite; }
@keyframes busy { from { margin-left: -30%; } to { margin-left: 100%; } }
.kbd { position: fixed; left: 0; right: 0; bottom: 0; background: #131d2af5; border-top: 1px solid var(--line);
padding: 12px; display: grid; gap: 8px; z-index: 20; box-shadow: 0 -20px 50px #0009; }
.kbd .row { display: flex; gap: 8px; justify-content: center; }
.kbd button { min-width: 76px; min-height: 60px; padding: 0 10px; font-size: 21px; }
.kbd .wide { min-width: 320px; }
.toast { position: fixed; left: 50%; top: 18px; transform: translateX(-50%); background: #0d2236; border: 1px solid var(--blue);
border-radius: 14px; padding: 12px 20px; z-index: 30; max-width: 900px; box-shadow: 0 10px 30px #0008; }
.toast.bad { background: #3a1416; border-color: var(--bad); }
</style>
</head>
<body>
<div class="app">
<div class="rail" role="navigation" aria-label="Store">
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 7h16l-1.5 12.5a1 1 0 0 1-1 .9H6.5a1 1 0 0 1-1-.9z"/><path d="M8.5 9V6a3.5 3.5 0 0 1 7 0v3"/></svg>APK Store</h1>
<div id="cats"></div>
<div class="sep"></div>
<button data-view="installed" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><rect x="3.5" y="4" width="7" height="7" rx="1.5"/><rect x="13.5" y="4" width="7" height="7" rx="1.5"/><rect x="3.5" y="14" width="7" height="7" rx="1.5"/><rect x="13.5" y="14" width="7" height="7" rx="1.5"/></svg>Installed<span class="n" id="instN"></span></button>
<label title="Apps built around a phone: calls and SMS, launchers, keyboards, widgets, fitness, system tools. Searching always finds them."><input type="checkbox" id="phone"> Show phone-only apps</label>
</div>
<main>
<div class="bar">
<label class="search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="q" placeholder="Search games and apps" autocomplete="off" spellcheck="false" aria-label="Search games and apps"></label>
<button id="kbdBtn" title="On-screen keyboard">⌨︎ Keyboard</button>
</div>
<div class="heading"><b id="title">Home</b><span id="count"></span><span class="note" id="note"></span></div>
<div class="content" id="content"></div>
<div class="pager">
<button id="prev">◀ Previous</button>
<span class="dots" id="page"></span>
<button id="next">Next ▶</button>
</div>
</main>
</div>
<div class="overlay" id="detail" hidden><div class="sheet" id="sheet"></div></div>
<div class="kbd" id="kbd" hidden></div>
<div class="toast" id="toast" hidden></div>
<script>
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
const $ = id => document.getElementById(id);
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const PER_PAGE = 8;
// Same ids and order as STORE_CATS in index.html and ui/apk_sources/_categories.py.
const CATS = [
["", "Home", '<path d="M4 11.5 12 5l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5h-5v5H5a1 1 0 0 1-1-1z"/>'],
["vr", "VR", '<path d="M3.5 8.5A1.5 1.5 0 0 1 5 7h14a1.5 1.5 0 0 1 1.5 1.5v6A1.5 1.5 0 0 1 19 16h-3.5l-2-2.5h-3l-2 2.5H5a1.5 1.5 0 0 1-1.5-1.5z"/><circle cx="8" cy="11.5" r="1.5"/><circle cx="16" cy="11.5" r="1.5"/>'],
["games", "Games", '<path d="M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.4.9L14.5 15h-5l-2.4 2.4a2 2 0 0 1-3.4-.9l-.6-2.6A4 4 0 0 1 7 9z"/><path d="M8 11.5v3M6.5 13h3"/>'],
["emulators", "Emulators", '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M9 20h6M12 16v4M8 10.5h3M9.5 9v3"/>'],
["watch", "Watch & listen", '<rect x="3.5" y="5.5" width="17" height="11" rx="1.5"/><path d="m10.5 8.5 4 2.5-4 2.5z"/><path d="M8 20h8"/>'],
["read", "Read & learn", '<path d="M12 7c-2-1.5-5-2-8-1.5v12c3-.5 6 0 8 1.5 2-1.5 5-2 8-1.5v-12c-3-.5-6 0-8 1.5zM12 7v12"/>'],
["create", "Create", '<path d="m14.5 5.5 4 4L9 19H5v-4z"/><path d="m12.5 7.5 4 4"/>'],
["work", "Work", '<rect x="4" y="7" width="16" height="12" rx="1.5"/><path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7M4 12h16"/>'],
["web", "Web & chat", '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.5 2.5 2.5 13.5 0 16M12 4c-2.5 2.5-2.5 13.5 0 16"/>'],
["tools", "Tools", '<path d="M14.5 6.5a4 4 0 0 0-5.3 5.3L4.5 16.5l3 3 4.7-4.7a4 4 0 0 0 5.3-5.3l-2.4 2.4-2.6-.4-.4-2.6z"/>'],
];
const catLabel = id => (CATS.find(c => c[0] === id) || [])[1] || "";
const FIT = { works: "Works on the Frame", ready: "Fits the Frame", blocked: "Won't run" };
// view: "" (home), a category id, or "installed"; jobs: offer key -> {stage, percent, done, result, error}
// installedFrom: offer key -> package it installed as (GitHub listings have no package);
// needsData: installed packages still waiting for their game data (any view, while installed)
const st = { view: "", q: "", phone: false, page: 0, apps: [], installed: new Map(), jobs: new Map(), installedFrom: new Map(),
needsData: new Set(), request: 0, loading: false, retry: 0 };
async function api(path, body) {
const opts = body === undefined ? { headers: { "X-Frame-UI": UI_KEY } } : {
method: "POST", headers: { "Content-Type": "application/json", "X-Frame-UI": UI_KEY }, body: JSON.stringify(body) };
const r = await fetch(path, opts);
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
return j;
}
let toastTimer;
function toast(msg, bad = false) {
const t = $("toast");
t.textContent = msg; t.className = "toast" + (bad ? " bad" : ""); t.hidden = false;
clearTimeout(toastTimer); toastTimer = setTimeout(() => t.hidden = true, bad ? 8000 : 4500);
}
// ---- Artwork (only the server's /source-image/ handles are ever used as an image) ----
const hue = name => [...String(name)].reduce((n, c) => (n * 31 + c.charCodeAt(0)) >>> 0, 7) % 360;
const initial = name => [...String(name || "?")][0].toUpperCase();
const valid = u => /^\/source-image\/[\w-]+$/.test(u || "");
const img = (u, cls = "") => valid(u) ? `<img${cls ? ` class="${cls}"` : ""} src="${esc(u)}" alt="" decoding="async">` : "";
function art(name, a = {}, extra = "") {
const base = `style="--hue:${hue(name)}"`, ini = `<span class="initial" aria-hidden="true">${esc(initial(name))}</span>`;
if (valid(a.banner)) return `<span class="art" ${base}>${ini}${img(a.banner)}${extra}</span>`;
const shots = (a.screenshots || []).filter(valid).slice(0, 3);
if (shots.length) return `<span class="art shots" ${base}>${ini}${shots.map(u => img(u)).join("")}${extra}</span>`;
if (valid(a.icon)) return `<span class="art iconart" ${base}>${ini}${img(a.icon, "backdrop")}${img(a.icon, "mark")}${extra}</span>`;
return `<span class="art" ${base}>${ini}${extra}</span>`;
}
const icon = (name, a = {}) => `<span class="icon" style="--hue:${hue(name)}" aria-hidden="true">${esc(initial(name))}${img(a.icon)}</span>`;
document.addEventListener("error", e => {
const el = e.target;
if (!el.matches?.(".art img, .icon img")) return;
if (!el.dataset.retried) { el.dataset.retried = "1"; setTimeout(() => { el.src = el.getAttribute("src").split("?")[0] + "?retry=1"; }, 1500); return; }
const box = el.closest(".shots, .iconart"); el.remove();
if (box && !box.querySelector("img")) box.classList.remove("shots", "iconart");
}, true);
const text = s => String(s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ");
// ---- Data ----
async function loadInstalled() {
try {
st.installed = new Map((await api("/api/android")).apps.map(a => [a.package, a]));
for (const pkg of st.needsData) if (!st.installed.has(pkg)) { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); } // removed
} catch (e) { toast(`Couldn't list installed apps: ${e.message}`, true); }
$("instN").textContent = st.installed.size || "";
}
async function search(quiet) {
const request = ++st.request;
clearTimeout(st.retry);
if (st.view === "installed") { st.loading = false; st.page = 0; return render(); } // a pending search is now stale
st.loading = !quiet; if (!quiet) render();
const params = new URLSearchParams({ q: st.q, category: st.view, phone: String(st.phone) });
try {
const result = await api("/api/search?" + params);
if (request !== st.request) return;
st.apps = result.apps; st.loading = false;
const loading = result.sources.filter(s => s.status === "loading").map(s => s.name);
$("note").textContent = loading.length ? `Still fetching ${loading.join(" and ")}; more apps will appear.` : "";
if (loading.length) st.retry = setTimeout(() => request === st.request && search(true), 6000);
if (!quiet) st.page = 0;
render();
} catch (e) {
if (request !== st.request) return;
st.loading = false; st.apps = []; render(`Couldn't reach the app shelves: ${e.message}`);
}
}
const offerKey = app => `${app.offers[0].source}:${app.offers[0].id}`;
// Always the latest state: an Installed card's own copy goes stale after Stop or Remove.
function installedOf(app) {
const pkg = app.installed?.package || app.package || st.installedFrom.get(offerKey(app));
return pkg ? st.installed.get(pkg) : undefined;
}
// ---- Pages ----
function list() {
if (st.view === "installed") {
const q = st.q.toLowerCase();
return [...st.installed.values()].filter(m => !q || `${m.label} ${m.package}`.toLowerCase().includes(q))
.map(m => ({ installed: m, name: m.label || m.package, package: m.package, summary: m.version ? `v${String(m.version).replace(/^v/i, "")}` : "",
offers: [{ name: m.label, artwork: m.store_art || {}, vr: m.vr && !m.flatscreen }] }));
}
return st.home ? st.home.rest : st.apps;
}
function card(app, i) {
const o = app.offers[0], m = installedOf(app), job = st.jobs.get(offerKey(app));
const state = m ? `<span class="tag">${m.running ? "Running" : "Installed"}</span>`
: job && !job.done ? `<span class="tag">Installing…</span>`
: FIT[o.verdict?.tone] ? `<span class="fit ${o.verdict.tone}">${FIT[o.verdict.tone]}</span>` : "";
const kind = o.phone_only ? "Phone app" : catLabel(app.category || o.category) || (m ? (o.vr ? "VR app" : "Android app") : "Android app");
return `<button class="card" data-i="${i}" aria-label="${esc(app.name)}">${art(app.name, o.artwork, o.vr ? '<span class="vr">VR</span>' : "")}
<span class="body"><span class="title">${icon(app.name, o.artwork)}<strong>${esc(app.name)}</strong></span>
<span class="sum">${esc(text(app.summary))}</span><span class="foot"><span>${esc(kind)}</span>${state}</span></span></button>`;
}
function render(error) {
const home = st.view === "" && !st.q;
$("title").textContent = st.q ? `Results for “${st.q}”` : st.view === "installed" ? "Installed" : st.view ? catLabel(st.view) : "Home";
document.querySelectorAll("[data-view]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.view === st.view)));
const content = $("content");
if (st.loading) {
content.innerHTML = `<div class="grid">${Array.from({ length: PER_PAGE }, () => '<div class="skeleton"></div>').join("")}</div>`;
$("count").textContent = ""; $("page").textContent = ""; $("prev").disabled = $("next").disabled = true; return;
}
// Home: a featured VR app and four picks, then pages of one app from each category in turn.
st.home = null;
if (home && st.apps.length) {
const ok = st.apps.filter(a => a.offers[0].verdict?.tone !== "blocked");
const heroes = ok.filter(a => a.offers[0].vr && valid(a.offers[0].artwork?.banner));
const featured = heroes[Math.floor(Date.now() / 864e5) % (heroes.length || 1)] || ok.find(a => valid(a.offers[0].artwork?.banner));
const queues = CATS.slice(1).map(([id]) => ok.filter(a => a.category === id && a !== featured));
const mixed = [];
for (let i = 0; queues.some(q => q[i]); i++) queues.forEach(q => q[i] && mixed.push(q[i]));
st.home = { featured, rest: mixed.slice(0, 4 + 5 * PER_PAGE) }; // six pages of picks; the categories have the rest
}
const items = list();
const first = st.home?.featured ? 4 : PER_PAGE; // the first home page shares its space with the banner
const pages = Math.max(1, items.length <= first ? 1 : 1 + Math.ceil((items.length - first) / PER_PAGE));
st.page = Math.min(st.page, pages - 1);
const start = st.page === 0 ? 0 : first + (st.page - 1) * PER_PAGE;
const shown = items.slice(start, start + (st.page === 0 ? first : PER_PAGE));
$("count").textContent = home ? "" : `${items.length.toLocaleString()} app${items.length === 1 ? "" : "s"}`;
$("page").textContent = `Page ${st.page + 1} of ${pages}`;
$("prev").disabled = st.page === 0; $("next").disabled = st.page >= pages - 1;
const empty = error ? `<div class="empty"><h2>No connection</h2>${esc(error)}</div>`
: st.view === "installed" && !st.q ? `<div class="empty"><h2>Nothing installed yet</h2>Pick an app in the store and press Install.<button class="primary" data-view="">Browse the store</button></div>`
: `<div class="empty"><h2>No apps here</h2>Try another category${st.phone ? "" : ", or show phone-only apps"}.</div>`;
let html = "";
if (st.page === 0 && st.home?.featured) {
const f = st.home.featured, o = f.offers[0];
html += `<button class="hero" data-feature="1" aria-label="${esc(f.name)}">${art(f.name, o.artwork)}<span class="copy">
<span class="eyebrow">${o.vr ? '<span class="vr">VR</span>' : ""}Featured</span><strong>${esc(f.name)}</strong>
<span class="blurb">${esc(text(f.summary))}</span><span class="cta">See ${esc(f.name)} ›</span></span></button>`;
}
html += `<div class="grid${st.page === 0 && st.home?.featured ? " one-row" : ""}">${shown.length ? shown.map((a, i) => card(a, start + i)).join("") : empty}</div>`;
content.innerHTML = html;
}
function turn(by) {
if (by < 0 ? $("prev").disabled : $("next").disabled) return;
st.page += by; render();
}
// ---- Detail sheet ----
let current = null; // {app, installed}
const ver = v => String(v || "").replace(/^v/i, "");
const size = n => n >= 1e9 ? (n / 1e9).toFixed(1) + " GB" : n >= 1e6 ? Math.round(n / 1e6) + " MB" : Math.max(1, Math.round(n / 1e3)) + " KB";
function openDetail(app) {
current = app; drawDetail(); $("detail").hidden = false;
const o = app.offers[0];
if (o.source && o.id && !app.detailed) { // the full entry has all the screenshots
api(`/api/sources/details?source=${encodeURIComponent(o.source)}&id=${encodeURIComponent(o.id)}`)
.then(full => { if (current === app) { app.offers[0] = { ...o, ...full }; app.detailed = true; drawDetail(); } }).catch(() => {});
}
}
function drawDetail() {
const app = current; if (!app) return;
const o = app.offers[0], m = installedOf(app), key = offerKey(app), job = st.jobs.get(key), data = m && st.jobs.get("data:" + m.package);
if (app.installed && !m) return closeDetail(); // removed
const shots = (o.artwork?.screenshots || []).filter(valid);
const top = valid(o.artwork?.banner) ? art(app.name, { banner: o.artwork.banner })
: shots.length ? `<div class="gallery">${shots.slice(0, 3).map(u => img(u)).join("")}</div>` : art(app.name, o.artwork || {});
const meta = [o.developer, o.source_name, o.version ? `v${ver(o.version)}` : m?.version ? `v${ver(m.version)}` : "", o.size ? size(o.size) : ""].filter(Boolean).join(" · ");
const reasons = (o.fit?.reasons || []).filter(Boolean);
let acts;
const progress = j => {
const pct = typeof j.percent === "number" ? Math.max(3, Math.min(100, j.percent)) : null;
return `<div class="progress${pct === null ? " busy" : ""}"><span>${esc(j.stage || "Downloading")}${pct === null ? "…" : ` ${Math.round(pct)}%`}</span><div class="track"><div class="fill" style="${pct === null ? "" : `width:${pct}%`}"></div></div></div>`;
};
const gameData = !(m && st.needsData.has(m.package)) ? "" : data && !data.done ? progress(data)
: `<button class="primary" data-act="game-data">Add game data</button><span class="meta">Open it once on the Frame first.</span>`;
if (job && !job.done) {
acts = progress(job);
} else if (m) {
acts = gameData + (m.running ? `<button data-act="stop">■ Stop</button>` : `<button class="play" data-act="launch">▶ Open</button>`)
+ `<button class="danger" data-act="remove">Remove</button>`;
} else if (!o.source) {
acts = "";
} else if (o.downloadable === false) {
acts = `<button disabled>Get it from the developer's page</button>`;
} else if (o.verdict?.tone === "blocked") {
acts = `<button disabled>Won't run on the Frame</button>`;
} else {
acts = `<button class="primary" data-act="install">Install</button>`;
}
const blurb = o.description || o.summary || (app.installed ? "" : app.summary); // an Installed card's summary is its version, already in meta
const failed = data?.error || job?.error;
const note = failed ? `<div class="meta" style="color:var(--bad)">${esc(failed)}</div>` : "";
$("sheet").innerHTML = `${top}<div class="inner">
<div class="top">${icon(app.name, o.artwork)}<div><h2>${esc(app.name)}</h2><div class="meta">${esc(meta)}</div></div>
${o.vr ? '<span class="vr" style="margin-left:auto">VR</span>' : ""}</div>
${blurb ? `<div class="text">${esc(text(blurb))}</div>` : ""}
${o.verdict ? `<div class="why"><b>${esc(o.verdict.label)}</b>${reasons.length ? `<ul>${reasons.slice(0, 3).map(r => `<li>${esc(r)}</li>`).join("")}</ul>` : ""}</div>` : ""}
${note}<div class="acts">${acts}<button class="close" data-act="close">Close</button></div></div>`;
}
function closeDetail() { current = null; $("detail").hidden = true; }
async function runJob(key, start) {
const job = { stage: "Downloading", percent: null, done: false };
st.jobs.set(key, job); drawDetail(); render();
try {
const { job: id } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(id)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
Object.assign(job, { stage: j.stage || job.stage, percent: j.percent ?? null });
if (!j.done) { drawDetail(); continue; }
if (j.error) throw new Error(j.error);
toast(j.message); job.result = j.result || {};
break;
}
} catch (e) { job.error = e.message; toast(e.message, true); }
finally {
job.done = true;
if (!job.error) st.jobs.delete(key); // a failure stays, so the sheet can say why
await loadInstalled(); render(); drawDetail();
}
return job;
}
$("sheet").onclick = async e => {
const b = e.target.closest("[data-act]"); if (!b || !current) return;
const app = current, o = app.offers[0], m = installedOf(app), act = b.dataset.act, key = offerKey(app);
if (act === "close") return closeDetail();
if (act === "install") {
toast(`Installing ${app.name}. The first time takes a minute…`);
const job = await runJob(key, () => api("/api/sources/install", { source: o.source, id: o.id, version_code: o.version_code ?? null }));
if (job.result?.package) {
st.installedFrom.set(key, job.result.package);
if (job.result.game_data) st.needsData.add(job.result.package);
}
return render(), drawDetail();
}
if (act === "game-data" && m) { // its own job, by package: a failed copy keeps the pending data, so it can be tried again
const job = await runJob("data:" + m.package, () => api("/api/sources", { action: "game-data", package: m.package }));
if (!job.error) st.needsData.delete(m.package);
return render(), drawDetail();
}
const pkg = m?.package; if (!pkg) return;
if (act === "remove" && b.dataset.sure !== "1") {
b.dataset.sure = "1"; b.textContent = "Remove it and its data?"; return; // no confirm(): a separate window the laser has to find
}
b.disabled = true;
try {
const r = await api("/api/android", { action: act, package: pkg });
toast(r.message);
if (act === "remove") { st.needsData.delete(pkg); st.jobs.delete("data:" + pkg); }
if (act === "launch") closeDetail();
} catch (err) { toast(err.message, true); }
await loadInstalled(); render();
if (current === app) drawDetail();
};
$("detail").onclick = e => { if (e.target === $("detail")) closeDetail(); };
// ---- Navigation ----
$("cats").innerHTML = CATS.map(([id, label, path]) => `<button data-view="${id}" aria-pressed="false"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" stroke-linecap="round" aria-hidden="true">${path}</svg>${esc(label)}</button>`).join("");
document.addEventListener("click", e => {
const v = e.target.closest("[data-view]");
if (v && !e.target.closest("#sheet")) {
st.view = v.dataset.view; st.q = ""; $("q").value = "";
if (st.view === "installed") loadInstalled().then(() => search()); else search();
return;
}
if (e.target.closest("[data-feature]")) return openDetail(st.home.featured);
const c = e.target.closest(".card[data-i]");
if (c) openDetail(list()[+c.dataset.i]);
});
$("phone").onchange = e => { st.phone = e.target.checked; search(); };
$("prev").onclick = () => turn(-1);
$("next").onclick = () => turn(1);
// The thumbstick arrives as a mouse wheel: one notch, one page.
let wheelAt = 0;
document.querySelector("main").addEventListener("wheel", e => {
e.preventDefault();
if (!$("detail").hidden || Date.now() - wheelAt < 350 || Math.abs(e.deltaY) < 4) return;
wheelAt = Date.now(); turn(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
let qTimer;
$("q").oninput = e => { clearTimeout(qTimer); qTimer = setTimeout(() => { st.q = e.target.value.trim(); search(); }, 250); };
document.addEventListener("keydown", e => { if (e.key === "Escape") { closeDetail(); $("kbd").hidden = true; } });
// ---- On-screen keyboard (a text field on a panel may not bring up Steam's) ----
const ROWS = ["1234567890", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
$("kbd").innerHTML = ROWS.map(r => `<div class="row">${[...r].map(k => `<button data-k="${k}">${k}</button>`).join("")}</div>`).join("")
+ `<div class="row"><button data-k="clear">Clear</button><button class="wide" data-k=" ">space</button><button data-k="back">⌫</button><button data-k="done" class="primary">Done</button></div>`;
$("kbd").onclick = e => {
const b = e.target.closest("[data-k]"); if (!b) return;
const k = b.dataset.k, q = $("q");
if (k === "done") { $("kbd").hidden = true; return; }
q.value = k === "clear" ? "" : k === "back" ? q.value.slice(0, -1) : q.value + k;
q.dispatchEvent(new Event("input"));
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
</script>
</body>
</html>