Compare commits

..
94 Commits
Author SHA1 Message Date
SAPHID a13f4fafab fix(ui): reconcile PC host controls with current main 2026-10-18 00:36:47 +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
SAPHID ea0b6a80bd Merge main and resolve native host documentation and build triggers 2026-10-18 00:06:01 +00: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 612a766057 test(layout): provide page helpers in isolated control center fixtures 2026-10-17 23:58:37 +00:00
Alex Southwell 5b531c84fb Merge pull request #90 from saphid/feat/repo-auth
Store: repositories behind a login, and a GitHub token in source settings
2026-10-18 10:57:12 +11: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
Alex Southwell e89912d936 Merge pull request #64 from saphid/chore/windows-vm-rdp-repro
Windows test VM: a driver script and how to set one up
2026-10-18 10:52:10 +11:00
Alex Southwell 4c27a3397c Merge pull request #57 from saphid/fix/ci-failures
CI: stop recurring false failures on main and pull requests
2026-10-18 10:52:06 +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 fa9ee01cc1 chore: resolve conflicts with main 2026-10-17 22:10:07 +00:00
SAPHID 63017e1969 chore: resolve conflicts with main 2026-10-17 22:10:04 +00:00
SAPHID b351fe7ad6 chore: resolve conflicts with main 2026-10-17 22:09:58 +00:00
SAPHID 6aab62983e chore: resolve conflicts with main 2026-10-17 22:08:52 +00:00
saphidandClaude Opus 5.5 4ecc03280e Source logins: refuse addresses with a backslash and a login; decode escapes in one pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:40 +11:00
saphidandClaude Opus 5.5 2f1aeaac72 Source logins: refuse an invalid address that holds a login; keep undecodable bytes as typed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:35:50 +11:00
saphidandClaude Opus 5.5 8347c2c3f7 Source logins: let the URL parser find a pasted login; text matching only as a fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:30:54 +11:00
saphidandClaude Opus 5.5 a94c45d014 Source logins: strip a pasted login even with tabs, newlines or backslashes in the address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:18 +11:00
saphidandClaude Opus 5.5 e20225604d Source logins: keep repository logins inside the repository directory
- Every request made with an F-Droid repository's login (entry.jar, index,
  APKs), redirects included, now uses the scoped check: same origin and
  inside the repository directory by whole plain path segments. A
  same-origin redirect elsewhere on the server gets no login.
- _child() refuses file names a server could normalise outside the
  repository: double-encoded dots, ';' path parameters, encoded slashes or
  backslashes (anything _credentials.path_segments rejects).
- Store page: moveLogin() strips the login from the address with plain
  text matching first, then decodes with a fallback to the text as typed,
  so '100%done', '%FF' or an address the URL parser rejects can no longer
  leave the password in the field.

Tests: outgoing headers for '%252e%252e/...' and '..;/...' payloads and a
same-origin redirect outside the repository carry no Authorization; the
page test covers undecodable and unparseable logins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:12:10 +11:00
saphidandClaude Opus 5.5 2d81c92114 Source logins: fix review findings (artwork scope, file lock, page, backoff)
- Artwork: a saved login is only sent for URLs under the repository by whole
  path segments. Paths with dot segments (plain or percent-encoded), encoded
  slashes/backslashes, double encoding or ';' get nothing, since a server
  could normalise them outside the repository. Checked again on every
  redirect hop; Handler(scoped=True) does the same for urllib callers.
- Credentials file: every read-modify-write holds an inter-process lock
  (source-credentials.lock, flock or msvcrt), so the CLI and the app can't
  undo each other's saves or bring back a removed login.
- Store page: a login typed into the address moves into the login fields,
  and out of the address field, before the add starts, and stays there if
  it fails.
- Rate-limit backoff is keyed per login (anonymous or each token), so saving
  a GitHub token after anonymous requests were limited is used at once.

Regression tests: outgoing artwork headers for traversal URLs and a
redirect to one, two processes churning the credentials file, anonymous
limit then token, and the page's login handling in node.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:00:33 +11:00
saphidandClaude Opus 5.5 fe73906f1f Docs: repositories behind a login and GitHub tokens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
saphidandClaude Opus 5.5 84912ea4e1 Store: repositories behind a login, and a GitHub token in source settings
F-Droid repositories can now be added with HTTP basic auth, either as
https://user:pass@host/fdroid/repo (as F-Droid's client accepts) or with
separate username/password fields (store form, server API, and the CLI's
--username/--password-stdin). The login is stripped from the saved URL and
kept in a new shared module, apk_sources/_credentials.py, which writes
source-credentials.json in the data folder with mode 0600.

A credential is only ever added per request to its exact origin (scheme,
host, port): redirects elsewhere get nothing and have Authorization
removed. HTTPS only. 401 (or 403 with a login) on add gives a "check the
username and password" error and saves nothing; signature, pin, TOFU,
hash and rollback checks are unchanged. Removing a repository deletes its
login.

GitHub gets a user-settable token (FRAME_GITHUB_TOKEN still wins), sent as
Bearer only to api.github.com. If GitHub answers 401, the request is
retried once without it and the rejection is reported in source settings.
The source list shows only whether a token is set, never its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +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 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +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
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +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
saphidandClaude Opus 5.5 73b97fedc5 windows-vm.sh: stop the input helper before giving up on a click
On a timeout or a setup error the request now stops the scheduled task and
waits for it to end before releasing the mutex, so a stalled helper can't
act later inside another request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:50:13 +10:00
saphidandClaude Opus 5.5 924e37aa30 windows-vm.sh: serialise clicks with a named mutex instead of a lock file
Windows releases the mutex when the request's process ends, so there's no
stale lock to recover and no window where two requests can take each
other's. Requests sent together now run one after another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:35 +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
saphidandClaude Opus 5.5 bd24436fbc windows-vm.sh: an atomic click lock, and setup errors fail the click
input.txt is now created with CreateNew, so only one request can hold it; a
lock older than 15 s is treated as left by a dead run. The outer PowerShell
stops on any error, says why in one line, releases the lock and exits 1.
docs/testing.md names both keys Set Up Connection can add to the headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:07 +10:00
saphidandClaude Opus 5.5 895cc0ebbd windows-vm.sh: review fixes for the headset key and input helper
- frame-key remove deletes only the exact line add wrote, through a private
  temporary file, and leaves authorized_keys alone if it can't read or
  rewrite it. add checks the VM's key is a plain ed25519 key, keeps a last
  line without a newline intact, and doesn't duplicate a key the headset
  already trusts.
- The input helper stops on any error before acknowledging, refuses a
  second click while one is in flight, and coordinates are bounded.
- Host, container and user names are checked before they reach a remote
  shell; PowerShell errors come back as plain text.
- docs/testing.md says what frame-key does and doesn't undo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:35 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +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 f7dbad500a Docs: signing in over RDP as steamos reaches a Plasma desktop (verified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:24:48 +10:00
saphidandClaude Opus 5.5 3f20e5f9d3 Windows test VM: a driver script and how to set one up
scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over
ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's
screendump, clicks and scrolls replayed by a scheduled task in the signed-in
session, QEMU key names, and a tagged, removable headset key for tests
against a real Frame. docs/testing.md explains setting it up (Windows from
Microsoft, loopback-only ports) and the pitfalls found reproducing the
Windows "RDP not working" report. docs/streaming.md records what Remote
Desktop from Windows does against the Frame's xrdp (verified 2026-09-30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:29:56 +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 8051687e1e CI: stop recurring false failures on main and pull requests
- checks: the runner's packages.microsoft.com apt source now 403s, failing
  `apt-get update` and with it "Install zsh"; tolerate the refresh error
  (Ubuntu's lists still update and the install still fails if it can't).
- e2e: a failed quiet image build is rebuilt with the full log, but the
  script exited 2 even when that rebuild succeeded; only fail if it fails.
- ArtworkTests: a timed-out lookup gives its resolver slot back from its own
  thread, which a busy macOS runner may schedule after the next test starts.
  Wait for every slot before each test, and always return slots a test took,
  so one late thread can't cascade into three failures.
- release: tagged builds no longer re-upload the installers as workflow
  artifacts (they are attached to the release; the artifact finalize timed
  out on v0.3.0). Pull requests build installers only when the Electron app,
  the macOS helper it compiles, or the workflow changes.
- checks and release: a newer push to a pull request cancels the older run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:18:58 +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
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +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
saphid 3cb3defaa7 Record blocked shared-Frame verification and portal limits 2026-09-29 11:13:08 +10:00
saphidandClaude Opus 5.5 a992f6d896 PC host: review fixes (idle watchdog, controller close race, malformed input, loader path)
- The 10 s watchdog no longer ends idle sessions. PipeWire and WGC deliver
  frames only on damage; fail only when a frame entered the encoder and never
  came out, or the pipeline never produced its initial frame.
- Controller.state/call/update are inert after close(), so a /status poll
  racing Stop cannot dereference the freed native controller.
- Malformed viewer fields drop the event instead of ending the session.
- PATH/LD_LIBRARY_PATH prepends add no empty (current directory) entry.
- portal.c: document fd ownership. pipewiresrc dups the fd it is given
  (gstpipewirecore.c, F_DUPFD_CLOEXEC), so the portal closing its own fd is
  correct; clear it after close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:44:12 +10:00
saphidandClaude Opus 5.5 d9d538690e Merge origin/main into pc-in-headset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:38:37 +10:00
saphid 391ecbeac5 Record final local test coverage and desktop verification limits 2026-09-28 23:14:36 +10:00
saphid 1dc2a99617 Map the exported PipeWire video adapter factory 2026-09-28 23:13:19 +10:00
saphid d6f3d7c380 Set Windows DPI coordinates before native plugin initialization 2026-09-28 23:10:00 +10:00
saphid 838eaa4e6b Cover the available PipeWire converter and its D-Bus support 2026-09-28 23:09:04 +10:00
saphid 6104c3f192 Refuse window input when Windows cannot focus its target 2026-09-28 23:07:38 +10:00
saphid da5c954ce4 Retain the final idle-window update while the stream gate is closed 2026-09-28 23:05:23 +10:00
saphid 2466c5aa14 Include and smoke-test PipeWire video adapter factories 2026-09-28 23:00:13 +10:00
saphid 060cf9cd57 Bundle PipeWire client modules and record the real Frame probe 2026-09-28 22:58:41 +10:00
saphid cd202433bb Verify shared adaptation and harden PC stream lifecycle 2026-09-28 22:50:58 +10:00
saphid c5fc552c60 Add native PC capture adapters behind the shared panel viewer 2026-09-28 22:29:34 +10:00
saphid 559caa2dd0 Share the desktop stream rate controller across host platforms 2026-09-28 22:23:10 +10:00
76 changed files with 7088 additions and 697 deletions

No files matched your search

