Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:42:03 +11:00
33 changed files with 1448 additions and 1384 deletions

No files matched your search

+3 -2
View File
@@ -115,10 +115,11 @@ function ping(target) {
}
async function startServer() {
// The version and whether this is a built app go to ui/frame_telemetry.py, which
// sends nothing from a source checkout.
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
// which sends nothing from a source checkout.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.4",
"version": "0.4.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.4",
"version": "0.4.6",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.4",
"version": "0.4.6",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+1 -54
View File
@@ -58,61 +58,8 @@ 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, username=None, password=None)`, `remove_repo(source_id)`,
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
+4 -17
View File
@@ -45,23 +45,10 @@ 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.
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
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
+26 -10
View File
@@ -33,15 +33,28 @@ computer, exactly as they were sent.
yours.
- Every event includes the app version, OS name (macOS, Windows or Linux),
CPU architecture and Python version.
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
two-letter code such as `DE`, worked out on your computer from its time zone
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
Control is used without knowing your address. The time zone itself isn't
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
Copies that showed the first-run notice before it mentioned this (before
0.4.6) show it again, and don't send a country until it has been seen.
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
so the machine-id itself never leaves it. It is the same for one headset on
every computer, which lets us count headsets rather than installs; it can't
be turned back into the machine-id or a serial number. `headsets_saved` is how
many headsets this copy of Frame Control has set up.
## Usage events
| Event | When | Properties besides the common ones |
|---|---|---|
| `app_installed` | First run | |
| `app_updated` | First run of a new version | `from_version` |
| `app_opened` | At most once a day | |
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
| `app_installed` | First run | `country` |
| `app_updated` | First run of a new version | `from_version`, `country` |
| `app_opened` | At most once a day | `country` |
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
| `tab_viewed` | The first click on each tab in a session | `tab` |
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
@@ -51,12 +64,15 @@ error becomes one category from this fixed list, plus Android's own
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
Control's OpenXR compatibility layer, because this copy of the app is missing
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
names what was installed only when that's already public:
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
APK that installed without Frame Control's OpenXR compatibility layer,
because this copy of the app is missing it, carries `xr_layer_missing: true`;
otherwise that field is left out. It names what was installed only when
that's already public:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
+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')
-79
View File
@@ -1,79 +0,0 @@
// 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://repo.example:x/'):
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')
+44
View File
@@ -256,3 +256,47 @@ class SensorTests(unittest.TestCase):
self.assertIsNone(status.activity_level())
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
self.assertEqual(status.activity_level(), 3)
class DesktopBundleTests(unittest.TestCase):
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
def test_sources_are_sent_once_per_version(self):
import server
with tempfile.TemporaryDirectory() as tmp:
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
here.mkdir()
home.mkdir()
for name in server.COMFORT_FILES:
(here / name).write_text('# ' + name + '\n')
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
sent = []
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
sent.append(stdin)
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
env={**os.environ, 'HOME': str(home)}).stdout
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
first = server.comfort({'action': 'status'})
self.assertEqual(json.loads(first['got']), {'action': 'status'})
self.assertEqual(len(sent), 2) # the check, then the sources
self.assertNotIn('# frame_steam.py', sent[0])
self.assertIn('# frame_steam.py', sent[1])
self.assertEqual(server.comfort({'action': 'status'}), first)
self.assertEqual(len(sent), 3)
self.assertNotIn('# frame_steam.py', sent[2])
cached, = (home / '.cache/frame-control/comfort').iterdir()
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
# A copy cut short is completed; a changed helper is a new version.
(cached / 'frame_status.py').unlink()
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 5)
self.assertTrue((cached / 'frame_status.py').exists())
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
server.comfort({'action': 'status'})
self.assertEqual(len(sent), 7)
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
+29
View File
@@ -59,3 +59,32 @@ const log = ()=>{}, toast = ()=>{};
'''
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_poll_waits_while_the_frame_is_unreachable(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
start = page.index('async function pollComfort()')
code = page[start:page.index('$("comfortForm").onsubmit', start)]
js = r'''
const assert = require('node:assert/strict');
const el = {textContent: 'Checking session…'};
const $ = () => el;
let online = false, comfortBusy = false, calls = 0, timers = 0;
const api = async () => { calls++; return {}; };
const renderComfort = () => {};
const setTimeout = () => { timers++; };
''' + code + r'''
(async () => {
await pollComfort();
assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
assert.match(el.textContent, /when the Frame is connected/);
assert.equal(timers, 1); // still scheduled, to resume when it's back
online = true;
await pollComfort();
assert.equal(calls, 1);
online = null; // a status check that failed for another reason: keep polling
await pollComfort();
assert.equal(calls, 2);
})().catch(e => { console.error(e); process.exitCode = 1; });
'''
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
+6 -6
View File
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
state.clear()
self.addCleanup(state.clear)
def fetch(self, url, path, maximum, auth=None):
def fetch(self, url, path, maximum):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
@@ -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, auth=None):
def fetch(url, path, maximum):
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, auth=None):
def fetch(url, path, maximum):
name = threading.current_thread().name
if name in jars and url.endswith('entry.jar'):
Path(path).write_bytes(jars[name])
@@ -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, auth=None):
def fetch(url, path, maximum):
if url.endswith('index-v1.jar') and not inner:
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
self.v1 = False
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
source = self.add()
self.expire(source)
release = threading.Event()
def slow(url, path, maximum, auth=None):
def slow(url, path, maximum):
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, auth=None):
def slow(url, path, maximum):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
+56
View File
@@ -423,6 +423,19 @@ class InstallTests(unittest.TestCase):
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
patch.object(android, 'ssh', return_value='') as ssh, \
patch.object(android, 'shortcut_tool') as api:
with self.assertRaisesRegex(android.FrameError, 'timed out'):
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
commands = [c.args[0] for c in ssh.call_args_list]
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
api.assert_not_called()
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
patch.object(android, 'stop'), \
@@ -486,6 +499,49 @@ class InstallTests(unittest.TestCase):
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
class CopyAndSshTests(unittest.TestCase):
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
gets the time it needs."""
def fake(self, stderr, code=1, stdout=b''):
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
f'sys.exit({code})')
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
def test_large_apk_copy_timeout_grows_with_its_size(self):
seen = {}
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
android._copy(f.name, 'Applications/Android/x/app.apk.part')
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
android._copy(f.name, 'x/launch.sh', timeout=120)
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
# A message from a Windows code page, or a file name, isn't UTF-8.
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
patch.object(android.shutil, 'which', return_value=None), \
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
android._copy(f.name, 'x/app.apk.part')
def test_ssh_failure_messages_are_text(self):
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
android.ssh('mv a b')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
with self.assertRaises(android.FrameError) as caught:
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
class SteamAPITests(unittest.TestCase):
def test_artwork_api_enums_and_safe_serialization(self):
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
+14
View File
@@ -93,6 +93,20 @@ class ApkInfo(unittest.TestCase):
frame_apk.MAX_MANIFEST = limit
self.assertLess(peak, 8 * 1024**2)
def test_damaged_download_is_an_apk_error(self):
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
# (a 500, and no install report) and frame_android.install reported as 'other'.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
data = bytearray(buf.getvalue())
at = data.index(b'PK\x03\x04')
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
for i in range(start, start + 40):
data[i] ^= 0x5a
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
self.read(bytes(data))
def test_refuses_compression_android_cant_read(self):
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
buf = io.BytesIO()
+17 -1
View File
@@ -46,6 +46,17 @@ def explain(msg):
return None
class FailureDetail(unittest.TestCase):
def test_an_address_that_failed_says_why_in_a_few_words(self):
# #87: the banner put each address's detail after the headline, and with the whole
# message as the detail it said everything twice ("...may be answering here..").
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
"SSH identity changed")
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
"The connection to the Frame dropped. Try again.")
self.assertEqual(fl.failure_detail(None), "SSH failed")
class Probe(unittest.TestCase):
def test_answers_refusals_and_unknown_names(self):
with socket.socket() as srv:
@@ -200,7 +211,8 @@ class Connecting(unittest.TestCase):
rows = {p["host"]: p for p in s["probes"]}
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
self.assertEqual(rows["::1"]["state"], "sshfailed")
self.assertIn("different SSH identity", rows["::1"]["detail"])
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
# Every ssh command was pointed at the winner, with the host key pinned per device.
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-t")
@@ -260,6 +272,8 @@ class Connecting(unittest.TestCase):
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
self.assertIn("SSH key", s["error"]["message"])
self.assertEqual(s["error"]["auth"], "denied") # the page offers Set Up Connection
self.assertEqual(s["error"]["short"], "This computer's key refused") # the header pill's words (#87)
self.assertEqual(s["probes"][0]["detail"], "This computer's key refused")
def test_pinned_identity_is_checked_strictly(self):
d = self.device("localhost")
@@ -349,6 +363,7 @@ class Connecting(unittest.TestCase):
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
self.assertIn("missing or can't be read", s["error"]["message"])
self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
self.assertTrue(view["identity_lost"])
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
@@ -634,6 +649,7 @@ class Connecting(unittest.TestCase):
self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
self.assertIn("Devices tab", s["error"]["message"])
self.assertEqual(s["error"]["short"], "SSH identity changed")
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
r = self.command(d)
self.assertEqual(r.returncode, 255)
+136
View File
@@ -0,0 +1,136 @@
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import pathlib
import re
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
def between(start, end):
return PAGE[PAGE.index(start):PAGE.index(end)]
SETUP = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
return elements.get(id);
};
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
const serverNow = () => 1000;
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
const ago = () => 'a minute ago';
const openTrust = () => {};
const window = {};
let online = false;
const link = {live: true, s: null};
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
let rendered = 0;
const renderDevices = () => { rendered++; };
let refuse = false;
const act = async (label, fn) => refuse ? undefined : fn();
const api = async () => ({ok: true});
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
+ 'If not, a different device may be answering here.'}};
'''
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
class LinkUI(unittest.TestCase):
def run_page(self, checks):
code = (between("function netLabel(s) {", "function renderConnDlg() {")
+ between("async function useDevice(id) {", "async function devAction(")
+ between("function deviceStatus(d) {", "function renderDevices() {"))
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_the_pill_leads_with_the_reason(self):
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
# the reason comes first, in a few words, and only the countdown gets cut.
self.run_page(r'''
link.s = changed;
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
assert.match($('conn').innerHTML, /<span>Checking the headset&#39;s identity failed<\/span>/);
''')
def test_the_banner_never_doubles_a_full_stop(self):
# "…may be answering here.." and "…(Developer Mode must be on).." (#87).
self.run_page(r'''
link.s = changed;
renderBanner();
assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
link.s = {...changed, probes: [
{host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
{host: '100.64.0.2', detail: 'No answer'}]};
renderBanner();
const text = $('offDetail').textContent;
assert.ok(!/\.\./.test(text), text);
assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
''')
def test_the_devices_list_keeps_the_whole_message_within_reach(self):
self.run_page(r'''
link.s = changed;
const [dot, text, full] = deviceStatus({active: true, addresses: []});
assert.equal(dot, 'off');
assert.equal(text, 'SSH identity changed');
assert.equal(full, changed.error.message); // the row's tooltip
''')
def test_using_a_headset_selects_it(self):
# After "Use this headset" on Office Frame, the last headset's details stayed on show.
self.run_page(r'''
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
dv.sel = 'd0';
await useDevice('d1');
assert.equal(dv.sel, 'd1');
assert.equal(dv.found, null);
assert.equal(dv.editing, null);
assert.equal(rendered, 1);
// Refused (an install is running): the selection stays, and the header shows the one in use.
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
dv.sel = 'd0'; refuse = true;
await useDevice('d2');
assert.equal(dv.sel, 'd0');
assert.equal($('devSel').value, 'd0');
''')
class HeaderLayout(unittest.TestCase):
def test_the_devices_tab_explanation_wraps(self):
# It was cut to one line with no way to read the rest.
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
def test_the_header_buttons_dont_give_way(self):
# Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
header = between("<header>", "</header>")
end = header[header.index('<div class="hdr-end">'):]
for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
self.assertIn(el, end)
self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
if __name__ == "__main__":
unittest.main()
-566
View File
@@ -1,566 +0,0 @@
"""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()
+203 -6
View File
@@ -138,7 +138,7 @@ class Gates(Base):
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
["frame_unreachable", "other", "other", "other"])
["frame_unreachable", "internal_error", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
@@ -159,11 +159,79 @@ class Lifecycle(Base):
e = self.queued()[-1]
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
def test_opens_installs_and_updates_carry_the_country(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
tm.app_started()
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
def test_only_zones_in_the_tz_table_give_a_country(self):
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
("Japan", "JP"), ("UTC", None), ("alice", None),
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
self.assertEqual(tm.country(), want, tz)
def test_country_from_tz_or_localtime_in_a_checkout(self):
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
self.assertEqual(tm.country(), "JP")
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
with mock.patch.object(tm.os.path, "realpath", return_value=target):
self.assertEqual(tm.country(), want, target)
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
tm.update_settings({"compat": True})
self.assertFalse(tm.state()["noticeShown"])
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
def test_frame_build_once(self):
tm.frame_seen("20260922.1", "3.8")
tm.frame_seen("20260922.1", "3.8")
self.assertEqual(len(self.queued()), 1)
def test_frame_once_per_headset_with_saved_count(self):
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
props = [e["properties"] for e in self.queued()]
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
def test_frame_many_headsets_are_not_sent_twice(self):
ids = ["%016x" % n for n in range(25)]
for _ in range(2):
for h in ids:
tm.frame_seen("20260922.1", "3.8", h, 25)
self.assertEqual(len(self.queued()), 25)
def test_frame_headset_id_must_be_the_hash(self):
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
p = self.queued()[0]["properties"]
self.assertNotIn("headset_id", p)
self.assertNotIn("headsets_saved", p)
class HeadsetId(unittest.TestCase):
def test_hash_of_machine_id_and_none_without_one(self):
import hashlib
import frame_status
with mock.patch.object(frame_status, "read", return_value="abc123"):
self.assertEqual(frame_status.headset_id(),
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
with mock.patch.object(frame_status, "read", return_value=None):
self.assertIsNone(frame_status.headset_id())
class Sending(Base):
def serve(self, status=200):
@@ -198,6 +266,39 @@ class Sending(Base):
self.assertEqual(self.queued(), [])
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
def test_an_older_notice_still_sends(self):
got = self.serve()
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
tm.capture("app_opened")
self.assertEqual(tm.flush(), 1)
self.assertEqual(len(got), 1)
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # as 0.4.5 saved it
self.assertFalse(tm.state()["noticeShown"])
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1) # the usual statistics still go
tm.update_settings({"noticeShown": True})
self.assertTrue(tm.state()["noticeShown"])
tm._save({**tm.settings(), "last_open_day": None}) # the next day
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
def test_turning_usage_on_still_waits_for_the_new_notice(self):
got = self.serve()
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
"last_version": "9.9.9"})) # 0.4.5, usage off
tm.update_settings({"usage": True})
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
tm.app_started()
self.assertEqual(tm.flush(), 1)
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
def test_a_failed_send_keeps_the_events(self):
self.serve(status=500)
tm.update_settings({"noticeShown": True})
@@ -263,6 +364,72 @@ class Scrub(unittest.TestCase):
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
def test_apk_install_failures_that_were_other(self):
"""What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
(or as frame_unreachable, when it was the Steam client or a slow copy)."""
tail = 'Traceback (most recent call last):\n File "<stdin>", line 19, in target_ws\n'
cases = {
"steam_not_running": [
"SharedJSContext not found: is the Steam client running?",
tail + "urllib.error.URLError: <urlopen error [Errno 111] Connection refused>",
tail + "ConnectionRefusedError: [Errno 111] Connection refused",
],
"steam_not_responding": [
"websocket handshake failed",
tail + "TimeoutError: timed out",
tail + "socket.timeout: timed out",
tail + ' File "<stdin>", line 45, in _read\nEOFError',
],
"steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
'{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
"artwork_failed": [
"Steam artwork renderer did not produce every slot",
tail + "ValueError: incomplete artwork render",
tail + "ValueError: hero render is 0 bytes of png",
"could not prepare artwork: unknown artwork slot",
tail + "ValueError: all five Steam artwork slots are required",
],
"copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
"copying launch.sh to the Frame timed out"],
"copy_failed": [
"copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
"so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
"copying game.apk to the Frame failed: ",
],
"apk_unreadable": [
"x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
"x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
"x.apk: the APK manifest has no package name",
"x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
"x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
],
}
for category, messages in cases.items():
for message in messages:
self.assertEqual(tm.categorize(message)[0], category, message)
# Still where they were: ssh's own failures, and a full disk during a copy.
self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
"port 22: Connection refused")[0], "frame_unreachable")
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
"\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
def test_python_errors_are_internal_whatever_they_say(self):
import json as _json
for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
KeyError("timed out")):
self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
def test_privacy_doc_lists_every_category(self):
doc = (ROOT / "docs" / "privacy.md").read_text()
for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
self.assertIn(f"`{name}`", doc)
class Compat(Base):
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
@@ -376,14 +543,17 @@ class LinkFailureProvenance(Base):
c.close()
return result
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the status poll's first connection failure, theirs are held back too,
# also past the 10-minute window.
def serve(self):
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
# Failure; after the first connection failure, theirs are held back too, also past the
# 10-minute window.
self.serve()
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
@@ -399,7 +569,34 @@ class LinkFailureProvenance(Base):
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
# The comfort poll's own failure isn't one (see the next test).
self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
# 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
# refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
# by hand still is, once, and a real failure of the poll is too.
self.serve()
for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
"ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
"checking.\r\nHost key verification failed.\r\n",
"steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
self.assertEqual(self.queued(), [])
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
with mock.patch.object(frame_host, "run_ssh", return_value=down):
self.request("/api/comfort", {"action": "cancel"})
broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
with mock.patch.object(frame_host, "run_ssh", return_value=broken):
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
if e["event"] == "$exception"]
self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
def test_download_failures_after_a_poll_failure_are_still_sent(self):
# Review round 3: these file names, in the real checksum message through the web-link worker,
-4
View File
@@ -45,10 +45,6 @@ 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
@@ -1,270 +0,0 @@
"""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
+2 -6
View File
@@ -9,7 +9,7 @@ import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError, _credentials
from apk_sources import SourceError
_lock = threading.Lock()
_urls = OrderedDict()
@@ -141,11 +141,7 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
path = p.path or '/'
if p.query:
path += '?' + p.query
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)
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
live[0].settimeout(min(10, left()))
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
+13 -28
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 SourceAuthError, SourceError, SourceLimited, _credentials
from . import SourceError, SourceLimited
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 (+ login identity) -> time.time() before which we don't ask it with that login
_limited = {} # host -> time.time() before which we don't contact it
_limited_lock = threading.Lock()
@@ -46,16 +46,8 @@ def _host(url):
return (urllib.parse.urlsplit(url).hostname or '').lower()
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."""
def throttle(url, headers=None):
"""Remember that url's host asked us to back off; return the delay in seconds."""
headers = headers or {}
now, delay = time.time(), None
value = (headers.get('Retry-After') or '').strip()
@@ -70,15 +62,14 @@ def throttle(url, headers=None, auth=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[key] = max(_limited.get(key, 0), now + delay)
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
return delay
def wait_time(url, auth=None):
def wait_time(url):
with _limited_lock:
return max(0, _limited.get(_who(url, auth), 0) - time.time())
return max(0, _limited.get(_host(url), 0) - time.time())
def limited_error(name, seconds, hint=''):
@@ -145,8 +136,6 @@ 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
@@ -158,15 +147,13 @@ class Redirect(urllib.request.HTTPRedirectHandler):
return result
def open_url(url, hosts, headers=None, auth=None):
"""auth: an _credentials.Credential, sent only to requests on its own origin."""
def open_url(url, hosts, headers=None):
checked_url(url, hosts)
handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else [])
return urllib.request.build_opener(*handlers).open(
return urllib.request.build_opener(Redirect(hosts)).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
name = name or _host(url) or 'The source'
@@ -178,13 +165,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
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, auth)
wait = wait_time(url)
if wait:
data = cached()
if data is None:
raise limited_error(name, wait, hint)
return data
with open_url(url, hosts, headers, auth) as r:
with open_url(url, hosts, headers) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
@@ -198,10 +185,8 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
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, auth)
delay = throttle(url, e.headers)
data = cached()
if data is None:
raise limited_error(name, delay, hint) from e
+16 -82
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 SourceAuthError, SourceError, SourceLimited, _credentials, _web
from apk_sources import SourceError, SourceLimited, _web
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
@@ -71,39 +71,6 @@ 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)
@@ -114,9 +81,6 @@ 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
@@ -127,17 +91,14 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
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."""
def _fetch(url, path, maximum):
host = urllib.parse.urlsplit(url).hostname
wait = _web.wait_time(url, auth)
wait = _web.wait_time(url)
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(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f:
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
@@ -148,11 +109,8 @@ def _fetch(url, path, maximum, auth=None):
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, auth)) from e
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
raise
@@ -384,7 +342,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] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']]
for i, n, u, p in builtins] + settings['repos']
def _text(value):
@@ -556,12 +514,10 @@ def _source_lock(source_id):
return _load_locks.setdefault(source_id, threading.Lock())
def _load(source, force=False, auth=None):
"""auth: a login to use instead of the saved one (while adding, before anything is saved)."""
def _load(source, force=False):
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:
@@ -579,7 +535,7 @@ def _load(source, force=False, auth=None):
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth)
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
@@ -587,7 +543,7 @@ def _load(source, force=False, auth=None):
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, auth=auth)
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
@@ -596,7 +552,7 @@ def _load(source, force=False, auth=None):
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, auth=auth)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
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)
@@ -616,10 +572,7 @@ def _load(source, force=False, auth=None):
raise SourceError('cannot load repository: ' + str(e)) from e
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)
def add_repo(url, fingerprint=None, name=None):
url, pin = _url(url, fingerprint)
with _LOCK:
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
@@ -630,26 +583,15 @@ def add_repo(url, fingerprint=None, name=None, username=None, password=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)
# 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['fingerprint'] = _load(source, force=True) # network work outside the settings lock
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')
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
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
return source
@@ -660,7 +602,6 @@ 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
@@ -722,7 +663,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, auth=_login(source))
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
@@ -743,8 +684,6 @@ 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')
@@ -763,12 +702,7 @@ def main():
def _cli(parser, args):
try:
if args.command == 'add':
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)
result = add_repo(args.url, args.fingerprint, args.name)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
+8 -88
View File
@@ -1,77 +1,17 @@
"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
import fnmatch, json, os, re, urllib.parse
from . import SourceAuthError, SourceError, _credentials, _web
from . import SourceError, _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', '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'}
'builtin': True, 'enabled': True, 'trust': 'community'}]
def _curated():
@@ -80,33 +20,13 @@ 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, _ = _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)
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
try:
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)
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)'))
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
-8
View File
@@ -113,14 +113,6 @@ 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()))
+28 -5
View File
@@ -61,13 +61,20 @@ def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
# The Frame writes UTF-8, whatever this computer's code page (Windows would decode it as
# cp1252). errors='replace': stray bytes (a file name) mustn't turn a failed command, or
# one that worked, into a UnicodeDecodeError.
text = ({'text': True, 'encoding': 'utf-8', 'errors': 'replace'}
if isinstance(input, str) or input is None else {})
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
timeout=timeout, **text)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
# Bytes input gives bytes output: decode it, so the message isn't "b'...'".
stderr, stdout = (v if isinstance(v, str) else (v or b'').decode(errors='replace')
for v in (p.stderr, p.stdout))
error = FrameError((stderr or stdout or f'ssh exited {p.returncode}').strip()[-600:])
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -136,11 +143,19 @@ def check_installable(info):
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
COPY_MIN_RATE = 512 * 1024
def _copy(src, dest, executable=False, timeout=600):
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp.
timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
name = os.path.basename(src)
try:
timeout = max(timeout, os.path.getsize(src) / COPY_MIN_RATE)
except OSError:
pass # rsync or scp says what's wrong with the file
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
if rsync:
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
@@ -148,7 +163,8 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
@@ -279,6 +295,13 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
ssh(f'rm -rf {d}', timeout=30)
except FrameError:
pass
elif not ok:
# An update that failed: the app still works, but a partial copy of the new APK
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
try:
ssh(f'rm -f {d}/app.apk.part', timeout=30)
except FrameError:
pass
+4 -1
View File
@@ -7,6 +7,7 @@ the resource table in resources.arsc.
"""
import struct
import zipfile
import zlib
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
@@ -221,7 +222,9 @@ def apk_info(path):
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
from frame_apk_vr import detection
vr_info = detection(manifest_data, names)
except (struct.error, IndexError, zipfile.BadZipFile) as e:
# A damaged download: corrupt deflate data (zlib.error), a member cut short (EOFError),
# undecodable text (UnicodeDecodeError) or an encrypted entry (RuntimeError).
except (struct.error, IndexError, zipfile.BadZipFile, zlib.error, EOFError, ValueError, RuntimeError) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
+15 -4
View File
@@ -238,6 +238,13 @@ def scrub_failure(text, hosts=()):
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
def failure_detail(error):
"""What an address that answered but failed SSH shows: the short reason when there is one
(the whole message is the banner's headline already), else the message."""
error = error or {}
return error.get("short") or error.get("message") or "SSH failed"
def failure_category(message, raw=""):
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
word, never its text."""
@@ -810,7 +817,9 @@ class Link:
return "Connecting"
def fail(self, sid, message, raw="", **more):
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth)."""
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth), and `short`:
the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
the address that failed)."""
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
@@ -896,7 +905,7 @@ class Link:
self.learn(device, a["host"], net, None)
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(i, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -946,7 +955,7 @@ class Link:
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
why_not = failure_detail(self.state["error"])
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -1269,6 +1278,7 @@ class Link:
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
"it on the Devices tab. If not, a different device may be answering here.")
self.fail("identity", message, text,
short="Saved SSH identity missing" if lost else "SSH identity changed",
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
"fingerprint": shown[1] if shown else None, "host": host})
return "next"
@@ -1279,7 +1289,8 @@ class Link:
if re.search(r"Permission denied", text) and not forward:
# Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
"Connection: it pairs this computer with the headset again.", text, auth="denied")
"Connection: it pairs this computer with the headset again.", text, auth="denied",
short="This computer's key refused")
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
+11 -1
View File
@@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
"""
import ctypes as C
import glob
import hashlib
import math
import json
import os
@@ -121,7 +122,16 @@ def os_release():
k, _, v = line.partition("=")
out[k] = v.strip('"')
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
"variant": out.get("VARIANT_ID")}
"variant": out.get("VARIANT_ID"), "headset": headset_id()}
def headset_id():
"""A one-way hash of the headset's /etc/machine-id: the same for this headset on every
computer, and the machine-id itself never leaves the headset. None without one."""
mid = read("/etc/machine-id")
if not mid:
return None
return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16]
def volume():
+81 -18
View File
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
same project whatever is chosen here.
Events are identified by a random id made on first run, not by the person or
computer, and sent without person profiles or GeoIP. Nothing is sent without a
computer, and sent without person profiles or GeoIP. Opens, installs and updates
carry a country instead, worked out on this computer from its time zone setting
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
$FRAME_CONTROL_TELEMETRY=0.
@@ -43,6 +45,7 @@ from pathlib import Path
from urllib.parse import urlsplit
import frame_host
import frame_timezones
HERE = Path(__file__).resolve().parent
STATE = frame_host.data_dir('telemetry')
@@ -50,6 +53,7 @@ SETTINGS = STATE / 'settings.json'
OUTBOX = STATE / 'outbox.jsonl'
SENT = STATE / 'sent.jsonl'
SENT_KEEP = 200
FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
FLUSH_EVERY = 60
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
@@ -64,6 +68,7 @@ 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'}}
@@ -104,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': []}
@@ -146,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'):
@@ -160,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]}
@@ -266,9 +277,23 @@ CATEGORIES = [
('layer_missing', re.compile(r'OpenXR compatibility layer')),
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name|'
r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
# The Frame-side scripts that talk to the Steam client's UI (frame/android/steam_shortcuts.py), which
# fail with their own words or a Python traceback, its last lines kept. Before frame_unreachable:
# "Connection refused" and "timed out" here are Steam's debug port on the Frame, not ssh.
('steam_not_running', re.compile(r'SharedJSContext not found|'
r'URLError: <urlopen error \[Errno 111\] Connection refused>|'
r'ConnectionRefusedError: \[Errno 111\]')),
('steam_not_responding', re.compile(r'websocket handshake failed|(?:socket\.timeout|TimeoutError): timed out|'
r'(?:\A|\n)EOFError\Z')),
('steam_js_error', re.compile(r'JS error: ')),
('artwork_failed', re.compile(r'incomplete artwork render|artwork renderer did not produce|'
r'render is \d+ bytes of|could not prepare artwork|all five Steam artwork slots')),
# Copying the APK (or its launcher) to the Frame: rsync or scp ran out of time.
('copy_timeout', re.compile(r'to the Frame timed out\Z')),
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
@@ -280,6 +305,8 @@ CATEGORIES = [
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
r'ssh: connect to host |mux_client_|client_loop: ')),
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
# rsync or scp failed for another reason ("connection unexpectedly closed", or no words at all).
('copy_failed', re.compile(r'\Acopying .+ to the Frame failed: ')),
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
('flatpak', re.compile(r'(?i)flatpak|flathub')),
('cancelled', re.compile(r'(?i)cancel')),
@@ -287,8 +314,16 @@ CATEGORIES = [
]
# Python's own errors for a mistake in Frame Control's code, whatever their message says.
BUG_ERRORS = (KeyError, IndexError, TypeError, AttributeError, NameError, AssertionError,
ZeroDivisionError, RecursionError, UnicodeError, json.JSONDecodeError)
def categorize(message):
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
"""(category, detail): a fixed category name, plus an Android installer code when there is one.
message may be the exception itself: one of BUG_ERRORS is internal_error."""
if isinstance(message, BUG_ERRORS):
return 'internal_error', None
text = str(message or '')
for name, pattern in CATEGORIES:
m = pattern.search(text)
@@ -347,35 +382,59 @@ def page_event(body):
return {'queued': capture(name, props)}
def country():
"""The two-letter country of the computer's time zone setting, or None. Only names in the
tz database's table count, so nothing else in the setting can be sent. The app passes the
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
if not tz:
try:
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
except (OSError, IndexError):
return None
return frame_timezones.COUNTRY.get(tz)
def app_started():
"""Once per server start: first install, an update, and one open a day."""
"""Once per server start: first install, an update, and one open a day, with the country
(left out when sending if the notice this person saw came before it: flush)."""
if blocked():
return
with _lock:
s = settings()
version, today = app_version(), time.strftime('%Y-%m-%d')
cc = country()
where = {'country': cc} if cc else {}
if not s['installed_sent']:
capture('app_installed')
capture('app_installed', where)
s['installed_sent'] = True
elif s['last_version'] and s['last_version'] != version:
capture('app_updated', {'from_version': s['last_version']})
capture('app_updated', {'from_version': s['last_version'], **where})
if s['last_open_day'] != today:
capture('app_opened')
capture('app_opened', where)
s['last_open_day'] = today
s['last_version'] = version
_save(s)
def frame_seen(build, version):
"""The Frame's SteamOS build, once per build (public build numbers)."""
key = f'{build}/{version}'
def frame_seen(build, version, headset=None, saved=None):
"""The Frame's SteamOS build (public build numbers), once per build for each headset.
headset: the anonymous hash from frame_status.headset_id; saved: how many headsets
this copy of the app has set up."""
headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None
key = f'{build}/{version}' + (f'/{headset}' if headset else '')
with _lock:
s = settings()
if not build or key in s['frames_seen']:
return
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
_save(s)
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}
if headset:
p['headset_id'] = headset
if isinstance(saved, int) and saved >= 0:
p['headsets_saved'] = saved
capture('frame_connected', p)
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
@@ -547,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',
}
+56 -78
View File
@@ -51,6 +51,10 @@
nav a:hover { color: var(--bright); }
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
.spacer { flex: 1; }
/* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
.hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
.hdr-end > :not(.pill, .devsel) { flex: none; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
@@ -60,6 +64,7 @@
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
@media (max-width: 820px) { .brand b { display: none; } }
nav svg { display: none; }
@@ -74,7 +79,7 @@
.page { display: flex; flex-direction: column; gap: 26px; }
.page:not(.on) { display: none; }
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
@media (max-width: 1150px) { nav kbd { display: none; } }
@media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -87,14 +92,14 @@
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); }
.pill .dot { flex: none; }
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; }
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
.dlg::backdrop { background: rgba(0,0,0,.55); }
@@ -132,7 +137,7 @@
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
padding: 8px 8px; font: inherit; font-size: 13px; }
.addr .t { overflow-wrap: anywhere; }
.addr .s { white-space: normal; }
.addr .s, .dev-problem .s { white-space: normal; }
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
@@ -611,8 +616,6 @@
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
.source-repo-form details { font-size:11px; color:var(--muted); }
.source-repo-form button { justify-self:start; border-radius:6px; }
.source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; }
.source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; }
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.store-search input:focus-visible { outline:0!important; }
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
@@ -657,7 +660,7 @@
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
</nav>
<div class="spacer"></div>
<div class="hdr-end">
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
<span class="chip" id="battChip" title="Battery">—</span>
@@ -667,6 +670,7 @@
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
</button>
</div>
</header>
<main>
@@ -679,7 +683,8 @@
</div>
<div class="notice" id="privacyNotice" hidden>
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
worked and the details of errors, which helps fix problems faster.</div>
<button class="small" id="noticeSettings">Privacy settings</button>
@@ -1143,8 +1148,9 @@
<section class="panel" id="privacy">
<div class="shelf-head"><h2>Privacy &amp; updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only.</span></label>
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
compatibility database, so the verdicts get better for everyone.</span></label>
@@ -1209,7 +1215,7 @@
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -1589,7 +1595,7 @@ $("vrUtilities").onclick = async e => {
await loadUtilities();
};
// The Frame owns the clock. Poll independently of status and the selected tab.
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
async function localNotification(message, request = false) {
try {
@@ -1648,7 +1654,12 @@ function renderComfort(s) {
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
if (!comfortBusy) {
// Not while the Frame can't be reached: each poll would be another ssh attempt waiting to
// time out, and the banner already says why. Status checks bring it back (setOnline).
if (online === false) {
const message = "Session status appears when the Frame is connected.";
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
} else if (!comfortBusy) {
comfortBusy = true;
try { renderComfort(await api("/api/comfort", {action: "status"})); }
catch (e) {
@@ -1682,7 +1693,6 @@ $("castBtn").onclick = () => {
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
$("viewer").requestFullscreen?.().catch(() => {});
};
pollComfort();
// ---- battery ----
function battery(b, power) {
@@ -3141,18 +3151,12 @@ function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
// GitHub's optional access token: only whether one is set is ever shown, never its value.
function sourceToken(s) {
const t=s.token;if(!t)return "";
const note=t.rejected ? "GitHub rejected this token, so searches continue without it. Replace or clear it." : t.from==="env" ? "Using the token from FRAME_GITHUB_TOKEN." : t.set ? "A token is saved." : "Optional: a token raises GitHub's limit of 60 requests an hour.";
return `<form class="source-token" data-token="${esc(s.id)}"><input name="token" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(storeName(s.name))} access token" placeholder="${t.set ? "Replace access token" : "Access token"}"><button class="small">Save</button>${t.from==="saved" ? `<button type="button" class="small" data-clear-token="${esc(s.id)}">Clear</button>` : ""}</form><small>${esc(note)}</small>`;
}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</small>${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
function storeCard(app) {
@@ -3279,8 +3283,7 @@ $("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDet
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});const c=e.target.closest('[data-clear-token]');if(c)changeSource({action:'clear-token',source:c.dataset.clearToken});};
$("sourcesList").onsubmit=e=>{const f=e.target.closest('[data-token]');if(!f)return;e.preventDefault();const token=f.elements.token.value.trim();f.reset();if(token)changeSource({action:'set-token',source:f.dataset.token,token});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
async function addSource(body) {
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
let error="";
@@ -3288,49 +3291,7 @@ async function addSource(body) {
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
// A login typed into the address moves into the login fields, on the page too, before anything
// is sent: the address is shown and logged, the login never. It stays there if adding fails.
// Plain text, not the URL parser: an address it rejects (or a login it can't decode) must
// still lose its login first. Decoding falls back to the text as typed.
// Percent-decode what decodes; keep any byte sequence that isn't UTF-8 as typed.
// One forward pass: each lead byte gives its sequence length (at most 4 bytes).
function decodeLenient(s) {
return s.replace(/(?:%[0-9a-f]{2})+/gi, run => {
const b=run.match(/%[0-9a-f]{2}/gi); let out='', i=0;
while(i<b.length){
const v=parseInt(b[i].slice(1),16);
const n=v<0x80 ? 1 : v>=0xC2&&v<0xE0 ? 2 : v>=0xE0&&v<0xF0 ? 3 : v>=0xF0&&v<0xF5 ? 4 : 0;
if(n){ try { out+=decodeURIComponent(b.slice(i,i+n).join('')); i+=n; continue; } catch {} }
out+=b[i]; i++;
}
return out;
});
}
// Moves a login typed into the address into the login fields. Returns false for an
// address the URL parser rejects that may hold a login: it is refused, not sent.
function moveLogin(form) {
// Normalise the way URL parsers do: trim C0 controls and spaces, drop tabs and newlines.
const el=form.elements;
el.url.value=el.url.value.replace(/^[\u0000- ]+|[\u0000- ]+$/g,'').replace(/[\t\n\r]/g,'');
let u;
// A backslash and an '@' together are read differently by browsers and by the
// server, so the address can't be split safely: refuse it rather than guess.
if(el.url.value.includes('@') && el.url.value.includes('\\'))return false;
try { u=new URL(el.url.value); } catch { return !el.url.value.includes('@'); }
if(!u.username && !u.password)return true;
const user=decodeLenient(u.username), pass=decodeLenient(u.password);
u.username=''; u.password=''; el.url.value=u.href;
if(!el.username.value)el.username.value=user;
if(!el.password.value)el.password.value=pass;
el.username.closest('details').open=true;
return true;
}
$("addSource").onsubmit=e=>{
e.preventDefault();
if(!moveLogin(e.target)){ $("sourceError").textContent="That address isn't valid. Put the username and password in the login fields below, not in the address."; return; }
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
};
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
@@ -4546,7 +4507,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => loadShots()); // after status, so app names resolve
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -4575,6 +4536,20 @@ function netLabel(s) {
}
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
// Why the last attempt failed, in a few words: the server's short reason, else the stage that failed.
function failureReason(s) {
const err = s.error || {};
if (err.short) return err.short;
const st = (s.stages || []).find(x => x.id === err.stage);
return st ? `${st.label} failed` : "Not connected";
}
// A sentence or a label as a clause inside another sentence: no closing full stop (the sentence
// adds its own) and a lower-case first letter, unless the first word is an abbreviation ("SSH").
function asClause(text) {
const t = String(text || "").trim().replace(/[.\s]+$/, "");
return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t;
}
// The pill in the header: always there, and says what's happening right now.
function renderPill() {
const s = link.s;
@@ -4586,9 +4561,8 @@ function renderPill() {
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
} else if (s.phase === "failed") {
dot = "off";
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
// The reason first, in a few words, so a narrow pill cuts the countdown rather than the reason.
line = failureReason(s) + (s.retry_at ? ` · retry in ${Math.max(0, Math.ceil(s.retry_at - serverNow()))} s` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
@@ -4596,7 +4570,7 @@ function renderPill() {
: "Connecting…";
}
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
$("conn").title = `${dev}: ${line}. Click for details.`;
$("conn").title = `${dev}${s.phase === "failed" ? " is offline" : ""}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
@@ -4613,13 +4587,13 @@ function renderBanner() {
if (s.phase === "failed" && s.error) {
$("offline").hidden = false;
$("offMsg").textContent = s.error.message;
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
const tried = (s.probes || []).map(p => `${p.host}: ${asClause(p.detail)}`).join("; ");
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
(left != null ? `Trying again in ${left} s.` : "");
} else if (s.phase === "connecting" && online === false) {
const st = activeStage(s);
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
$("offDetail").textContent = `Trying again: ${st ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
@@ -4797,7 +4771,10 @@ async function useDevice(id) {
if (!res) { // refused (an install is running): show the headset still in use
const current = dv.list.find(x => x.active);
if (current) $("devSel").value = current.id;
return;
}
// The Devices tab shows the headset now in use, not the one it was showing before.
if (dv.sel !== id) { dv.sel = id; dv.found = null; dv.editing = null; renderDevices(); }
}
async function devAction(label, body, btn) {
const res = await act(label, () => api("/api/devices", body), btn);
@@ -4809,7 +4786,8 @@ function deviceStatus(d) {
const s = link.s;
if (d.active && s) {
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
: s.phase === "failed" ? ["off", s.error ? s.error.short || s.error.message : "Offline", s.error && s.error.message]
: ["", "Connecting…"];
}
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
@@ -4819,10 +4797,10 @@ function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text] = deviceStatus(d);
const [dot, text, full] = deviceStatus(d);
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
<div class="s"${full ? ` title="${esc(full)}"` : ""}>ssh ${esc(d.alias)} · ${esc(text)}</div></div>
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
@@ -4873,10 +4851,10 @@ function renderDetail() {
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
const problem = err && err.hostkey ? `<div class="item" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
const problem = err && err.hostkey ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
: err && err.auth === "denied" ? `<div class="item" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
: err && err.auth === "denied" ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
<button class="small action" id="dvPair">Set Up Connection…</button></div>` : "";
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
+47 -46
View File
@@ -403,11 +403,38 @@ def status(_body):
s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
osr = s.get("os") if isinstance(s, dict) else None
if isinstance(osr, dict):
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"))
# The headset's hash is for usage statistics only; the page and MCP clients don't get it.
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None),
len(LINK.reg.devices()) if LINK else None)
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
return s
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
# `files` that aren't there (none: just check they all are). Private, and each file written
# whole or not at all, so a copy cut short is finished by the next call.
COMFORT_RUN = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
if files:
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
if not all((root / name).is_file() for name in %r):
print('{"comfort": "missing"}')
sys.exit()
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
"""
def comfort(body):
try:
frame_comfort.validate(body)
@@ -415,25 +442,14 @@ def comfort(body):
raise Failure(str(e), 400)
# Content-addressed, user-only helper bundle. Desktop and phone use the same
# on-headset state/lock; no listener, service registration or third-party app.
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
# The page polls this every few seconds, so run the copy already on the headset and
# send the sources only when that version isn't there yet.
files = {name: (HERE / name).read_text() for name in COMFORT_FILES}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
script = """import json, os, pathlib, subprocess, sys
os.umask(0o077)
files = %r
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
root.mkdir(parents=True, exist_ok=True)
for name, source in files.items():
path = root / name
if not path.exists():
tmp = root / (name + '.' + str(os.getpid()))
tmp.write_text(source)
tmp.replace(path)
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
print(r.stdout, end='')
""" % (files, version, json.dumps(body))
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % ({}, version, COMFORT_FILES, json.dumps(body)), timeout=65))
if out == COMFORT_MISSING:
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % (files, version, COMFORT_FILES, json.dumps(body)),
timeout=65))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
@@ -2153,16 +2169,6 @@ 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]
@@ -2201,31 +2207,19 @@ def source_manage(body):
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
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)
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)
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
# 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)
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
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))
@@ -2302,6 +2296,10 @@ def connection_state():
# ---- HTTP ------------------------------------------------------------------
# POST reads the page repeats on a timer, as (path, action).
POLLS = {("/api/comfort", "status")}
def action_of(body):
"""The action a request asked for, for diagnostics: a short word, never user data."""
a = body.get("action") if isinstance(body, dict) else None
@@ -2555,7 +2553,10 @@ class Handler(BaseHTTPRequestHandler):
except ClientGone:
raise
except Failure as e:
if e.status >= 500:
# A poll that couldn't reach the headset is the headset asleep or away, which the page
# already shows; like GET /api/status, not an error to report.
polled = (path, action_of(body)) in POLLS and frame_telemetry.link_failed(e)
if e.status >= 500 and not polled:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
self.send_error_json(str(e), e.status, e.apk)
except (ValueError, TypeError) as e: