Compare commits

..
Author SHA1 Message Date
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 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 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 6aab62983e chore: resolve conflicts with main 2026-10-17 22:08:52 +00:00
Alex Southwell bcdd9d0830 Merge pull request #99: Queue Android and Quest installs
Queue Android and Quest installs; prepare Frame Control 0.4.7
2026-10-15 13:32:54 +11:00
saphid ddd2d1c295 Merge landed Quest and Meta library stack 2026-10-15 13:10:26 +11:00
Alex Southwell 8f2e34a40e Merge pull request #93: My Meta library (Experimental)
My Meta library (Experimental): install and test Quest games you own
2026-10-15 13:10:18 +11:00
Alex Southwell 351230fcad Merge pull request #92: Import from my Quest
Import from my Quest: copy games you own off a USB-connected Quest
2026-10-15 13:10:04 +11:00
saphid 5b35e9cc5d Merge current main and prepare Frame Control 0.4.7 2026-10-15 13:09:59 +11:00
saphidandClaude Opus 5.5 671c4c06e7 Install queue: only the install showing its progress hides the bar
Removing a waiting import hid the progress of the one still running.
Found by GPT-6.1 Sol review round 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:43:18 +11:00
saphidandClaude Opus 5.5 443813a088 Install queue: Android store, Quest and Meta installs wait their turn
Installs from the Android store, Import from my Quest and My Meta library
now go into one server-side queue and run one after another, instead of
being refused while another runs. The page shows the queue with Remove
for installs that haven't started; each queued install runs only on the
headset it was queued for.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:41:19 +11:00
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
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
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 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
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 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 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
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
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
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 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 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
54 changed files with 4248 additions and 319 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
+12 -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
# macOS helper it compiles, 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/**", "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
@@ -94,7 +102,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: |
+2 -2
View File
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
@@ -224,7 +224,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [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>
+3 -2
View File
@@ -116,10 +116,11 @@ function ping(target) {
}
async function startServer() {
// The version and whether this is a built app go to ui/frame_telemetry.py, which
// sends nothing from a source checkout.
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
// which sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.5",
"version": "0.4.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.5",
"version": "0.4.7",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.5",
"version": "0.4.7",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -51,6 +51,7 @@
"*.py",
"*.html",
"*.js",
"*.css",
"telemetry.json"
]
},
+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
+62 -6
View File
@@ -17,14 +17,15 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has five tabs: **Home** (headset view, status, screenshots),
The window has six tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (sending files and text,
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
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
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 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.
@@ -96,6 +97,61 @@ counts them while they run.
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## Control Center
Open **Control** or `#control` to search controls, status and help. Categories
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
preferences. Search is local: it matches labels, descriptions and familiar terms
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
existing screen; it does not install, cast, open a helper or change a setting.
Home, Games, Android, Tools and Devices retain their existing features.
The overview and detail cards use the shared `/api/status` sample: charge and
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
and VR timings. Values refresh with the normal 30-second poll. A failed status
request, lost connection, headset switch or sample received more than 90 seconds
ago clears current readings and disables audio writes. The client receipt time
and failed-read state are retained through page startup and UI redraws. Only a
new successful status read clears a failure; reusing the retained sample does not.
The headset timestamp is shown without assuming
its clock matches this computer. Missing sensors show **Unavailable**;
they are not converted to zero. Compositor FPS is separate from application FPS;
GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
blocks duplicate/pending actions, and requests status after command acknowledgment
for readback, draining an earlier poll first. Mute preserves an unapplied volume
draft; switching headsets discards it. It reports
unconfirmed or differing output separately from confirmed state. API validation
rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal **On your Frame** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
Hardware guidance is deliberately separate from controls. Microphone/routing,
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
tracking-origin changes, saved spatial layouts, firmware updates and recovery
have no verified remote setting here. Use the headset's own controls or the
documented manual procedures. Movement comfort remains per-game; automatic
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
Android data backup/restore remain script/MCP capabilities, not new UI switches.
**Verification:** the Control Center's JavaScript runs under Node with mocked
API and DOM boundaries; the server tests mock SSH and reject invalid values
before any device command. Packaging tests cover the shared CSS/JS in desktop
and iPhone bundles. These checks establish wiring, search, navigation, offline,
error and device-switch behavior; they do not establish wearer experience,
physical settings persistence or a new hardware capability.
## How it works
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

+6 -2
View File
@@ -124,7 +124,10 @@ the redaction look for it.
it has game data to add: once to add it, once for the test), watches it for
about 30 seconds, and records what happened. **Test again** repeats only
the test.
5. **Disconnect** deletes the token from your computer and, in the desktop
5. Choose **Install** on as many games as you like: they wait in the
**Install queue** and install one after another, with Quest imports and
Android store installs. **Remove** takes one out before it starts.
6. **Disconnect** deletes the token from your computer and, in the desktop
app, signs out of Meta in Frame Control (clears the sign-in window's
storage). Games already on the Frame stay.
@@ -145,7 +148,8 @@ The local API: `GET /api/meta` (connected or not, never the token),
`{"action": "connect", "token": "...", "acknowledged": true}`,
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
test return a background job (`/api/job`). Sign in with Meta uses the same
test return a background job (`/api/job`); an install waits its turn in the
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). Sign in with Meta uses the same
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
+11 -4
View File
@@ -33,6 +33,13 @@ computer, exactly as they were sent.
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
two-letter code such as `DE`, worked out on your computer from its time zone
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
Control is used without knowing your address. The time zone itself isn't
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
Copies that showed the first-run notice before it mentioned this (before
0.4.6) show it again, and don't send a country until it has been seen.
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
so the machine-id itself never leaves it. It is the same for one headset on
@@ -44,9 +51,9 @@ computer, exactly as they were sent.
| Event | When | Properties besides the common ones |
|---|---|---|
| `app_installed` | First run | |
| `app_updated` | First run of a new version | `from_version` |
| `app_opened` | At most once a day | |
| `app_installed` | First run | `country` |
| `app_updated` | First run of a new version | `from_version`, `country` |
| `app_opened` | At most once a day | `country` |
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
@@ -125,7 +132,7 @@ runs.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
**Report a problem** is the speech-bubble button in the header, also in the
Privacy panel 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
+12 -1
View File
@@ -45,6 +45,13 @@ files in Android/data** to bring the game's external files too (some games
keep saves there). Progress shows in the panel; the result and any reason it
stopped appear in the message log.
You can choose **Import** on several games at once: they wait in the **Install
queue** (above the Quest panel) and import one after another, together with
installs from the Android store and My Meta library. **Remove** takes a game
out of the queue before it starts. Each queued import runs on the headset that
was selected when you queued it; if you switch headsets first, it stops with a
message instead.
A game with game data (an OBB folder, or Android/data when ticked) takes two
imports, because Android can only take that data while the game's Lepton
instance is running, and there is no way to start the instance without the
@@ -69,7 +76,11 @@ python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
its apps) and `POST /api/quest` with
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
which returns a background job (`/api/job`) with a stage and percentage.
which returns a background job (`/api/job`) with a stage and percentage. The
job waits in the install queue first (`"queued": true`, stage `Queued: N
installs before it`). `GET /api/queue` lists the running install and those
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
waiting one (409 once it has started).
## What an import does
+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.
+104
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
@@ -15,6 +16,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
python3 -m unittest discover -s tests
```
The Control Center is covered by `tests/test_control_center.py` and its Node
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
They exercise production search, metrics, navigation and action handlers with
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
stale samples, selected-headset capture, duplicate actions and validation before
SSH. Integration regressions run the production shared refresh, Home renderer,
host callback and Control Center bootstrap: a pre-command status request must be
drained before audio readback starts, and delayed startup retains sample receipt
age, failed-read state and early host capabilities. The actual Flatpak install
redraw cannot revive a failed sample; a new successful poll restores metrics and
audio after either redraw or delayed mount. Draft regressions cover a pending headset
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
or JavaScript. A focused run is:
```sh
python3 -m unittest discover -s tests -p test_control_center.py -v
node --test tests/control_center.cjs
node --check ui/control-center.js
```
Responsive layout, actual browser rendering and keyboard/touch behavior still
need browser verification. No Control Center test connects to a real headset.
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
@@ -163,6 +187,86 @@ 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.
## 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
@@ -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
@@ -18,7 +18,7 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+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
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
names that link to them), as installed on macOS and most Linux systems. It prints the
old names left out because their zone is shared by several countries: check any new
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
"""
import sys
from pathlib import Path
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
country = {}
for line in (src / 'zone.tab').read_text().splitlines():
if line and not line.startswith('#'):
cc, _, zone = line.split('\t')[:3]
country[zone] = cc
links = {}
for line in (src / 'tzdata.zi').read_text().splitlines():
if line.startswith('L '):
_, target, alias = line.split()
links[alias] = target
def resolve(z):
for _ in range(5):
z = links.get(z, z)
return z
# An old name links to a zone with the same clocks, which can be in another country
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
# zone.tab zone sharing that target is in one country.
countries_of = {}
for zone, cc in country.items():
countries_of.setdefault(resolve(zone), set()).add(cc)
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
# Places that have left zone.tab, so their target's group looks like another country's.
PLACES = {'Pacific/Johnston': 'UM'}
dropped = []
for alias in links:
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
continue
if alias in PLACES:
country[alias] = PLACES[alias]
continue
ccs = countries_of.get(resolve(alias), set())
if len(ccs) == 1:
country[alias] = next(iter(ccs))
else:
dropped.append(alias)
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
OLD_SPELLINGS = {
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
'Pacific/Yap': 'Pacific/Chuuk'}
for alias, zone in OLD_SPELLINGS.items():
if alias in links and zone in country:
country[alias] = country[zone]
dropped.remove(alias)
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
Zones that aren't in one country, such as UTC, aren't here."""
COUNTRY = {{
{rows}}}
''')
print(f'{len(country)} zones, tz {version} -> {out}')
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
+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
+471
View File
@@ -0,0 +1,471 @@
// Real Control Center logic with a minimal DOM; no network, browser or headset.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const cc = require('../ui/control-center.js');
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
function fixture() {
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
return elements.get(id);
}
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
const page = element('controlCenter');
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
return {document:{getElementById:element},element,cards,buttons};
}
function mounted(options = {}) {
const f = fixture(), calls = [];
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
}
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
// Execute the actual shared refresh/deduplication, host callback and mount
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
// rendering helpers, timers and transport are stubbed.
function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
api:(path,body,device)=>{
if(path==='/api/host')return host.promise;
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
watchConnection(){context.watched=true;}};
vm.createContext(context);
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
if(productionApi) {
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
// rejection classification and the real switch/reset notifications run.
context.fetch=(path,opts)=>{
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
resetShots(){},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 = {};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
}
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
vm.runInContext(pageSource.slice(installStart,installEnd),context);
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
}
const response = data => ({ok:true,status:200,json:async()=>data});
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
const f=sharedPage({productionApi:true});
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
const old=f.refresh(),body=deferred();
assert.equal(f.requests[0].path,'/api/status');
if(rejection==='body') {
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
}
f.switchHeadset();
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
f.requests[1].resolve(response(next));await flush();
const receipt=vm.runInContext('stateReceivedAt',f.context);
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
assert.equal(vm.runInContext('stateRead.error',f.context),null);
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
const assertB=()=>{
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
assert.equal(f.element('metric-battery').textContent,'20%');
assert.equal(f.element('ccVolume').value,20);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
};
assertB();vm.runInContext('render(state)',f.context);assertB();
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
// An ordinary failure on B must still invalidate the current sample.
const failed=f.refresh();
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
else f.requests[2].reject(Error('B transport failed'));
await failed;
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
});
}
test('production headset-free API keeps transport errors and responses across a switch', async t => {
for(const failure of [false,true]) {
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
const pending=vm.runInContext('api("/api/devices")',f.context);
const result=pending.then(value=>({value}),error=>({error}));
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
if(failure)f.requests[0].reject(Error('Device list transport failed'));
else f.requests[0].resolve(response({devices:['frame-b']}));
const settled=await result;
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
else assert.deepEqual(settled.value,{devices:['frame-b']});
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
}
});
test('search finds settings by aliases, all words, category and guidance', () => {
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
assert.ok(find('DPI').includes('display'));
assert.ok(find('sleep inactivity').includes('power'));
assert.ok(find('MOTION SICKNESS').includes('playspace'));
assert.ok(find('font','apps').includes('display'));
assert.deepEqual(find('font','vr'),[]);
assert.deepEqual(find('<script>alert(1)</script>'),[]);
assert.ok(find(' ').length > 20);
});
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
const m = cc.metrics(sample());
assert.equal(m.battery,'99%');
assert.equal(m.power,'0 W');
assert.match(m.batteryDetail,/not charging/);
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
assert.equal(m.thermal,'No hot / critical trip reported');
assert.equal(cc.metrics(null).fps,'Unavailable');
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
});
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
await assert.rejects(controller.audio(change),/valid mute state/);
}
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
assert.equal(calls.length,0);
});
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
const pending=controller.audio({level:.7});
await assert.rejects(controller.audio({muted:true}),/still in progress/);
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
assert.equal(await pending,null);assert.equal(controller.busy,false);
});
test('controller surfaces real API errors and allows a deliberate retry', async () => {
let calls=0;
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
assert.equal(controller.busy,false);
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
});
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
const f=mounted();t.after(()=>f.ui.destroy());
assert.equal(f.element('ccApplyVolume').disabled,true);
f.ui.status(sample());
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('ccVolume').value,40);
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
assert.equal(f.calls.length,0);
assert.equal(f.element('ccVolumeValue').textContent,'65%');
f.ui.status(sample()); // a poll must not overwrite an unapplied value
assert.equal(f.element('ccVolume').value,'65');
await f.element('ccApplyVolume').onclick();
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
assert.equal(f.refreshes,1);
await f.element('ccMute').onclick();
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccAudioResult').textContent,'');
});
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
f.context.online=false;f.ui.connection(false);
assert.equal(f.element('metric-fps').textContent,'Unavailable');
assert.equal(f.element('ccVolume').disabled,true);
await f.element('ccMute').onclick();
assert.equal(f.calls.length,0);
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
f.ui.unavailable(Error('Local server is unavailable'));
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
f.context.online=true;f.ui.status(sample());
assert.equal(f.element('ccVolume').disabled,false);
});
test('mounted audio failure is visible and never reports success', async t => {
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.refreshes,0);
});
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
let expected=.65, mode='confirmed';
const f=mounted({refresh:async()=>{
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
mode='offline';await f.element('ccApplyVolume').onclick();
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
assert.equal(f.element('ccApplyVolume').disabled,true);
});
test('pending audio never overwrites the new headset with old success or error', async t => {
let complete;
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
const pending=f.element('ccMute').onclick();
assert.equal(f.element('ccApplyVolume').disabled,true);
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
complete({message:'Old headset muted'});await pending;
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
assert.equal(f.element('ccAudioResult').textContent,'');
assert.equal(f.refreshes,0);
assert.equal(f.element('ccMute').textContent,'Unmute');
f.ui.unavailable({stale:true,message:'Switched headsets'});
assert.equal(f.element('metric-battery').textContent,'99%');
assert.equal(f.element('ccMute').disabled,false);
});
test('pending old-headset volume cannot become a draft on the new headset', async t => {
for(const failure of [false,true]) {
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const pending=f.element('ccApplyVolume').onclick();
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
const next=sample();next.volume.level=.2;f.ui.status(next);
assert.equal(f.element('ccApplyVolume').disabled,true);
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
await pending;
assert.equal(f.element('ccVolume').value,20);
assert.equal(f.element('ccVolumeValue').textContent,'20%');
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('ccAudioResult').textContent,'');
}
});
test('production host response works before and after mounting with the actual bridge shape', async t => {
for(const early of [true,false]) {
const f=sharedPage();let ui;
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
f.host.resolve({os:'macOS',mobile:false});await flush();
if(early){ui=f.mount();t.after(()=>ui.destroy());}
assert.equal(f.context.watched,true);
assert.equal(f.element('macLink').hidden,false);
f.element('ccSearch').value='mac';f.element('ccSearch').input();
assert.equal(f.element('macLink').hidden,false);
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
}
});
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
const prior=f.refresh();assert.equal(f.requests.length,1);
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const command=f.element('ccApplyVolume').onclick();await flush();
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
f.requests[0].resolve(sample());await prior;await flush();
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
assert.equal(f.element('ccApplyVolume').disabled,true);
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
assert.equal(f.element('ccApplyVolume').disabled,false);
});
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
for(const failedReadback of [false,true]) {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
const command=f.element('ccApplyVolume').onclick();await flush();
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
assert.equal(f.requests.length,2);
assert.equal(f.element('ccApplyVolume').disabled,true);
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
await command;
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
}
});
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
const receipt=vm.runInContext('stateReceivedAt',f.context);
assert.equal(f.element('ccApplyVolume').disabled,false);
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
f.installFlatpak(); // actual production render(state), with job held pending
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
const fresh=f.refresh();
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
const next=sample();next.battery.percent=98;next.volume.level=.2;
f.requests[2].resolve(next);await fresh;
assert.equal(f.element('metric-battery').textContent,'98%');
assert.equal(f.element('ccVolume').value,20);
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
});
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
for(const priorSuccess of [true,false]) {
const f=sharedPage();
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
assert.equal(f.requests.length,calls);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
f.requests.at(-1).resolve(sample());await fresh;
assert.equal(f.element('metric-battery').textContent,'99%');
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
}
});
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
f.ui.status(reading,Date.now()+1000);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.match(f.element('ccSampleState').textContent,/Status failure/);
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
});
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
f.requests[0].resolve(reading);await prior;
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
assert.equal(f.element('metric-battery').textContent,'Unavailable');
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.match(f.element('ccSampleState').textContent,/expired/);
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.equal(f.element('metric-battery').textContent,'99%');
});
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
now+=91000;f.ui.status(reading);
assert.equal(f.element('ccApplyVolume').disabled,true);
assert.equal(f.element('metric-battery').textContent,'Unavailable');
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
f.ui.status({...sample(),time});
assert.equal(f.element('ccApplyVolume').disabled,false);
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
}
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
}
});
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
let reading=sample();const f=mounted({api:async(path,change,device)=>{
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
await f.element('ccMute').onclick();
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
await f.element('ccApplyVolume').onclick();
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
assert.equal(f.element('ccVolume').value,65);
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
});
test('live samples expire even when the status poll is paused', t => {
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
now+=91000;
// A connection update must also re-check freshness before enabling a control.
f.ui.connection(true);
assert.equal(f.element('ccApplyVolume').disabled,true);
});
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
const f=mounted();t.after(()=>f.ui.destroy());
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
assert.equal(f.element('ccResultCount').textContent,'1 section');
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
assert.equal(f.element('ccEmpty').hidden,false);
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
});
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
let activePage,scrolled;
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},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.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
if(!href.startsWith('#')||href==='#connection')continue;
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
assert.equal(activePage,expected[id],href);
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
}
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
assert.equal(vm.runInContext('PAGES[5]',context),'control');
});
+6 -3
View File
@@ -23,9 +23,9 @@ const metaLib = { connected: true, results: new Map(), apps: [
data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
{ id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
] };
const render = new Function("$", "metaLib", "androidApps", "installing",
const render = new Function("$", "metaLib", "androidApps", "installing", "queued",
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
render(id => els[id], metaLib, [], new Set());
render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
const html = els.metaApps.innerHTML;
if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
const climb = html.split('class="item"')[1];
@@ -34,7 +34,10 @@ if (!/Not installed: 1 required data file \(main\.com\.crytek\.climb\.obb\)/.tes
const saber = html.split('class="item"')[2];
if (!/<div class="s v-no"[^>]*>Meta&#39;s API said HTTP 500</.test(saber)) fail("problem not on its own line: " + saber);
if (/Not installed/.test(saber)) fail("warning without skipped files");
// Waiting in the install queue: marked, and not offered again.
if (!/<span class="tag">Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
metaLib.apps = metaLib.apps.slice(0, 1);
render(id => els[id], metaLib, [], new Set());
render(id => els[id], metaLib, [], new Set(), new Set());
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
console.log("ok");
+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");
+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')
+75
View File
@@ -0,0 +1,75 @@
"""Control Center API guards and shared-page behavior, without a real headset."""
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
import importlib.util
import json
from pathlib import Path
import shutil
import subprocess
import sys
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import server
import frame_status
class AudioValidation(unittest.TestCase):
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
for body in invalid:
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
with self.assertRaises(server.Failure) as raised:
server.set_volume(body)
self.assertEqual(raised.exception.status, 400)
ssh.assert_not_called()
def test_supported_actions_use_wpctl_with_exact_values(self):
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
def test_device_error_propagates_without_claiming_success(self):
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
server.set_volume({'level': .5})
class Coverage(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
def test_control_center_behavior(self):
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_shared_assets_ship_to_iphone_and_desktop(self):
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
bundle = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bundle)
files = set(bundle.files())
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
self.assertIn(ROOT / 'ui' / name, files)
config = json.loads((ROOT / 'app/package.json').read_text())
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
self.assertIn('*.js', ui['filter'])
self.assertIn('*.css', ui['filter'])
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
# Run the real aggregator with every external probe stubbed.
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
probes['read'].return_value = '120 0'
probes['process_names'].return_value = set()
result = frame_status.status()
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
+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
+127
View File
@@ -0,0 +1,127 @@
"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
from pathlib import Path
import sys
import threading
import time
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import server # noqa: E402
def wait(job, timeout=5):
end = time.time() + timeout
while time.time() < end:
j = server.job_status(f'id={job}')
if j['done']:
return j
time.sleep(0.01)
raise AssertionError(f'job {job} never finished')
class InstallQueue(unittest.TestCase):
def setUp(self):
self.assertEqual(server._queue, [])
def blocker(self):
"""A queued install that runs until released."""
go, started = threading.Event(), threading.Event()
def work():
started.set()
go.wait(5)
return {'message': 'first done'}
job = server.queue_job('Install first', work)['job']
self.assertTrue(started.wait(5))
self.addCleanup(go.set)
return job, go
def test_runs_in_order_one_at_a_time(self):
order, running = [], []
def work(name):
def run(report):
running.append(name)
self.assertEqual(len(running), 1, 'two installs ran at once')
report('Copying', 50)
time.sleep(0.02)
order.append(name)
running.remove(name)
return {'message': f'{name} done'}
return run
first, go = self.blocker()
started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')]
self.assertEqual([s['queued'] for s in started], [1, 2, 3])
self.assertIn('queued, 3 before it', started[2]['message'])
q = server.install_queue()['jobs']
self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c'])
self.assertEqual([j['queued'] for j in q], [False, True, True, True])
self.assertEqual(q[1]['stage'], 'Queued: 1 install before it')
self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it')
go.set()
self.assertEqual(wait(first)['message'], 'first done')
for s, n in zip(started, 'abc'):
j = wait(s['job'])
self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False))
self.assertEqual(order, ['a', 'b', 'c'])
self.assertEqual(server.install_queue(), {'jobs': []})
def test_a_failure_does_not_stop_the_rest(self):
def fail():
raise server.Failure('no room on the Frame')
bad = server.queue_job('Install bad', fail)['job']
good = server.queue_job('Install good', lambda: {'message': 'ok'})['job']
self.assertEqual(wait(bad)['error'], 'no room on the Frame')
self.assertEqual(wait(good)['message'], 'ok')
def test_a_waiting_install_can_be_removed_but_not_a_running_one(self):
ran = []
first, go = self.blocker()
second = server.queue_job('Install second', lambda: ran.append(2) or {})['job']
third = server.queue_job('Install third', lambda: ran.append(3) or {})['job']
self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message'])
j = server.job_status(f'id={second}')
self.assertTrue(j['done'] and j['cancelled'])
self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it')
for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409),
({'action': 'nope', 'job': third}, 400)):
with self.assertRaises(server.Failure) as e:
server.queue_action(body)
self.assertEqual(e.exception.status, status)
go.set()
wait(third)
self.assertEqual(ran, [3])
def test_runs_only_on_the_headset_it_was_queued_for(self):
class Link:
device = 'frame-a'
def active_device(self):
return {'id': self.device}
link = Link()
with patch.object(server, 'LINK', link):
first, go = self.blocker()
ran = []
later = server.queue_job('Install later', lambda: ran.append(1) or {})['job']
link.device = 'frame-b'
go.set()
self.assertIn('switched headsets', wait(later)['error'])
self.assertEqual(ran, [])
def test_installs_from_each_place_are_queued(self):
with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q:
server.quest({'action': 'import', 'package': 'com.example.game'})
server.meta({'action': 'install', 'app': '123'})
with patch.object(server.frame_catalog, 'app'):
server.android({'action': 'install', 'package': 'com.example.game'})
server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'})
with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})):
server.source_install({'source': 'fdroid', 'id': 'com.example.game'})
self.assertEqual(q.call_count, 5)
if __name__ == '__main__':
unittest.main()
+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):
+1 -1
View File
@@ -1224,7 +1224,7 @@ class Server(unittest.TestCase):
self.assertEqual({a['id'] for a in status['apps']}, {'111', '222'})
self.assertIn('sharing', status)
self.assertNotIn(TOKEN, json.dumps([result, status]))
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
self.assertEqual(s.meta({'action': 'install', 'app': '111', 'test': True}), {'job': 'j'})
work = job.call_args.args[1]
with patch.object(meta, 'install', side_effect=KeyError(f'access_token={TOKEN}')):
+1 -1
View File
@@ -1130,7 +1130,7 @@ class Server(unittest.TestCase):
with self.assertRaises(s.Failure) as e:
s.quest(body)
self.assertEqual(e.exception.status, 400, body)
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
self.assertEqual(s.quest({'action': 'import', 'package': PKG, 'serial': QUEST['serial'], 'data': True}),
{'job': 'j'})
self.assertTrue(job.call_args.kwargs['progress'])
+9
View File
@@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<html", payload.lower())
def test_control_center_assets_served_with_correct_types(self):
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
('/control-center.css', 'text/css', b'.cc-workspace')):
with self.subTest(path=path):
status, headers, payload = self.request('GET', path)
self.assertEqual(status, 200)
self.assertIn(mime, headers['Content-Type'])
self.assertIn(marker, payload)
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
+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()
+69
View File
@@ -159,6 +159,42 @@ class Lifecycle(Base):
e = self.queued()[-1]
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
def test_opens_installs_and_updates_carry_the_country(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
tm.app_started()
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
def test_only_zones_in_the_tz_table_give_a_country(self):
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
("Japan", "JP"), ("UTC", None), ("alice", None),
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
self.assertEqual(tm.country(), want, tz)
def test_country_from_tz_or_localtime_in_a_checkout(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
self.assertEqual(tm.country(), "JP")
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
with mock.patch.object(tm.os.path, "realpath", return_value=target):
self.assertEqual(tm.country(), want, target)
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
tm.update_settings({"compat": True})
self.assertFalse(tm.state()["noticeShown"])
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
def test_frame_build_once(self):
tm.frame_seen("20260922.1", "3.8")
tm.frame_seen("20260922.1", "3.8")
@@ -230,6 +266,39 @@ class Sending(Base):
self.assertEqual(self.queued(), [])
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
def test_an_older_notice_still_sends(self):
got = self.serve()
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
tm.capture("app_opened")
self.assertEqual(tm.flush(), 1)
self.assertEqual(len(got), 1)
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # as 0.4.5 saved it
self.assertFalse(tm.state()["noticeShown"])
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1) # the usual statistics still go
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
tm._save({**tm.settings(), "last_open_day": None}) # the next day
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
def test_turning_usage_on_still_waits_for_the_new_notice(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # 0.4.5, usage off
tm.update_settings({"usage": True})
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
def test_a_failed_send_keeps_the_events(self):
self.serve(status=500)
tm.update_settings({"noticeShown": True})
+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()))
+49
View File
@@ -0,0 +1,49 @@
/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
.cc { gap:24px; }
.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
.cc-hero > div { min-width:0; }
.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
.cc-topline button { margin-left:auto; border-radius:6px; }
.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
+318
View File
@@ -0,0 +1,318 @@
/* The Control Center uses the shared page's guarded api() and status poll.
* Browsing never starts a stream, installs a helper or changes the headset. */
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.FrameControlCenter = factory();
})(typeof window === 'object' ? window : globalThis, function () {
'use strict';
const categories = [
['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
];
// Each destination is an existing workflow, with its own guards and confirmations.
const catalog = [
{id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
keywords:'sound speakers headphones loud quiet', special:'audio'},
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
{id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
{id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
{id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
{id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
{id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
{id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
{id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
{id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
{id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
{id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
{id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
{id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
{id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
];
const paths = {
grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
};
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[name] || paths.settings}"/></svg>`;
function matches(item, query, category) {
const text = [item.title, item.description, item.keywords, item.guidance, item.label,
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
}
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
function metrics(sample) {
const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
const capacity = b.percent;
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
const charging = b.status === 'Charging';
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
return {
battery:number(capacity, '%'), batteryDetail:batteryState,
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
};
}
// Strict validation and captured device identity apply even if a stale enabled
// button is clicked during a disconnect, headset switch, or pending request.
function createController({api, getContext}) {
let busy = false;
return {
get busy() { return busy; },
async audio(change) {
if (busy) throw new Error('An audio change is still in progress.');
const context = getContext();
if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
const keys = Object.keys(change);
if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
busy = true;
try {
const result = await api('/api/volume', change, context.device);
if (getContext().generation !== context.generation) return null;
return result;
} finally { busy = false; }
},
};
}
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
const get = id => document.getElementById(id), page = get('controlCenter');
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
const specials = {audio, performance:perf, health, storage};
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
// Host-only features stay searchable with a reason; only their unsupported
// action disappears. The host API, rather than viewport/UA, decides availability.
let macSupported = platform === 'darwin';
const visibleCatalog = catalog;
function filter() {
const query = get('ccSearch').value;
let count = 0;
catalog.forEach(item => {
const visible = visibleCatalog.includes(item) && matches(item, query, category);
page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
if (visible) count++;
});
get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
get('ccEmpty').hidden = count > 0;
get('ccClear').hidden = !query;
page.querySelectorAll('[data-cc-category]').forEach(button => {
button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
});
page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
}
get('ccSearch').addEventListener('input', filter);
function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
get('ccResetFilter').onclick = resetFilter;
get('ccCategories').onclick = event => {
const button = event.target.closest('[data-cc-category]');
if (!button) return;
category = button.dataset.ccCategory; filter();
};
page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
// The headset clock can differ from this client's. Age uses the client
// receipt time, retained by the shared poll when it buffers an early sample.
const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
const controller = createController({api, getContext:context});
function audioAvailability() {
const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
}
function renderSample() {
const values = metrics(isFresh() ? sample : null);
page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
get('ccSampleState').classList.toggle('cc-offline', !isFresh());
get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
get('ccStatusDot').classList.toggle('on', isFresh());
get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
const services = isFresh() ? sample.services || {} : {};
get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`<span><i class="dot ${services[key] === true ? 'on' : ''}"></i>${label}<b>${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}</b></span>`).join('');
if (sample?.volume && isFresh()) {
const pct = Math.round(sample.volume.level * 100);
if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
} else {
get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
get('ccVolumeValue').textContent = '—';
}
audioAvailability();
}
async function changeAudio(change) {
if (audioPending) return;
const generation = getContext().generation;
audioPending = true;
get('ccAudioResult').textContent = 'Applying…';
try {
const pending = controller.audio(change);
audioAvailability();
const result = await pending;
if (!result || generation !== getContext().generation) return;
if ('level' in change) dirty = false;
get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
await refreshAudio();
if (generation !== getContext().generation) return;
if (!isFresh() || !sample?.volume) {
get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
} else {
const reported = sample.volume;
const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
(!('muted' in change) || reported.muted === change.muted);
get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
}
} catch (e) {
if (generation !== getContext().generation) return;
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
} finally { audioPending = false; renderSample(); }
}
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
get('ccRefresh').onclick = async () => {
get('ccRefresh').disabled = true;
try { await refresh(); } finally { get('ccRefresh').disabled = false; }
};
filter(); renderSample();
// Expire UI readings even when the shared poll is paused in a hidden tab.
const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
return {
host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
status(value, receipt = Date.now(), read = {valid: true, error: null}) {
// A redraw is not a successful read: preserve receipt age and any local
// invalidation. Shared validity also carries failures across late mount.
const replay = sample === value;
receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
sample = value;
if (!read.valid) {
available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
} else if (!replay) { available = true; error = ''; }
sampleGeneration = getContext().generation; renderSample();
},
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
destroy() { clearInterval(timer); },
};
}
return {catalog, categories, matches, metrics, createController, mount};
});
+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']}"
+1
View File
@@ -302,6 +302,7 @@ def status():
"disk": {"root": disk("/"), "home": disk("/home")},
"memory": memory(),
"temp": max_temp(),
"thermalAlerts": thermal_alerts(),
"wifi": wifi(),
"ip": ip_addr(),
"volume": volume(),
+42 -12
View File
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
same project whatever is chosen here.
Events are identified by a random id made on first run, not by the person or
computer, and sent without person profiles or GeoIP. Nothing is sent without a
computer, and sent without person profiles or GeoIP. Opens, installs and updates
carry a country instead, worked out on this computer from its time zone setting
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
$FRAME_CONTROL_TELEMETRY=0.
@@ -43,6 +45,7 @@ from pathlib import Path
from urllib.parse import urlsplit
import frame_host
import frame_timezones
HERE = Path(__file__).resolve().parent
STATE = frame_host.data_dir('telemetry')
@@ -65,10 +68,11 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
DEFAULT_HOST = 'https://us.i.posthog.com'
LEVELS = ('usage', 'compat', 'diagnostics')
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
# Events the page may send through /api/telemetry, and the properties each may carry.
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
TABS = {'home', 'games', 'android', 'tools'}
TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
_lock = threading.RLock()
_send_lock = threading.Lock() # held while sending; consent changes wait for it
@@ -105,7 +109,7 @@ def blocked():
def _defaults():
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'frames_seen': [], 'compat_sent': []}
@@ -147,12 +151,12 @@ def update_settings(changes):
under way finishes first, so nothing leaves after this returns."""
with _send_lock, _lock:
s = settings()
if 'noticeShown' in changes:
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
if changes.get('noticeShown'):
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
for level in LEVELS:
if level in changes:
s[level] = bool(changes[level])
s['notice_shown'] = True
s['notice_shown'] = True # only the notice itself moves notice_version on
_save(s)
_drop_unwanted(s)
if changes.get('compat'):
@@ -161,10 +165,16 @@ def update_settings(changes):
return state()
def _notice_current(s):
"""Whether the notice this person saw lists everything that's sent now. If not, the page
shows it again, and what it added waits until it has been seen."""
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
def state():
"""What the page shows: the choices, why sending is blocked, and what was sent."""
s = settings()
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
'diagnostics': s['diagnostics'],
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
'sent': list(reversed(_read_lines(SENT)))[:50]}
@@ -372,20 +382,36 @@ def page_event(body):
return {'queued': capture(name, props)}
def country():
"""The two-letter country of the computer's time zone setting, or None. Only names in the
tz database's table count, so nothing else in the setting can be sent. The app passes the
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
if not tz:
try:
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
except (OSError, IndexError):
return None
return frame_timezones.COUNTRY.get(tz)
def app_started():
"""Once per server start: first install, an update, and one open a day."""
"""Once per server start: first install, an update, and one open a day, with the country
(left out when sending if the notice this person saw came before it: flush)."""
if blocked():
return
with _lock:
s = settings()
version, today = app_version(), time.strftime('%Y-%m-%d')
cc = country()
where = {'country': cc} if cc else {}
if not s['installed_sent']:
capture('app_installed')
capture('app_installed', where)
s['installed_sent'] = True
elif s['last_version'] and s['last_version'] != version:
capture('app_updated', {'from_version': s['last_version']})
capture('app_updated', {'from_version': s['last_version'], **where})
if s['last_open_day'] != today:
capture('app_opened')
capture('app_opened', where)
s['last_open_day'] = today
s['last_version'] = version
_save(s)
@@ -580,10 +606,14 @@ def flush(timeout=20):
if blocked() or not settings()['notice_shown']:
return 0
with _lock:
_drop_unwanted(settings())
s = settings()
_drop_unwanted(s)
batch = _read_lines(OUTBOX)[:100]
if not batch:
return 0
if not _notice_current(s): # what a newer notice added stays here until it's been seen
for e in batch:
e['properties'].pop('country', None)
try:
post(batch, timeout)
except SendError:
+547
View File
@@ -0,0 +1,547 @@
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
Zones that aren't in one country, such as UTC, aren't here."""
COUNTRY = {
'Africa/Abidjan': 'CI',
'Africa/Accra': 'GH',
'Africa/Addis_Ababa': 'ET',
'Africa/Algiers': 'DZ',
'Africa/Asmara': 'ER',
'Africa/Asmera': 'ER',
'Africa/Bamako': 'ML',
'Africa/Bangui': 'CF',
'Africa/Banjul': 'GM',
'Africa/Bissau': 'GW',
'Africa/Blantyre': 'MW',
'Africa/Brazzaville': 'CG',
'Africa/Bujumbura': 'BI',
'Africa/Cairo': 'EG',
'Africa/Casablanca': 'MA',
'Africa/Ceuta': 'ES',
'Africa/Conakry': 'GN',
'Africa/Dakar': 'SN',
'Africa/Dar_es_Salaam': 'TZ',
'Africa/Djibouti': 'DJ',
'Africa/Douala': 'CM',
'Africa/El_Aaiun': 'EH',
'Africa/Freetown': 'SL',
'Africa/Gaborone': 'BW',
'Africa/Harare': 'ZW',
'Africa/Johannesburg': 'ZA',
'Africa/Juba': 'SS',
'Africa/Kampala': 'UG',
'Africa/Khartoum': 'SD',
'Africa/Kigali': 'RW',
'Africa/Kinshasa': 'CD',
'Africa/Lagos': 'NG',
'Africa/Libreville': 'GA',
'Africa/Lome': 'TG',
'Africa/Luanda': 'AO',
'Africa/Lubumbashi': 'CD',
'Africa/Lusaka': 'ZM',
'Africa/Malabo': 'GQ',
'Africa/Maputo': 'MZ',
'Africa/Maseru': 'LS',
'Africa/Mbabane': 'SZ',
'Africa/Mogadishu': 'SO',
'Africa/Monrovia': 'LR',
'Africa/Nairobi': 'KE',
'Africa/Ndjamena': 'TD',
'Africa/Niamey': 'NE',
'Africa/Nouakchott': 'MR',
'Africa/Ouagadougou': 'BF',
'Africa/Porto-Novo': 'BJ',
'Africa/Sao_Tome': 'ST',
'Africa/Timbuktu': 'ML',
'Africa/Tripoli': 'LY',
'Africa/Tunis': 'TN',
'Africa/Windhoek': 'NA',
'America/Adak': 'US',
'America/Anchorage': 'US',
'America/Anguilla': 'AI',
'America/Antigua': 'AG',
'America/Araguaina': 'BR',
'America/Argentina/Buenos_Aires': 'AR',
'America/Argentina/Catamarca': 'AR',
'America/Argentina/ComodRivadavia': 'AR',
'America/Argentina/Cordoba': 'AR',
'America/Argentina/Jujuy': 'AR',
'America/Argentina/La_Rioja': 'AR',
'America/Argentina/Mendoza': 'AR',
'America/Argentina/Rio_Gallegos': 'AR',
'America/Argentina/Salta': 'AR',
'America/Argentina/San_Juan': 'AR',
'America/Argentina/San_Luis': 'AR',
'America/Argentina/Tucuman': 'AR',
'America/Argentina/Ushuaia': 'AR',
'America/Aruba': 'AW',
'America/Asuncion': 'PY',
'America/Atikokan': 'CA',
'America/Atka': 'US',
'America/Bahia': 'BR',
'America/Bahia_Banderas': 'MX',
'America/Barbados': 'BB',
'America/Belem': 'BR',
'America/Belize': 'BZ',
'America/Blanc-Sablon': 'CA',
'America/Boa_Vista': 'BR',
'America/Bogota': 'CO',
'America/Boise': 'US',
'America/Buenos_Aires': 'AR',
'America/Cambridge_Bay': 'CA',
'America/Campo_Grande': 'BR',
'America/Cancun': 'MX',
'America/Caracas': 'VE',
'America/Catamarca': 'AR',
'America/Cayenne': 'GF',
'America/Cayman': 'KY',
'America/Chicago': 'US',
'America/Chihuahua': 'MX',
'America/Ciudad_Juarez': 'MX',
'America/Coral_Harbour': 'CA',
'America/Cordoba': 'AR',
'America/Costa_Rica': 'CR',
'America/Coyhaique': 'CL',
'America/Creston': 'CA',
'America/Cuiaba': 'BR',
'America/Curacao': 'CW',
'America/Danmarkshavn': 'GL',
'America/Dawson': 'CA',
'America/Dawson_Creek': 'CA',
'America/Denver': 'US',
'America/Detroit': 'US',
'America/Dominica': 'DM',
'America/Edmonton': 'CA',
'America/Eirunepe': 'BR',
'America/El_Salvador': 'SV',
'America/Ensenada': 'MX',
'America/Fort_Nelson': 'CA',
'America/Fort_Wayne': 'US',
'America/Fortaleza': 'BR',
'America/Glace_Bay': 'CA',
'America/Godthab': 'GL',
'America/Goose_Bay': 'CA',
'America/Grand_Turk': 'TC',
'America/Grenada': 'GD',
'America/Guadeloupe': 'GP',
'America/Guatemala': 'GT',
'America/Guayaquil': 'EC',
'America/Guyana': 'GY',
'America/Halifax': 'CA',
'America/Havana': 'CU',
'America/Hermosillo': 'MX',
'America/Indiana/Indianapolis': 'US',
'America/Indiana/Knox': 'US',
'America/Indiana/Marengo': 'US',
'America/Indiana/Petersburg': 'US',
'America/Indiana/Tell_City': 'US',
'America/Indiana/Vevay': 'US',
'America/Indiana/Vincennes': 'US',
'America/Indiana/Winamac': 'US',
'America/Indianapolis': 'US',
'America/Inuvik': 'CA',
'America/Iqaluit': 'CA',
'America/Jamaica': 'JM',
'America/Jujuy': 'AR',
'America/Juneau': 'US',
'America/Kentucky/Louisville': 'US',
'America/Kentucky/Monticello': 'US',
'America/Knox_IN': 'US',
'America/Kralendijk': 'BQ',
'America/La_Paz': 'BO',
'America/Lima': 'PE',
'America/Los_Angeles': 'US',
'America/Louisville': 'US',
'America/Lower_Princes': 'SX',
'America/Maceio': 'BR',
'America/Managua': 'NI',
'America/Manaus': 'BR',
'America/Marigot': 'MF',
'America/Martinique': 'MQ',
'America/Matamoros': 'MX',
'America/Mazatlan': 'MX',
'America/Mendoza': 'AR',
'America/Menominee': 'US',
'America/Merida': 'MX',
'America/Metlakatla': 'US',
'America/Mexico_City': 'MX',
'America/Miquelon': 'PM',
'America/Moncton': 'CA',
'America/Monterrey': 'MX',
'America/Montevideo': 'UY',
'America/Montreal': 'CA',
'America/Montserrat': 'MS',
'America/Nassau': 'BS',
'America/New_York': 'US',
'America/Nipigon': 'CA',
'America/Nome': 'US',
'America/Noronha': 'BR',
'America/North_Dakota/Beulah': 'US',
'America/North_Dakota/Center': 'US',
'America/North_Dakota/New_Salem': 'US',
'America/Nuuk': 'GL',
'America/Ojinaga': 'MX',
'America/Panama': 'PA',
'America/Pangnirtung': 'CA',
'America/Paramaribo': 'SR',
'America/Phoenix': 'US',
'America/Port-au-Prince': 'HT',
'America/Port_of_Spain': 'TT',
'America/Porto_Acre': 'BR',
'America/Porto_Velho': 'BR',
'America/Puerto_Rico': 'PR',
'America/Punta_Arenas': 'CL',
'America/Rainy_River': 'CA',
'America/Rankin_Inlet': 'CA',
'America/Recife': 'BR',
'America/Regina': 'CA',
'America/Resolute': 'CA',
'America/Rio_Branco': 'BR',
'America/Rosario': 'AR',
'America/Santa_Isabel': 'MX',
'America/Santarem': 'BR',
'America/Santiago': 'CL',
'America/Santo_Domingo': 'DO',
'America/Sao_Paulo': 'BR',
'America/Scoresbysund': 'GL',
'America/Shiprock': 'US',
'America/Sitka': 'US',
'America/St_Barthelemy': 'BL',
'America/St_Johns': 'CA',
'America/St_Kitts': 'KN',
'America/St_Lucia': 'LC',
'America/St_Thomas': 'VI',
'America/St_Vincent': 'VC',
'America/Swift_Current': 'CA',
'America/Tegucigalpa': 'HN',
'America/Thule': 'GL',
'America/Thunder_Bay': 'CA',
'America/Tijuana': 'MX',
'America/Toronto': 'CA',
'America/Tortola': 'VG',
'America/Vancouver': 'CA',
'America/Virgin': 'VI',
'America/Whitehorse': 'CA',
'America/Winnipeg': 'CA',
'America/Yakutat': 'US',
'America/Yellowknife': 'CA',
'Antarctica/Casey': 'AQ',
'Antarctica/Davis': 'AQ',
'Antarctica/DumontDUrville': 'AQ',
'Antarctica/Macquarie': 'AU',
'Antarctica/Mawson': 'AQ',
'Antarctica/McMurdo': 'AQ',
'Antarctica/Palmer': 'AQ',
'Antarctica/Rothera': 'AQ',
'Antarctica/South_Pole': 'AQ',
'Antarctica/Syowa': 'AQ',
'Antarctica/Troll': 'AQ',
'Antarctica/Vostok': 'AQ',
'Arctic/Longyearbyen': 'SJ',
'Asia/Aden': 'YE',
'Asia/Almaty': 'KZ',
'Asia/Amman': 'JO',
'Asia/Anadyr': 'RU',
'Asia/Aqtau': 'KZ',
'Asia/Aqtobe': 'KZ',
'Asia/Ashgabat': 'TM',
'Asia/Ashkhabad': 'TM',
'Asia/Atyrau': 'KZ',
'Asia/Baghdad': 'IQ',
'Asia/Bahrain': 'BH',
'Asia/Baku': 'AZ',
'Asia/Bangkok': 'TH',
'Asia/Barnaul': 'RU',
'Asia/Beirut': 'LB',
'Asia/Bishkek': 'KG',
'Asia/Brunei': 'BN',
'Asia/Calcutta': 'IN',
'Asia/Chita': 'RU',
'Asia/Choibalsan': 'MN',
'Asia/Chongqing': 'CN',
'Asia/Chungking': 'CN',
'Asia/Colombo': 'LK',
'Asia/Dacca': 'BD',
'Asia/Damascus': 'SY',
'Asia/Dhaka': 'BD',
'Asia/Dili': 'TL',
'Asia/Dubai': 'AE',
'Asia/Dushanbe': 'TJ',
'Asia/Famagusta': 'CY',
'Asia/Gaza': 'PS',
'Asia/Harbin': 'CN',
'Asia/Hebron': 'PS',
'Asia/Ho_Chi_Minh': 'VN',
'Asia/Hong_Kong': 'HK',
'Asia/Hovd': 'MN',
'Asia/Irkutsk': 'RU',
'Asia/Istanbul': 'TR',
'Asia/Jakarta': 'ID',
'Asia/Jayapura': 'ID',
'Asia/Jerusalem': 'IL',
'Asia/Kabul': 'AF',
'Asia/Kamchatka': 'RU',
'Asia/Karachi': 'PK',
'Asia/Kashgar': 'CN',
'Asia/Kathmandu': 'NP',
'Asia/Katmandu': 'NP',
'Asia/Khandyga': 'RU',
'Asia/Kolkata': 'IN',
'Asia/Krasnoyarsk': 'RU',
'Asia/Kuala_Lumpur': 'MY',
'Asia/Kuching': 'MY',
'Asia/Kuwait': 'KW',
'Asia/Macao': 'MO',
'Asia/Macau': 'MO',
'Asia/Magadan': 'RU',
'Asia/Makassar': 'ID',
'Asia/Manila': 'PH',
'Asia/Muscat': 'OM',
'Asia/Nicosia': 'CY',
'Asia/Novokuznetsk': 'RU',
'Asia/Novosibirsk': 'RU',
'Asia/Omsk': 'RU',
'Asia/Oral': 'KZ',
'Asia/Phnom_Penh': 'KH',
'Asia/Pontianak': 'ID',
'Asia/Pyongyang': 'KP',
'Asia/Qatar': 'QA',
'Asia/Qostanay': 'KZ',
'Asia/Qyzylorda': 'KZ',
'Asia/Rangoon': 'MM',
'Asia/Riyadh': 'SA',
'Asia/Saigon': 'VN',
'Asia/Sakhalin': 'RU',
'Asia/Samarkand': 'UZ',
'Asia/Seoul': 'KR',
'Asia/Shanghai': 'CN',
'Asia/Singapore': 'SG',
'Asia/Srednekolymsk': 'RU',
'Asia/Taipei': 'TW',
'Asia/Tashkent': 'UZ',
'Asia/Tbilisi': 'GE',
'Asia/Tehran': 'IR',
'Asia/Tel_Aviv': 'IL',
'Asia/Thimbu': 'BT',
'Asia/Thimphu': 'BT',
'Asia/Tokyo': 'JP',
'Asia/Tomsk': 'RU',
'Asia/Ujung_Pandang': 'ID',
'Asia/Ulaanbaatar': 'MN',
'Asia/Ulan_Bator': 'MN',
'Asia/Urumqi': 'CN',
'Asia/Ust-Nera': 'RU',
'Asia/Vientiane': 'LA',
'Asia/Vladivostok': 'RU',
'Asia/Yakutsk': 'RU',
'Asia/Yangon': 'MM',
'Asia/Yekaterinburg': 'RU',
'Asia/Yerevan': 'AM',
'Atlantic/Azores': 'PT',
'Atlantic/Bermuda': 'BM',
'Atlantic/Canary': 'ES',
'Atlantic/Cape_Verde': 'CV',
'Atlantic/Faeroe': 'FO',
'Atlantic/Faroe': 'FO',
'Atlantic/Jan_Mayen': 'SJ',
'Atlantic/Madeira': 'PT',
'Atlantic/Reykjavik': 'IS',
'Atlantic/South_Georgia': 'GS',
'Atlantic/St_Helena': 'SH',
'Atlantic/Stanley': 'FK',
'Australia/ACT': 'AU',
'Australia/Adelaide': 'AU',
'Australia/Brisbane': 'AU',
'Australia/Broken_Hill': 'AU',
'Australia/Canberra': 'AU',
'Australia/Currie': 'AU',
'Australia/Darwin': 'AU',
'Australia/Eucla': 'AU',
'Australia/Hobart': 'AU',
'Australia/LHI': 'AU',
'Australia/Lindeman': 'AU',
'Australia/Lord_Howe': 'AU',
'Australia/Melbourne': 'AU',
'Australia/NSW': 'AU',
'Australia/North': 'AU',
'Australia/Perth': 'AU',
'Australia/Queensland': 'AU',
'Australia/South': 'AU',
'Australia/Sydney': 'AU',
'Australia/Tasmania': 'AU',
'Australia/Victoria': 'AU',
'Australia/West': 'AU',
'Australia/Yancowinna': 'AU',
'Brazil/Acre': 'BR',
'Brazil/DeNoronha': 'BR',
'Brazil/East': 'BR',
'Brazil/West': 'BR',
'Canada/Atlantic': 'CA',
'Canada/Central': 'CA',
'Canada/Eastern': 'CA',
'Canada/Mountain': 'CA',
'Canada/Newfoundland': 'CA',
'Canada/Pacific': 'CA',
'Canada/Saskatchewan': 'CA',
'Canada/Yukon': 'CA',
'Chile/Continental': 'CL',
'Chile/EasterIsland': 'CL',
'Cuba': 'CU',
'Egypt': 'EG',
'Eire': 'IE',
'Europe/Amsterdam': 'NL',
'Europe/Andorra': 'AD',
'Europe/Astrakhan': 'RU',
'Europe/Athens': 'GR',
'Europe/Belfast': 'GB',
'Europe/Belgrade': 'RS',
'Europe/Berlin': 'DE',
'Europe/Bratislava': 'SK',
'Europe/Brussels': 'BE',
'Europe/Bucharest': 'RO',
'Europe/Budapest': 'HU',
'Europe/Busingen': 'DE',
'Europe/Chisinau': 'MD',
'Europe/Copenhagen': 'DK',
'Europe/Dublin': 'IE',
'Europe/Gibraltar': 'GI',
'Europe/Guernsey': 'GG',
'Europe/Helsinki': 'FI',
'Europe/Isle_of_Man': 'IM',
'Europe/Istanbul': 'TR',
'Europe/Jersey': 'JE',
'Europe/Kaliningrad': 'RU',
'Europe/Kiev': 'UA',
'Europe/Kirov': 'RU',
'Europe/Kyiv': 'UA',
'Europe/Lisbon': 'PT',
'Europe/Ljubljana': 'SI',
'Europe/London': 'GB',
'Europe/Luxembourg': 'LU',
'Europe/Madrid': 'ES',
'Europe/Malta': 'MT',
'Europe/Mariehamn': 'AX',
'Europe/Minsk': 'BY',
'Europe/Monaco': 'MC',
'Europe/Moscow': 'RU',
'Europe/Nicosia': 'CY',
'Europe/Oslo': 'NO',
'Europe/Paris': 'FR',
'Europe/Podgorica': 'ME',
'Europe/Prague': 'CZ',
'Europe/Riga': 'LV',
'Europe/Rome': 'IT',
'Europe/Samara': 'RU',
'Europe/San_Marino': 'SM',
'Europe/Sarajevo': 'BA',
'Europe/Saratov': 'RU',
'Europe/Simferopol': 'UA',
'Europe/Skopje': 'MK',
'Europe/Sofia': 'BG',
'Europe/Stockholm': 'SE',
'Europe/Tallinn': 'EE',
'Europe/Tirane': 'AL',
'Europe/Tiraspol': 'MD',
'Europe/Ulyanovsk': 'RU',
'Europe/Uzhgorod': 'UA',
'Europe/Vaduz': 'LI',
'Europe/Vatican': 'VA',
'Europe/Vienna': 'AT',
'Europe/Vilnius': 'LT',
'Europe/Volgograd': 'RU',
'Europe/Warsaw': 'PL',
'Europe/Zagreb': 'HR',
'Europe/Zaporozhye': 'UA',
'Europe/Zurich': 'CH',
'GB': 'GB',
'GB-Eire': 'GB',
'Hongkong': 'HK',
'Iceland': 'IS',
'Indian/Antananarivo': 'MG',
'Indian/Chagos': 'IO',
'Indian/Christmas': 'CX',
'Indian/Cocos': 'CC',
'Indian/Comoro': 'KM',
'Indian/Kerguelen': 'TF',
'Indian/Mahe': 'SC',
'Indian/Maldives': 'MV',
'Indian/Mauritius': 'MU',
'Indian/Mayotte': 'YT',
'Indian/Reunion': 'RE',
'Iran': 'IR',
'Israel': 'IL',
'Jamaica': 'JM',
'Japan': 'JP',
'Kwajalein': 'MH',
'Libya': 'LY',
'Mexico/BajaNorte': 'MX',
'Mexico/BajaSur': 'MX',
'Mexico/General': 'MX',
'NZ': 'NZ',
'NZ-CHAT': 'NZ',
'Navajo': 'US',
'PRC': 'CN',
'Pacific/Apia': 'WS',
'Pacific/Auckland': 'NZ',
'Pacific/Bougainville': 'PG',
'Pacific/Chatham': 'NZ',
'Pacific/Chuuk': 'FM',
'Pacific/Easter': 'CL',
'Pacific/Efate': 'VU',
'Pacific/Enderbury': 'KI',
'Pacific/Fakaofo': 'TK',
'Pacific/Fiji': 'FJ',
'Pacific/Funafuti': 'TV',
'Pacific/Galapagos': 'EC',
'Pacific/Gambier': 'PF',
'Pacific/Guadalcanal': 'SB',
'Pacific/Guam': 'GU',
'Pacific/Honolulu': 'US',
'Pacific/Johnston': 'UM',
'Pacific/Kanton': 'KI',
'Pacific/Kiritimati': 'KI',
'Pacific/Kosrae': 'FM',
'Pacific/Kwajalein': 'MH',
'Pacific/Majuro': 'MH',
'Pacific/Marquesas': 'PF',
'Pacific/Midway': 'UM',
'Pacific/Nauru': 'NR',
'Pacific/Niue': 'NU',
'Pacific/Norfolk': 'NF',
'Pacific/Noumea': 'NC',
'Pacific/Pago_Pago': 'AS',
'Pacific/Palau': 'PW',
'Pacific/Pitcairn': 'PN',
'Pacific/Pohnpei': 'FM',
'Pacific/Ponape': 'FM',
'Pacific/Port_Moresby': 'PG',
'Pacific/Rarotonga': 'CK',
'Pacific/Saipan': 'MP',
'Pacific/Samoa': 'AS',
'Pacific/Tahiti': 'PF',
'Pacific/Tarawa': 'KI',
'Pacific/Tongatapu': 'TO',
'Pacific/Truk': 'FM',
'Pacific/Wake': 'UM',
'Pacific/Wallis': 'WF',
'Pacific/Yap': 'FM',
'Poland': 'PL',
'Portugal': 'PT',
'ROC': 'TW',
'ROK': 'KR',
'Singapore': 'SG',
'Turkey': 'TR',
'US/Alaska': 'US',
'US/Aleutian': 'US',
'US/Arizona': 'US',
'US/Central': 'US',
'US/East-Indiana': 'US',
'US/Eastern': 'US',
'US/Hawaii': 'US',
'US/Indiana-Starke': 'US',
'US/Michigan': 'US',
'US/Mountain': 'US',
'US/Pacific': 'US',
'US/Samoa': 'AS',
'W-SU': 'RU',
}
+575 -168
View File
File diff suppressed because it is too large. Load diff
+149 -36
View File
@@ -137,7 +137,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
# install's clean-up) to the other headset.
_work_lock = threading.Lock()
_work = [0]
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
@contextlib.contextmanager
@@ -286,40 +286,120 @@ def backfill_art(apps=(), titles=()):
return True
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
def _new_job_locked(label, **fields):
now = time.time()
with _jobs_lock:
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
del _jobs[j]
job = secrets.token_hex(8)
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
return job
def _run_job(job, label, work, progress):
def report(stage, percent=None):
with _jobs_lock:
_jobs[job].update(stage=stage, percent=percent)
def run():
fields = {}
try:
result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
finally:
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
fields = {}
try:
result = work(report) if progress else work()
fields = {"message": result.get("message") or f"{label}: done", "result": result}
except (Failure, frame_android.FrameError) as e:
fields = {"error": unreachable(str(e)) or str(e)}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
except SourceError as e: # already user-readable, and about a store, not the Frame
fields = {"error": str(e)}
except Exception as e:
fields = {"error": f"{type(e).__name__}: {e}"}
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
finally:
with _jobs_lock:
_jobs[job].update(fields, done=True, time=time.time())
busy_thread(run).start()
def start_job(label, work, progress=False):
"""Run work() in the background. It returns a dict with a "message"."""
with _jobs_lock:
job = _new_job_locked(label)
busy_thread(_run_job, job, label, work, progress).start()
return {"message": f"{label}…", "job": job}
# ---- The install queue: Android games and apps, one after another ----------
#
# Installs from the Android store, Import from my Quest and My Meta library each copy
# a game into a Lepton instance, and each refuses to start while another is running.
# Queued, the page can line up several: one worker runs them in order, each as a
# normal job (/api/job) that reads "queued" until its turn. A queued install can be
# removed until it starts; each runs on the headset it was queued for, or not at all.
_queue = [] # [job, label, work, progress, headset id], in order; the running one is not here
_queue_state = {"worker": False, "running": None}
def _restage_locked():
ahead = 1 if _queue_state["running"] else 0
for i, (job, *_) in enumerate(_queue):
n = ahead + i
_jobs[job]["stage"] = f"Queued: {n} install{'' if n == 1 else 's'} before it" if n else "Queued: next"
def queue_job(label, work, progress=False):
"""Like start_job, but runs after every install queued before it."""
device = LINK.active_device()["id"] if LINK else None
with _jobs_lock:
job = _new_job_locked(label, queued=True, stage=None, percent=None)
_queue.append([job, label, work, progress, device])
_restage_locked()
ahead = len(_queue) - 1 + (1 if _queue_state["running"] else 0)
start, _queue_state["worker"] = not _queue_state["worker"], True
if start:
threading.Thread(target=_drain_queue, daemon=True).start()
return {"message": f"{label}: queued, {ahead} before it" if ahead else f"{label}…", "job": job, "queued": ahead}
def _drain_queue():
while True:
with _jobs_lock:
if not _queue:
_queue_state.update(worker=False, running=None)
return
job, label, work, progress, device = _queue.pop(0)
_queue_state["running"] = job
_jobs[job].update(queued=False, stage=None)
_restage_locked()
def guarded(*report, work=work, device=device):
with working(device): # refused if the app switched headsets since it was queued
return work(*report)
_run_job(job, label, guarded, progress)
def install_queue():
"""The running install and those waiting, in order."""
with _jobs_lock:
ids = ([_queue_state["running"]] if _queue_state["running"] else []) + [q[0] for q in _queue]
return {"jobs": [{"job": j, "label": _jobs[j]["label"], "queued": bool(_jobs[j].get("queued")),
"stage": _jobs[j].get("stage"), "percent": _jobs[j].get("percent")}
for j in ids if j in _jobs]}
def queue_action(body):
"""{"action": "remove", "job"}: take a waiting install out of the queue."""
if body.get("action") != "remove":
raise Failure("unknown action", 400)
job = body.get("job")
with _jobs_lock:
for i, q in enumerate(_queue):
if q[0] == job:
del _queue[i]
_jobs[job].update(done=True, queued=False, cancelled=True, stage=None, time=time.time(),
error="Removed from the install queue")
_restage_locked()
return {"message": f"Removed {_jobs[job]['label']} from the install queue"}
raise Failure("that install isn't waiting in the queue (it may have started or finished)", 409)
def job_status(query):
with _jobs_lock:
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
@@ -701,9 +781,14 @@ def set_volume(body):
# Validate everything before touching the headset.
level = None
if "level" in body:
level = float(body["level"])
if not 0 <= level <= 1:
value = body["level"]
if type(value) not in (int, float) or not 0 <= value <= 1:
raise Failure("level must be 0..1", 400)
level = float(value)
if not body or set(body) - {"level", "muted"}:
raise Failure("Provide level or muted", 400)
if "muted" in body and type(body["muted"]) is not bool:
raise Failure("muted must be true or false", 400)
if "muted" in body:
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
if level is not None:
@@ -1293,7 +1378,7 @@ def android(body):
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
return start_job(f"Install {pkg}", work)
return queue_job(f"Install {pkg}", work)
if action == "refresh-art":
if not pkg and not body.get("all"):
raise Failure('choose a package or all apps', 400)
@@ -1364,7 +1449,7 @@ def quest(body):
if type(data) is not bool or type(older) is not bool:
raise Failure("data and allowDowngrade must be true or false", 400)
ensure_master()
return start_job(f"Import {pkg} from Quest",
return queue_job(f"Import {pkg} from Quest",
lambda report: frame_quest.import_game(pkg, serial, data=data, progress=report,
allow_downgrade=older), progress=True)
@@ -1445,7 +1530,7 @@ def meta(body):
raise frame_meta.MetaError(frame_meta.redact(e, 1200)) from None
except Exception as e: # never let an unexpected error's text carry the token
raise frame_meta.MetaError(frame_meta.redact(f"{type(e).__name__}: {e}", 600)) from None
return start_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
return queue_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
if action == "test":
pkg = str(body.get("package", ""))
if not frame_android.PKG_RE.match(pkg):
@@ -2314,6 +2399,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]
@@ -2339,7 +2434,7 @@ def source_install(body):
raise SourceError('This source is disabled')
except SourceError as e:
raise Failure(str(e), 400)
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
return queue_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
def source_manage(body):
@@ -2352,19 +2447,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))
@@ -2382,7 +2489,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/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/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,
@@ -2559,6 +2666,10 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/control-center.js":
self.send_bytes((HERE / 'control-center.js').read_bytes(), 'text/javascript; charset=utf-8')
elif path == "/control-center.css":
self.send_bytes((HERE / 'control-center.css').read_bytes(), 'text/css; charset=utf-8')
elif path == "/assistant":
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
self.send_bytes(page.encode(), "text/html; charset=utf-8")
@@ -2622,6 +2733,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
elif path == "/api/job":
self.send_json(job_status(url.query))
elif path == "/api/queue":
self.send_json(install_queue())
elif path == "/api/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":