+11 -2
View File
@@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
runs-on: ubuntu-latest
@@ -17,7 +22,9 @@ jobs:
with:
node-version: "24"
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
sh -n ui/local-bin/ssh
@@ -84,6 +91,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
+53
View File
@@ -0,0 +1,53 @@
name: PC host libraries
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "mac/frame-mac-view/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
- uses: actions/upload-artifact@v4
with:
name: pc-host-${{ matrix.os }}
path: desktop/bundle
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-python
mingw-w64-ucrt-x86_64-gstreamer
mingw-w64-ucrt-x86_64-gst-plugins-base
mingw-w64-ucrt-x86_64-gst-plugins-good
mingw-w64-ucrt-x86_64-gst-plugins-bad
mingw-w64-ucrt-x86_64-gst-plugins-ugly
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py
FRAME_PC_REQUIRE_NATIVE=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
- uses: actions/upload-artifact@v4
with:
name: pc-host-windows
path: desktop/bundle
+56 -2
View File
@@ -2,14 +2,22 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts.
# Pull requests that change how the installers are built (the Electron app, the
# native PC helpers, macOS helper, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
@@ -61,18 +69,62 @@ jobs:
- os: ubuntu-latest
script: dist:linux
files: app/dist/*.AppImage app/dist/*.deb
- os: ubuntu-24.04-arm
script: dist:linux:arm64
files: app/dist/*.AppImage app/dist/*.deb
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Linux streaming libraries
if: runner.os == 'Linux'
shell: bash
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
arch=x64; if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
python3 desktop/build.py --out "app/build/desktop/linux-$arch"
- uses: msys2/setup-msys2@v2
if: runner.os == 'Windows'
with:
msystem: UCRT64
update: true
install: >-
mingw-w64-ucrt-x86_64-gcc
mingw-w64-ucrt-x86_64-pkgconf
mingw-w64-ucrt-x86_64-python
mingw-w64-ucrt-x86_64-gstreamer
mingw-w64-ucrt-x86_64-gst-plugins-base
mingw-w64-ucrt-x86_64-gst-plugins-good
mingw-w64-ucrt-x86_64-gst-plugins-bad
mingw-w64-ucrt-x86_64-gst-plugins-ugly
- name: Windows streaming libraries
if: runner.os == 'Windows'
shell: msys2 {0}
run: python desktop/build.py --out app/build/desktop/win-x64
- name: Build
working-directory: app
shell: bash
run: npm ci && npm run ${{ matrix.script }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Test the bundled Python with the native PC libraries
if: runner.os != 'macOS'
shell: bash
run: |
if [ "$RUNNER_OS" = Windows ]; then
platform=win; arch=x64
python="$PWD/app/build/deps/win-x64/python/python.exe"
native="$(cygpath -w "$PWD/app/build/desktop/win-x64")"
else
platform=linux; arch=x64
if [ "$(uname -m)" = aarch64 ]; then arch=arm64; fi
python="$PWD/app/build/deps/linux-$arch/python/bin/python3"
native="$PWD/app/build/desktop/linux-$arch"
fi
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 "$python" -m unittest discover -s tests -p 'test_pc*.py' -v
# The installer, not just the unpacked build: its 7-Zip payload once dropped
# the OpenXR layer's arm64 library without any error. Install it silently
# and compare every installed file with the unpacked build.
@@ -94,7 +146,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with:
name: frame-control-${{ matrix.os }}
path: |
+10
View File
@@ -7,5 +7,15 @@ compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
+4 -3
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:
@@ -218,13 +218,14 @@ Frame's software fits together, all checked against a real headset and labelled
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [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 |
| [PC in the headset](docs/pc-in-headset.md) | Windows and Linux host implementation and test coverage |
| [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 |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
+23 -4
View File
@@ -11,8 +11,9 @@
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
"dist:linux": "node build/fetch-deps.js linux x64 && electron-builder --linux --x64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
"dist:linux:arm64": "node build/fetch-deps.js linux arm64 && electron-builder --linux --arm64 --publish never"
},
"devDependencies": {
"electron": "^44.4.5",
@@ -184,7 +185,16 @@
"entry": {
"StartupWMClass": "frame-control"
}
}
},
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
},
"deb": {
"depends": [
@@ -197,7 +207,16 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}"
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
},
"nsis": {
"oneClick": false,
@@ -0,0 +1,311 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -0,0 +1,302 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad', 'x264']
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
build(parser.parse_args().out.resolve())
+125
View File
@@ -0,0 +1,125 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
+127
View File
@@ -0,0 +1,127 @@
/* Extracted from the Mac agent's RateController. Same gate, demand protection,
* bitrate recovery and tier hysteresis for every host. Bounded sample buffers. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
+147
View File
@@ -0,0 +1,147 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
+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
+54 -1
View File
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
+17 -4
View File
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
+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
+16 -10
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.
@@ -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).
+199
View File
@@ -0,0 +1,199 @@
# PC in the headset
Windows and Linux hosts use **Tools → PC in the headset**. The host shares a
window or screen, and the existing Frame viewer makes it a SteamVR panel.
Move it with the dashboard's Float in World, Move and Size controls.
**Inferred / not yet verified on a desktop host:** the Windows and Linux
capture and input paths below. This work was developed on a Mac with no
Windows or Linux desktop VM. A native build or test-pattern test in CI does
not establish that desktop capture, a permission dialog, hardware encoding
or laser input works. Keep this feature in the draft/testing stage until
those paths have been tried on real hosts.
## Own implementation, platform APIs and bundled libraries
Frame Control owns the host agent, input routing, authentication, streaming
protocol, panel launcher and adaptation. It does not launch or require
Sunshine, OBS or another desktop-streaming app. GStreamer and its codec
plugins are ordinary libraries bundled with the Windows and Linux app;
users do not install a GStreamer application. The shared library build keeps
license texts and package provenance alongside the libraries. Linux also
bundles the PipeWire client’s dynamically loaded SPA/protocol modules and a
private client configuration; it does not change the desktop’s configuration.
First-party alternatives considered (**documented**): Valve Remote Play
streams a game/desktop, rather than providing this per-window panel protocol;
Windows Remote Desktop opens a remote session; Linux's desktop portal is the
consent mechanism for sharing the current desktop. The chosen paths are:
| Host | Capture | Encoding | Input |
|---|---|---|---|
| Windows | Windows.Graphics.Capture, through `d3d11screencapturesrc capture-api=wgc`; HWND or HMONITOR | Hardware Media Foundation (`mfh264enc`), low latency, no B-frames | `SendInput`, with source bounds and per-monitor DPI awareness |
| Linux | RemoteDesktop + ScreenCast portal, then the returned PipeWire fd/node | VA-API (`vah264enc`) where registered; x264 otherwise | RemoteDesktop portal notifications, using only granted pointer/keyboard devices |
API choices are **documented**, not device verification:
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html),
[ScreenCast portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.ScreenCast.html),
[RemoteDesktop portal](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.RemoteDesktop.html).
Windows needs a WGC-capable Windows 10/11 desktop and an available hardware
Media Foundation H.264 encoder. Elevated windows and the secure desktop
cannot be driven by an ordinary Frame Control process. If Windows refuses
to focus a selected window, input is refused rather than sent to the app
covering it; bring the selected window forward, then Stop and Show again. Minimized/closed
windows may stop producing frames. Protected content is not supported.
On Linux, press **Choose a window or screen…** and approve the desktop's
sharing dialog. Choose another source to add another panel. Stop releases
that source's portal session; sharing it again asks for consent again.
A desktop must implement both ScreenCast and RemoteDesktop for this path;
a ScreenCast-only compositor cannot provide laser input through this API.
Cancelling or denying a dialog is reported on the card. No portal permission
is bypassed, and Frame Control does not open `/dev/uinput` or the unrestricted
PipeWire daemon on the host.
The host's own keyboard still works. Input from the viewer uses normalized
picture coordinates, maps through the selected source's bounds, and releases
held buttons/keys on blur, disconnect and Stop. Linux requires the pointer
and keyboard grants. **Untested:** desktop-specific consent, mixed-DPI
Windows input alignment, multi-monitor layouts, hardware encoder behavior,
window resize/minimize, and non-US keyboard layouts.
## Shared pieces
- `ui/frame_macview.py` owns the SSH tunnel, reconnect supervision, quality
presets and panel launch for all hosts. `ui/frame_pcview.py` selects the PC
helper; `/api/macview` remains the compatible endpoint.
- `ui/mac-view.html` is the one viewer. The 17-byte big-endian frame header,
Annex-B H.264/JPEG payloads, `hello`/`ack` reconnect handshake, clock sync,
`rx`/`fd` timing reports and input messages are unchanged.
- `desktop/controller.c` is the rate controller shared by the Mac Swift
binding and the PC Python binding. Capture is gated **before** encoding;
encoded reference frames are never discarded. A bounded raw-frame queue
keeps the newest picture, including the last update of an idle window,
until the gate opens. A native one-frame-source test covers that case.
It keeps the Mac's bitrate demand protection and tier hysteresis.
- PC records use the existing `Stats.swift` JSON schema, with bounded
4096-frame/512-input storage in `ui/frame_stream_stats.py`. The benchmark's
analysis, targets and network shaping are shared, not reimplemented.
Capture timestamps describe the native pipeline's source time; they do
not prove the time at which the host compositor displayed the pixels.
- Mac virtual-display separation remains Mac-only. Windows WGC and the
Linux portal share the selected window directly.
PC capture follows the shared frame-rate and resolution tiers. x264 updates
bitrate while running. Hardware encoders are drained and reopened when the
budget changes materially, at most once a second, because their live property
support varies. Reopening starts a new keyframe and retains the consented
portal session. **Untested:** hardware reconfiguration latency and whether a
particular desktop permits reconnecting its PipeWire stream this way.
## Build and measure
Packaged Windows/Linux builds include `desktop/bundle/pc-host` and its shared
libraries. Source checkouts build them with `python3 desktop/build.py` after
installing GStreamer development packages (see the `PC host libraries` CI
workflow). The feature reports a missing bundle; it does not download or
install a streaming app on first use.
The existing benchmark now accepts a PC host:
```sh
python3 scripts/macview-bench.py run --pc --scenario test --label pc-test
# Linux: select a real source in the desktop's sharing dialog
python3 scripts/macview-bench.py run --pc --scenario capture --source choose --label linux-window
# Windows: use the HWND/monitor source ID shown by the host's /windows or /displays
python3 scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
```
The synthetic PC pattern uses bundled x264 so headless CI can verify the
wire protocol without claiming that a GPU was exercised. The `capture`
scenario measures the selected real source without injecting input or
assuming that it animates at 60 fps. Mac-only Chrome/virtual-display typing
and scrolling automation is not run on PC hosts. Results retain the same
latency stages and record `host_platform`, `pc_host` and `source`. CPU sampling
on PC hosts is explicitly unavailable. `--net` and `--delay` still use the
same bounded shaping relay, without administrator privileges.
## Evidence
- **Verified, Mac, 2026-09-28:** the final full unit/integration suite ran
183 tests successfully, including the real Mac helper's H.264, ticket, timing
and input-echo tests. The native PC test class was skipped locally because
its libraries were absent; the PC adapter and shared-controller tests passed.
- **Verified, real Frame, 2026-09-28, BUILD_ID 20260925.6191901:** the base
helper's synthetic source created panel `valve.steam.desktopgame.2001639889`,
and the shared Chromium viewer decoded H.264. It recorded 286 frames over
the short probe, with a two-second summary of 19.5 fps shown and total
latency p50/p95 83/156.5 ms. This establishes the existing viewer/transport
route, not Windows/Linux capture, input or a latency target. The probe's
helper, tunnel and viewer were stopped afterward.
- **Verified in CI:** native library builds and real x264 protocol tests passed
on Windows, Ubuntu x64 and Ubuntu ARM64 in
[run 36422214445](https://github.com/saphid/frame-control/actions/runs/36422214445).
All four installer builds passed in
[run 36422214425](https://github.com/saphid/frame-control/actions/runs/36422214425).
These are build/synthetic tests, not desktop-host verification. No VM was used.
- **Verified, real Frame, same build/date:** the ARM64 PC agent and its
bundled libraries ran from a temporary user directory, using x264's moving
test pattern. Traffic travelled Frame → Mac SSH relay → Frame viewer.
The shared bench recorded 351 drawn frames, content p50/p95 15.1/80.3 ms,
and 34.8 fps. The frame-rate and late-frame targets failed. This checks the
new agent and real viewer together; it is not a representative PC link.
The helper exited 0, its viewer/tunnels stopped, and its directory was removed.
[Raw benchmark result](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
This first probe's input echo measured message receipt to the next capture,
not a visible pattern response; later builds make test clicks change its color.
- **Verified, Mac:** all 600 states in a 60-second congestion/recovery trace
matched the original Swift controller. `tests/test_pc_controller.py` retains
the original trace digest as a regression check.
- **Verified, real Frame, agent at `cd20243`:** the repeated synthetic
probe drew 400 frames at 39.3 fps, content p50/p95 15.1/28.6 ms, and synthetic
input-to-drawn p50 76.6 ms. Test clicks now change the pattern color before
injection is timestamped. The frame-rate/late-frame targets still failed;
this remains a Frame-hosted x264 test through a Mac relay, not a desktop or
physical-laser measurement. Helper exit 0 and cleanup succeeded.
[Latest device probe result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json).
- **Untested:** real Windows WGC → Media Foundation → Frame; real Linux
portal → PipeWire → VA-API/x264 → Frame; physical laser input on either.
No benchmark numbers for those desktop paths are claimed.
## Independent review availability
A direct read-only review was attempted with
`devin -p --model swe-2-max --permission-mode auto --prompt-file …`. The first
attempt exited 0 after rejecting a tool that needed interactive permission;
it did not inspect the diff. A full inline-diff attempt returned no output
for 15 minutes and was terminated (shell exit 143). A smaller inline native
code review returned no output within 300 seconds (process exit -15).
SWE-2 Max was requested; no completed review or findings were received, so
independent review is **unverified**, not a passed check. The PR remains draft.
## Device follow-up, 2026-09-29
**Verified, read-only, SteamOS BUILD_ID 20260925.6191901:** the Frame was
reachable, charging (44% initially, 63% at the end), and SteamVR reported
activity level 3 (standby). No Plasma desktop was running. Introspection of
the active `org.freedesktop.portal.Desktop` service exposed ScreenCast with
source types 3, but no RemoteDesktop interface. **Inferred:** this gamescope
session cannot provide this feature's required consented input path; a
ScreenCast-only session is insufficient.
**Blocked, not a device execution result:** the shared `/tmp/frame-test.lock`
was held by another thread. Ten lock-acquisition attempts over five minutes
all failed, after earlier preparation-time attempts also found it occupied.
The current `a992f6d` ARM64 CI bundle was downloaded to the Mac, but nothing
was installed, launched or stopped on the Frame. No new screenshots or
current-revision device timings were obtained. The lock was not removed and
no global settings were changed. The earlier synthetic results above remain
valid only for their named revisions; standby visibility, physical laser
input and worn-headset performance remain unverified.
**Verified, Mac:** a fresh full suite ran 309 tests successfully, with one
native-PC class skipped. Current-revision CI passed
[native hosts](https://github.com/saphid/frame-control/actions/runs/36504546909),
[installers](https://github.com/saphid/frame-control/actions/runs/36504546800)
and [general checks](https://github.com/saphid/frame-control/actions/runs/36504546887).
+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
+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
+14
View File
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
Remote Desktop never asks for a user or password. It warns that the certificate
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
frame" box with the username blank. Any user but `steamos` gets "User does not
exist, or could not be authenticated". Signing in as `steamos` with the
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
It's a new session on display `:10`, separate from what the headset shows,
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
it running, and the next sign-in reconnects to it. To end it, find its
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
and run `loginctl terminate-session <id>`; the headset's own session keeps
running.
**Recommendation for A:** start with Steam Link for macOS, because Valve
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
+92
View File
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
## Unit tests
@@ -186,6 +187,97 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## PC panel host tests
`tests/test_pcview.py` checks source-bound tickets, reconnect/Stop revocation,
input release, source-coordinate mapping and use of the existing panel
launcher. The `PC host libraries` workflow builds the bundled native adapter
on Windows, Ubuntu x64 and Ubuntu ARM64, then runs a real x264 synthetic
stream through the HTTP/WebSocket agent. `FRAME_PC_REQUIRE_NATIVE=1` makes a
missing native bundle fail CI instead of skipping. These tests do not grant a
portal dialog, capture a desktop, exercise a hardware encoder or validate
laser alignment. See [PC host evidence](pc-in-headset.md#evidence).
## Windows test VM
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
depends on the Windows desktop (Remote Desktop, the installer, the bundled
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
Setting it up, once, on the Linux machine:
- **Windows comes from Microsoft.** The container downloads the Windows 11
image from Microsoft on first start. Windows runs unactivated, which is fine
for testing. Don't use activation workarounds or third-party Windows images.
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
`127.0.0.1:8006:8006` (the web console) and, if you need it,
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
testing, and a `docker stop` shuts Windows down cleanly.
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
as its default shell, and put a dedicated public key (for example
`~/.ssh/id_ed25519_winvm` on the Mac) in
`C:\ProgramData\ssh\administrators_authorized_keys`.
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
and the tests.
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
```sh
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
scripts/windows-vm.sh shot screen.png # what's on its screen
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
scripts/windows-vm.sh down # shut Windows down
```
- **Clicks go through a scheduled task.** Commands over SSH run in a
session with no desktop, so `click` and `scroll` write the position to a
file, and a scheduled task running as the signed-in user replays it. The
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
the same time run one after another. QEMU's own `mouse_move` is relative
and drifts, so the script doesn't use it.
- **Screenshots may not show the pointer.** Check the result of a click (a
menu that opens, a button that changes) rather than the pointer's position.
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
Do the same if you run `ssh` in the VM by hand.
- **Starting the app.** Run Frame Control in the signed-in session, not over
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
one `click` uses.
**Testing against a real Frame.** The VM reaches the headset on the LAN like
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
that makes the VM's keys. So the VM doesn't keep access between test runs,
afterwards take the lines it added out of the headset's
`~/.ssh/authorized_keys`: the ones matching the VM's
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
in the VM now fails. Then, around each run:
```sh
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
# ... test ...
scripts/windows-vm.sh frame-key remove # and take it out again
```
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
already trusts that key through another line. `remove` deletes only the exact
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
the file alone if it can't rewrite it. Follow the shared-device
procedure in [Headset smoke test](#headset-smoke-test) before installing or
launching anything on the headset.
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
+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
+2 -2
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
@@ -26,7 +26,7 @@ it twice reuses the existing process.
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
OpenVR uses background application mode, which does not start SteamVR or keep
it running. This mode also returned live timing in a read-only device probe.
+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.
@@ -26,3 +26,5 @@
- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
@end
#include "../../../desktop/controller.h"
+30 -219
View File
@@ -1,234 +1,45 @@
// Adapts each stream to its network, latency first. The viewer acknowledges
// every frame as it arrives ("rx"); from those acks the controller knows how
// long frames take to get through and how fast the link delivers them.
//
// - The gate: a new frame is sent only while the oldest unacknowledged one is
// younger than the path's usual round trip plus a little slack. So frames
// never queue up in SSH, TCP or the Wi-Fi driver; while the link is stuck
// the newest picture waits and goes out as soon as it moves again.
// - The bitrate: when frames start queueing (the round trip grows) or the
// gate has to hold frames back, it drops to a bit under what the link
// actually delivered; once things are clear it probes up again slowly, never
// above the quality setting's bitrate (the ceiling).
// - The tier: as the bitrate falls, fewer frames per second (60, 45, 30), then
// a smaller picture (75%, then 50% of the panel's pixels).
// See docs/mac-in-headset.md ("Adapting to the network"). Thread-safe.
// Thin, serialized binding to the same controller used by the PC host.
// Algorithm and bounded state live in desktop/controller.c.
import Foundation
final class RateController {
struct Tier: Equatable {
let fps: Int
let scale: Double // of the picture's long side
}
static let tiers = [Tier(fps: 60, scale: 1), Tier(fps: 45, scale: 1), Tier(fps: 30, scale: 1),
Tier(fps: 30, scale: 0.75), Tier(fps: 30, scale: 0.5)]
/// A tier is used while the target bitrate is at least this share of the ceiling.
static let floors = [0.45, 0.28, 0.16, 0.08, 0]
static let enabled = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ADAPT"] != "0"
let maxFps: Int
private let lock = NSLock()
private var ceiling = 0 // bits/s at full size and frame rate
private(set) var target = 0
private(set) var tier = 0
private var unacked: [(seq: UInt32, sent: Int64, bytes: Int)] = []
/// Round trips (send -> ack arrives here), for the baseline: the lowest
/// in the last 10 s is the path without any queue.
private var rtts: [(t: Int64, v: Int64)] = []
private var acked: [(t: Int64, bytes: Int)] = [] // the last second
private var sentLog: [(t: Int64, bytes: Int)] = []
private var captures: [Int64] = [] // the last second
private var frameBytes = 0 // average recent frame, kept while the gate holds everything back
private var held = 0 // frames the gate held back since the last update
private var lastSignal = false
private var sawAck = false
private var lastDecrease: Int64 = 0
private var lastIncrease: Int64 = 0
private var belowSince: Int64 = 0, aboveSince: Int64 = 0
/// What changed, for the timeline: (time, event).
private(set) var events: [(Int64, String)] = []
init(maxFps: Int) { self.maxFps = maxFps }
private let core: OpaquePointer
private var events: [[String: Any]] = []
init(maxFps: Int) {
self.maxFps = maxFps
core = fc_new(Int32(maxFps), Self.enabled ? 1 : 0)!
}
deinit { fc_free(core) }
private func locked<T>(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
/// The quality setting's bitrate at full size; the first call also starts there.
func setCeiling(_ bps: Int) {
locked {
if target == 0 || target > bps { target = bps }
ceiling = bps
}
}
var fps: Int { locked { fpsLocked } }
private var fpsLocked: Int { min(maxFps, RateController.tiers[tier].fps) }
var scale: Double { locked { RateController.tiers[tier].scale } }
var baseRtt: Int64 { locked { baseline() } }
private func baseline() -> Int64 { rtts.map(\.v).min() ?? 0 }
/// How late a frame may be before the gate holds the next one: one frame
/// interval, plus room for the jitter this link normally has (1.5 times
/// its recent spread), so ordinary Wi-Fi jitter doesn't cost frames but a
/// real queue does. Updated in update().
private var slack: Int64 = 40_000
/// Whether a frame may be sent now without queueing behind earlier ones.
/// `counts`: a held frame is a sign of congestion (not when merely
/// re-checking whether a held frame can go yet).
func maySend(now: Int64, counts: Bool = true) -> Bool {
locked {
guard RateController.enabled, sawAck else { return true } // not heard from the viewer yet
// Unacknowledged for 2 s: gone with a reconnection, not in a queue.
unacked.removeAll { now - $0.sent > 2_000_000 }
guard let oldest = unacked.first else { return true }
// Age is what bounds latency. The count only stops a burst, and it
// allows a full round trip of frames, so a long but clear path
// (100 ms away) still gets every frame.
let interval = Int64(1_000_000 / max(1, fpsLocked))
let window = max(3, Int((baseline() + slack) / interval) + 1)
if unacked.count < window, now - oldest.sent <= baseline() + slack { return true }
if counts { held += 1 }
return false
}
}
/// A picture was captured (sent or not): with the frame sizes, what this
/// stream would send if the link allowed.
func captured(at t: Int64) {
locked {
captures.append(t)
if captures.count > 256 { captures.removeFirst(captures.count - 256) }
}
}
func sent(seq: UInt32, bytes: Int, at t: Int64) {
locked {
// Bounded even if the viewer never acknowledges (an old viewer, or
// the controller is off).
unacked.append((seq, t, bytes))
if unacked.count > 512 { unacked.removeFirst(unacked.count - 512) }
sentLog.append((t, bytes))
if sentLog.count > 1024 { sentLog.removeFirst(sentLog.count - 1024) }
}
}
/// The viewer has frame `seq`. Returns true if that may let a held frame go.
func acked(seq: UInt32, at now: Int64) -> Bool {
locked {
sawAck = true
guard let i = unacked.firstIndex(where: { $0.seq == seq }) else { return false }
let f = unacked[i]
unacked.removeSubrange(0...i) // TCP delivers in order: earlier ones arrived too
rtts.append((now, now - f.sent))
acked.append((now, f.bytes))
return true
}
}
/// Called every 100 ms. Returns the new bitrate target, or nil if the
/// controller is off.
private func value(_ field: Int32) -> Int { Int(fc_value(core, field)) }
var target: Int { locked { value(0) } }
var tier: Int { locked { value(2) } }
var fps: Int { locked { value(3) } }
var scale: Double { locked { Double(value(4)) / 100 } }
var baseRtt: Int64 { locked { fc_value(core, 5) } }
func setCeiling(_ bps: Int) { locked { fc_ceiling(core, Int32(bps)) } }
func maySend(now: Int64, counts: Bool = true) -> Bool { locked { fc_gate(core, now, counts ? 1 : 0) != 0 } }
func captured(at t: Int64) { locked { fc_capture(core, t) } }
func sent(seq: UInt32, bytes: Int, at t: Int64) { locked { fc_sent(core, seq, Int32(bytes), t) } }
func acked(seq: UInt32, at t: Int64) -> Bool { locked { fc_ack(core, seq, t) != 0 } }
func update(now: Int64) -> Int? {
locked {
rtts.removeAll { now - $0.t > 10_000_000 }
acked.removeAll { now - $0.t > 500_000 }
sentLog.removeAll { now - $0.t > 500_000 }
unacked.removeAll { now - $0.sent > 2_000_000 }
captures.removeAll { now - $0 > 1_000_000 }
guard RateController.enabled, ceiling > 0 else { return nil }
let base = baseline()
let spread = rtts.filter { now - $0.t < 2_000_000 }.map(\.v).sorted()
let jitter = spread.isEmpty ? 0 : spread[spread.count * 9 / 10] - base
let interval = Int64(1_000_000 / max(1, fpsLocked))
slack = interval + min(max(jitter * 3 / 2, 25_000), 80_000)
let recent = rtts.filter { now - $0.t < 300_000 }.map(\.v).sorted()
let queueing = recent.isEmpty ? 0 : recent[recent.count / 2] - base
let oldestAge = unacked.first.map { now - $0.sent } ?? 0
let stuck = oldestAge > base + 100_000
let delivered = acked.reduce(0) { $0 + $1.bytes } * 16 // bits/s over the last half second
let sending = sentLog.reduce(0) { $0 + $1.bytes } * 16
// Demand: captures per second (up to the tier's rate) times the
// average frame. A test card or a mostly still window wants far
// less than its budget; when the link hiccups, cutting its bitrate
// can't help, and it would only look link-limited afterwards.
if !sentLog.isEmpty { frameBytes = sentLog.reduce(0) { $0 + $1.bytes } / sentLog.count }
let demand = min(captures.count, fpsLocked) * frameBytes * 8
// Delay alone isn't our queue: Wi-Fi jitters by itself. It only
// counts while this stream uses a good part of its budget (so its
// own data could be what's queueing). Frames the gate had to hold,
// or one stuck in flight, show demand the link isn't carrying
// whatever was sent (the gate itself keeps what's sent low).
let busy = sending >= target / 2
// Twice in a row (200 ms), so one late ack doesn't count.
let signal = (busy && queueing > 40_000) || held >= 3 || stuck
let congested = signal && lastSignal
lastSignal = signal
let heldNow = held
held = 0
let floorBps = 300_000
if congested, now - lastDecrease > 300_000 {
// Down to a bit under what got through: at least a fifth off, at
// most half (a stall delivers nothing, but the link is still there).
let measured = Int(Double(delivered) * 0.9)
var next = max(floorBps, min(target * 4 / 5, max(measured, target / 2)))
// App-limited (it wants about half its budget or less): never
// below twice what it wants, however many cuts in a row. The
// extra quarter is hysteresis, so frame sizes wobbling at the
// floor don't switch the protection off.
if demand > 0, demand * 2 <= target * 5 / 4 { next = max(next, min(target, demand * 2)) }
target = next
lastDecrease = now
events.append((now, "down to \(target / 1000) kbit/s: queue \(queueing / 1000) ms, held \(heldNow), "
+ "oldest \(oldestAge / 1000) ms, base \(base / 1000) ms, sent \(sending / 1000) got \(delivered / 1000) "
+ "wants \(demand / 1000)"))
} else if !congested, now - lastDecrease > 1_000_000, now - lastIncrease > 250_000, target < ceiling,
sending > target * 6 / 10 || now - lastDecrease > 3_000_000 {
// Clear for a second and using what it has: probe up.
target = min(ceiling, Int(Double(target) * 1.1) + 50_000)
lastIncrease = now
let old = value(0), tier = value(2)
let result = Int(fc_update(core, now))
if value(0) < old || value(2) != tier {
events.append(["t": now, "e": "target \(value(0)) bit/s; tier \(value(2))"])
if events.count > 200 { events.removeFirst() }
}
// Fewer frames or pixels only help a stream that fills its budget;
// a small one (a still window, a test card) keeps its tier.
retier(now: now, linkLimited: sending >= target * 7 / 10)
if events.count > 200 { events.removeFirst(events.count - 200) }
return target
return result == 0 ? nil : result
}
}
/// Steps down quickly, straight to the tier the bitrate supports, and back
/// up one tier at a time only when there's clearly room (hysteresis).
private func retier(now: Int64, linkLimited: Bool) {
let share = Double(target) / Double(max(ceiling, 1))
if tier < RateController.tiers.count - 1, share < RateController.floors[tier], linkLimited {
if belowSince == 0 { belowSince = now }
if now - belowSince > 500_000 {
tier = RateController.floors.firstIndex { share >= $0 } ?? RateController.tiers.count - 1
belowSince = 0
events.append((now, "tier \(tier)"))
}
} else {
belowSince = 0
}
if tier > 0, share > RateController.floors[tier - 1] * 1.25 {
if aboveSince == 0 { aboveSince = now }
if now - aboveSince > 2_000_000 {
tier -= 1
aboveSince = 0
events.append((now, "tier \(tier)"))
}
} else {
aboveSince = 0
}
}
func state() -> [String: Any] {
locked {
["target": target, "ceiling": ceiling, "tier": tier, "fps": min(maxFps, RateController.tiers[tier].fps),
"scale": RateController.tiers[tier].scale, "baseRtt": Double(baseline()) / 1000,
"inFlight": unacked.count, "slack": Double(slack) / 1000, "adapt": RateController.enabled]
}
locked { ["target": value(0), "ceiling": value(1), "tier": value(2), "fps": value(3),
"scale": Double(value(4)) / 100, "baseRtt": Double(value(5)) / 1000,
"inFlight": value(6), "slack": Double(value(7)) / 1000, "adapt": Self.enabled] }
}
func eventList() -> [[String: Any]] { locked { events.map { ["t": $0.0, "e": $0.1] } } }
func eventList() -> [[String: Any]] { locked { events } }
}
+4 -1
View File
@@ -5,7 +5,10 @@ set -eu
here=$(cd "$(dirname "$0")" && pwd)
out=${1:-$here/../bin/frame-mac-view}
mkdir -p "$(dirname "$out")"
obj=$(mktemp /tmp/frame-controller.XXXXXX)
trap 'rm -f "$obj"' EXIT
xcrun clang -O2 -target arm64-apple-macos14.0 -c "$here/../../desktop/controller.c" -o "$obj"
xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
-import-objc-header "$here/Sources/CGVirtualDisplay.h" \
-o "$out" "$here"/Sources/*.swift
-o "$out" "$here"/Sources/*.swift "$obj"
echo "built $out"
+2 -1
View File
@@ -23,7 +23,8 @@ started=$SECONDS
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
# That second build also rides out a flaky package mirror: only its failure is fatal.
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
+55 -13
View File
@@ -55,6 +55,7 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
RESULTS = ROOT / "bench" / "results"
PAGES = ROOT / "bench" / "pages"
@@ -254,7 +255,10 @@ class LabView(frame_macview.MacView):
def ssh_runner(base):
def run(remote, stdin=None, timeout=30):
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
capture_output=True, timeout=timeout)
r.stdout = r.stdout.decode("utf-8", errors="replace")
r.stderr = r.stderr.decode("utf-8", errors="replace")
if r.returncode:
e = RuntimeError((r.stderr or r.stdout).strip())
e.stdout = r.stdout
@@ -381,7 +385,11 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if args.browser_flag is not None:
mv.browser_flags = [f for f in args.browser_flag if f]
chrome = None
src = "test"
src = args.source or "test"
cpu = None
if args.pc:
mv.host = "windows" if sys.platform == "win32" else "linux"
mv.viewer_profile = "pc-view"
try:
if scenario in ("scroll", "type"):
chrome = chrome_window(f"{scenario}.html")
@@ -405,9 +413,10 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
since = max([f["s"] for f in first["frames"]] or [0])
captured_before = first["captured"]
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
if not args.pc:
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
if relay:
relay.begin()
expected = 0 # input events the harness asked the viewer for
@@ -443,7 +452,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if not streams:
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
data = streams[0]
cpu.stop()
if cpu:
cpu.stop()
if args.raw:
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
@@ -452,7 +462,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
for e in data.get("events", []) if e["t"] >= start_mac_us]
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
result["cpu"] = cpu.summary()
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
result["viewer"] = data["summary"].get("decoder", "")
result["show_s"] = show_s
result["panel"] = shown.get("panel")
@@ -460,6 +470,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
result["route"] = mv.route
return result
finally:
if cpu:
cpu.stop()
try:
mv.stop(src)
except Exception: # noqa: BLE001 - best effort
@@ -670,6 +682,8 @@ def fmt(v):
def add_run_args(r):
r.add_argument("--scenario", default="test,scroll,type")
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
r.add_argument("--duration", type=float, default=20)
r.add_argument("--warmup", type=float, default=3)
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
@@ -717,7 +731,7 @@ def frame_ssh_for(args):
def run_suite(args, frame_ssh, quiet=False):
agent_port, token = lab_agent()
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
results = []
for sc in args.scenario.split(","):
if not quiet:
@@ -744,11 +758,12 @@ def document(args, frame_ssh, results, **extra):
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
return {
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
"scenarios": results, **extra,
}
@@ -819,19 +834,46 @@ def main():
print("\nworse:\n " + "\n ".join(regs))
sys.exit(1 if regs else 0)
lab_agent()
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
if args.pc and args.scenario == "capture" and not args.source:
p.error("capture needs --source window:ID, display:ID, or choose")
if not args.pc:
lab_agent()
frame_ssh = frame_ssh_for(args)
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
args.pc_view = None
try:
if args.pc:
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
args.pc_view.ensure_agent()
if args.source == "choose":
args.pc_view.call("/permissions", method="POST")
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
deadline = time.monotonic()+125
while time.monotonic() < deadline:
state = args.pc_view.call("/status")
if not state.get("selecting"):
sources = args.pc_view.call("/windows")["windows"]
if not sources:
raise SystemExit(state.get("selectionError") or "Nothing was shared")
args.source = sources[-1]["src"]
break
time.sleep(.5)
else:
raise SystemExit("Sharing dialog timed out")
if args.cmd == "ab":
arm_runs, table = ab(args, frame_ssh)
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
return
results = run_suite(args, frame_ssh)
finally:
awake.terminate()
if args.pc_view:
args.pc_view.shutdown()
if awake:
awake.terminate()
doc = document(args, frame_ssh, results)
write(doc, args)
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env zsh
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
# The VM is a dockur/windows container on another machine; this reaches it over
# SSH through that machine. See docs/testing.md#windows-test-vm.
#
# Usage: scripts/windows-vm.sh <command> [args]
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
# ps '<PowerShell>' run PowerShell as the VM's user
# put <file> [<dir>] copy a file in (default: the user's Downloads)
# shot <out.png> save the VM's screen
# click <x> <y> click screen pixel x,y in the signed-in session
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
# alias) for a test run, then take it out again
#
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
set -euo pipefail
setopt extendedglob
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
ctr=${WINVM_CONTAINER:-frame-winvm}
user=${WINVM_USER:-frame}
port=${WINVM_PORT:-2222}
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
die() { print -u2 "windows-vm: $*"; exit 1 }
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
# These go into commands run by a shell on the other machine, so keep them plain.
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
vm_ps() {
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
}
# QEMU's monitor inside the container: one command per line on stdin.
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
# Input has to come from the signed-in desktop session, not SSH's session 0, so
# a scheduled task running as the user replays one click or wheel turn written
# to input.txt, then deletes the file to say it's done. Any error stops it
# before that, so the caller times out instead of reporting a click.
INPUT_PS1='$ErrorActionPreference = "Stop"
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
"@
[WinVm.Input]::SetProcessDPIAware() | Out-Null
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
Start-Sleep -Milliseconds 150
if ($a[2] -eq "click") {
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
Remove-Item "$PSScriptRoot\input.txt"'
pointer() { # x y click|wheel [notches]
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
vm_ps '$ErrorActionPreference = "Stop"
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
# Giving up: end the helper first, or it could wake up and act in a later request.
function Abandon {
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
foreach ($i in 1..25) {
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
Start-Sleep -Milliseconds 200
}
Remove-Item $req -ErrorAction SilentlyContinue
}
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
# One request at a time: the helper, the task and input.txt are shared. Windows
# frees the mutex when this process ends, however it ends.
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
New-Item -ItemType Directory -Force $d | Out-Null
[IO.File]::WriteAllText($req, "'"$*"'")
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
Start-ScheduledTask -TaskName WindowsVmInput
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
Abandon
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
}
(( $# )) || die "usage: see the top of $0"
cmd=$1; shift
case $cmd in
up)
ssh $host "docker start $ctr" >/dev/null
for i in {1..60}; do
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
sleep 5
done
die "Windows didn't answer on SSH within 5 minutes" ;;
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
put)
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
shot)
(( $# == 1 )) || die "usage: shot <out.png>"
print "screendump /tmp/windows-vm-shot.ppm" | monitor
sleep 1
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
import re, struct, sys, zlib
d = sys.stdin.buffer.read()
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
w, h = int(m[1]), int(m[2]); px = d[m.end():]
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
' $1
print $1 ;;
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
keys)
(( $# )) || die "usage: keys <key>..."
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
for k; do print "sendkey $k"; done | monitor ;;
frame-key)
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
f=~/.ssh/authorized_keys
if [ "$1" = add ]; then
if grep -qxF "$3" "$f"; then exit 0; fi
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
echo "$3" >> "$f"
else
t=$(mktemp "$f.XXXXXX") || exit 1
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
fi
fi
EOF
print "frame-key $1: done" ;;
*) die "unknown command: $cmd (see the top of $0)" ;;
esac
+7 -6
View File
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
@@ -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(){},loadMacView(){},loadPanelSwitcher(){}};
},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');
});
+79
View File
@@ -0,0 +1,79 @@
// The store's "Add a source" login handling and GitHub token field, run in node against
// the real functions from ui/index.html. A login typed into the address must leave the
// address field (into the login fields) before anything is sent, and stay out of it.
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const { moveLogin, sourceToken } = new Function(
"const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" +
grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")();
function form(url, username = "", password = "") {
const details = { open: false };
const field = value => ({ value, closest: () => details });
return { details, elements: { url: field(url), username: field(username), password: field(password) } };
}
let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo");
moveLogin(f);
if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value);
if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields");
if (!f.details.open) fail("login fields not shown");
f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw");
moveLogin(f);
if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw")
fail("typed login replaced, or address kept the login");
// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed.
for (const [url, address, user, pass] of [
["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"],
["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"],
["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"],
["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"],
["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"],
["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""],
]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`);
if (f.elements.username.value !== user || f.elements.password.value !== pass)
fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`);
}
// An address the parser rejects that may hold a login is refused and left as typed, never sent.
for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) {
f = form(url);
if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url);
if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url);
}
for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url);
}
const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } });
if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html);
if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one");
const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now();
f = form(slow); moveLogin(f);
if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong");
console.log("ok");
+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");
+1 -1
View File
@@ -325,7 +325,7 @@ class EndpointTests(SettingsTest):
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')
+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
@@ -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")
+6 -6
View File
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
state.clear()
self.addCleanup(state.clear)
def fetch(self, url, path, maximum):
def fetch(self, url, path, maximum, auth=None):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
@@ -311,7 +311,7 @@ class Repositories(unittest.TestCase):
source = self.add()
other = dict(source, id='other-repo')
started, release = threading.Event(), threading.Event()
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if threading.current_thread().name == 'slow':
started.set()
release.wait(5)
@@ -355,7 +355,7 @@ class Repositories(unittest.TestCase):
self.files['entry.jar'], _ = entry_jar(50)
source = fdroid.add_repo(URL)
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
name = threading.current_thread().name
if name in jars and url.endswith('entry.jar'):
Path(path).write_bytes(jars[name])
@@ -401,7 +401,7 @@ class Repositories(unittest.TestCase):
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
self.v1 = True
inner = []
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if url.endswith('index-v1.jar') and not inner:
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
self.v1 = False
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
source = self.add()
self.expire(source)
release = threading.Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
@@ -528,7 +528,7 @@ class Repositories(unittest.TestCase):
self.expire(source)
before = self.fetch_mock.call_count
release = __import__('threading').Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
+29 -8
View File
@@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase):
FIXTURES = ROOT / 'tests/fixtures/library'
def wait_for_idle_resolvers(timeout=10):
# Name lookups that outlast their deadline give their slot back from their own thread,
# which a busy runner may not schedule before the next test; wait until all are back.
from apk_sources import _images
held = []
try:
end = time.monotonic() + timeout
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
held.append(1)
if len(held) == 4:
return
raise AssertionError('artwork name lookups from an earlier test are still running')
finally:
for _ in held:
_images._resolvers.release()
class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
def test_source_inputs_and_url(self):
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
@@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(sum('Too many' in e for e in errors), 2)
finally:
gate.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
time.sleep(0.01)
_images._resolvers.release() # the stuck lookups finished and gave their slots back
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
def test_resolver_slot_released_when_thread_cannot_start(self):
from apk_sources import _images
@@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
for _ in range(6):
with self.assertRaises(RuntimeError):
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
for _ in range(4): # every slot came back
self.assertTrue(_images._resolvers.acquire(blocking=False))
for _ in range(4):
_images._resolvers.release()
held = 0
try:
while held < 4 and _images._resolvers.acquire(blocking=False):
held += 1
self.assertEqual(held, 4) # every slot came back
finally:
for _ in range(held): # even on failure, so later tests don't inherit the leak
_images._resolvers.release()
def test_deadline_covers_name_resolution(self):
import threading
+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):
+4 -1
View File
@@ -84,7 +84,10 @@ class Helpers(unittest.TestCase):
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
if sys.platform == "win32" and not Path(bash).exists():
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_show_checks_the_source_before_anything_else(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()
+64
View File
@@ -0,0 +1,64 @@
"""Frozen regression trace from the original mac-in-headset Swift controller.
The 600-state reference was produced at 1b90c64, before the C extraction:
60 seconds with a slow/congested middle, then recovery. No generated behavior
from the new implementation is used to calculate the expected digest.
"""
import ctypes as C
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
from frame_pc_capture import Controller
class ControllerTrace(unittest.TestCase):
def test_original_mac_congestion_and_recovery(self):
cc = shutil.which('cc') or shutil.which('gcc')
if not cc:
self.skipTest('C compiler not installed; native build jobs cover this trace')
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
lib = C.CDLL(str(path))
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
for name, (ret, args) in sig.items():
fn = getattr(lib, 'fc_'+name)
fn.restype, fn.argtypes = ret, args
c = Controller(lib, 60, 8000000)
due, states, seq = [], [], 0
try:
for tick in range(6000):
now = 10000000+tick*10000
for t, s in due:
if t <= now:
c.call('ack', s, now)
due = [(t, s) for t, s in due if t > now]
if tick % 2 == 0:
c.call('capture', now)
if c.call('gate', now, 1):
seq += 1
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
if tick % 10 == 0:
c.update(now)
x = c.state()
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
finally:
c.close()
if sys.platform == 'win32':
import _ctypes
_ctypes.FreeLibrary(lib._handle)
+388
View File
@@ -0,0 +1,388 @@
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
successful real-host capture.
"""
import ctypes
import http.client
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import threading
import time
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import frame_pc_agent as agent
import frame_pc_capture as capture
import frame_pcview
import frame_macview
from frame_stream_stats import Stats
from test_macview import WS
class GrantsTests(unittest.TestCase):
def test_source_bound_retry_ack_and_stop(self):
g = agent.Grants('master')
ticket = g.ticket('window:1')
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
key = g.redeem('window:1', {'t': ticket})
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
g.ack(key)
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
self.assertEqual(key, g.redeem('window:1', {'r': key}))
self.assertIsNone(g.redeem('window:2', {'r': key}))
g.revoke('window:1')
self.assertIsNone(g.redeem('window:1', {'r': key}))
def test_expiry_and_stop_before_redeem(self):
g = agent.Grants('master')
with mock.patch.object(agent.time, 'monotonic', return_value=1):
ticket = g.ticket('test')
with mock.patch.object(agent.time, 'monotonic', return_value=62):
self.assertIsNone(g.redeem('test', {'t': ticket}))
ticket = g.ticket('test')
g.revoke()
self.assertIsNone(g.redeem('test', {'t': ticket}))
class AdapterTests(unittest.TestCase):
def test_windows_uses_wgc_and_media_foundation(self):
for kind in ('window', 'display'):
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
self.assertIn('capture-api=wgc', pipeline)
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
self.assertNotIn('drop=true', pipeline)
with self.assertRaises(ValueError):
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
def test_linux_uses_only_portal_fd_and_node(self):
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
self.assertIn('pipewiresrc fd=19 path=47', p)
self.assertIn('tune=zerolatency', p)
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
def test_portal_input_clamps_and_releases_held_state(self):
native = mock.Mock()
native.lib.fc_portal_input.return_value = 1
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
inp.handle(dict(t='k', e='down', key='Control'))
inp.release()
calls = native.lib.fc_portal_input.call_args_list
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
self.assertFalse(inp.buttons or inp.keys)
def test_windows_release_and_negative_monitor_origin(self):
host = mock.Mock()
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
inp.release()
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
def test_refused_windows_focus_never_clicks_the_covering_app(self):
host = mock.Mock()
host.rect.return_value = (0, 0, 800, 600)
host.user.GetForegroundWindow.return_value = 99
host.user.SetForegroundWindow.return_value = False
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
host.send.assert_not_called()
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
view.ensure_tunnel = mock.Mock()
view.remote_port = 47900
view.show('window:12')
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
self.assertFalse(view.prefer_usb)
with self.assertRaises(frame_macview.MacViewError):
view.show('separate:12')
def test_session_end_releases_native_input_only_once(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event = threading.Event()
session.ws, session.input = mock.Mock(), mock.Mock()
session.released = False
session.end()
session.end()
session.input.release.assert_called_once()
self.assertTrue(session.stop_event.is_set())
def test_idle_source_is_not_a_stall(self):
# PipeWire/WGC deliver frames only on damage: a static desktop sends
# its first frame and then nothing. That must not end the session.
session = object.__new__(agent.Session)
session.lock, session.pending = threading.RLock(), {}
session.stats = Stats(lambda: 0)
session.stats.captured = 1
self.assertIsNone(session.stalled(60000000, 0, 0))
# A frame that entered the encoder and never came out is a fault.
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
self.assertIn('encoder', session.stalled(12000000, 0, 0))
self.assertIsNone(session.stalled(10000000, 0, 0))
# No initial frame at all after the pipeline opened is a fault.
session.pending.clear()
self.assertIn('capture', session.stalled(12000000, 0, 1))
self.assertIsNone(session.stalled(9000000, 0, 1))
def test_controller_is_inert_after_close(self):
lib = mock.Mock()
lib.fc_new.return_value = 1234
lib.fc_value.return_value = 100
controller = capture.Controller(lib, 60, 5000000)
controller.close()
lib.fc_free.assert_called_once_with(1234)
lib.reset_mock()
state = controller.state()
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
self.assertEqual(controller.call('gate', 1, 1), 0)
self.assertEqual(controller.update(1), 0)
controller.close()
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
def test_library_path_prepend_adds_no_empty_entry(self):
env = {}
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
with mock.patch.dict(os.environ, clear=True):
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
for name in ('PATH', 'LD_LIBRARY_PATH'):
parts = env.get(name, 'x').split(os.pathsep)
self.assertNotIn('', parts, name)
def test_malformed_viewer_input_is_dropped_not_fatal(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event, session.key_event = threading.Event(), threading.Event()
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
session.source = dict(src='display:1', w=100, h=100, portal=1)
session.input_enabled, session.released = True, False
session.native = mock.Mock()
session.native.now.return_value = 0
session.controller, session.agent = mock.Mock(), mock.Mock()
session.stats = Stats(lambda: 0)
session.produce = lambda: None
lib = mock.Mock()
lib.fc_portal_input.return_value = 1
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
session.ws = mock.Mock()
session.ws.receive.side_effect = [
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
with self.assertRaises(ConnectionError):
session.start()
# Every malformed message was skipped; the good move after them still ran.
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
self.assertAlmostEqual(moves[-1][2], .5*99)
self.assertEqual(session.ws.receive.call_count, 7)
def test_stats_keep_capture_time_and_bound_records(self):
stats = Stats(lambda: 10000000)
stats.input(dict(t='m', i=1, tv=9999990))
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
self.assertEqual(f['echo'], 1)
stats.report(dict(t='rx', s=f['s'], r=10000010))
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
for _ in range(4100):
stats.add(cap=10000001)
self.assertEqual(len(stats.frames), 4096)
class NativeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not capture.LIBRARY.exists():
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
raise AssertionError('CI required native PC libraries but they were not built')
raise unittest.SkipTest('native PC libraries not built on this host')
cls.token = 'test-' + os.urandom(8).hex()
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env)
line = cls.proc.stdout.readline()
if 'listening' not in line:
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
cls.port = int(line.rsplit(':', 1)[1])
@classmethod
def tearDownClass(cls):
cls.proc.stdin.close()
try:
rc = cls.proc.wait(10)
except subprocess.TimeoutExpired:
cls.proc.kill()
raise AssertionError('Native agent did not stop after EOF')
error = cls.proc.stderr.read()
cls.proc.stdout.close()
cls.proc.stderr.close()
if rc:
raise AssertionError('Native agent exited %s: %s' % (rc, error))
def request(self, path, method='GET'):
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
try:
conn.request(method, path)
response = conn.getresponse()
return response.status, response.read()
finally:
conn.close()
def tearDown(self):
self.request('/close?k='+self.token, 'POST')
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
def test_bundled_pipewire_context_needs_no_system_modules(self):
# No portal request, remote connection or desktop capture: create and
# destroy a private client context to test the dynamically loaded SPA
# and protocol modules that ldd cannot discover.
code = """
import ctypes as C, os
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
for name, ret, args in [
('pw_init', None, [C.c_void_p, C.c_void_p]),
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
('pw_context_destroy', None, [C.c_void_p]),
('pw_main_loop_destroy', None, [C.c_void_p])]:
fn = getattr(lib, name)
fn.restype, fn.argtypes = ret, args
lib.pw_init(None, None)
loop = lib.pw_main_loop_new(None)
assert loop, 'bundled SPA loop support did not load'
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
assert context, 'bundled PipeWire client modules did not load'
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
# The exported factory is video.adapt, which needs a live follower node to
# instantiate. Check its configured library and exported factory without
# pretending a headless context is a consented video stream.
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
assert plugin_name, 'video.adapt has no bundled library mapping'
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
class Factory(C.Structure):
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
plugin.spa_handle_factory_enum.restype = C.c_int
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
names.append(factory.contents.name)
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
lib.pw_context_destroy(context)
lib.pw_main_loop_destroy(loop)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
def test_last_raw_picture_survives_a_closed_gate(self):
# A source sends ONE changed frame, then becomes idle. It must reach
# the encoder after congestion clears, without needing another update.
code = """
import ctypes as C, sys, time
from frame_pc_capture import Native, GATE, Encoded, pipeline
native = Native()
start = None
closing = False
phases = []
def gate(stage, pts, capture, arrived):
global start
phases.append(stage)
if closing: return -1
if start is None: start = time.monotonic()
return int(stage == 1 or time.monotonic()-start >= .15)
callback = GATE(gate)
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
error = C.create_string_buffer(1024)
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
assert handle, error.value
try:
frame = Encoded()
result = 0
deadline = time.monotonic()+5
while not result and time.monotonic() < deadline:
result = native.lib.fc_capture_pull(handle, C.byref(frame))
assert result == 1, native.lib.fc_capture_error(handle)
assert frame.key and frame.size > 17
assert phases.count(0) == 1 and 2 in phases, phases
finally:
closing = True
native.lib.fc_capture_close(handle)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['PYTHONPATH'] = str(ROOT / 'ui')
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
def test_auth_and_real_h264_stats(self):
self.assertEqual(self.request('/status')[0], 403)
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
self.assertEqual(status, 200)
ticket = json.loads(body)['ticket']
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
try:
self.assertEqual(ws.status, 101)
key, got, echo = None, 0, False
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
deadline = time.monotonic()+10
while time.monotonic() < deadline and (got < 5 or not echo):
op, data = ws.recv()
if op == 1:
m = json.loads(data)
self.assertNotEqual(m.get('t'), 'error', m)
if m.get('t') == 'hello':
key = m['r']
ws.send_text(json.dumps(dict(t='ack')))
elif op == 2:
got += 1
seq, echoed = struct.unpack('!II', data[9:17])
now = struct.unpack('!Q', data[1:9])[0]
echo |= echoed == 7
self.assertIn(b'\x00\x00\x00\x01', data[17:])
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
self.assertGreaterEqual(got, 5)
self.assertTrue(echo)
time.sleep(.1)
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
self.assertIn('controller', stats)
used = WS(self.port, '/stream?src=test&t='+ticket)
self.assertEqual(used.status, 403)
used.close()
self.request('/close?src=test&k='+self.token, 'POST')
revoked = WS(self.port, '/stream?src=test&r='+key)
self.assertEqual(revoked.status, 403)
revoked.close()
finally:
ws.close()
if __name__ == '__main__':
unittest.main()
+566
View File
@@ -0,0 +1,566 @@
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
request processing still run), so these tests see every request and its headers.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import contextlib
import http.client
import io
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock, patch
import urllib.request
import urllib.response
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _images, _web, fdroid, github, search
import server
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
REPO = 'https://repo.example/fdroid/repo/'
USER, PASSWORD = 'alice', 's3cr@t-pässword'
TOKEN = 'ghp_frameControlTestToken0123456789'
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
REAL_BUILD_OPENER = urllib.request.build_opener
def basic(user, password):
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
class FakeHTTPS(urllib.request.HTTPSHandler):
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
def __init__(self, route):
super().__init__()
self.route, self.seen = route, []
def https_open(self, req):
auth = req.get_header('Authorization')
self.seen.append((req.full_url, auth))
status, headers, body = self.route(req.full_url, auth)
message = http.client.HTTPMessage()
for key, value in headers.items():
message[key] = value
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
response.msg = 'fake'
return response
class Base(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
mock = patch.object(fdroid.frame_host, name, value)
mock.start()
self.addCleanup(mock.stop)
self.web = FakeHTTPS(self.route)
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
mock.start()
self.addCleanup(mock.stop)
mock = patch.dict(os.environ, {github.ENV: ''})
mock.start()
self.addCleanup(mock.stop)
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
state.clear()
self.addCleanup(state.clear)
self.stderr = io.StringIO()
mock = patch('sys.stderr', self.stderr)
mock.start()
self.addCleanup(mock.stop)
def route(self, url, auth):
raise AssertionError('unexpected request ' + url)
def assertNoSecrets(self, *texts):
for text in texts:
for secret in SECRETS + (USER + ':',):
self.assertNotIn(secret, text)
def disk(self):
"""Everything saved under the data and cache folders except the credentials file."""
out = []
for path in self.root.rglob('*'):
if path.is_file() and path.name != 'source-credentials.json':
out.append(path.read_bytes().decode('utf-8', 'replace'))
return '\n'.join(out)
def credentials_file(self):
return self.root / 'data' / 'source-credentials.json'
class Repository(Base):
"""A repository at REPO that wants USER and PASSWORD."""
def setUp(self):
super().setUp()
self.login = basic(USER, PASSWORD)
self.redirect = None # file name -> absolute URL it redirects to
self.forbidden = 401
def serve(self, name):
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
def route(self, url, auth):
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
return 200, {}, self.serve(url[len(mirror):])
self.assertTrue(url.startswith(REPO), url)
if auth != self.login:
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
name = url[len(REPO):]
if self.redirect and name in self.redirect:
return 302, {'Location': self.redirect[name]}, b''
return 200, {}, self.serve(name)
def add(self, **kw):
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
class RepositoryLogin(Repository):
def test_add_search_download_with_login_in_the_url(self):
source = self.add()
self.assertEqual(source['url'], REPO)
self.assertEqual(source['fingerprint'], PIN)
saved = fdroid.user_repos()[0]
self.assertEqual(saved['url'], REPO)
self.assertNotIn('login', saved)
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
downloaded = fdroid.download(source, 'org.example.app', 1)
self.assertTrue(downloaded['verified'])
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
# The settings file holds no part of the login; the credentials file holds it, privately.
settings = (self.root / 'data' / 'apk-repos.json').read_text()
self.assertNotIn(USER, settings)
self.assertTrue(self.credentials_file().is_file())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
self.assertEqual(source['url'], REPO)
self.web.seen.clear()
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
self.assertTrue(_credentials.has(source['id']))
def test_wrong_or_missing_login_saves_nothing(self):
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
({}, 'needs a username and password')]:
with self.subTest(kw=kw):
with self.assertRaisesRegex(SourceAuthError, message) as caught:
fdroid.add_repo(REPO, PIN, **kw)
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
self.assertNotIn('wrong', str(caught.exception))
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
self.forbidden = 403
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
self.assertEqual(_web._limited, {})
self.assertEqual(fdroid.user_repos(), [])
def test_signature_checks_still_apply(self):
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
def test_cross_origin_redirect_never_gets_the_login(self):
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
source = self.add()
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
self.assertEqual(mirror, [None])
self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either
def test_traversal_names_are_refused_and_never_get_the_login(self):
for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk',
'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'):
with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'):
fdroid._child(REPO, name)
self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'),
REPO + 'app/en-US/phoneScreenshots/%230%20a.png')
# Even if such a URL reached the downloader directly, the login stays home.
login = _credentials.basic(REPO, USER, PASSWORD)
for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'):
with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp:
self.web.seen.clear()
with contextlib.suppress(SourceError): # the repository answers 401 without the login
fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login)
self.assertEqual(self.web.seen, [(url, None)])
def test_same_origin_redirect_outside_the_repository_gets_no_login(self):
self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'}
source = self.add()
self.web.seen.clear()
self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified'])
self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login),
('https://repo.example/elsewhere/example1.apk', None)])
def test_other_ports_and_http_never_get_the_login(self):
login = _credentials.basic(REPO, USER, PASSWORD)
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
self.assertFalse(login.matches(url), url)
self.assertTrue(login.matches('https://REPO.example:443/anything'))
self.assertNoSecrets(repr(login))
handler = _credentials.Handler(login)
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
def test_http_and_bad_logins_are_refused_before_any_request(self):
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
(REPO, {'username': 'a:b', 'password': PASSWORD}),
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
(REPO, {'password': PASSWORD}),
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
fdroid.add_repo(url, PIN, **kw)
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
self.assertNoSecrets(str(caught.exception))
self.assertEqual(self.web.seen, [])
def test_remove_deletes_the_login(self):
source = self.add()
other = _credentials.bearer('https://api.github.com/', TOKEN)
_credentials.put('github', other)
fdroid.remove_repo(source['id'])
self.assertFalse(_credentials.has(source['id']))
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
_credentials.remove('github')
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
def test_cli_reads_the_password_from_stdin(self):
out = io.StringIO()
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
contextlib.redirect_stdout(out):
fdroid.main()
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
self.assertNoSecrets(out.getvalue())
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
source = self.add()
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
self.assertTrue(_credentials.has(source['id']))
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
class GitHubToken(Base):
def setUp(self):
super().setUp()
self.accept = {TOKEN}
self.releases = json.dumps([]).encode()
self.anonymous_limited = False
def route(self, url, auth):
if url.startswith('https://api.github.com/'):
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
return 401, {}, b'{"message": "Bad credentials"}'
if auth is None and self.anonymous_limited:
return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}'
if url.endswith('/redirected'):
return 302, {'Location': 'https://github.com/landed'}, b''
return 200, {}, self.releases
if url == 'https://github.com/landed':
return 200, {}, b'{"ok": true}'
raise AssertionError('unexpected request ' + url)
def test_saved_token_goes_only_to_the_api(self):
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
auth = github._credential(github._token()[0])
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
b'{"ok": true}')
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
('https://github.com/landed', None)]) # redirect: no token
status = github.sources()[0]['token']
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_rejected_token_retries_once_without_it(self):
github.set_token(TOKEN)
self.accept = set()
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
self.assertTrue(github.token_status()['rejected'])
self.assertIn('rejected', self.stderr.getvalue())
self.assertNoSecrets(self.stderr.getvalue())
self.web.seen.clear()
github._api('/repos/a/b/other')
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
github.set_token(TOKEN) # saving it again gives it a fresh chance
self.assertFalse(github.token_status()['rejected'])
def test_environment_variable_wins(self):
github.set_token(TOKEN)
env = 'ghp_fromTheEnvironment'
self.accept = {env}
with patch.dict(os.environ, {github.ENV: env}):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
self.assertEqual(github.token_status()['from'], 'saved')
def test_clear_removes_it_from_disk(self):
github.set_token(TOKEN)
github.clear_token()
self.assertFalse(self.credentials_file().exists())
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
def test_saving_a_token_escapes_the_anonymous_rate_limit(self):
self.anonymous_limited = True
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota
self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN))
github.clear_token()
count = len(self.web.seen)
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/third')
self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request
def test_bad_tokens_are_refused(self):
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
with self.subTest(token=token), self.assertRaises(SourceError):
github.set_token(token)
self.assertFalse(self.credentials_file().exists())
PNG = b'\x89PNG\r\n\x1a\nfixture'
PRIVATE = 'https://repo.example/private/repo/'
TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png',
PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png',
PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png',
'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png']
class ArtworkScope(Base):
"""The artwork proxy: actual outgoing headers, through _images.get's own HTTP code."""
def setUp(self):
super().setUp()
self.sent, self.redirects = [], {}
_credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD))
test = self
class Response:
def __init__(self, status, location=''):
self.status, self.location, self.body = status, location, PNG if status == 200 else b''
def getheader(self, name, default=''):
return self.location if name == 'Location' else default
def read1(self, n):
chunk, self.body = self.body[:n], self.body[n:]
return chunk
class Connection:
def __init__(self, host, port, timeout=None):
self.host, self.port = host, port
def request(self, method, path, headers):
url = 'https://%s%s' % (self.host, path)
test.sent.append((url, headers.get('Authorization')))
self.url = url
def getresponse(self):
if self.url in test.redirects:
return Response(302, test.redirects[self.url])
return Response(200)
def close(self):
pass
# A public address, no real socket or TLS, and Connection in place of http.client's.
for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]),
(_images.socket, 'create_connection', lambda *a, **k: Mock()),
(_images.ssl, 'create_default_context', lambda: Mock()),
(_images.http.client, 'HTTPConnection', Connection)]:
mock = patch.object(target, name, value)
mock.start()
self.addCleanup(mock.stop)
def test_artwork_in_the_repository_gets_the_login(self):
self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG)
self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))])
def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self):
for url in TRAVERSALS:
with self.subTest(url=url):
self.sent.clear()
self.assertEqual(_images.get(url), PNG)
self.assertEqual(self.sent, [(url, None)])
self.assertIsNone(_credentials.for_url(url))
def test_redirect_to_a_traversal_gets_nothing(self):
start = PRIVATE + 'icons/a.png'
self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'
self.assertEqual(_images.get(start), PNG)
self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)])
class ScopedHandler(Base):
"""Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included."""
def route(self, url, auth):
if url == PRIVATE + 'icons/a.png':
return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b''
return 200, {}, PNG
def test_redirect_into_a_traversal_is_stripped(self):
login = _credentials.basic(PRIVATE, USER, PASSWORD)
opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True))
with opener.open(PRIVATE + 'icons/a.png') as r:
self.assertEqual(r.read(), PNG)
self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)),
(PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)])
for url in TRAVERSALS:
self.assertFalse(_credentials.in_scope(login, url), url)
self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1'))
self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/')))
CHURN = '''
import sys
sys.path.insert(0, sys.argv[1])
from apk_sources import _credentials
for i in range(int(sys.argv[3])):
key = sys.argv[2] + str(i)
_credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p'))
if i % 2:
_credentials.remove(key)
'''
class TwoProcesses(Base):
def test_interleaved_saves_and_removals_are_not_lost(self):
ui = str(Path(__file__).resolve().parents[1] / 'ui')
env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data'))
count = 60
procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')]
for p in procs:
out, err = p.communicate(timeout=120)
self.assertEqual(p.returncode, 0, err.decode())
saved = set(json.loads(self.credentials_file().read_text()))
self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)})
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
@unittest.skipUnless(shutil.which('node'), 'needs node')
class PageLogin(unittest.TestCase):
def test_login_leaves_the_address_field_before_sending(self):
"""tests/page/source_login.mjs runs the real page functions from index.html."""
r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('ok', r.stdout)
class Endpoints(Repository):
"""The server API: logins in, never out, in responses or the server log."""
def setUp(self):
super().setUp()
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
mock.start()
self.addCleanup(mock.stop)
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def request(self, method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
raw = r.read().decode()
c.close()
self.replies.append(raw)
return r.status, json.loads(raw)
def job(self, reply):
for _ in range(500):
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
if job['done']:
return job
time.sleep(.01)
self.fail('job did not finish')
def test_add_with_login_and_token_settings(self):
self.replies = []
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': 'wrong'})[1])
self.assertIn('check the username and password', bad['error'])
self.assertEqual(fdroid.user_repos(), [])
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': PASSWORD})
self.assertEqual(code, 200)
job = self.job(reply)
self.assertIsNone(job['error'])
listed = self.request('GET', '/api/sources')[1]['sources']
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
self.assertEqual((repo['url'], repo['login']), (REPO, True))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
'token': TOKEN})[0], 200)
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
'token': TOKEN})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
self.assertFalse(_credentials.has('github'))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
self.assertFalse(self.credentials_file().exists())
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
def test_login_in_the_url_is_accepted(self):
self.replies = []
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
self.assertIsNone(job['error'])
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
if __name__ == '__main__':
unittest.main()
+4
View File
@@ -45,6 +45,10 @@ class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
class SourceAuthError(SourceError):
"""The server refused our login (HTTP 401, or 403 when a login was sent)."""
class SourceLimited(SourceError):
"""The source's host asked us to slow down; retry_after is in seconds."""
+270
View File
@@ -0,0 +1,270 @@
"""Logins for sources behind one: HTTP basic auth and access tokens.
Saved apart from the source settings, in frame_host.data_dir('source-credentials.json')
(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each
credential belongs to one exact origin (https, host, port) and is only ever sent
to a request for that origin: Handler adds it to each request, redirects
included, and removes any Authorization header from every other request.
Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and
artwork use) is stricter: the URL must also be under the credential's scope by whole
path segments, with no dot segments, encoded slashes or ';' a server could normalise
into a path outside it. Origin-only matching suits a host-wide token (GitHub's API).
Secrets leave this module only as an Authorization header value. Credential's
repr hides them, and nothing here puts them in an exception message.
Each change is a read-modify-write under an inter-process lock (the CLI and the
app may both save), so one process can't undo another's save or removal.
"""
import base64
import contextlib
import errno
import json
import os
import tempfile
import threading
import urllib.parse
import urllib.request
import frame_host
_lock = threading.RLock()
MAX = 4096 # longest username, password or token accepted
def _path():
return frame_host.data_dir('source-credentials.json')
@contextlib.contextmanager
def _changing():
"""This thread, then this process, holds the credentials file for a read-modify-write."""
path = _path().with_name('source-credentials.lock')
with _lock:
path.parent.mkdir(parents=True, exist_ok=True)
with open(str(path), 'a+b') as f:
if os.name == 'nt':
import msvcrt
while True:
try:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
if e.errno not in (errno.EACCES, errno.EDEADLK):
raise
try:
yield
finally:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def path_segments(path):
"""A URL path's decoded segments, or None if a server might read it as another path:
'.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding
(double encoding), or ';' path parameters."""
if ';' in path or '\\' in path:
return None
parts = [urllib.parse.unquote(part) for part in path.split('/')]
if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts):
return None
return parts
def in_scope(credential, url):
"""True if url is on credential's origin and under its scope by whole, plain path segments."""
if credential is None or not credential.matches(url):
return False
try:
path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path
except ValueError:
return False
parts, base = path_segments(path), path_segments(scope)
if parts is None or base is None:
return False
if base and base[-1] == '':
base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository'
return parts[:len(base)] == base
def origin(url):
"""'https://host:port' for an https URL; ValueError for anything else."""
p = urllib.parse.urlsplit(url)
port = p.port # raises ValueError on a bad port
if p.scheme.lower() != 'https' or not p.hostname:
raise ValueError('credentials are only sent over HTTPS')
host = p.hostname.lower()
return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443)
def _check(value, what, allow_empty=False):
if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX:
raise ValueError('invalid ' + what)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError(what + ' must not contain control characters')
return value
class Credential:
"""A username and password (basic) or a token (bearer) for one origin.
scope is the URL prefix it belongs under: in_scope checks it (by path segments),
matches() checks only the origin."""
__slots__ = ('origin', 'scope', 'username', '_secret')
def __init__(self, scope, secret, username=None):
self.origin = origin(scope)
self.scope = scope
if username is not None:
_check(username, 'username')
if ':' in username:
raise ValueError('username must not contain ":"')
_check(secret, 'password', allow_empty=True)
else:
_check(secret, 'token')
if any(c.isspace() for c in secret):
raise ValueError('token must not contain spaces')
self.username, self._secret = username, secret
@property
def kind(self):
return 'bearer' if self.username is None else 'basic'
def header(self):
if self.username is None:
return 'Bearer ' + self._secret
return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii')
def matches(self, url):
"""True only for a URL on exactly this credential's origin."""
try:
return origin(url) == self.origin
except ValueError:
return False
def same(self, other):
return isinstance(other, Credential) and (self.origin, self.username, self._secret) == (
other.origin, other.username, other._secret)
def __repr__(self):
return '<Credential %s for %s>' % (self.kind, self.origin)
def _json(self):
return {'scope': self.scope, 'username': self.username, 'secret': self._secret}
def basic(scope, username, password):
"""Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw')."""
return Credential(scope, password, username)
def bearer(scope, token):
"""An access token for scope's origin, sent as 'Authorization: Bearer <token>'."""
return Credential(scope, token)
def _read():
try:
with open(str(_path()), encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError
return data
except FileNotFoundError:
return {}
except (OSError, ValueError):
return {} # unreadable: as if none were saved; the next save rewrites it
def _save(data):
path = _path()
if not data:
try:
os.remove(str(path))
except FileNotFoundError:
pass
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, separators=(',', ':'))
os.chmod(tmp, 0o600)
os.replace(tmp, str(path))
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def get(key):
"""The saved Credential for key, or None."""
with _lock:
item = _read().get(key)
try:
return Credential(item['scope'], item['secret'], item.get('username'))
except (TypeError, KeyError, ValueError):
return None
def has(key):
return get(key) is not None
def put(key, credential):
if not isinstance(credential, Credential):
raise TypeError('put() needs a Credential')
with _changing():
data = _read()
data[key] = credential._json()
_save(data)
def remove(key):
"""Delete key's credential from disk; True if there was one."""
with _changing():
data = _read()
if key not in data:
return False
del data[key]
_save(data)
return True
def for_url(url):
"""A saved credential url is in the scope of (see in_scope), or None. Call it again for
each redirect hop: the answer depends only on the URL being requested."""
with _lock:
keys = list(_read())
for key in keys:
credential = get(key)
if in_scope(credential, url):
return credential
return None
class Handler(urllib.request.BaseHandler):
"""Adds the credential to requests for its origin (redirects included) and strips
Authorization from every other request, so a redirect elsewhere never gets it.
scoped=True also requires each request, redirects included, to be in_scope."""
handler_order = 900 # after the default processors
def __init__(self, credential, scoped=False):
self.credential, self.scoped = credential, scoped
def _request(self, req):
req.remove_header('Authorization')
allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url)
if allowed(self.credential, req.full_url):
req.add_unredirected_header('Authorization', self.credential.header())
return req
http_request = https_request = _request
+6 -2
View File
@@ -9,7 +9,7 @@ import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
from apk_sources import SourceError, _credentials
_lock = threading.Lock()
_urls = OrderedDict()
@@ -141,7 +141,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
path = p.path or '/'
if p.query:
path += '?' + p.query
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})}
login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop
if login:
sent['Authorization'] = login.header()
conn.request('GET', path, headers=sent)
live[0].settimeout(min(10, left()))
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
+28 -13
View File
@@ -3,7 +3,7 @@ import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.pars
from email.utils import parsedate_to_datetime
import frame_host
from . import SourceError, SourceLimited
from . import SourceAuthError, SourceError, SourceLimited, _credentials
UA = 'FrameControl/0.1'
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
@@ -38,7 +38,7 @@ def release(path):
else:
_in_use.pop(path, None)
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
_limited = {} # host -> time.time() before which we don't contact it
_limited = {} # host (+ login identity) -> time.time() before which we don't ask it with that login
_limited_lock = threading.Lock()
@@ -46,8 +46,16 @@ def _host(url):
return (urllib.parse.urlsplit(url).hostname or '').lower()
def throttle(url, headers=None):
"""Remember that url's host asked us to back off; return the delay in seconds."""
def _who(url, auth=None):
"""Backoff key: the host, per login. A token has its own quota, so anonymous
rate limiting mustn't hold back requests made with one (or the reverse)."""
if auth is None:
return _host(url)
return _host(url) + ' ' + hashlib.sha256(auth.header().encode()).hexdigest()[:16]
def throttle(url, headers=None, auth=None):
"""Remember that url's host asked us (with this login) to back off; return the delay in seconds."""
headers = headers or {}
now, delay = time.time(), None
value = (headers.get('Retry-After') or '').strip()
@@ -62,14 +70,15 @@ def throttle(url, headers=None):
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
delay = int(reset) - now # GitHub
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
key = _who(url, auth)
with _limited_lock:
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
_limited[key] = max(_limited.get(key, 0), now + delay)
return delay
def wait_time(url):
def wait_time(url, auth=None):
with _limited_lock:
return max(0, _limited.get(_host(url), 0) - time.time())
return max(0, _limited.get(_who(url, auth), 0) - time.time())
def limited_error(name, seconds, hint=''):
@@ -136,6 +145,8 @@ def checked_url(url, hosts):
class Redirect(urllib.request.HTTPRedirectHandler):
"""Only to the allowed hosts, and never carrying an Authorization header along:
a credential passed to open_url() is added again, by its Handler, only on its origin."""
def __init__(self, hosts):
self.hosts = hosts
@@ -147,13 +158,15 @@ class Redirect(urllib.request.HTTPRedirectHandler):
return result
def open_url(url, hosts, headers=None):
def open_url(url, hosts, headers=None, auth=None):
"""auth: an _credentials.Credential, sent only to requests on its own origin."""
checked_url(url, hosts)
return urllib.request.build_opener(Redirect(hosts)).open(
handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else [])
return urllib.request.build_opener(*handlers).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
name = name or _host(url) or 'The source'
@@ -165,13 +178,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
with open(path, 'rb') as f:
return f.read()
wait = wait_time(url)
wait = wait_time(url, auth)
if wait:
data = cached()
if data is None:
raise limited_error(name, wait, hint)
return data
with open_url(url, hosts, headers) as r:
with open_url(url, hosts, headers, auth) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
@@ -185,8 +198,10 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
os.remove(tmp)
return data
except urllib.error.HTTPError as e:
if e.code == 401: # not 403: GitHub also uses that for rate limits, token or not
raise SourceAuthError('%s refused the login (HTTP 401)' % name) from e
if e.code in (403, 429):
delay = throttle(url, e.headers)
delay = throttle(url, e.headers, auth)
data = cached()
if data is None:
raise limited_error(name, delay, hint) from e
+82 -16
View File
@@ -20,7 +20,7 @@ import zipfile
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from apk_sources import SourceError, SourceLimited, _web
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _web
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
@@ -71,6 +71,39 @@ def _url(url, fingerprint=None):
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
def _split_login(url, username=None, password=None):
"""(url without user:pass, credential or None). The address may carry the login, as
F-Droid's client accepts (https://user:pass@host/fdroid/repo), or it may be given apart."""
try:
p = urllib.parse.urlsplit(url)
p.port
except ValueError as e:
raise SourceError('invalid repository URL') from e
if '@' in p.netloc:
if p.username is None:
raise SourceError('invalid repository URL')
linked = urllib.parse.unquote(p.username), None if p.password is None else urllib.parse.unquote(p.password)
if username not in (None, '', linked[0]) or (linked[1] is not None and password not in (None, '', linked[1])):
raise SourceError('the address and the username/password fields disagree')
username = linked[0]
password = linked[1] if linked[1] is not None else password
url = urllib.parse.urlunsplit((p.scheme, p.netloc.rpartition('@')[2], p.path, p.query, p.fragment))
if username in (None, ''):
if password:
raise SourceError('a password needs a username')
return url, None
clean, _ = _url(url)
try:
return url, _credentials.basic(clean, username, password or '')
except ValueError as e:
raise SourceError('cannot use that login: ' + str(e)) from None
def _login(source):
"""The saved login for a user repository, or None."""
return None if source.get('builtin') else _credentials.get(source['id'])
def _child(base, name):
name = str(name).lstrip('/')
decoded = urllib.parse.unquote(name)
@@ -81,6 +114,9 @@ def _child(base, name):
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
raise SourceError('repository file is outside its repository')
# Nothing a server could normalise elsewhere: double-encoded dots, ';' parameters, encoded slashes.
if _credentials.path_segments(urllib.parse.urlsplit(url).path) is None:
raise SourceError('unsafe repository file name')
return url
@@ -91,14 +127,17 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _fetch(url, path, maximum):
def _fetch(url, path, maximum, auth=None):
"""auth: the repository's login, sent only inside the repository (its origin and directory,
by whole plain path segments), on every request including redirects."""
host = urllib.parse.urlsplit(url).hostname
wait = _web.wait_time(url)
wait = _web.wait_time(url, auth)
if wait:
raise _web.limited_error(host, wait)
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
handlers = [_HTTPSRedirect()] + ([_credentials.Handler(auth, scoped=True)] if auth else [])
try:
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
with urllib.request.build_opener(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
@@ -109,8 +148,11 @@ def _fetch(url, path, maximum):
raise SourceError('repository file exceeds size limit')
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code == 401 or (e.code == 403 and auth):
raise SourceAuthError('The repository refused the login (HTTP %d); check the username and password'
% e.code if auth else 'This repository needs a username and password') from None
if e.code in (403, 429):
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
raise _web.limited_error(host, _web.throttle(url, e.headers, auth)) from e
raise
@@ -342,7 +384,7 @@ def sources():
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
# The archive only holds superseded versions; it clutters search unless asked for.
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
for i, n, u, p in builtins] + settings['repos']
for i, n, u, p in builtins] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']]
def _text(value):
@@ -514,10 +556,12 @@ def _source_lock(source_id):
return _load_locks.setdefault(source_id, threading.Lock())
def _load(source, force=False):
def _load(source, force=False, auth=None):
"""auth: a login to use instead of the saved one (while adding, before anything is saved)."""
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
raise SourceError('invalid source id')
_url(source['url'], source.get('fingerprint'))
auth = auth or _login(source)
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
found = None if force else _cached(source, cache)
if found:
@@ -535,7 +579,7 @@ def _load(source, force=False):
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
@@ -543,7 +587,7 @@ def _load(source, force=False):
raise SourceError('repository no longer serves its signed v2 index; '
'refusing to fall back to the older v1 index') from e
v2 = False
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024, auth=auth)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
@@ -552,7 +596,7 @@ def _load(source, force=False):
signed = json.loads(content)
timestamp, entry = signed.get('timestamp'), signed['index']
_check_timestamp(source, timestamp)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024, auth=auth)
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source)
@@ -572,7 +616,10 @@ def _load(source, force=False):
raise SourceError('cannot load repository: ' + str(e)) from e
def add_repo(url, fingerprint=None, name=None):
def add_repo(url, fingerprint=None, name=None, username=None, password=None):
"""Check and save a repository. A login (in the URL or apart) is kept out of the
saved URL, in _credentials, and saved only once the repository's index verified."""
url, login = _split_login(url, username, password)
url, pin = _url(url, fingerprint)
with _LOCK:
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
@@ -583,15 +630,26 @@ def add_repo(url, fingerprint=None, name=None):
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
# Network work outside the settings lock; without a new login, a saved one (re-adding) is used.
_, source['fingerprint'] = _load(source, force=True, auth=login)
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
with _LOCK:
settings = _read()
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
if current and current['fingerprint'] != source['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
previous = _credentials.get(source['id'])
if login:
_credentials.put(source['id'], login)
try:
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
except BaseException:
if login and previous: # not saved after all: leave the login as it was
_credentials.put(source['id'], previous)
elif login:
_credentials.remove(source['id'])
raise
return source
@@ -602,6 +660,7 @@ def remove_repo(source_id):
raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings)
_credentials.remove(source_id)
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
states = _states()
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
@@ -663,7 +722,7 @@ def download(source, entry_id, version_code=None):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
try:
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3, auth=_login(source))
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
@@ -684,6 +743,8 @@ def main():
add.add_argument('url')
add.add_argument('--fingerprint')
add.add_argument('--name')
add.add_argument('--username', help='for a repository behind a login (HTTP basic auth)')
add.add_argument('--password-stdin', action='store_true', help='read the password from the first line of stdin')
sub.add_parser('list')
remove = sub.add_parser('remove')
remove.add_argument('source')
@@ -702,7 +763,12 @@ def main():
def _cli(parser, args):
try:
if args.command == 'add':
result = add_repo(args.url, args.fingerprint, args.name)
password = None
if args.password_stdin:
if not args.username:
raise SourceError('--password-stdin needs --username')
password = sys.stdin.readline().rstrip('\r\n')
result = add_repo(args.url, args.fingerprint, args.name, args.username, password)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
+88 -8
View File
@@ -1,17 +1,77 @@
"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, json, os, re, urllib.parse
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
from . import SourceError, _web
from . import SourceAuthError, SourceError, _credentials, _web
KIND = 'github'
API = 'https://api.github.com'
ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings
CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com
_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only)
_rejected_lock = threading.Lock()
TOPICS = ('oculus-quest', 'openxr', 'quest')
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
'builtin': True, 'enabled': True, 'trust': 'community'}]
'builtin': True, 'enabled': True, 'trust': 'community', 'token': token_status()}]
def _token():
"""(token, where it came from) or (None, None). The environment variable wins."""
env = os.environ.get(ENV)
if env:
return env, 'env'
saved = _credentials.get(CREDENTIAL)
if saved and saved.kind == 'bearer':
return saved, 'saved'
return None, None
def _credential(token):
if isinstance(token, _credentials.Credential):
return token
try:
return _credentials.bearer(API + '/', token)
except ValueError as e:
raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None
def _digest(credential):
return hashlib.sha256(credential.header().encode()).hexdigest()
def token_status():
"""Whether a token is set, where from, and whether GitHub rejected it; never the token."""
token, where = _token()
rejected = False
if token is not None:
try:
with _rejected_lock:
rejected = _digest(_credential(token)) in _rejected
except SourceError:
rejected = True
return {'set': token is not None, 'from': where, 'rejected': rejected}
def set_token(token):
"""Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins)."""
if not isinstance(token, str) or not token.strip():
raise SourceError('Paste a GitHub token')
try:
credential = _credentials.bearer(API + '/', token.strip())
except ValueError as e:
raise SourceError('That is not a usable GitHub token: ' + str(e)) from None
_credentials.put(CREDENTIAL, credential)
with _rejected_lock:
_rejected.discard(_digest(credential)) # a fresh chance
return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')}
def clear_token():
_credentials.remove(CREDENTIAL)
return {'message': 'GitHub token removed'}
def _curated():
@@ -20,13 +80,33 @@ def _curated():
def _api(path):
"""GET from the GitHub API, with the user's token if there is one. If GitHub rejects
the token (401), retry once without it, like Obtainium, and remember the rejection."""
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
token, _ = _token()
auth = _credential(token) if token is not None else None
with _rejected_lock:
if auth and _digest(auth) in _rejected:
auth = None # already refused this run: don't spend a request on it again
hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV
if token is not None and not auth:
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
def get(auth):
return _web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if auth else hint, auth=auth)
try:
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
try:
data = get(auth)
except SourceAuthError:
if not auth:
raise
with _rejected_lock:
_rejected.add(_digest(auth))
print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr)
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
data = get(None)
return json.loads(data)
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
+8
View File
@@ -113,6 +113,14 @@ def manage_repo(action, **kwargs):
return getattr(module, action)(**kwargs)
def set_token(source_id, token):
"""Save (token) or remove (None) a source's access token; status is in sources()."""
module, _ = resolve(source_id)
if not hasattr(module, 'set_token'):
raise SourceError('This source does not use an access token')
return module.set_token(token) if token is not None else module.clear_token()
def normalise(name):
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
+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']}"
+29 -17
View File
@@ -56,12 +56,12 @@ BROWSER_FLAGS = []
PANEL_BOX = (1920, 1080)
# Opens one viewer on gamescope's X display and gives its window its own panel
# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
# id. Args: appid url width height tag profile [browser flags...]. The page puts "[tag]" in its title at
# once, which is how its X window is found (Chromium may hand the URL to an
# instance that's already running, so there's no process to follow).
LAUNCH = r"""set -u
appid=$1 url=$2 w=$3 h=$4 tag=$5
shift 5
appid=$1 url=$2 w=$3 h=$4 tag=$5 profile=$6
shift 6
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
@@ -73,15 +73,15 @@ common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-d
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
"--window-size=$w,$h" "$@" "--app=$url")
if [ -x "$HOME/chromium-xr/chrome" ]; then
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/$profile" "${common[@]}")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
cmd=(flatpak run org.chromium.Chromium
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-$profile" "${common[@]}")
else
echo "NO_BROWSER"
exit 3
fi
log=/tmp/frame-mac-view.log
log=/tmp/frame-$profile.log
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
for _ in $(seq 1 60); do
sleep 0.5
@@ -105,9 +105,9 @@ class MacViewError(Exception):
pass
def panel_id(src):
def panel_id(src, host="mac"):
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
return 2_001_000_000 + zlib.crc32(f"{host}:{src}".encode()) % 1_000_000
def fit(w, h, box=PANEL_BOX):
@@ -116,6 +116,9 @@ def fit(w, h, box=PANEL_BOX):
class MacView:
host = "mac"
viewer_profile = "mac-view"
def __init__(self, tunnel_ssh, run, frame, track=None):
self.tunnel_ssh = list(tunnel_ssh)
self.run = run
@@ -170,10 +173,17 @@ class MacView:
def _stale(self):
try:
built = AGENT.stat().st_mtime
return any(p.stat().st_mtime > built for p in (SOURCES / "Sources").glob("*.swift"))
return any(p.stat().st_mtime > built for p in [*(SOURCES / "Sources").glob("*.swift"),
ROOT / "desktop" / "controller.c", ROOT / "desktop" / "controller.h"])
except OSError:
return False
def agent_command(self):
return [str(AGENT)]
def agent_environment(self):
return {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
def ensure_agent(self):
with self.lock:
if self.agent and self.agent.poll() is None:
@@ -182,11 +192,11 @@ class MacView:
if reason:
raise MacViewError(reason)
self.build()
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
env = self.agent_environment()
# The same port as before when restarting, so a running tunnel still
# fits; otherwise (or if it's gone) whatever the system gives.
for port in dict.fromkeys([self.port or 0, 0]):
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
self.agent = subprocess.Popen([*self.agent_command(), "serve", "--port", str(port), "--page", str(PAGE),
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
line = self.agent.stdout.readline()
@@ -195,7 +205,7 @@ class MacView:
break
self.agent.kill()
else:
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
raise MacViewError(f"The streaming helper didn't start: {line.strip() or 'no output'}")
if self.port != int(m.group(1)):
self._drop_tunnel()
self.port = int(m.group(1))
@@ -211,7 +221,7 @@ class MacView:
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
except (urllib.error.URLError, OSError, ValueError) as e:
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
raise MacViewError(f"The streaming helper didn't answer: {e}")
# ---- the tunnel from the Frame ----
@@ -396,7 +406,7 @@ class MacView:
# the agent no longer counts them, so only move when none are out there.
others = self.shown - {src}
self.ensure_tunnel(allow_new_port=not others)
appid = panel_id(src)
appid = panel_id(src, self.host)
# Unique per launch, so a new window is never confused with an old one.
tag = "fc" + secrets.token_hex(4)
# A single-use ticket for this source, not Frame Control's key: the URL
@@ -406,7 +416,7 @@ class MacView:
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags))
try:
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
@@ -438,7 +448,8 @@ class MacView:
if self.shown or self.shows != shows or self.launching:
return
try:
self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
pattern = "[f]rame-control/" + self.viewer_profile + "|[d]ata/frame-" + self.viewer_profile
self.run("pkill -f " + _quote(pattern) + " || true", timeout=10)
except Exception:
pass
@@ -449,7 +460,8 @@ class MacView:
status = self.call("/status")
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
displays = self.call("/displays").get("displays", [])
return {"available": True, "screen": status.get("screen", False),
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
"route": self.route}
+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)
+624
View File
@@ -0,0 +1,624 @@
#!/usr/bin/env python3
"""Frame Control's Windows/Linux host. Same ticket, H.264/JPEG, timing and
input protocol as frame-mac-view; serves the same ui/mac-view.html.
Only loopback is bound. Frame Control owns the master token, the Frame gets
one-source tickets and reconnect keys. Native libraries are bundled.
"""
import argparse
import base64
import ctypes as C
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import math
import os
import queue
from pathlib import Path
import secrets
import socket
import struct
import sys
import threading
import time
from urllib.parse import parse_qs, urlsplit
from frame_pc_capture import Native, Controller, Windows, WindowsInput, PortalInput, Encoded, GATE, dimensions, pipeline
from frame_stream_stats import Stats
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
class Grants:
"""Caller holds the agent lock, including replacement and Stop."""
def __init__(self, token):
self.token, self.tickets, self.keys = token, {}, {}
def master(self, key):
return isinstance(key, str) and secrets.compare_digest(key, self.token)
def ticket(self, src):
self.tickets = {k: v for k, v in self.tickets.items() if v[1] > time.monotonic()}
if len(self.tickets) >= 128:
raise ValueError('Too many pending viewers')
ticket = secrets.token_urlsafe(24)
self.tickets[ticket] = (src, time.monotonic()+60, None)
return ticket
def redeem(self, src, q):
if self.master(q.get('k')):
key = secrets.token_urlsafe(24)
self.keys[key] = src
return key
entry = self.tickets.get(q.get('t'))
if entry and entry[0] == src and entry[1] > time.monotonic():
key = entry[2] or secrets.token_urlsafe(24)
self.tickets[q['t']] = (src, entry[1], key)
self.keys[key] = src
return key
key = q.get('r')
return key if key and self.keys.get(key) == src else None
def ack(self, key):
self.tickets = {k: v for k, v in self.tickets.items() if v[2] != key}
def revoke(self, src=None):
self.tickets = {k: v for k, v in self.tickets.items() if src is not None and v[0] != src}
self.keys = {k: v for k, v in self.keys.items() if src is not None and v != src}
class WebSocket:
def __init__(self, handler):
self.sock, self.reader = handler.connection, handler.rfile
self.lock = threading.Lock()
self.closed = False
self.sock.settimeout(5)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def send(self, data, opcode=1):
if not isinstance(data, bytes):
data = json.dumps(data, separators=(',', ':')).encode()
n = len(data)
head = bytes([0x80 | opcode, n]) if n < 126 else bytes([0x80 | opcode, 126]) + struct.pack('!H', n) if n <= 65535 else bytes([0x80 | opcode, 127]) + struct.pack('!Q', n)
with self.lock:
if self.closed:
raise ConnectionError('Viewer disconnected')
self.sock.sendall(head + data)
def exact(self, n):
data = self.reader.read(n)
if len(data) != n:
raise ConnectionError('Viewer disconnected')
return data
def receive(self):
a, b = self.exact(2)
opcode, size = a & 15, b & 127
if a & 0x70 or not a & 0x80 or not b & 0x80 or opcode not in (1, 8, 9, 10):
raise ValueError('Unsupported WebSocket frame')
if size == 126:
size = struct.unpack('!H', self.exact(2))[0]
elif size == 127:
size = struct.unpack('!Q', self.exact(8))[0]
if size > 65536 or (opcode >= 8 and size > 125):
raise ValueError('WebSocket message too large')
mask = self.exact(4)
data = bytes(v ^ mask[i % 4] for i, v in enumerate(self.exact(size)))
if opcode == 8:
raise ConnectionError('Viewer closed')
if opcode == 9:
self.send(data, 10)
if opcode != 1:
return {}
message = json.loads(data)
if not isinstance(message, dict):
raise ValueError('Expected an input object')
return message
def close(self):
self.closed = True
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
class Session:
def __init__(self, agent, ws, key, source, query):
self.agent, self.native, self.ws, self.key, self.source = agent, agent.native, ws, key, source
self.src = source['src']
self.codec = query.get('codec', 'h264')
if self.codec not in ('h264', 'jpeg'):
raise ValueError('Unsupported codec')
self.fps = min(120, max(5, int(query.get('fps', 60))))
bpp = float(query.get('bpp', .1))
if not math.isfinite(bpp):
raise ValueError('Invalid bitrate')
self.w, self.h = dimensions(source['w'], source['h'], min(3840, max(320, int(query.get('max', 1920)))))
self.bitrate = max(300000, int(self.w*self.h*self.fps*min(.5, max(.02, bpp))))
self.controller = Controller(self.native.lib, self.fps, self.bitrate)
self.stats = Stats(self.native.now)
self.stop_event, self.key_event = threading.Event(), threading.Event()
self.lock, self.input_lock = threading.RLock(), threading.RLock()
self.pending, self.last_submit = {}, 0
self.reconfiguring = False
self.test_inputs = queue.Queue(maxsize=128)
self.input = None if self.src == 'test' else WindowsInput(agent.windows, source) if agent.windows else PortalInput(self.native, source)
self.writer = None
self.released = False
self.input_enabled = self.src == 'test' or self.source.get('devices', 3) == 3
def gate(self, stage, pts, capture, arrived):
# Exceptions cannot cross a ctypes callback boundary.
try:
with self.lock:
if self.stop_event.is_set() or self.reconfiguring:
return -1
if stage == 1:
if pts in self.pending:
self.pending[pts]['e0'] = self.native.now()
return 1
if stage == 0:
self.stats.captured += 1
self.controller.call('capture', arrived)
now = self.native.now()
state = self.controller.state()
if len(self.pending) >= 3 or now-self.last_submit < 1000000/state['fps'] or not self.controller.call('gate', now, int(stage == 0)):
if stage == 0:
self.stats.skipped += 1
return 0
self.pending[pts] = dict(cap=capture, arr=arrived, e0=now, tier=state['tier'], br=state['target'])
self.last_submit = now
return 1
except Exception:
self.stop_event.set()
return -1
STALL = 10000000
def stalled(self, now, opened, captured_at_open):
"""PipeWire and WGC deliver frames only on screen damage, so an idle
desktop legitimately produces no output. Fail only on evidence of a
fault: a frame entered the encoder and never came out, or the capture
never produced its initial frame after the pipeline opened."""
with self.lock:
oldest = min((r['e0'] for r in self.pending.values()), default=None)
captured = self.stats.captured
if oldest is not None and now-oldest > self.STALL:
return 'The encoder stopped producing frames; check the video encoder'
if captured == captured_at_open and now-opened > self.STALL:
return 'No frames captured for 10 seconds; check capture permissions'
return None
def fresh_pipewire(self):
if 'portal' not in self.source:
return
error = C.create_string_buffer(1024)
fd = self.native.lib.fc_portal_refresh(self.source['portal'], error, len(error))
if fd < 0:
raise RuntimeError(error.value.decode(errors='replace'))
self.source['fd'] = fd
def produce(self):
native, capture = self.native.lib, None
try:
encoder = "x264enc" if self.src == "test" and self.native.has("x264enc") else self.agent.encoder
self.fresh_pipewire()
description = pipeline(self.source, sys.platform, encoder, self.w, self.h, self.fps, self.bitrate, self.codec)
self.callback = GATE(self.gate)
error = C.create_string_buffer(1024)
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
last_update = last_stats = self.native.now()
last_config = opened = last_update
captured_at_open = self.stats.captured
applied = (self.w, self.h, self.bitrate)
wanted = applied
while not self.stop_event.is_set():
while not self.test_inputs.empty():
event = self.test_inputs.get_nowait()
try:
native.fc_capture_test(capture, int(event.get('i', 0)) & 0xffffffff)
self.stats.input(event)
except MALFORMED:
pass # a bad probe field drops the event, not the stream
output = Encoded()
result = native.fc_capture_pull(capture, C.byref(output))
now = self.native.now()
if result < 0:
raise RuntimeError(native.fc_capture_error(capture).decode(errors='replace'))
if result:
with self.lock:
# At most three raw frames are in flight; no B-frames.
# x264 offsets PTS, so match the FIFO encode order while
# retaining the actual pre-encode capture timestamp.
raw_pts = next(iter(self.pending), None)
record = self.pending.get(raw_pts)
if record is None:
raise RuntimeError('Encoder changed frame timestamps; timing cannot be matched')
data = C.string_at(output.data, output.size)
f = self.stats.add(**record, e1=now, snd=now, b=len(data), k=output.key,
w=output.width or self.w, h=output.height or self.h)
self.controller.call('sent', f['s'], len(data)+17, now)
self.ws.send(struct.pack('!BQII', output.key, max(0, f['cap']), f['s'], f['echo'])+data, 2)
with self.lock:
f['wire'] = self.native.now()
self.pending.pop(raw_pts, None)
stall = self.stalled(now, opened, captured_at_open)
if stall:
raise RuntimeError(stall)
if self.key_event.is_set():
self.key_event.clear()
if self.codec == 'h264':
native.fc_capture_key(capture)
if now-last_update >= 100000:
target = self.controller.update(now)
state = self.controller.state()
w, h = dimensions(self.w, self.h, max(320, int(max(self.w, self.h)*state['scale'])))
if target and self.codec == 'h264' and encoder == 'x264enc':
native.fc_capture_bitrate(capture, target)
self.bitrate = target
# Hardware properties are not uniformly mutable in PLAYING.
# Drain then reopen the pipeline at a keyframe when its
# budget changes materially. The portal fd/session stays
# alive, so this does not bypass or repeat user consent.
desired_bitrate = target or applied[2]
bitrate_change = self.codec == 'h264' and encoder != 'x264enc' and abs(desired_bitrate-applied[2]) > applied[2]*.2
if not self.reconfiguring and now-last_config >= 1000000 and ((w, h) != applied[:2] or bitrate_change):
wanted = (w, h, desired_bitrate)
with self.lock:
self.reconfiguring = True
last_update = now
if self.reconfiguring:
with self.lock:
drained = not self.pending
if drained:
native.fc_capture_close(capture)
capture = None
self.fresh_pipewire()
description = pipeline(self.source, sys.platform, encoder, wanted[0], wanted[1], self.fps, wanted[2], self.codec)
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
applied, self.bitrate, last_config = wanted, wanted[2], now
opened, captured_at_open = now, self.stats.captured
with self.lock:
self.reconfiguring = False
if now-last_stats >= 1000000:
self.ws.send(dict(self.stats.summary(), t='stats', bitrate=self.bitrate,
size='%dx%d' % (self.w, self.h), tier=self.controller.state()['tier']))
last_stats = now
except Exception as e:
try:
self.ws.send({'t': 'error', 'message': str(e)})
except OSError:
pass
finally:
self.stop_event.set()
if capture:
native.fc_capture_close(capture)
self.ws.close()
def start(self):
if self.stop_event.is_set():
self.controller.close()
return
self.ws.send(dict(t='hello', r=self.key))
self.ws.send(dict(t='info', src=self.src, title=self.source.get('title', self.source.get('name', 'Test pattern')),
app='PC', codec=self.codec, input=self.input_enabled,
inputMessage='Allow pointer and keyboard control in the host sharing dialog.',
aspect=self.w/self.h, warm=0))
with self.lock:
if self.stop_event.is_set():
self.controller.close()
return
self.writer = threading.Thread(target=self.produce, daemon=True)
self.writer.start()
try:
while not self.stop_event.is_set():
m = self.ws.receive()
t = m.get('t')
if t == 'ping':
self.ws.send(dict(t='pong', c=m.get('c', 0), a=self.native.now()))
elif t == 'ack':
with self.agent.lock:
self.agent.grants.ack(self.key)
elif t == 'key-frame':
self.key_event.set()
elif t in ('rx', 'fd', 'clock'):
try:
self.stats.report(m)
except MALFORMED:
pass
if t == 'rx' and isinstance(m.get('s'), int):
self.controller.call('ack', m['s'] & 0xffffffff, self.native.now())
elif t in ('m', 'wheel', 'k', 'text', 'release'):
with self.input_lock:
if self.stop_event.is_set():
break
if self.input:
if not self.input_enabled and t != 'release':
continue
try:
self.input.handle(m)
self.stats.input(m)
except MALFORMED:
pass # drop a malformed viewer event; keep the session
except RuntimeError as e:
self.input_enabled = False
try:
self.input.release()
except RuntimeError:
pass
self.ws.send(dict(t='error', message=str(e)))
elif m.get('i'):
try:
self.test_inputs.put_nowait(m)
except queue.Full:
pass
finally:
self.end()
self.writer.join(6)
if not self.writer.is_alive():
self.controller.close()
def end(self):
with self.lock:
self.stop_event.set()
self.ws.close()
with self.input_lock:
if self.input and not self.released:
self.released = True
try:
self.input.release()
except (RuntimeError, OSError):
pass
class Agent:
def __init__(self, native, token, page):
self.native, self.page = native, page
self.lock = threading.RLock()
self.grants = Grants(token)
self.sessions, self.sources = {}, {}
self.next_id, self.selecting, self.selection_error = 1, False, ''
self.windows = Windows() if sys.platform == 'win32' else None
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
self.shutting_down = False
self.selection_generation = 0
def lists(self):
if self.windows:
windows, displays = self.windows.sources()
self.sources = {s['src']: s for s in windows + displays}
return windows, displays
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
def source(self, src):
if src == 'test':
return dict(src='test', title='Test pattern', w=1280, h=720)
self.lists()
if src not in self.sources:
raise ValueError('Choose a window or screen on this computer first')
return dict(self.sources[src])
def select(self):
if self.windows or self.selecting or self.shutting_down:
return
if len(self.sources) >= 8:
raise ValueError('Stop a panel before sharing another source')
self.selecting, self.selection_error = True, ''
generation = self.selection_generation
def choose():
error = C.create_string_buffer(1024)
portal = self.native.lib.fc_portal_select(error, len(error))
with self.lock:
self.selecting = False
if not portal:
self.selection_error = error.value.decode(errors='replace')
elif self.shutting_down or generation != self.selection_generation:
self.native.lib.fc_portal_close(portal)
else:
fd, node, w, h, devices = [self.native.lib.fc_portal_value(portal, i) for i in range(5)]
src = 'window:' + secrets.token_hex(8)
self.sources[src] = dict(src=src, title='Shared window or screen', app='Linux portal',
w=w, h=h, fd=fd, node=node, portal=portal, devices=devices)
threading.Thread(target=choose, daemon=True).start()
def stop(self, src=None):
with self.lock:
if src is None:
self.selection_generation += 1
self.grants.revoke(src)
sessions = [s for s in self.sessions.values() if src is None or s.src == src]
for session in sessions:
try:
session.ws.send(dict(t='close'))
except OSError:
pass
session.end()
for session in sessions:
if session.writer and session.writer is not threading.current_thread():
session.writer.join(6)
# The capture is stopped before releasing its PipeWire fd/session.
with self.lock:
if not self.windows:
for key, source in list(self.sources.items()):
if src is None or key == src:
if any(s.src == key and s.writer and s.writer.is_alive() for s in sessions):
continue
self.native.lib.fc_portal_close(source['portal'])
self.sources.pop(key, None)
return {'closed': len(sessions)}
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass # URLs contain credentials
def reply(self, data, status=200, content='application/json'):
if not isinstance(data, bytes):
data = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', content)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(data)
self.close_connection = True
def do_GET(self):
self.dispatch('GET')
def do_POST(self):
self.dispatch('POST')
def dispatch(self, method):
agent = self.server.agent
url = urlsplit(self.path)
q = {k: v[-1] for k, v in parse_qs(url.query).items()}
try:
if method == 'GET' and url.path == '/ping':
return self.reply(b'frame-mac-view', content='text/plain')
if method == 'GET' and url.path == '/view':
return self.reply(agent.page.read_bytes(), content='text/html; charset=utf-8')
if method == 'GET' and url.path == '/stream':
return self.stream(q)
if not agent.grants.master(q.get('k', self.headers.get('X-Token'))):
return self.reply({'error': 'forbidden'}, 403)
if method == 'POST' and url.path == '/close':
return self.reply(agent.stop(q.get('src')))
with agent.lock:
windows, displays = agent.lists()
if method == 'GET' and url.path == '/status':
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
for i, s in agent.sessions.items() if not s.stop_event.is_set()])
elif method == 'GET' and url.path in ('/windows', '/displays'):
data = dict(windows=windows, displays=displays, screen=True)
elif method == 'POST' and url.path == '/ticket':
agent.source(q.get('src'))
data = {'ticket': agent.grants.ticket(q['src'])}
elif method == 'POST' and url.path == '/permissions':
agent.select()
data = {'selecting': agent.selecting}
elif method == 'GET' and url.path == '/stats':
data = dict(now=agent.native.now(), streams=[dict(id=i, src=s.src, controller=s.controller.state(),
events=list(s.controller.events), **s.stats.snapshot(max(0, int(q.get('since', 0))), max(0, int(q.get('settle', 1500000)))))
for i, s in agent.sessions.items() if q.get('id', str(i)) == str(i) and not s.stop_event.is_set()])
elif method == 'POST' and url.path == '/bench':
data = {'sent': 0}
for s in agent.sessions.values():
if s.src == q.get('src'):
m = {k: v for k, v in q.items() if k not in ('k', 'src')}
for k in ('x', 'y', 'interval'):
if k in m:
m[k] = float(m[k])
s.ws.send(dict(m, t='bench'))
data['sent'] += 1
else:
return self.reply({'error': 'not found'}, 404)
self.reply(data)
except (ValueError, RuntimeError) as e:
self.reply({'error': str(e)}, 400)
except (OSError, ConnectionError):
self.close_connection = True
def stream(self, query):
agent = self.server.agent
with agent.lock:
key = agent.grants.redeem(query.get('src'), query)
if not key:
return self.reply({'error': 'forbidden'}, 403)
source = agent.source(query.get('src'))
if self.headers.get('Upgrade', '').lower() != 'websocket' or self.headers.get('Sec-WebSocket-Version') != '13':
return self.reply({'error': 'expected WebSocket'}, 400)
wskey = self.headers.get('Sec-WebSocket-Key', '')
if len(base64.b64decode(wskey, validate=True)) != 16:
raise ValueError('Bad WebSocket key')
if len(agent.sessions) >= 8:
raise ValueError('At most eight panels may be open')
# Stop can revoke and close only a registered session. Register
# under the same lock as redemption, before any capture starts.
session = Session(agent, WebSocket(self), key, source, query)
for old in list(agent.sessions.values()):
if old.key == key or old.src == source['src']:
if old.key != key:
agent.grants.keys.pop(old.key, None)
try:
old.ws.send(dict(t='close'))
except OSError:
pass
old.end()
if old.writer:
old.writer.join(6)
if old.writer.is_alive():
session.controller.close()
raise ValueError('The previous capture is still stopping; retry shortly')
ident = agent.next_id
agent.next_id += 1
agent.sessions[ident] = session
accept = base64.b64encode(hashlib.sha1((wskey+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
self.send_response(101)
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers()
try:
session.start()
except (OSError, ValueError, RuntimeError):
session.end()
finally:
session.end()
if session.writer:
session.writer.join(6)
if not session.writer or not session.writer.is_alive():
session.controller.close()
with agent.lock:
agent.sessions.pop(ident, None)
self.close_connection = True
def main():
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=['serve'])
parser.add_argument('--port', type=int, default=0)
parser.add_argument('--page', type=Path, required=True)
parser.add_argument('--exit-on-eof', action='store_true')
args = parser.parse_args()
token = os.environ.get('FRAME_MAC_VIEW_TOKEN')
if not token:
raise SystemExit('Frame Control must provide a private token')
native = Native()
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
agent = server.agent = Agent(native, token, args.page)
if args.exit_on_eof:
def eof():
sys.stdin.buffer.read()
with agent.lock:
agent.shutting_down = True
agent.stop()
server.shutdown()
threading.Thread(target=eof, daemon=True).start()
print('listening on 127.0.0.1:%d' % server.server_port, flush=True)
try:
server.serve_forever()
finally:
agent.shutting_down = True
agent.stop()
server.server_close()
if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, AttributeError) as e:
raise SystemExit('PC streaming helper: ' + str(e))
+383
View File
@@ -0,0 +1,383 @@
"""Native PC capture and input bindings. No desktop-streaming app required.
The packaged native library contains our adapter and the shared rate controller;
GStreamer supplies WGC/Media Foundation and PipeWire/VA-API/x264 libraries.
"""
import ctypes as C
import os
from pathlib import Path
import re
import sys
import threading
ROOT = Path(__file__).resolve().parent.parent
NATIVE = Path(os.environ.get('FRAME_PC_NATIVE', ROOT / 'desktop' / 'bundle'))
LIBRARY = NATIVE / ('pc-host.dll' if sys.platform == 'win32' else 'pc-host.so')
class Encoded(C.Structure):
_fields_ = [('data', C.c_void_p), ('size', C.c_int), ('key', C.c_int),
('width', C.c_int), ('height', C.c_int), ('pts', C.c_int64)]
GATE = C.CFUNCTYPE(C.c_int, C.c_int, C.c_int64, C.c_int64, C.c_int64)
class Native:
def __init__(self, path=LIBRARY):
self.dll_dirs = []
if sys.platform == 'win32':
# Set process coordinates before loading GStreamer: a plugin may
# create a helper HWND during discovery, after which it is too late.
user = C.WinDLL('user32')
user.SetProcessDpiAwarenessContext.argtypes = [C.c_void_p]
user.SetProcessDpiAwarenessContext(C.c_void_p(-4))
for folder in (path.parent, path.parent / 'bin'):
self.dll_dirs.append(os.add_dll_directory(str(folder)))
self.lib = C.CDLL(str(path))
signatures = {
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
'fc_has_element': (C.c_int, [C.c_char_p]),
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
'fc_capture': (None, [C.c_void_p, C.c_int64]),
'fc_sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
'fc_ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]),
'fc_update': (C.c_int, [C.c_void_p, C.c_int64]),
'fc_value': (C.c_int64, [C.c_void_p, C.c_int]),
'fc_capture_open': (C.c_void_p, [C.c_char_p, GATE, C.c_char_p, C.c_int]),
'fc_capture_pull': (C.c_int, [C.c_void_p, C.POINTER(Encoded)]),
'fc_capture_error': (C.c_char_p, [C.c_void_p]),
'fc_capture_bitrate': (None, [C.c_void_p, C.c_int]),
'fc_capture_key': (None, [C.c_void_p]),
'fc_capture_test': (None, [C.c_void_p, C.c_uint32]), 'fc_capture_close': (None, [C.c_void_p]),
}
if sys.platform.startswith('linux'):
signatures.update({
'fc_portal_select': (C.c_void_p, [C.c_char_p, C.c_int]),
'fc_portal_close': (None, [C.c_void_p]),
'fc_portal_refresh': (C.c_int, [C.c_void_p, C.c_char_p, C.c_int]),
'fc_portal_value': (C.c_int, [C.c_void_p, C.c_int]),
'fc_portal_input': (C.c_int, [C.c_void_p, C.c_int, C.c_double, C.c_double, C.c_int, C.c_int]),
})
for name, (result, args) in signatures.items():
fn = getattr(self.lib, name)
fn.restype, fn.argtypes = result, args
self.lib.fc_gst_init()
def has(self, name):
return bool(self.lib.fc_has_element(name.encode()))
def now(self):
return self.lib.fc_now()
class Controller:
def __init__(self, lib, fps, ceiling):
# close() may run on the stream thread while /status reads state();
# after close every entry point is a no-op, never a NULL dereference.
self.lib, self.lock, self.fps = lib, threading.RLock(), fps
self.enabled = os.environ.get('FRAME_MAC_VIEW_ADAPT') != '0'
self.ptr = lib.fc_new(fps, self.enabled)
if not self.ptr:
raise MemoryError('Unable to allocate the streaming controller')
lib.fc_ceiling(self.ptr, ceiling)
self.events = []
def state(self):
with self.lock:
names = ['target', 'ceiling', 'tier', 'fps', 'scale', 'baseRtt', 'inFlight', 'slack']
if not self.ptr:
out = dict.fromkeys(names, 0)
out.update(fps=self.fps, scale=100)
else:
out = {k: self.lib.fc_value(self.ptr, i) for i, k in enumerate(names)}
out.update(scale=out['scale'] / 100, baseRtt=out['baseRtt'] / 1000,
slack=out['slack'] / 1000, adapt=self.enabled)
return out
def call(self, name, *args):
with self.lock:
return getattr(self.lib, 'fc_' + name)(self.ptr, *args) if self.ptr else 0
def update(self, now):
with self.lock:
if not self.ptr:
return 0
old = self.state()
result = self.lib.fc_update(self.ptr, now)
state = self.state()
if state['target'] < old['target'] or state['tier'] != old['tier']:
self.events.append({'t': now, 'e': 'target %s bit/s; tier %s' % (state['target'], state['tier'])})
self.events = self.events[-200:]
return result
def close(self):
with self.lock:
if self.ptr:
self.lib.fc_free(self.ptr)
self.ptr = None
def dimensions(w, h, maximum):
scale = min(1, maximum / max(w, h))
return max(2, int(w * scale) // 2 * 2), max(2, int(h * scale) // 2 * 2)
def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
"""Only locally constructed numeric source IDs enter the pipeline parser."""
if source['src'] == 'test':
capture = 'videotestsrc name=source is-live=true pattern=ball'
elif platform == 'win32':
kind, ident = source['src'].split(':')
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
raise ValueError('Invalid Windows capture source')
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
capture = 'd3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true %s=%d' % (prop, int(ident))
else:
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
# The source gate runs before conversion/encoding. There is no leaky queue
# of H.264 frames; every encoded reference frame reaches the socket.
raw = '%s ! video/x-raw,framerate=%d/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=%d,height=%d' % (capture, fps, w, h)
if codec == 'jpeg':
enc = 'jpegenc name=enc quality=80'
parse = ''
else:
choices = {
'mfh264enc': 'mfh264enc name=enc low-latency=true bframes=0 gop-size=60',
'vah264enc': 'vah264enc name=enc b-frames=0 key-int-max=60',
'x264enc': 'x264enc name=enc tune=zerolatency speed-preset=ultrafast bframes=0 key-int-max=60',
}
enc = choices[encoder] + ' bitrate=%d' % max(1, bitrate // 1000)
raw += ',format=NV12' if encoder != 'x264enc' else ',format=I420'
parse = ' ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au'
return raw + ' ! ' + enc + parse + ' ! appsink name=out sync=false max-buffers=2 drop=false'
# X11 keysyms work on Wayland through the RemoteDesktop portal too. The host's
# keyboard layout handles physical keys; Unicode text is sent as Unicode keysyms.
KEYSYMS = {'Enter': 0xff0d, 'Tab': 0xff09, 'Backspace': 0xff08, 'Escape': 0xff1b,
'Delete': 0xffff, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52, 'ArrowRight': 0xff53,
'ArrowDown': 0xff54, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
'PageDown': 0xff56, 'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffeb}
class PortalInput:
def __init__(self, native, source):
self.lib, self.source = native.lib, source
self.buttons, self.keys = set(), set()
def emit(self, kind, x=0, y=0, code=0, down=0):
if not self.lib.fc_portal_input(self.source['portal'], kind, x, y, code, down):
raise RuntimeError('The desktop portal refused input; check the sharing permission')
def release(self):
error = None
for values, kind in ((self.buttons, 1), (self.keys, 3)):
for code in list(values):
try:
self.emit(kind, code=code, down=0)
values.discard(code)
except RuntimeError as e:
error = e
if error:
raise error
def handle(self, m):
t = m['t']
if t == 'release':
return self.release()
if t in ('m', 'wheel'):
x, y = (max(0, min(1, float(m.get(k, 0)))) for k in ('x', 'y'))
self.emit(0, x * (self.source['w'] - 1), y * (self.source['h'] - 1))
if t == 'm' and m.get('e') in ('up', 'down'):
b = {0: 0x110, 1: 0x112, 2: 0x111}.get(m.get('b', 0))
if b is not None:
down = m['e'] == 'down'
self.emit(1, code=b, down=down)
(self.buttons.add if down else self.buttons.discard)(b)
elif t == 'wheel':
self.emit(2, max(-4096, min(4096, float(m.get('dx', 0)))), max(-4096, min(4096, float(m.get('dy', 0)))))
elif t == 'k':
key = str(m.get('key', ''))
k = KEYSYMS.get(key) or (ord(key) if len(key) == 1 and ord(key) < 256 else
0x1000000 + ord(key) if len(key) == 1 else 0)
if k:
down = m.get('e') == 'down'
self.emit(3, code=k, down=down)
(self.keys.add if down else self.keys.discard)(k)
elif t == 'text':
for ch in str(m.get('s', ''))[:4096]:
k = ord(ch) if ord(ch) < 256 else 0x1000000 + ord(ch)
self.emit(3, code=k, down=1)
self.emit(3, code=k, down=0)
class Windows:
"""WGC source handles and SendInput. No elevation or global input hook."""
def __init__(self):
from ctypes import wintypes as W
self.W = W
self.user = C.WinDLL('user32', use_last_error=True)
self.dwm = C.WinDLL('dwmapi')
self.user.IsWindow.argtypes = [W.HWND]
self.user.IsWindowVisible.argtypes = [W.HWND]
self.user.IsIconic.argtypes = [W.HWND]
self.user.GetWindowTextLengthW.argtypes = [W.HWND]
self.user.GetWindowTextW.argtypes = [W.HWND, W.LPWSTR, C.c_int]
self.user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
self.user.SetForegroundWindow.argtypes = [W.HWND]
self.user.GetForegroundWindow.argtypes = []
self.user.GetForegroundWindow.restype = W.HWND
self.dwm.DwmGetWindowAttribute.argtypes = [W.HWND, W.DWORD, C.c_void_p, W.DWORD]
self.callback = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
self.monitor_callback = C.WINFUNCTYPE(W.BOOL, W.HMONITOR, W.HDC, C.POINTER(W.RECT), W.LPARAM)
self.user.EnumWindows.argtypes = [self.callback, W.LPARAM]
self.user.EnumDisplayMonitors.argtypes = [W.HDC, C.c_void_p, self.monitor_callback, W.LPARAM]
class Mouse(C.Structure):
_fields_ = [('dx', W.LONG), ('dy', W.LONG), ('data', W.DWORD), ('flags', W.DWORD),
('time', W.DWORD), ('extra', C.c_size_t)]
class Key(C.Structure):
_fields_ = [('vk', W.WORD), ('scan', W.WORD), ('flags', W.DWORD), ('time', W.DWORD), ('extra', C.c_size_t)]
class Union(C.Union):
_fields_ = [('mouse', Mouse), ('key', Key)]
class Input(C.Structure):
_fields_ = [('type', W.DWORD), ('data', Union)]
self.Mouse, self.Key, self.Input = Mouse, Key, Input
self.user.SendInput.argtypes = [W.UINT, C.POINTER(Input), C.c_int]
self.user.SendInput.restype = W.UINT
def rect(self, hwnd):
r = self.W.RECT()
if not self.user.IsWindow(hwnd) or self.user.IsIconic(hwnd):
raise RuntimeError('The captured window closed or was minimized')
# WGC captures the visible extended frame, excluding invisible resize borders.
if self.dwm.DwmGetWindowAttribute(hwnd, 9, C.byref(r), C.sizeof(r)):
if not self.user.GetWindowRect(hwnd, C.byref(r)):
raise RuntimeError('Cannot locate the captured window')
return r.left, r.top, r.right - r.left, r.bottom - r.top
def sources(self):
windows, displays = [], []
def window(hwnd, _):
if not self.user.IsWindowVisible(hwnd) or self.user.IsIconic(hwnd):
return True
n = self.user.GetWindowTextLengthW(hwnd)
if n:
title = C.create_unicode_buffer(n + 1)
self.user.GetWindowTextW(hwnd, title, n + 1)
try:
x, y, w, h = self.rect(hwnd)
if w > 0 and h > 0:
windows.append(dict(src='window:%d' % hwnd, id=hwnd, title=title.value,
app='Windows', w=w, h=h, x=x, y=y))
except RuntimeError:
pass
return True
def monitor(handle, dc, rect, data):
r = rect.contents
displays.append(dict(src='display:%d' % handle, name='Display %d' % (len(displays) + 1),
x=r.left, y=r.top, w=r.right-r.left, h=r.bottom-r.top))
return True
self.user.EnumWindows(self.callback(window), 0)
self.user.EnumDisplayMonitors(None, None, self.monitor_callback(monitor), 0)
return windows, displays
def send(self, mouse=None, key=None):
inp = self.Input()
inp.type = 0 if mouse else 1
if mouse:
inp.data.mouse = self.Mouse(*mouse, 0, 0)
else:
inp.data.key = self.Key(*key, 0, 0)
if self.user.SendInput(1, C.byref(inp), C.sizeof(inp)) != 1:
raise RuntimeError('Windows refused input (elevated apps and the secure desktop cannot be controlled)')
class WindowsInput:
def __init__(self, host, source):
self.host, self.source = host, source
self.buttons, self.keys = set(), set()
def release(self):
error = None
for button in list(self.buttons):
try:
self.host.send(mouse=(0, 0, 0, {0: 4, 1: 0x40, 2: 0x10}[button]))
self.buttons.discard(button)
except RuntimeError as e:
error = e
for vk in list(self.keys):
try:
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
self.host.send(key=(vk, 0, 2 | extended))
self.keys.discard(vk)
except RuntimeError as e:
error = e
if error:
raise error
def focus(self):
if not self.source['src'].startswith('window:'):
return
hwnd = int(self.source['src'].split(':')[1])
user = self.host.user
if user.GetForegroundWindow() != hwnd and not user.SetForegroundWindow(hwnd):
raise RuntimeError('Windows could not focus this window. Bring it to the front on your PC, then press Stop and Show again.')
if user.GetForegroundWindow() != hwnd:
raise RuntimeError('The selected window is not in front. Reopen the view after bringing it to the front on your PC.')
def handle(self, m):
t, u = m['t'], self.host.user
if t == 'release':
return self.release()
if t in ('m', 'wheel'):
source = self.source
if source['src'].startswith('window:'):
hwnd = int(source['src'].split(':')[1])
x, y, w, h = self.host.rect(hwnd)
if m.get('e') == 'down' or t == 'wheel':
self.focus()
else:
x, y, w, h = (source[k] for k in ('x', 'y', 'w', 'h'))
x += max(0, min(1, float(m.get('x', 0)))) * (w - 1)
y += max(0, min(1, float(m.get('y', 0)))) * (h - 1)
left, top, width, height = (u.GetSystemMetrics(i) for i in (76, 77, 78, 79))
self.host.send(mouse=(round((x-left)*65535/max(1,width-1)), round((y-top)*65535/max(1,height-1)), 0, 0xc001))
if t == 'm' and m.get('e') in ('up', 'down'):
b = m.get('b', 0)
if b in (0, 1, 2):
down = m['e'] == 'down'
self.host.send(mouse=(0, 0, 0, {0: 2, 1: 0x20, 2: 8}[b] * (1 if down else 2)))
(self.buttons.add if down else self.buttons.discard)(b)
elif t == 'wheel':
for name, flag, direction in (('dy', 0x800, -1), ('dx', 0x1000, 1)):
delta = int(max(-4096, min(4096, float(m.get(name, 0))))) * direction
if delta:
self.host.send(mouse=(0, 0, delta & 0xffffffff, flag))
elif t == 'k':
code, down = str(m.get('code', '')), m.get('e') == 'down'
if down:
self.focus()
special = {'Enter': 13, 'Escape': 27, 'Tab': 9, 'Backspace': 8, 'Space': 32,
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
'Delete': 46, 'Home': 36, 'End': 35, 'PageUp': 33, 'PageDown': 34,
'ShiftLeft': 160, 'ShiftRight': 161, 'ControlLeft': 162, 'ControlRight': 163,
'AltLeft': 164, 'AltRight': 165, 'MetaLeft': 91, 'MetaRight': 92}
vk = special.get(code, 0)
if re.fullmatch(r'Key[A-Z]', code) or re.fullmatch(r'Digit[0-9]', code):
vk = ord(code[-1])
if vk:
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
self.host.send(key=(vk, 0, extended | (0 if down else 2)))
(self.keys.add if down else self.keys.discard)(vk)
elif down and len(str(m.get('key', ''))) == 1:
self.handle({'t': 'text', 's': m['key']})
elif t == 'text':
self.focus()
raw = str(m.get('s', ''))[:4096].encode('utf-16-le')
for i in range(0, len(raw), 2):
unit = int.from_bytes(raw[i:i+2], 'little')
self.host.send(key=(0, unit, 4))
self.host.send(key=(0, unit, 6))
+90
View File
@@ -0,0 +1,90 @@
"""PC host selection behind the existing MacView tunnel/panel controller.
The public /api/macview name and viewer URL remain compatible with the Mac
base branch. Only helper launch, platform availability and source validation
are different. No second viewer, SSH supervisor or benchmark launcher.
"""
import os
import sys
import subprocess
from frame_macview import MacView, MacViewError, ROOT
from frame_pc_capture import LIBRARY, NATIVE
def prepend(env, name, path):
"""An empty entry means the current directory to the loader; never add one."""
rest = env.get(name, '')
env[name] = str(path) + (os.pathsep + rest if rest else '')
class PCView(MacView):
viewer_profile = 'pc-view'
host = 'windows' if sys.platform == 'win32' else 'linux'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.prefer_usb = False # Mac networksetup probe is platform-specific
def unavailable(self):
if sys.platform not in ('win32', 'linux'):
return 'PC streaming needs Windows or a Linux desktop.'
if not LIBRARY.is_file():
return 'The PC streaming libraries are missing from this build. See docs/pc-in-headset.md.'
return None
def state(self):
result = super().state()
result["host"] = self.host
return result
def build(self):
pass # native libraries are built and bundled with the app
def agent_command(self):
return [sys.executable, str(ROOT / 'ui' / 'frame_pc_agent.py')]
def agent_environment(self):
env = super().agent_environment()
env['GST_PLUGIN_PATH_1_0'] = str(NATIVE / 'lib' / 'gstreamer-1.0')
env['GST_PLUGIN_SYSTEM_PATH_1_0'] = ''
cache = os.path.join(os.path.expanduser('~'), '.cache', 'frame-control')
os.makedirs(cache, exist_ok=True)
env['GST_REGISTRY_1_0'] = os.path.join(cache, 'gstreamer-registry.bin')
env['GST_REGISTRY_FORK'] = 'no'
if sys.platform == 'win32':
prepend(env, 'PATH', NATIVE / 'bin')
else:
prepend(env, 'LD_LIBRARY_PATH', NATIVE / 'lib')
env['PIPEWIRE_MODULE_DIR'] = str(NATIVE / 'lib' / 'pipewire-0.3')
env['SPA_PLUGIN_DIR'] = str(NATIVE / 'lib' / 'spa-0.2')
env['PIPEWIRE_CONFIG_DIR'] = str(NATIVE / 'share' / 'pipewire')
env['PIPEWIRE_CONFIG_NAME'] = 'client.conf'
return env
def shutdown(self):
self.closing = True
try:
self.stop()
except MacViewError:
pass
if self.tunnel and self.tunnel.poll() is None:
self.tunnel.terminate()
if self.agent and self.agent.poll() is None:
# EOF gives the host a chance to release held input and portal
# sessions, including on Windows where terminate is uncatchable.
if self.agent.stdin:
self.agent.stdin.close()
try:
self.agent.wait(10)
except subprocess.TimeoutExpired:
self.agent.kill()
self.agent.wait(5)
def _show(self, src, quality, width, height):
if src.startswith('separate:'):
raise MacViewError('Separate virtual displays are a Mac-only feature. Choose a window or screen.')
return super()._show(src, quality, width, height)
def host_view(*args, **kwargs):
return (MacView if sys.platform == 'darwin' else PCView)(*args, **kwargs)
+88
View File
@@ -0,0 +1,88 @@
"""The desktop stream's timing records, in the Mac viewer/bench schema.
Times are host monotonic microseconds. Zero means unknown, never a fabricated
capture/display timestamp. Storage is bounded to the same 4096/512 records as
Stats.swift. The existing macview-bench.py grades these records unchanged.
"""
from collections import OrderedDict
import threading
def distribution(values):
values = sorted(v for v in values if v is not None)
if not values:
return {}
return {key: round(values[min(len(values)-1, int((len(values)-1)*p + .5))], 1)
for key, p in (('p50', .5), ('p95', .95))}
class Stats:
def __init__(self, now):
self.now, self.lock = now, threading.RLock()
self.frames, self.inputs = OrderedDict(), OrderedDict()
self.captured = self.skipped = self.dropped = 0
self.rtt, self.decoder, self.synced = 0, '', False
self.sequence = 0
def add(self, **values):
with self.lock:
self.sequence = (self.sequence + 1) & 0xffffffff
record = dict.fromkeys(('s', 'k', 'b', 'w', 'h', 'cap', 'arr', 'e0', 'e1', 'snd',
'wire', 'rx', 'dec', 'drw', 'vs', 'echo', 'tier', 'br'), 0)
record.update(values, s=self.sequence)
self.frames[self.sequence] = record
while len(self.frames) > 4096:
self.frames.popitem(last=False)
for event in self.inputs.values():
if not event['frame'] and event['inj'] <= record['cap']:
event.update(frame=self.sequence, cap=record['cap'])
record['echo'] = event['id']
return record
def report(self, message):
with self.lock:
if message['t'] == 'rx':
frame = self.frames.get(message.get('s'))
if frame and isinstance(message.get('r'), (int, float)):
frame['rx'] = int(message['r'])
elif message['t'] == 'fd':
for item in message.get('f', [])[:4096]:
if isinstance(item, list) and len(item) >= 4:
frame = self.frames.get(item[0])
if frame:
for name, val in zip(('dec', 'drw', 'vs'), item[1:4]):
if isinstance(val, (int, float)):
frame[name] = int(val)
self.dropped += max(0, int(message.get('drop', 0)))
elif message['t'] == 'clock':
self.rtt = float(message.get('rtt', 0))
self.decoder = str(message.get('dec', ''))[:1024]
self.synced = True
def input(self, m):
if not isinstance(m.get('i'), int) or not m['i']:
return
with self.lock:
self.inputs[m['i']] = dict(id=m['i'], kind=m['t'], tv=m.get('tv') or 0,
inj=self.now(), frame=0, cap=0)
while len(self.inputs) > 512:
self.inputs.popitem(last=False)
def summary(self):
with self.lock:
now = self.now()
frames = [f for f in self.frames.values() if f['cap'] >= now-2000000]
out = dict(captured=self.captured, skipped=self.skipped, dropped=self.dropped,
rtt=self.rtt, decoder=self.decoder, synced=self.synced,
fps=sum(f['vs'] > 0 for f in frames)/2, sentFps=len(frames)/2,
mbps=round(sum(f['b'] for f in frames)*4/1e6, 2))
for key, start, end in (('capture', 'cap', 'arr'), ('queue', 'arr', 'e0'),
('encode', 'e0', 'e1'), ('network', 'e1', 'rx'),
('decode', 'rx', 'dec'), ('draw', 'dec', 'drw'), ('total', 'cap', 'drw')):
out[key] = distribution([(f[end]-f[start])/1000 for f in frames if f[start] and f[end]])
return out
def snapshot(self, since=0, settle=1500000):
with self.lock:
return dict(frames=[dict(f) for f in self.frames.values() if f['s'] > since and f['cap'] <= self.now()-settle],
inputs=[dict(i) for i in self.inputs.values()], captured=self.captured, summary=self.summary())
+772 -297
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -22,7 +22,7 @@
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<div id="note">Connecting to the Mac…</div>
<div id="note">Connecting to your computer…</div>
<div id="hud" hidden></div>
<script>
"use strict";
@@ -243,14 +243,14 @@ async function connect() {
// Refused before ever getting a key: the ticket expired or was revoked,
// and retrying can't help.
if (!reconnectKey && ++failedStarts >= 3) {
show("This view has expired. Press Show in Frame Control on the Mac to open it again.");
show("This view has expired. Press Show in Frame Control on your computer to open it again.");
return;
}
hideNoteOnFrame = true;
// The Mac may be asleep or the tunnel restarting: keep trying.
retry = Math.min(retry + 1, 6);
// Keep the Mac's reason (a missing permission, a closed window) on screen.
show(lastError ? `${lastError} Retrying…` : "Lost the Mac. Reconnecting…");
show(lastError ? `${lastError} Retrying…` : "Lost the computer. Reconnecting…");
setTimeout(connect, 500 * 2 ** retry);
};
}
@@ -269,7 +269,7 @@ function control(m) {
document.title = tag ? `${name} [${tag}]` : name;
if (!m.input) {
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
show("Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
show(m.inputMessage || "Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
}
} else if (m.t === "error") {
stats.errors.push(m.message);
@@ -391,7 +391,7 @@ addEventListener("keydown", e => onKey(e, true));
addEventListener("keyup", e => onKey(e, false));
addEventListener("blur", () => send({ t: "release" }));
show("Connecting to the Mac…");
show("Connecting to your computer…");
connect();
</script>
</body>
+50 -11
View File
@@ -55,6 +55,7 @@ import frame_contact # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_quest # noqa: E402
@@ -709,13 +710,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 +768,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()
@@ -2328,8 +2344,9 @@ def push_media(path):
# The tunnel gets its own connection: the shared master's options would win
# over anything added after them.
macview = frame_macview.MacView(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin, timeout=timeout),
macview = frame_pcview.host_view(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin.encode("utf-8") if stdin is not None else None,
timeout=timeout, text=False).decode("utf-8", errors="replace"),
FRAME, track=_live_tunnels.add)
@@ -2399,6 +2416,16 @@ def source_text(body, key, optional=False):
return value.strip()
def login_text(body, key):
"""A username, password or token: kept exactly (no stripping) and never echoed back."""
value = body.get(key)
if value in (None, ''):
return None
if not isinstance(value, str) or len(value) > 4096:
raise Failure('Provide a valid ' + key, 400)
return value
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
@@ -2437,19 +2464,31 @@ def source_manage(body):
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
parts = urlparse(url)
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
username, password = login_text(body, 'username'), login_text(body, 'password')
try:
parts = urlparse(url)
parts.port
except ValueError:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400) from None
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400)
apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
# A login (here or in the URL) is stored apart and never comes back in a response.
login = {'username': username, 'password': password} if username or password else {}
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name, **login)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action in ('set-token', 'clear-token'):
token = login_text(body, 'token') if action == 'set-token' else None
if action == 'set-token' and not token:
raise Failure('Paste a token', 400)
return apk_search.set_token(source_text(body, 'source'), token)
if action == 'game-data':
package = source_text(body, 'package')
return start_job('Add game data', lambda: apk_search.add_game_data(package))
@@ -2467,7 +2506,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,