Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 0f8aaa393b Store: show owned itch.io games under either VR filter
itch.io's game records don't say which games are VR, so owned purchases
came back with VR unknown and the "VR experiences" filter hid them (found
in a live test: an owned Quest game vanished). Unknown still counts as flat
for every other source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:08:19 +11:00
saphidandClaude Opus 5.5 382fb0f906 itch.io purchases: find overlapping key matches so a partial match can't hide the real start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:44:38 +11:00
saphidandClaude Opus 5.5 76e90af5b7 itch.io purchases: redact the key when it's percent-encoded twice
_clean (and so _carries_key) now also matches each key character as %25XX,
hex in either case and for either letter case of the character, keeping
the 8+ character starts. Tests: a fully double-encoded key, recased forms,
a start of it, a mix of literal, single and double encoding, and in a
storage URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:20:54 +11:00
saphidandClaude Opus 5.5 5456f2f6f6 itch.io purchases: redact the key when it's percent-encoded after a letter-case change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:48:54 +11:00
saphidandClaude Opus 5.5 2cfb61a208 itch.io purchases: round-4 review fixes for per-hop key checks, redaction, IPv4 and the connect watchdog
- public_apk takes a check(url) callback run on every hop, before any lookup
  or connection; the itch.io download passes its key refusal, so a later
  redirect carrying the key (or ?api_key=) is refused too.
- One redaction function removes the key and its 8+ character starts in any
  letter case, with each character literal or percent-encoded (either hex
  case); used for every itch.io message, keeping error type and retry_after.
- IPv4 is an allow-list too: the whole special-purpose blocks (incl.
  192.0.0.0/24, which Python 3.9 partly calls global) are refused besides
  requiring is_global.
- No watchdog thread: connect and TLS handshake each get what is left of the
  shared deadline as their socket timeout, then the normal read timeout, so
  nothing can shut a connection after it has been handed over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:40:54 +11:00
saphidandClaude Opus 5.5 c6e4362c66 itch.io purchases: round-3 review fixes for IPv6, keys in storage URLs, HTTP errors and connect time
- public_address treats IPv6 as an allow-list: global unicast (2000::/3)
  only, minus 6to4, Teredo, documentation, ORCHID and benchmarking blocks.
  Site-local fec0::/10 (is_global on Python 3.9) and everything else outside
  2000::/3 is refused.
- A storage URL from itch.io (Location or JSON url) that contains the API
  key, or 8+ characters of it in any case or encoding, or an api_key
  parameter, is refused before any connection. Storage-side errors lose the
  key and keep their type and retry_after.
- http.client errors (BadStatusLine, IncompleteRead, RemoteDisconnected)
  around every api.itch.io open and read become readable, key-free
  SourceErrors raised from None.
- Connecting shares one monotonic deadline (20 s, or the caller's timeout if
  shorter) across all addresses and both connect and TLS handshake, with a
  watchdog for a stalled handshake; reads keep their own timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:15 +11:00
saphidandClaude Opus 5.5 88d61cb3b6 itch.io purchases: round-2 review fixes for addresses, revocation races and key errors
- public_address tests each property explicitly (reserved, private,
  loopback, link-local, multicast, unspecified) as Python 3.9's is_global
  passes 4000::1 and 64:ff9b:1::/48. IPv6 forms carrying IPv4 (mapped,
  compatible, NAT64 incl. local-use, 6to4, Teredo) are refused outright.
- Every checked address is tried in turn (10 s each to connect and
  handshake), each pinned to its own address with the same TLS host name.
- _use reads the key and its connection generation together, and deletes
  only if both still match: an older refusal can't undo a reconnect, even
  with the same key.
- Key errors are judged on itch.io's own words before redaction, and any
  "invalid key" error outside /profile is confirmed with /profile before
  the key is deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:11:57 +11:00
saphidandClaude Opus 5.5 2d63dacdf9 itch.io purchases: review fixes for redirects, key redaction, races and revoked keys
- Storage downloads no longer go through a "any host name" redirect policy.
  _web.public_apk resolves each hop, refuses loopback, private, link-local,
  shared, reserved and multicast addresses (odd IPv4 spellings and IPv4
  mapped into IPv6 included), connects to the checked address, and checks
  again on every redirect. No credentials are sent there. _web.ANY and the
  extra open_url/apk parameters are gone; _web.apk's streaming is shared as
  save_apk.
- api.itch.io redirects are no longer followed with the key: the download
  step reads a 30x Location, a JSON body's url, or takes the file itself,
  since the live response is unverified.
- Error text loses the key, and any start of it from 8 characters, before
  joining and truncating.
- Saving and deleting the key are serialised; a connect whose /profile check
  finishes after a disconnect (or another connect) does not save. The key
  file is written through a unique mkstemp file.
- 401/403 from api.itch.io deletes the key only once /profile confirms the
  key itself is refused, including at download time with cached metadata.
  Storage-host 403s are download errors, not rate limits.
- Docs list which parts are verified against the public reference and which
  are assumed or unknown without a live account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:59:15 +11:00
saphidandClaude Opus 5.5 adefdf4398 Store: install the Android builds of games you own on itch.io
Opt-in: paste an itch.io API key under My itch.io purchases in the store's
sources dialog. It is checked with GET /profile, saved 0600 in the app data
dir (itch-account.json), and only ever sent to api.itch.io as a Bearer
header. A new "itch.io (my purchases)" source lists owned Android games
(/profile/owned-keys, all pages), labelled "Owned on itch.io"; installs
pick the best Android upload (/games/ID/uploads) and download it through
/uploads/ID/download into the normal APK install path. A refused key is
never saved, or is deleted if itch.io later revokes it; rate limits and
network errors stay per-source and readable. The key is kept out of URLs,
errors, logs, API responses and the page.

Tests use recorded JSON and a fake HTTPS layer under urllib's real opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:30 +11:00
saphidandClaude Opus 5.5 2c3f8b3e0c APK sources: optional request headers and a redirect host policy for downloads
_web.open_url/apk take headers and the hosts a download may redirect to;
_web.ANY allows any HTTPS host name (no IP literals, credentials or other
ports). Authorization is still dropped on every redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:23 +11:00
33 changed files with 1822 additions and 1425 deletions

No files matched your search

+2 -3
View File
@@ -115,11 +115,10 @@ function ping(target) {
}
async function startServer() {
// 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.
// The version 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.6",
"version": "0.4.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.6",
"version": "0.4.4",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.6",
"version": "0.4.4",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+82
View File
@@ -82,6 +82,88 @@ is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
Removing a repository clears its state.
## My itch.io purchases
The store can also install the Android (Quest) builds of games you **own** on
itch.io: bought, claimed free, from a bundle or a gift. It is opt-in. In the
store, open the sources dialog and use **My itch.io purchases**: create an API
key on itch.io (account settings › API keys) and paste it there. Frame Control
checks it with itch.io (`GET /profile`) before saving it; a wrong or revoked key
is reported and nothing is saved. Once connected, a source called
**itch.io (my purchases)** appears, and its games are marked **Owned on
itch.io** instead of Free. **Disconnect itch.io** deletes the key file.
How it works, using the documented
[server-side API](https://itch.io/docs/api/serverside) with
`Authorization: Bearer <key>`:
- `GET https://api.itch.io/profile/owned-keys` lists your download keys, page by
page until an empty page (500 per page, at most 40 pages). Games whose
`traits` don't include `p_android` are left out; one entry per game, with the
newest key.
- Opening a game asks `GET /games/<game_id>/uploads?download_key_id=<key id>`
for its files. Android uploads are those marked `p_android` (in `traits`, or
as a `p_android`/`platforms.android` flag) or named `.apk`. `.apk` files come
first, then full games before demos, newest first. Soundtracks, pre-orders and
externally hosted files are skipped.
- Installing asks `GET /uploads/<upload_id>/download?download_key_id=<key id>`.
The answer accepted is a redirect to itch.io's storage host, a JSON body
with a `url` field naming it, or the file itself. Frame Control follows the
first two without the key, to HTTPS on port 443 only, and only to host names
whose every address is public. For IPv4 that means outside every special
block (`0/8`, `10/8`, `100.64/10`, `127/8`, `169.254/16`, `172.16/12`,
`192.0.0/24`, `192.0.2/24`, `192.88.99/24`, `192.168/16`, `198.18/15`,
`198.51.100/24`, `203.0.113/24`, `224/4`, `240/4`), however written. IPv6 is
an allow-list: global unicast (`2000::/3`) only, minus 6to4, Teredo,
documentation, ORCHID and benchmarking blocks, so mapped or NAT64 IPv4,
site-local, unique-local and the rest are all refused. It connects only to
the addresses it checked, trying each in turn within one 20-second budget
for connecting and the TLS handshake, and checks again on each further
redirect. A storage URL, first or after any redirect, that contains the API
key (or 8+ characters of it, in any case, plain or percent-encoded) or an
`api_key` parameter is refused before it is looked up, so the key never
reaches the storage server. The APK then takes
the normal install path, with the same ABI, OpenXR layer and Meta-services
checks and warnings as any other store download.
The key is stored in `frame_host.data_dir('itch-account.json')`
(`~/Library/Application Support/Frame Control/itch-account.json` on macOS),
written through a new 0600 temporary file and moved into place. It is only
sent to `https://api.itch.io`, in the `Authorization` header, never in a URL;
Frame Control does not follow api.itch.io's redirects with it. The key, or any
part of it from 8 characters, never appears in the page, API responses, errors
or logs; the page clears the field as soon as it is sent. A disconnect made
while a key is still being checked wins: that key is not saved.
Owned-game lists are kept in memory for ten minutes and never written to the
disk cache.
When api.itch.io answers 401 or 403, Frame Control asks `GET /profile` with the
same key. If that is refused too (or itch.io says "invalid key"), the key is
deleted and the store says to connect again; otherwise only that game or
download is refused, and the key stays. Rate limits (429) and network errors
only fail that source's results, with a readable message. A storage host's
refusal is reported as a download error, not as a rate limit.
### What is verified and what isn't
Not tested against a live itch.io account at all: everything below was tested
with recorded and hand-made responses (`tests/test_itch_purchases.py`) only.
| Part | Status |
|---|---|
| Bearer key, `GET /profile`, `/profile/owned-keys` paging and its fields, the `errors` array | Documented in the public reference (checked 2026-10-13) |
| `GET /games/<id>/uploads` and `download_key_id` | Not in the public reference; paths and parameter from itch.io's app client, [go-itchio](https://github.com/itchio/go-itchio) |
| Upload fields (`filename`, `display_name`, `size`, `storage`, `type`, `traits`, `updated_at`, `demo`, `preorder`) | Assumed from go-itchio and the API's snake_case convention |
| The `p_android` trait, on games and uploads | Assumed from itch.io's `p_windows`/`p_linux`/`p_osx` naming |
| What `/uploads/<id>/download` returns | Unknown: go-itchio only builds the URL and hands it to a downloader, so a redirect, a JSON `url` and the file are all accepted |
| Whether that endpoint accepts the key as a Bearer header | Unknown: go-itchio puts it in the query string, which Frame Control refuses to do |
| itch.io's storage host names and addresses | Unknown; any public HTTPS host is allowed |
Limits: a game whose page doesn't mark Android, or whose Android build is
inside a `.zip`, won't install. Android builds have no version codes on
itch.io, so only the newest upload can be installed. Owning a game doesn't make
it run on the Frame: Quest builds that need Meta's platform services won't.
## Publish your own repository
Only publish free APKs you own or have the developer's permission to distribute.
+2
View File
@@ -60,6 +60,8 @@ does not suppress the other at the aggregator level. Queries filter the current
feed window locally: this is not an exhaustive historical itch search. Only
explicit zero-price Android entries are returned. Covers are exposed in
`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
Games the user owns on itch.io are a separate, opt-in source that downloads
through their own API key: see [My itch.io purchases](apk-repos.md#my-itchio-purchases).
Curated GitHub entries include publisher artwork and plain-language summaries.
Repository image URLs are pinned to inspected commits. Open Brush screenshots
come from its README-linked Steam listing; SuperTux uses the upstream gameplay
+10 -26
View File
@@ -33,28 +33,15 @@ 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 | `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` |
| `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` |
| `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` |
@@ -64,15 +51,12 @@ 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`, `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:
`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:
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
catalogue app's package name
-77
View File
@@ -1,77 +0,0 @@
#!/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')
+5
View File
@@ -0,0 +1,5 @@
Hand-made itch.io API responses for tests/test_itch_purchases.py; no real account or
network was used. owned-keys-*.json follow the documented /profile/owned-keys sample
(https://itch.io/docs/api/serverside). uploads.json follows the shape the itch.io app's
client library (github.com/itchio/go-itchio) reads from /games/ID/uploads, with the
`traits` array it handles; `p_android` there is assumed, not documented.
+22
View File
@@ -0,0 +1,22 @@
{
"page": 1,
"per_page": 500,
"owned_keys": [
{"id": 9001, "game_id": 341190, "purchase_id": 3923754, "downloads": 6,
"created_at": "2026-03-10T23:46:36Z", "updated_at": "2026-03-10T23:46:36Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"cover_url": "https://img.itch.zone/aW1nLzE2OTA3NTIuanBn/315x250%23c/Ihj7Wa.jpg",
"short_text": "Grow plants in zero gravity.", "type": "default", "classification": "game",
"min_price": 1299, "traits": ["p_windows", "p_android", "can_be_bought"],
"created_at": "2025-12-04T16:18:04Z", "published_at": "2026-01-03T17:28:47Z",
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio",
"url": "https://maker.itch.io"}}},
{"id": 9002, "game_id": 3, "purchase_id": null, "downloads": 0,
"created_at": "2026-02-01T10:00:00Z", "updated_at": "2026-02-01T10:00:00Z",
"game": {"id": 3, "title": "Desk Only", "url": "https://leafo.itch.io/x-moon",
"cover_url": "https://img.itch.zone/aW1hZ2UvMy8xODM3LnBuZw==/315x250%23/y2uYQI.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_windows", "p_linux", "p_osx"], "published_at": "2013-03-03T23:02:14Z",
"user": {"id": 1, "username": "leafo", "display_name": "leafo", "url": "https://leafo.itch.io"}}}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"page": 2,
"per_page": 500,
"owned_keys": [
{"id": 8001, "game_id": 341190, "purchase_id": null, "downloads": 1,
"created_at": "2025-06-01T10:00:00Z", "updated_at": "2025-06-01T10:00:00Z",
"game": {"id": 341190, "title": "Orbit Garden VR", "url": "https://maker.itch.io/orbit-garden",
"type": "default", "classification": "game", "min_price": 1299,
"traits": ["p_windows", "p_android", "can_be_bought"],
"user": {"id": 46531, "username": "maker", "display_name": "Maker Studio"}}},
{"id": 8002, "game_id": 77, "purchase_id": null, "downloads": 2,
"created_at": "2025-05-01T10:00:00Z", "updated_at": "2025-05-01T10:00:00Z",
"game": {"id": 77, "title": "Free Quest Toy", "url": "https://toys.itch.io/quest-toy",
"cover_url": "http://img.example/not-itch.png",
"type": "default", "classification": "game", "min_price": 0,
"traits": ["p_android"], "published_at": "2025-04-01T00:00:00Z",
"user": {"id": 5, "username": "toys"}}}
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"uploads": [
{"id": 501, "game_id": 341190, "filename": "OrbitGarden-win64.zip", "display_name": "Windows",
"size": 734003200, "storage": "hosted", "type": "default", "traits": ["p_windows"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-02T10:00:00Z"},
{"id": 502, "game_id": 341190, "filename": "OrbitGarden-Quest-old.apk", "display_name": "Quest 1.0",
"size": 104857600, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-05T10:00:00Z"},
{"id": 503, "game_id": 341190, "filename": "OrbitGarden-Quest.apk", "display_name": "Quest 1.2",
"size": 115343360, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-04-01T10:00:00Z", "updated_at": "2026-04-03T10:00:00Z"},
{"id": 504, "game_id": 341190, "filename": "OrbitGarden-demo.apk", "display_name": "Quest demo",
"size": 52428800, "storage": "hosted", "type": "default", "traits": ["p_android", "demo"],
"created_at": "2026-05-01T10:00:00Z", "updated_at": "2026-05-01T10:00:00Z"},
{"id": 505, "game_id": 341190, "filename": "OrbitGarden-android-bundle.zip", "display_name": "Android (zip)",
"size": 120000000, "storage": "hosted", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 506, "game_id": 341190, "filename": "Soundtrack.apk", "display_name": "Soundtrack",
"size": 1000, "storage": "hosted", "type": "soundtrack", "traits": [],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 507, "game_id": 341190, "filename": "mirror.apk", "display_name": "Mirror",
"size": 1000, "storage": "external", "host": "example.com", "type": "default", "traits": ["p_android"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"},
{"id": 508, "game_id": 341190, "filename": "OrbitGarden-mac.dmg", "display_name": "macOS",
"size": 1000, "storage": "hosted", "type": "default", "traits": ["p_osx"],
"created_at": "2026-06-01T10:00:00Z", "updated_at": "2026-06-01T10:00:00Z"}
]
}
+12 -1
View File
@@ -19,7 +19,7 @@ function grab(name) {
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"storeArt", "storeIcon", "sourceMark", "storePrice", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
const code = names.map(grab).join("");
@@ -54,6 +54,17 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A game the user owns on itch.io says so instead of "Free".
{
const p = page();
p.sourceState.detail = { app: { name: "Orbit Garden", offers: [{ ...offer, source: "itch-owned", source_name: "itch.io (my purchases)",
owned: true, owned_label: "Owned on itch.io", free: false }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("an owned listing reported a render failure");
const html = p.els.detailBody.innerHTML;
if (!html.includes('<div class="detail-price">Owned on itch.io</div>')) fail("owned listing has no Owned on itch.io label");
if (html.includes("No purchase needed")) fail("owned listing claims no purchase is needed");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
-44
View File
@@ -256,47 +256,3 @@ 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,32 +59,3 @@ 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)
-56
View File
@@ -423,19 +423,6 @@ 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'), \
@@ -499,49 +486,6 @@ 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,20 +93,6 @@ 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()
+862
View File
@@ -0,0 +1,862 @@
"""My itch.io purchases: the account key, owned games and their Android downloads.
Offline: a fake HTTPS layer under urllib's real opener (so redirects and header
handling are the real ones) serves the recorded JSON in fixtures/itch_account.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import contextlib, email.message, http.client, io, ipaddress, json, os, socket, sys, tempfile, threading, time
import unittest
import urllib.error, urllib.parse, urllib.request, zipfile
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, SourceLimited, _itch_account as account, _web, itch, search
import server
FIX = Path(__file__).parent / 'fixtures' / 'itch_account'
KEY = 'TESTKEYabcdefghijklmnopqrstuvwxyz0123456789'
OTHER = 'OTHERKEY0123456789abcdefghijklmnopqrstuv'
def apk_bytes():
out = io.BytesIO()
with zipfile.ZipFile(out, 'w') as z:
z.writestr('AndroidManifest.xml', b'\x03\x00\x08\x00')
z.writestr('classes.dex', b'dex\n035\x00')
return out.getvalue()
class Reply(io.BytesIO):
"""Just enough of http.client.HTTPResponse for urllib's handlers."""
def __init__(self, url, code=200, body=b'', headers=None):
super().__init__(body if isinstance(body, bytes) else json.dumps(body).encode())
self.url, self.code, self.status, self.msg, self.reason = url, code, code, 'fake', 'fake'
self.headers = email.message.Message()
for k, v in (headers or {}).items():
self.headers[k] = v
def info(self):
return self.headers
def geturl(self):
return self.url
def getcode(self):
return self.code
def getheader(self, name, default=None):
return self.headers.get(name, default)
class FakeConnection:
"""_web._connect's http.client connection, answered from the same routes."""
def __init__(self, web, host):
self.web, self.host, self.reply = web, host, None
def request(self, method, path, headers=None):
req = urllib.request.Request('https://' + self.host + path, headers=headers or {})
self.reply = self.web(None, req)
def getresponse(self):
return self.reply
def close(self):
pass
# What the fake resolver answers: the storage host is public; the rest are traps.
ADDRESSES = {'storage.itch-cdn.example': '151.101.1.1', 'other-cdn.example': '151.101.2.2',
'localhost.': '127.0.0.1', 'frame.local': '192.168.1.20', '127.0.0x0.1': '127.0.0.1',
'intranet.example': '10.0.0.5', 'mapped.example': '::ffff:127.0.0.1', 'cgnat.example': '100.64.0.1',
'mixed.example': ('151.101.1.1', '10.0.0.5'), 'dual.example': ('2a04:4e42::81', '151.101.1.1'),
'nat64.example': '64:ff9b:1::a00:1', 'sitelocal.example': 'fec0::1',
'many.example': tuple('151.101.%d.1' % n for n in range(1, 9))}
REAL_CONNECT = _web._connect
class FakeItch:
"""Routes https requests by host and path; records every request with its headers."""
def __init__(self):
self.calls, self.routes = [], {}
def route(self, host_path, reply):
self.routes[host_path] = reply
def __call__(self, handler, req):
url = urllib.parse.urlsplit(req.full_url)
headers = {k.lower(): v for k, v in req.header_items()}
self.calls.append((req.full_url, headers))
reply = self.routes.get(url.hostname + url.path)
if reply is None:
return Reply(req.full_url, 404, {'errors': ['not found']})
if callable(reply):
reply = reply(req, urllib.parse.parse_qs(url.query))
if isinstance(reply, Exception):
raise reply
if isinstance(reply, Reply):
return reply
return Reply(req.full_url, 200, reply)
def urls(self, host_path):
return [u for u, _ in self.calls if urllib.parse.urlsplit(u).hostname + urllib.parse.urlsplit(u).path == host_path]
def owned_pages(req, query):
page = query['page'][0]
return {'1': json.loads((FIX / 'owned-keys-1.json').read_text()),
'2': json.loads((FIX / 'owned-keys-2.json').read_text())}.get(page, {'page': int(page), 'owned_keys': []})
class ItchPurchases(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
for target, value in [(account.frame_host, 'data_dir'), (search.frame_host, 'data_dir')]:
p = patch.object(target, value, lambda *parts: root.joinpath('data', *parts))
p.start()
self.addCleanup(p.stop)
(root / 'cache').mkdir()
for p in (patch.object(_web, 'cache', return_value=str(root / 'cache')),
patch.object(_web.frame_host, 'cache_dir', lambda *parts: root.joinpath('cache', *parts))):
p.start()
self.addCleanup(p.stop)
self.web = FakeItch()
self.web.route('api.itch.io/profile', {'user': {'username': 'player', 'id': 1}})
self.web.route('api.itch.io/profile/owned-keys', owned_pages)
self.web.route('api.itch.io/games/341190/uploads', json.loads((FIX / 'uploads.json').read_text()))
p = patch('urllib.request.HTTPSHandler.https_open', autospec=True, side_effect=self.web)
p.start()
self.addCleanup(p.stop)
p = patch('urllib.request.HTTPHandler.http_open', side_effect=AssertionError('plain HTTP in test'))
p.start()
self.addCleanup(p.stop)
real_lookup = _web.socket.getaddrinfo
def lookup(host, port, *args, **kwargs):
if host not in ADDRESSES:
try:
ipaddress.ip_address(host) # IP literals (and the in-process test server) need no DNS
return real_lookup(host, port, *args, **kwargs)
except ValueError:
pass
if host == 'localhost':
return real_lookup(host, port, *args, **kwargs)
raise _web.socket.gaierror('unknown test host ' + host)
found = ADDRESSES[host]
return [(10 if ':' in a else 2, 1, 6, '', (a, port)) for a in ([found] if isinstance(found, str) else found)]
self.connected, self.unreachable = [], set()
def connect(host, address, timeout, deadline):
self.connected.append((host, address))
if address[0] in self.unreachable:
raise OSError('Network is unreachable')
self.assertLessEqual(deadline - time.monotonic(), _web.CONNECT_BUDGET)
return FakeConnection(self.web, host)
for p in (patch.object(_web.socket, 'getaddrinfo', lookup), patch.object(_web, '_connect', connect)):
p.start()
self.addCleanup(p.stop)
account._forget()
self.addCleanup(account._forget)
_web._limited.clear()
self.addCleanup(_web._limited.clear)
search._status.clear()
def owned_source(self):
return next(s for s in itch.sources() if s['id'] == itch.OWNED)
def connect(self):
self.assertTrue(account.connect(KEY)['itch']['connected'])
# -- the key ---------------------------------------------------------------
def test_connect_checks_the_key_then_saves_it_0600(self):
result = account.connect(' ' + KEY + '\n')
self.assertEqual(result['message'], 'Connected to itch.io as player')
self.assertEqual(result['itch'], {'connected': True, 'username': 'player'})
self.assertNotIn(KEY, json.dumps(result))
url, headers = self.web.calls[0]
self.assertEqual(url, 'https://api.itch.io/profile')
self.assertEqual(headers['authorization'], 'Bearer ' + KEY)
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json']) # no .tmp left
self.assertNotIn(KEY, json.dumps(account.status()))
def test_reconnecting_rewrites_the_file_0600_even_if_it_was_wider(self):
self.connect()
os.chmod(account.path(), 0o644)
account.connect(OTHER)
self.assertEqual(json.loads(account.path().read_text())['key'], OTHER)
if os.name == 'posix':
self.assertEqual(os.stat(account.path()).st_mode & 0o777, 0o600)
def test_bad_or_revoked_key_stays_disconnected(self):
for reply in (Reply('https://api.itch.io/profile', 401, {'errors': ['invalid key']}),
Reply('https://api.itch.io/profile', 403, b'Forbidden'),
{'errors': ['invalid key']}):
self.web.route('api.itch.io/profile', reply)
with self.assertRaisesRegex(account.BadKey, 'did not accept the API key') as caught:
account.connect(KEY)
self.assertNotIn(KEY, str(caught.exception))
self.assertFalse(account.path().exists())
self.assertEqual(account.status(), {'connected': False, 'username': None})
self.assertEqual([s['id'] for s in itch.sources()], ['itch', 'itch-quest'])
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect('short')
with self.assertRaisesRegex(account.BadKey, 'does not look like'):
account.connect(KEY + '\r\nX-Injected: 1')
self.assertEqual(len(self.web.calls), 3) # malformed keys never reach itch.io
def test_a_refused_request_with_a_working_key_keeps_the_key(self):
self.connect()
self.web.route('api.itch.io/games/341190/uploads', Reply('https://api.itch.io/games/341190/uploads', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
itch.details(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
def test_a_key_revoked_later_is_removed_with_a_clear_message(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', Reply('https://api.itch.io/profile/owned-keys', 401, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
itch.search(self.owned_source(), '')
self.assertFalse(account.path().exists())
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
def test_disconnect_deletes_the_key_file(self):
self.connect()
self.assertIn(itch.OWNED, [s['id'] for s in itch.sources()])
result = account.disconnect()
self.assertFalse(account.path().exists())
self.assertEqual(result['itch'], {'connected': False, 'username': None})
self.assertNotIn(itch.OWNED, [s['id'] for s in itch.sources()])
with self.assertRaisesRegex(SourceError, 'Connect your itch.io account first'):
account.owned_keys()
account.disconnect() # already gone: fine
# -- owned games -------------------------------------------------------------
def test_owned_keys_follow_pages_until_an_empty_one(self):
self.connect()
keys = account.owned_keys()
self.assertEqual([k['id'] for k in keys], [9001, 9002, 8001, 8002])
pages = self.web.urls('api.itch.io/profile/owned-keys')
self.assertEqual([urllib.parse.parse_qs(urllib.parse.urlsplit(u).query) for u in pages],
[{'page': [str(n)], 'per_page': ['500']} for n in (1, 2, 3)])
self.assertTrue(all('Bearer ' + KEY == h['authorization'] for _, h in self.web.calls))
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
account.owned_keys() # cached in memory, not on disk
self.assertEqual(len(self.web.urls('api.itch.io/profile/owned-keys')), 3)
self.assertFalse(any(KEY in p.read_text(errors='ignore') or b'Orbit' in p.read_bytes()
for p in Path(_web.cache()).iterdir()))
def test_page_cap_stops_a_server_that_never_ends(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', lambda req, q: {'owned_keys': [{'id': int(q['page'][0])}]})
with patch.object(account, 'PAGES', 3):
self.assertEqual(len(account.owned_keys()), 3)
def test_search_lists_owned_android_games_labelled(self):
self.connect()
entries = itch.search(self.owned_source(), '')
self.assertEqual([(e['id'], e['name']) for e in entries], [('341190', 'Orbit Garden VR'), ('77', 'Free Quest Toy')])
orbit, toy = entries
self.assertEqual(orbit['download_key_id'], 9001) # the newest key for a game owned twice
self.assertTrue(orbit['owned'])
self.assertEqual(orbit['owned_label'], 'Owned on itch.io')
self.assertFalse(orbit['free'])
self.assertTrue(toy['free'])
self.assertIsNone(toy['icon']) # not an itch.io image host
self.assertEqual(orbit['page'], 'https://maker.itch.io/orbit-garden')
self.assertEqual(itch.search(self.owned_source(), 'zero gravity')[0]['id'], '341190')
# Through the store's search, owned (paid) games pass the free-only filter.
result = search.search('orbit', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'ok')
offer = result['apps'][0]['offers'][0]
self.assertEqual((offer['source'], offer['source_name'], offer['owned_label']),
(itch.OWNED, 'itch.io (my purchases)', 'Owned on itch.io'))
self.assertNotIn(KEY, json.dumps(result))
def test_owned_games_show_under_either_vr_choice(self):
# itch.io doesn't say which purchases are VR, so neither filter hides them; other
# games of unknown kind still count as flat.
self.connect()
both = ['Free Quest Toy', 'Orbit Garden VR']
for vr in (True, False):
names = sorted(a['name'] for a in search.search('', vr=vr, source=itch.OWNED)['apps'])
self.assertEqual(names, both)
flat = {'source': 'x', 'id': '1', 'name': 'Flat', 'summary': '', 'vr': None}
self.assertEqual(search.group([flat], vr=True), [])
self.assertEqual(len(search.group([flat], vr=False)), 1)
self.assertEqual(search.group([dict(flat, owned=True, vr=False)], vr=True), [])
def test_only_the_account_source_may_mark_paid_games_owned(self):
entry = {'free': False, 'owned': True}
self.assertFalse(search.obtainable({'id': 'fdroid'}, entry))
self.assertTrue(search.obtainable({'id': itch.OWNED, 'owned': True}, entry))
self.assertTrue(search.obtainable({'id': 'fdroid'}, {'free': True}))
def test_android_upload_selection(self):
uploads = json.loads((FIX / 'uploads.json').read_text())['uploads']
# .apk files before other Android files, full games before demos, newest first; never
# soundtracks, externally hosted files or other platforms.
self.assertEqual([u['id'] for u in account.android_uploads(uploads)], [503, 502, 504, 505])
self.assertTrue(account.is_android({'filename': 'Game.APK'}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'p_android': True}))
self.assertTrue(account.is_android({'filename': 'game.zip', 'platforms': {'android': 'all'}}))
self.assertFalse(account.is_android({'filename': 'game.zip', 'traits': ['p_windows']}))
self.assertEqual(account.android_uploads({'not': 'a list'}), [])
def test_details_offer_the_android_uploads(self):
self.connect()
entry = itch.details(self.owned_source(), '341190')
self.assertTrue(entry['downloadable'])
self.assertEqual([v['upload_id'] for v in entry['versions']], [503, 502, 504, 505])
self.assertEqual((entry['version'], entry['size'], entry['updated']), ('Quest 1.2', 115343360, '2026-04-03'))
self.assertTrue(entry['versions'][2]['demo'])
url = self.web.urls('api.itch.io/games/341190/uploads')[0]
self.assertEqual(url, 'https://api.itch.io/games/341190/uploads?download_key_id=9001')
with self.assertRaisesRegex(SourceError, 'not among'):
itch.details(self.owned_source(), '3') # owned, but not for Android
self.web.route('api.itch.io/games/77/uploads', {'uploads': []})
self.assertFalse(itch.details(self.owned_source(), '77')['downloadable'])
with self.assertRaisesRegex(SourceError, 'no Android download'):
itch.download(self.owned_source(), '77')
def test_download_url_building(self):
self.assertEqual(account.download_url(503, 9001),
'https://api.itch.io/uploads/503/download?download_key_id=9001')
for bad in [(0, 1), (1, None), ('5', 1), (True, 1), (-2, 1)]:
with self.assertRaises(SourceError):
account.download_url(*bad)
# -- downloads -----------------------------------------------------------------
def storage(self, location='https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'):
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 302, b'', {'Location': location}))
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk', Reply(location, 200, apk_bytes()))
def test_download_sends_the_key_to_itch_only_and_strips_it_on_redirect(self):
self.connect()
self.storage()
result = itch.download(self.owned_source(), '341190')
self.assertTrue(os.path.isfile(result['apk']))
self.assertFalse(result['verified'])
api, cdn = self.web.calls[-2:]
self.assertEqual(api[0], 'https://api.itch.io/uploads/503/download?download_key_id=9001')
self.assertEqual(api[1]['authorization'], 'Bearer ' + KEY)
self.assertTrue(cdn[0].startswith('https://storage.itch-cdn.example/'))
self.assertNotIn('authorization', cdn[1])
# Connected to the address that was checked, not to a fresh lookup.
self.assertEqual(self.connected, [('storage.itch-cdn.example', ('151.101.1.1', 443))])
self.assertTrue(all(KEY not in u for u, _ in self.web.calls))
with self.assertRaisesRegex(SourceError, 'version codes'):
itch.download(self.owned_source(), '341190', version_code=3)
def test_download_also_accepts_a_json_url_or_the_file_itself(self):
self.connect()
self.storage()
location = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?sig=abc'
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'url': location},
{'Content-Type': 'application/json'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertNotIn('authorization', self.web.calls[-1][1])
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, apk_bytes(),
{'Content-Type': 'application/octet-stream'}))
self.connected.clear()
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, []) # api.itch.io served it; no storage host involved
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200, {'nothing': 1},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'did not say where'):
itch.download(self.owned_source(), '341190')
def test_storage_redirects_are_checked_on_every_hop(self):
self.connect()
self.storage('https://other-cdn.example/hop')
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk'}))
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example', 'storage.itch-cdn.example'])
self.web.route('other-cdn.example/hop', Reply('https://other-cdn.example/hop', 302, b'',
{'Location': 'https://intranet.example/a.apk'}))
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
def test_redirects_to_plain_http_or_private_addresses_are_refused(self):
self.connect()
for location in ('http://storage.itch-cdn.example/a.apk', 'https://user:pw@cdn.example/a.apk',
'https://cdn.example:8443/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'unexpected download URL'):
itch.download(self.owned_source(), '341190')
for location in ('https://localhost./a.apk', 'https://frame.local/a.apk', 'https://127.0.0x0.1/a.apk',
'https://intranet.example/a.apk', 'https://mapped.example/a.apk',
'https://cgnat.example/a.apk', 'https://mixed.example/a.apk',
'https://127.0.0.1/a.apk', 'https://[::1]/a.apk', 'https://169.254.169.254/a.apk',
'https://nat64.example/a.apk', 'https://[4000::1]/a.apk', 'https://[64:ff9b:1::a00:1]/a.apk',
'https://[fec0::1]/a.apk', 'https://sitelocal.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'private network address'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
self.assertEqual({urllib.parse.urlsplit(u).hostname for u, _ in self.web.calls}, {'api.itch.io'})
def test_public_address_rules(self):
for ip in ('151.101.1.1', '8.8.8.8', '2a04:4e42::81', '2001:4860:4860::8888'):
self.assertTrue(_web.public_address(ip), ip)
for ip in ('127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.0.1', '169.254.169.254', '100.64.0.1',
'0.0.0.0', '224.0.0.1', '255.255.255.255', '240.0.0.1', '::1', '::', 'fe80::1%en0', 'fc00::1',
'ff02::1', 'not an ip',
'192.0.0.8', '192.0.0.11', '192.0.0.255', '192.0.0.170', '192.0.0.9', '192.0.0.1', # 3.9: global
'192.0.2.1', '192.88.99.1', '198.18.0.1', '198.19.255.255', '198.51.100.7', '203.0.113.9',
'0.1.2.3', '10.255.255.255', '172.31.0.1', '239.1.1.1', '254.0.0.1',
'4000::1', '100::1', 'f000::1', # reserved: Python 3.9's is_global says True for 4000::1
'::ffff:10.0.0.1', '::ffff:127.0.0.1', '::ffff:8.8.8.8', # IPv4-mapped
'::a00:1', '::8.8.8.8', # IPv4-compatible
'64:ff9b::a00:1', '64:ff9b::808:808', # NAT64 well-known prefix
'64:ff9b:1::a00:1', '64:ff9b:1::808:808', # NAT64 local-use: 3.9's is_global says True
'2002:a00:1::1', '2002:808:808::1', # 6to4
'2001:0:4136:e378:8000:63bf:3fff:fdd2', # Teredo
'fec0::1', 'feff::1', # site-local: Python 3.9's is_global says True
'fc00::1', 'fd12:3456::1', 'fe80::1', 'ff00::1', 'ff0e::1', '8000::1', 'c000::1',
'2001:db8::1', '3fff::1', # documentation
'2001:10::1', '2001:20::1', '2001:2::1'): # ORCHID, ORCHIDv2, benchmarking
self.assertFalse(_web.public_address(ip), ip)
self.assertEqual(_web.resolve_public('storage.itch-cdn.example'), [('151.101.1.1', 443)])
self.assertEqual(_web.resolve_public('dual.example'), [('2a04:4e42::81', 443), ('151.101.1.1', 443)])
def test_a_storage_url_carrying_the_key_is_refused(self):
self.connect()
for location in ('https://storage.itch-cdn.example/a.apk?api_key=' + KEY,
'https://storage.itch-cdn.example/a.apk?API_KEY=someone-elses',
'https://storage.itch-cdn.example/a.apk?apikey=x',
'https://storage.itch-cdn.example/' + KEY + '/a.apk',
'https://storage.itch-cdn.example/a.apk#' + KEY[:10],
'https://storage.itch-cdn.example/a.apk?x=' + urllib.parse.quote('%' + KEY),
'https://' + KEY.lower() + '.itch-cdn.example/a.apk'):
self.storage(location)
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY.lower(), str(caught.exception).lower())
self.web.route('api.itch.io/uploads/503/download',
Reply('https://api.itch.io/uploads/503/download', 200,
{'url': 'https://storage.itch-cdn.example/a.apk?api_key=' + KEY},
{'Content-Type': 'application/json'}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused'):
itch.download(self.owned_source(), '341190')
self.assertEqual(self.connected, [])
# A storage URL's own signature or token parameters are fine.
self.storage('https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?token=abc&Signature=x')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
def test_a_later_redirect_carrying_the_key_is_refused_before_connecting(self):
self.connect()
bad = 'https://storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk?api_key=' + KEY
for hops in (['https://other-cdn.example/hop1'], # 2nd hop carries the key
['https://other-cdn.example/hop1', 'https://other-cdn.example/hop2']): # 3rd hop
self.connected.clear()
self.storage(hops[0])
for here, there in zip(hops, hops[1:] + [bad]):
path = urllib.parse.urlsplit(here).path
self.web.route('other-cdn.example' + path, Reply(here, 302, b'', {'Location': there}))
with self.assertRaisesRegex(SourceError, 'carrying your API key; refused') as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIn(KEY, str(caught.exception))
self.assertEqual([h for h, _ in self.connected], ['other-cdn.example'] * len(hops))
self.assertFalse(any(KEY in u for u, _ in self.web.calls))
# The check runs on every hop before the name is even looked up.
seen = []
with patch.object(_web, 'resolve_public', side_effect=AssertionError('looked up')):
with self.assertRaisesRegex(SourceError, 'no thanks'):
_web.public_apk('https://storage.itch-cdn.example/a.apk', 'itch.io',
check=lambda u: seen.append(u) or (_ for _ in ()).throw(SourceError('no thanks')))
self.assertEqual(seen, ['https://storage.itch-cdn.example/a.apk'])
def test_a_partial_match_before_the_key_cannot_hide_its_start(self):
key = 'abcdefghABCDEFGHi1234567890'
for text in (key[:8].upper() + key.upper(), key[:9] + key, 'xx' + key[:8] + key + 'yy'):
out = account._clean(text, key)
self.assertNotIn('1234567890', out, (text, out))
import time
t0 = time.monotonic(); account._clean(('abcdefgh' * 12500)[:100000], key)
self.assertLess(time.monotonic() - t0, 2.0)
def test_redaction_catches_every_spelling_of_the_key(self):
hexed = ''.join('%%%02X' % ord(c) for c in KEY)
for spelling in (KEY, KEY.lower(), KEY.upper(), hexed, hexed.lower(),
KEY[:5] + hexed[15:30] + KEY[10:], # partly encoded
KEY[:9], hexed[:30].lower(), # starts of it, 8+ characters
''.join('%%%02x' % ord(c) for c in KEY.lower()), # encoded after a case change
''.join('%%%02X' % ord(c) for c in KEY.swapcase()),
'%%%02x' % ord(KEY[0].swapcase()) + KEY[1:]): # one encoded, recased letter
cleaned = account._clean('before ' + spelling + ' after', KEY)
self.assertEqual(cleaned, 'before [API key] after', spelling)
double = ''.join('%%25%02X' % ord(c) for c in KEY) # percent-encoded twice
for spelling in (double, double.lower(), double.upper(), double[:5 * 9], # full, hex recased, 9-char start
''.join('%%25%02x' % ord(c) for c in KEY.lower()), # the key recased, then double-encoded
KEY[:4] + double[20:45] + hexed[27:33] + KEY[11:]): # literal, double and single mixed
self.assertEqual(account._clean('before ' + spelling + ' after', KEY), 'before [API key] after', spelling)
self.assertEqual(account._clean(double[:5 * 7], KEY), double[:5 * 7]) # 7 characters: kept
self.assertTrue(account._carries_key('https://cdn.example/a?x=' + double, KEY))
self.assertTrue(account._carries_key('https://cdn.example/' + double[:5 * 8].lower() + '/a', KEY))
self.assertEqual(account._clean('TESTKEY is short', KEY), 'TESTKEY is short') # 7 characters: kept
self.assertEqual(account._clean(KEY + KEY.lower(), KEY), '[API key][API key]')
for url in ('https://cdn.example/a?x=' + hexed, 'https://cdn.example/a?x=' + hexed.lower(),
'https://cdn.example/' + KEY.upper() + '/a', 'https://cdn.example/a?x=' + urllib.parse.quote_plus(KEY)):
self.assertTrue(account._carries_key(url, KEY), url)
self.assertFalse(account._carries_key('https://cdn.example/a?token=abc&Signature=x', KEY))
# Storage errors carrying an encoded or lowercased key lose it, keeping their kind.
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow ' + hexed.lower(), 42)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow [API key]', 42))
with patch.object(_web, 'public_apk', side_effect=SourceError('host ' + KEY.lower() + '.cdn')):
with self.assertRaisesRegex(SourceError, r'^host \[API key\]\.cdn$'):
itch.download(self.owned_source(), '341190')
def test_storage_errors_lose_the_key_but_keep_their_kind(self):
self.connect()
self.storage()
with patch.object(_web, 'public_apk', side_effect=SourceLimited('slow down ' + KEY, 90)):
with self.assertRaises(SourceLimited) as caught:
itch.download(self.owned_source(), '341190')
self.assertEqual((str(caught.exception), caught.exception.retry_after), ('slow down [API key]', 90))
with patch.object(_web, 'public_apk', side_effect=SourceError('Could not find the download server ' + KEY)):
with self.assertRaisesRegex(SourceError, r'^Could not find the download server \[API key\]$') as caught:
itch.download(self.owned_source(), '341190')
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
def test_broken_http_responses_are_readable_and_lose_the_key(self):
self.connect()
class Truncated(Reply):
def read(self, *args):
raise http.client.IncompleteRead(b'{"owned_keys": [' + KEY.encode(), 1000)
cases = [http.client.BadStatusLine('HTTP/1.1 ' + KEY + ' oops'),
lambda req, q: Truncated(req.full_url, 200, b''),
http.client.RemoteDisconnected('closed after ' + KEY)]
for case in cases:
account._forget()
self.web.route('api.itch.io/profile/owned-keys', case)
with self.assertRaisesRegex(SourceError, '^Could not reach itch.io: ') as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
self.assertTrue(account.connected())
# The same on the download request, for each kind of answer it reads.
for case in (http.client.BadStatusLine('HTTP/1.1 ' + KEY),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/json'}),
lambda req, q: Truncated(req.full_url, 200, b'', {'Content-Type': 'application/octet-stream'})):
self.web.route('api.itch.io/uploads/503/download', case)
with self.assertRaises(SourceError) as caught:
account.download(503, 9001)
self.assertNotIn(KEY, str(caught.exception))
self.assertRegex(str(caught.exception), '^Could not (reach itch.io|download APK): ')
def test_connecting_shares_one_deadline_across_addresses(self):
clock, deadlines = [1000.0], []
def slow(host, address, timeout, deadline):
deadlines.append(deadline)
clock[0] += 7 # each address hangs for 7 s before failing
raise socket.timeout('timed out')
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 60)
self.assertEqual(len(set(deadlines)), 1) # one deadline for every attempt
self.assertEqual(deadlines[0], 1000.0 + _web.CONNECT_BUDGET)
self.assertEqual(len(deadlines), 3) # 8 addresses, but only 21 s of trying, not 56
self.assertLessEqual(clock[0] - 1000.0, _web.CONNECT_BUDGET + 7)
deadlines.clear()
clock[0] = 1000.0
with patch.object(_web.time, 'monotonic', lambda: clock[0]), patch.object(_web, '_connect', slow):
with self.assertRaises(OSError):
_web._public_connection('many.example', 5) # the caller's shorter timeout wins
self.assertEqual(deadlines, [1005.0])
def test_a_stalled_handshake_stops_at_the_deadline(self):
# A local server that accepts and then says nothing: the TLS handshake never completes.
server = socket.socket()
server.bind(('127.0.0.1', 0))
server.listen(1)
self.addCleanup(server.close)
timeouts = []
real_settimeout = socket.socket.settimeout
def settimeout(sock, value):
timeouts.append(value)
return real_settimeout(sock, value)
start = time.monotonic()
# No thread is left behind to shut the connection later: the deadline is only socket timeouts.
with patch.object(_web.threading, 'Timer', side_effect=AssertionError('watchdog thread')), \
patch.object(socket.socket, 'settimeout', settimeout):
with self.assertRaises(OSError):
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, start + 0.5)
self.assertLess(time.monotonic() - start, 3)
self.assertTrue(timeouts and all(0 < t <= 0.5 for t in timeouts), timeouts) # what's left, each step
with patch.object(_web.socket, 'create_connection', side_effect=AssertionError('connected')):
with self.assertRaises(socket.timeout): # a deadline already gone: no connection attempt at all
REAL_CONNECT('storage.itch-cdn.example', server.getsockname(), 60, time.monotonic() - 1)
def test_an_unreachable_address_falls_back_to_the_next_checked_one(self):
self.connect()
self.storage('https://dual.example/upload/503/OrbitGarden-Quest.apk')
self.web.route('dual.example/upload/503/OrbitGarden-Quest.apk', Reply('https://dual.example/', 200, apk_bytes()))
self.unreachable.add('2a04:4e42::81')
self.assertTrue(os.path.isfile(itch.download(self.owned_source(), '341190')['apk']))
self.assertEqual(self.connected, [('dual.example', ('2a04:4e42::81', 443)), ('dual.example', ('151.101.1.1', 443))])
self.unreachable.add('151.101.1.1')
with self.assertRaisesRegex(SourceError, '^Could not download APK: Network is unreachable$'):
itch.download(self.owned_source(), '341190')
def test_storage_refusals_are_download_errors_not_rate_limits(self):
self.connect()
self.storage()
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 403, b'denied'))
with self.assertRaisesRegex(SourceError, r"itch.io's download server refused the file \(HTTP 403\)") as caught:
itch.download(self.owned_source(), '341190')
self.assertNotIsInstance(caught.exception, SourceLimited)
self.assertTrue(account.connected())
self.assertEqual(_web.wait_time('https://api.itch.io/'), 0)
self.web.route('storage.itch-cdn.example/upload/503/OrbitGarden-Quest.apk',
Reply('https://storage.itch-cdn.example/x', 429, b'', {'Retry-After': '60'}))
with self.assertRaises(SourceLimited):
itch.download(self.owned_source(), '341190')
def test_a_key_revoked_after_details_were_cached_is_removed_at_download(self):
self.connect()
itch.details(self.owned_source(), '341190') # uploads and owned keys now cached
for code in (401, 403):
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', code, b''))
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', code, b''))
with self.assertRaisesRegex(account.BadKey, 'no longer accepts the saved API key'):
account.download(503, 9001)
self.assertFalse(account.path().exists())
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
# The key still works, only this download is refused: keep the key, say so.
self.connect()
self.web.route('api.itch.io/uploads/503/download', Reply('https://api.itch.io/uploads/503/download', 403, b''))
with self.assertRaisesRegex(SourceError, r'refused this request \(HTTP 403\)') as caught:
account.download(503, 9001)
self.assertNotIsInstance(caught.exception, (account.BadKey, SourceLimited))
self.assertTrue(account.connected())
def test_a_disconnect_during_the_key_check_wins(self):
checking, release = threading.Event(), threading.Event()
def slow_profile(req, query):
checking.set()
release.wait(5)
return {'user': {'username': 'player'}}
self.web.route('api.itch.io/profile', slow_profile)
outcome = {}
def run():
try:
outcome['result'] = account.connect(KEY)
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(checking.wait(5))
account.disconnect()
release.set()
worker.join(5)
self.assertRegex(str(outcome.get('error')), 'changed while the key was being checked; nothing was saved')
self.assertFalse(account.path().exists())
self.assertFalse(account.connected())
def test_an_old_refusal_does_not_undo_a_reconnect_with_the_same_key(self):
self.connect()
listing, release, profile_ok = threading.Event(), threading.Event(), [True]
def slow_owned(req, query):
listing.set()
release.wait(5)
return Reply(req.full_url, 401, b'')
self.web.route('api.itch.io/profile/owned-keys', slow_owned)
self.web.route('api.itch.io/profile', lambda req, q: {'user': {'username': 'player'}} if profile_ok[0]
else Reply(req.full_url, 401, b''))
outcome = {}
def run():
try:
account.owned_keys()
except SourceError as e:
outcome['error'] = e
worker = threading.Thread(target=run)
worker.start()
self.assertTrue(listing.wait(5))
account.disconnect()
account.connect(KEY) # same key, connected again while the old listing waits
profile_ok[0] = False # the old listing's confirmation now sees a refusal
release.set()
worker.join(5)
self.assertIsInstance(outcome.get('error'), account.BadKey)
self.assertTrue(account.path().exists()) # the fresh connection survives
self.assertEqual(json.loads(account.path().read_text())['key'], KEY)
def test_redacted_text_is_not_mistaken_for_a_key_error(self):
self.connect()
before = len(self.web.urls('api.itch.io/profile'))
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['Invalid request: ' + KEY]})
with self.assertRaisesRegex(SourceError, r'^itch.io: Invalid request: \[API key\]$') as caught:
account.owned_keys()
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
self.assertEqual(len(self.web.urls('api.itch.io/profile')), before) # not even a key check needed
def test_an_invalid_key_error_elsewhere_is_confirmed_before_deleting(self):
self.connect()
self.web.route('api.itch.io/profile/owned-keys', {'errors': ['invalid key']})
with self.assertRaisesRegex(SourceError, '^itch.io: invalid key$') as caught:
account.owned_keys() # /profile still accepts the key: keep it
self.assertNotIsInstance(caught.exception, account.BadKey)
self.assertTrue(account.connected())
account._forget()
self.web.route('api.itch.io/profile', {'errors': ['invalid key']})
with self.assertRaisesRegex(account.BadKey, 'no longer accepts'):
account.owned_keys()
self.assertFalse(account.connected())
def test_concurrent_connects_never_share_a_temporary_file(self):
made = []
real = account.tempfile.mkstemp
def mkstemp(*args, **kwargs):
fd, name = real(*args, **kwargs)
made.append(name)
if os.name == 'posix':
self.assertEqual(os.stat(name).st_mode & 0o777, 0o600)
return fd, name
with patch.object(account.tempfile, 'mkstemp', mkstemp):
workers = [threading.Thread(target=lambda k=k: self.swallow(account.connect, k)) for k in (KEY, OTHER) * 3]
for w in workers:
w.start()
for w in workers:
w.join(5)
self.assertEqual(len(made), len(set(made)))
self.assertIn(json.loads(account.path().read_text())['key'], (KEY, OTHER))
self.assertEqual([p.name for p in account.path().parent.iterdir()], ['itch-account.json'])
@staticmethod
def swallow(fn, *args):
try:
fn(*args)
except SourceError:
pass # a connect overtaken by another one refuses to save: fine here
def test_existing_hosts_still_strip_authorization_on_redirect(self):
req = urllib.request.Request('https://api.github.com/a', headers={'Authorization': 'Bearer x'})
nxt = _web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://api.github.com/b')
self.assertFalse(nxt.has_header('Authorization'))
with self.assertRaises(SourceError):
_web.Redirect(('api.github.com',)).redirect_request(req, None, 302, 'Found', {}, 'https://evil.example/b')
def test_install_uses_the_normal_apk_install_path(self):
import frame_android
self.connect()
self.storage()
calls = []
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
with zipfile.ZipFile(apk_path) as z:
self.assertIn('AndroidManifest.xml', z.namelist())
calls.append((name, source))
return {'package': 'com.maker.orbit', 'message': 'Installed'}
with patch.object(frame_android, 'install', install):
result = search.install(itch.OWNED, '341190')
self.assertEqual(calls, [('Orbit Garden VR', 'itch.io (my purchases)')])
self.assertEqual(result['package'], 'com.maker.orbit')
# -- errors never carry the key ------------------------------------------------
def test_errors_are_readable_and_never_contain_the_key(self):
self.connect()
cases = [
(Reply('https://api.itch.io/profile/owned-keys', 429, b'', {'Retry-After': '120'}),
SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'),
(Reply('https://api.itch.io/profile/owned-keys', 503, b''), SourceError, 'HTTP error 503; try again later'),
(urllib.error.URLError('connect to api.itch.io with ' + KEY + ' failed'), SourceError,
r'^Could not reach itch.io: connect to api.itch.io with \[API key\] failed$'),
(OSError('timed out'), SourceError, '^Could not reach itch.io: timed out$'),
(Reply('https://api.itch.io/profile/owned-keys', 200, b'<html>'), SourceError, 'invalid response'),
({'errors': ['echo ' + KEY]}, SourceError, r'^itch.io: echo \[API key\]$'),
# The key straddles the 200-character cut: no piece of it may survive.
({'errors': ['x' * 180, KEY]}, SourceError, r'^itch.io: x{180}; \[API key\]$'),
({'errors': ['x' * 190 + KEY]}, SourceError, r'^itch.io: x{190}\[API key\]$'),
]
self.assertEqual(account._clean('cut at ' + KEY[:9], KEY), 'cut at [API key]') # a truncated key's start
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
for reply, kind, message in cases:
account._forget()
_web._limited.clear()
self.web.route('api.itch.io/profile/owned-keys', reply)
with self.assertRaisesRegex(kind, message) as caught:
account.owned_keys()
self.assertNotIn(KEY, str(caught.exception))
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(account.connected()) # not the key's fault: stays connected
# The store's search reports the failure as a non-fatal source status.
account._forget()
result = search.search('', source=itch.OWNED)
self.assertEqual(result['sources'][0]['status'], 'error')
self.assertNotIn(KEY, json.dumps(result) + stderr.getvalue())
def test_server_api_connects_and_never_echoes_the_key(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
self.addCleanup(httpd.server_close)
self.addCleanup(httpd.shutdown)
def request(method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', 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()
out = r.status, r.read().decode()
c.close()
return out
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr):
self.web.route('api.itch.io/profile', Reply('https://api.itch.io/profile', 401, b''))
bad = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
self.web.route('api.itch.io/profile', {'user': {'username': 'player'}})
good = request('POST', '/api/sources', {'action': 'itch-connect', 'key': KEY})
listed = request('GET', '/api/sources')
empty = request('POST', '/api/sources', {'action': 'itch-connect', 'key': ' '})
gone = request('POST', '/api/sources', {'action': 'itch-disconnect'})
self.assertEqual(bad[0], 400)
self.assertIn('did not accept the API key', json.loads(bad[1])['error'])
self.assertEqual(good[0], 200)
self.assertEqual(json.loads(good[1])['itch'], {'connected': True, 'username': 'player'})
self.assertEqual(json.loads(listed[1])['itch'], {'connected': True, 'username': 'player'})
self.assertIn(itch.OWNED, [s['id'] for s in json.loads(listed[1])['sources']])
self.assertEqual(empty[0], 400)
self.assertEqual(json.loads(gone[1])['itch'], {'connected': False, 'username': None})
self.assertFalse(account.path().exists())
for text in (bad[1], good[1], listed[1], empty[1], gone[1], stderr.getvalue()):
self.assertNotIn(KEY, text)
if __name__ == '__main__':
unittest.main()
+1 -17
View File
@@ -46,17 +46,6 @@ 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:
@@ -211,8 +200,7 @@ 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")
# 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")
self.assertIn("different SSH identity", rows["::1"]["detail"])
# Every ssh command was pointed at the winner, with the host key pinned per device.
alias, opts = self.routes[-1]
self.assertEqual(alias, "frame-t")
@@ -272,8 +260,6 @@ 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")
@@ -363,7 +349,6 @@ 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"]
@@ -649,7 +634,6 @@ 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
@@ -1,136 +0,0 @@
"""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()
+6 -203
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", "internal_error", "other", "other"])
["frame_unreachable", "other", "other", "other"])
def test_page_events_are_checked(self):
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
@@ -159,79 +159,11 @@ 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):
@@ -266,39 +198,6 @@ 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})
@@ -364,72 +263,6 @@ 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):
@@ -543,17 +376,14 @@ class LinkFailureProvenance(Base):
c.close()
return result
def serve(self):
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.
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), \
@@ -569,34 +399,7 @@ 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"]
# 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")])
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "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,
+3 -1
View File
@@ -32,7 +32,9 @@ Entry dict (missing facts are None, never guessed)
Rules
-----
- Only sources that distribute APKs with the developer's consent: free listings,
never paid apps re-hosted, no licence or entitlement workarounds.
never paid apps re-hosted, no licence or entitlement workarounds. The one paid
exception is a store's own download of a game the user bought there: a source
marked 'owned': True whose entries carry 'owned': True (itch.io purchases).
- Honour each site's terms and robots rules; if automated download isn't allowed,
return entries with 'downloadable': False and a 'page' link instead.
- Cache indexes under frame_host.cache_dir('apk-sources'); send a clear
+394
View File
@@ -0,0 +1,394 @@
"""The user's own itch.io account: an API key, the games it owns and their Android uploads.
Opt-in (store > sources > Connect itch.io). The key is kept in
frame_host.data_dir('itch-account.json') with mode 0600 and is only ever sent to
https://api.itch.io, in an Authorization header. Downloads end on itch.io's storage
host, fetched without the key from a checked public address (_web.public_apk).
The key never goes into a URL, an error, a log line or an API response. Owned-key
listings stay in memory, never in the disk cache.
API: https://itch.io/docs/api/serverside. See docs/apk-repos.md.
"""
import hashlib, http.client, json, os, re, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import frame_host
from . import SourceError, SourceLimited, _web
API = 'https://api.itch.io'
HOSTS = ('api.itch.io',)
NAME = 'itch.io'
TTL = 600 # owned keys and uploads are re-read after ten minutes
PER_PAGE = 500 # the documented maximum for /profile/owned-keys
PAGES = 40 # stop after 20,000 owned keys
KEY = re.compile(r'[A-Za-z0-9_.-]{16,256}')
BAD_KEY = ('itch.io did not accept the API key. Create one in itch.io settings > '
'API keys and connect again')
REVOKED = ('itch.io no longer accepts the saved API key, so it was removed. '
'Create a new one in itch.io settings > API keys and connect again')
REDIRECTS = (301, 302, 303, 307, 308)
# Everything a connection or response can raise. http.client's errors (IncompleteRead,
# BadStatusLine...) are not OSErrors, and BadStatusLine repeats what the server sent.
NETWORK = (OSError, ValueError, http.client.HTTPException)
_lock = threading.Lock() # the cache
_cache = {} # (generation, key digest, what) -> (time, value)
_account = threading.Lock() # saving and deleting the key file
_generation = [0] # bumped by every save and delete: a connect that started earlier doesn't save
class BadKey(SourceError):
"""itch.io refused the key: wrong, revoked or deleted."""
def path():
return frame_host.data_dir('itch-account.json')
def _load():
try:
data = json.loads(path().read_text())
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not isinstance(data.get('key'), str) or not KEY.fullmatch(data['key']):
return None
return data
def status():
"""{'connected', 'username'}: what the page may know. Never the key; no network."""
data = _load()
return {'connected': bool(data), 'username': data.get('username') if data else None}
def connected():
return _load() is not None
def _key():
data = _load()
if not data:
raise SourceError('Connect your itch.io account first (store > sources > Connect itch.io)')
return data['key']
def _save(key, username):
"""Write the key file through a fresh 0600 temporary file (mkstemp), then move it into place."""
target = path()
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(target.parent), prefix='.itch-account-', suffix='.tmp')
try:
with os.fdopen(fd, 'w') as f:
os.chmod(tmp, 0o600) # mkstemp already does on POSIX; say so for every platform
json.dump({'key': key, 'username': username}, f)
os.replace(tmp, str(target))
finally:
if os.path.exists(tmp):
os.remove(tmp)
def _forget():
with _lock:
_cache.clear()
def _clean(message, key):
"""message without the key, or any start of it (8+ characters, as truncation leaves), in
any letter case, with any of its characters written literally, percent-encoded (%4B or
%4b) or percent-encoded twice (%254B, %254b). The only redaction used here: every message
that might hold the key goes through it. (The key's characters are letters, digits and
_.-, so +/space encoding can't change it.)"""
if not key:
return message
# Each character: itself, or the percent-encoding (once or twice) of either of its letter cases.
tokens = ['(?:%s)' % '|'.join([re.escape(c)] + ['%%(?:25)?%02x' % ord(v) for v in sorted({c.lower(), c.upper()})])
for c in key]
start, rest = re.compile(''.join(tokens[:8]), re.I), [re.compile(t, re.I) for t in tokens[8:]]
spans, pos = [], 0
while True:
found = start.search(message, pos)
if not found:
break
end = found.end()
for token in rest: # as much more of the key as follows
more = token.match(message, end)
if not more:
break
end = more.end()
spans.append((found.start(), end))
# Look again from the next character, not the end: a partial match just before the
# real key can overlap its start. Overlapping spans merge below.
pos = found.start() + 1
out, pos = [], 0
for a, b in spans: # in order of start; only overlapping spans merge
if out and a < pos:
pos = max(pos, b)
continue
out += [message[pos:a], '[API key]']
pos = b
return ''.join(out) + message[pos:]
class _Stay(urllib.request.HTTPRedirectHandler):
"""Don't follow: the caller decides where a redirect may go, and never sends the key there."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _open(url, key, accept='application/json'):
"""GET url on api.itch.io with the key. Redirects come back as HTTPError 30x."""
_web.checked_url(url, HOSTS)
wait = _web.wait_time(url)
if wait:
raise _web.limited_error(NAME, wait)
return urllib.request.build_opener(_Stay()).open(urllib.request.Request(
url, headers={'User-Agent': _web.UA, 'Authorization': 'Bearer ' + key, 'Accept': accept}), timeout=60)
def _refused(code, key, api_path, message=None):
"""A refusal that may be about the key (401/403, or an "invalid key" error): BadKey only
once /profile confirms the key itself is refused; otherwise it's this request (a game or
upload this purchase doesn't cover), and the key stays."""
if api_path != '/profile':
_get('/profile', key) # raises BadKey if the key is the problem
raise SourceError(message or 'itch.io refused this request (HTTP %d); your purchase may not include it' % code)
raise BadKey(BAD_KEY)
def _network_error(e, key):
reason = getattr(e, 'reason', None) or e
return SourceError(_clean('Could not reach itch.io: ' + str(reason), key))
BAD_KEY_TEXT = re.compile(r'\b(invalid|revoked|expired)\b.*\bkey\b|\bkey\b.*\b(invalid|revoked|expired)\b', re.I)
def _checked(raw, key, api_path):
"""Decoded JSON object from api.itch.io, or SourceError/BadKey from its errors array."""
if len(raw) > 8 * 1024 * 1024:
raise SourceError('itch.io response is too large')
try:
data = json.loads(raw)
except ValueError:
raise SourceError('itch.io returned an invalid response') from None
if not isinstance(data, dict):
raise SourceError('itch.io returned an invalid response')
errors = data.get('errors')
if errors: # documented: errors is an array of strings
messages = [str(m) for m in errors if isinstance(m, (str, int))]
# Judged on itch.io's own words: redacted text says "[API key]", which would look like a key error.
about_key = any(BAD_KEY_TEXT.search(m) and 'download key' not in m.lower() for m in messages)
# Each message loses the key before joining and shortening, so no part of it survives the cut.
text = _clean('; '.join(_clean(m, key) for m in messages)[:200], key)
if about_key:
_refused(None, key, api_path, 'itch.io: ' + text)
raise SourceError('itch.io: ' + (text or 'request refused'))
return data
def _get(api_path, key, params=None):
"""JSON from api.itch.io; SourceError with a readable message (never the key) on failure."""
url = API + api_path + ('?' + urllib.parse.urlencode(params) if params else '')
try:
with _open(url, key) as r:
raw = r.read(8 * 1024 * 1024 + 1)
except urllib.error.HTTPError as e:
e.close()
if e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
if e.code in (401, 403):
_refused(e.code, key, api_path)
raise SourceError('itch.io returned HTTP error %d; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
return _checked(raw, key, api_path)
def _cached(key, what, load):
slot = (_generation[0], hashlib.sha256(key.encode()).hexdigest(), what)
with _lock:
hit = _cache.get(slot)
if hit and time.time() - hit[0] < TTL:
return hit[1]
value = load()
with _lock:
_cache[slot] = (time.time(), value)
return value
def _use(call):
"""Run call(key); a key itch.io now refuses is removed, so the account shows disconnected."""
with _account: # the key and the connection it belongs to, read together
key, generation = _key(), _generation[0]
try:
return call(key)
except BadKey:
with _account:
# Only the connection this call started with: a reconnect meanwhile (even with
# the same key) has been checked since, and is not undone by this older verdict.
if _generation[0] == generation and (_load() or {}).get('key') == key:
_delete()
raise BadKey(REVOKED) from None
def connect(key):
"""Check key with itch.io (GET /profile), then save it. A refused key is never saved, and
nor is one whose check finished after a disconnect or another connect."""
key = (key or '').strip() if isinstance(key, str) else ''
if not KEY.fullmatch(key):
raise BadKey('That does not look like an itch.io API key. Copy it from itch.io settings > API keys')
with _account:
started = _generation[0]
user = _get('/profile', key).get('user') # the network, outside the lock
if not isinstance(user, dict):
raise SourceError('itch.io returned an invalid profile')
username = user.get('username') if isinstance(user.get('username'), str) else None
with _account:
if _generation[0] != started:
raise SourceError('The itch.io connection changed while the key was being checked; '
'nothing was saved. Try again')
_generation[0] += 1
_save(key, username)
_forget()
return {'message': 'Connected to itch.io' + (' as ' + username if username else ''), 'itch': status()}
def _delete():
"""Remove the key file; call with _account held."""
_generation[0] += 1
try:
os.remove(str(path()))
except FileNotFoundError:
pass
_forget()
def disconnect():
with _account:
_delete()
return {'message': 'Disconnected from itch.io; the API key was deleted from this computer', 'itch': status()}
def owned_keys():
"""Every download key the account owns, newest first (GET /profile/owned-keys, all pages)."""
def load(key):
keys = []
for page in range(1, PAGES + 1):
batch = _get('/profile/owned-keys', key, {'page': page, 'per_page': PER_PAGE}).get('owned_keys')
# Documented: a page can hold fewer than per_page even when more follow; an empty one ends it.
if not batch or not isinstance(batch, list):
break
keys.extend(k for k in batch if isinstance(k, dict))
return keys
return _use(lambda key: _cached(key, 'owned', lambda: load(key)))
def _number(value):
return value if type(value) is int and value > 0 else None
def is_android(upload):
"""An upload marked for Android, or an .apk file. The marker's field name is not in the
public reference: itch.io's traits use p_<platform> (p_windows...), so p_android is assumed."""
traits = upload.get('traits') if isinstance(upload.get('traits'), list) else []
platforms = upload.get('platforms') if isinstance(upload.get('platforms'), dict) else {}
filename = str(upload.get('filename') or '').lower()
return 'p_android' in traits or upload.get('p_android') is True or 'android' in platforms \
or filename.endswith('.apk')
def android_uploads(uploads):
"""Installable Android uploads, best first: .apk files, then full games, then newest."""
chosen = []
for u in uploads if isinstance(uploads, list) else []:
if not isinstance(u, dict) or not _number(u.get('id')) or not is_android(u):
continue
traits = u.get('traits') if isinstance(u.get('traits'), list) else []
if u.get('type') not in (None, 'default') or u.get('preorder') is True or 'preorder' in traits:
continue # soundtracks, books, placeholders
if u.get('storage') == 'external':
continue # hosted on another site: not something itch.io can hand us
chosen.append(u)
chosen.sort(key=lambda u: str(u.get('updated_at') or u.get('created_at') or ''), reverse=True)
chosen.sort(key=lambda u: (not str(u.get('filename') or '').lower().endswith('.apk'),
u.get('demo') is True or 'demo' in (u.get('traits') or [])))
return chosen
def uploads(game_id, download_key_id):
"""The game's uploads this download key may fetch (GET /games/ID/uploads)."""
game_id, download_key_id = _number(game_id), _number(download_key_id)
if not game_id or not download_key_id:
raise SourceError('Invalid itch.io game')
return _use(lambda key: _cached(key, ('uploads', game_id, download_key_id), lambda: _get(
'/games/%d/uploads' % game_id, key, {'download_key_id': download_key_id}).get('uploads') or []))
def download_url(upload_id, download_key_id):
upload_id, download_key_id = _number(upload_id), _number(download_key_id)
if not upload_id or not download_key_id:
raise SourceError('Invalid itch.io upload')
return API + '/uploads/%d/download?' % upload_id + urllib.parse.urlencode({'download_key_id': download_key_id})
def _fetch(url, key):
"""Ask api.itch.io for the file. Not checked against a live account, so this accepts each
plausible answer: a redirect to storage, JSON naming the storage URL, or the file itself."""
try:
r = _open(url, key, 'application/vnd.android.package-archive, application/json;q=0.5, */*;q=0.1')
except urllib.error.HTTPError as e:
e.close()
if e.code in REDIRECTS:
target = e.headers.get('Location')
elif e.code == 429:
raise _web.limited_error(NAME, _web.throttle(url, e.headers)) from None
elif e.code in (401, 403):
_refused(e.code, key, '/uploads')
else:
raise SourceError('itch.io returned HTTP error %d for the download; try again later' % e.code) from None
except SourceError:
raise
except NETWORK as e:
raise _network_error(e, key) from None
else:
with r:
if 'json' not in (r.headers.get('Content-Type') or '').lower():
try:
return _web.save_apk(r) # served by api.itch.io itself
except NETWORK + (zipfile.BadZipFile,) as e:
raise SourceError(_clean('Could not download APK: ' + str(e), key)) from None
try:
raw = r.read(1024 * 1024 + 1)
except NETWORK as e:
raise _network_error(e, key) from None
target = _checked(raw, key, '/uploads').get('url')
if not isinstance(target, str) or not target:
raise SourceError('itch.io did not say where to download the file')
def refuse(hop): # on the first URL and on every redirect after it, before any lookup
if _carries_key(hop, key):
raise SourceError('itch.io pointed the download at a URL carrying your API key; refused, '
'so the key is not sent to the storage server')
try:
return _web.public_apk(urllib.parse.urljoin(url, target), NAME, check=refuse) # no key sent
except SourceLimited as e:
raise SourceLimited(_clean(str(e), key), e.retry_after) from None
except SourceError as e:
raise type(e)(_clean(str(e), key)) from None
def _carries_key(url, key):
"""True if url holds the key (or 8+ characters of its start), however cased or encoded, or
an itch.io API-key parameter: go-itchio puts the key in ?api_key=, and storage must never
get it. Other parameters (signatures, tokens) are the storage URL's own and are kept."""
for form in (url, urllib.parse.unquote(url), urllib.parse.unquote_plus(url)):
if _clean(form, key) != form:
return True
names = {name.lower() for name, _ in urllib.parse.parse_qsl(urllib.parse.urlsplit(url).query,
keep_blank_values=True)}
return bool(names & {'api_key', 'apikey', 'api-key'})
def download(upload_id, download_key_id):
"""The APK: the key goes to api.itch.io only, never to the storage host."""
url = download_url(upload_id, download_key_id)
return _use(lambda key: _fetch(url, key))
+161 -11
View File
@@ -1,5 +1,6 @@
"""Small HTTPS cache and APK downloader for public publisher sources."""
import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.parse, urllib.request, zipfile
import hashlib, http.client, ipaddress, os, shutil, socket, ssl, tempfile, threading, time
import urllib.error, urllib.parse, urllib.request, zipfile
from email.utils import parsedate_to_datetime
import frame_host
@@ -136,6 +137,8 @@ def checked_url(url, hosts):
class Redirect(urllib.request.HTTPRedirectHandler):
"""Follow redirects only to hosts; an Authorization header never follows one."""
def __init__(self, hosts):
self.hosts = hosts
@@ -196,19 +199,16 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
raise SourceError('Could not read source: ' + str(e)) from e
def apk(url, hosts, digest=None, name=None):
def save_apk(response, digest=None):
"""Stream response (anything with read(n)) into the cache as a checked APK."""
tmp = None
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
fd, tmp = tempfile.mkstemp(dir=cache(), suffix='.part')
h = hashlib.sha256()
with os.fdopen(fd, 'wb') as f, open_url(url, hosts) as r:
with os.fdopen(fd, 'wb') as f:
size = 0
while True:
chunk = r.read(1 << 20)
chunk = response.read(1 << 20)
if not chunk:
break
size += len(chunk)
@@ -225,12 +225,162 @@ def apk(url, hosts, digest=None, name=None):
path = os.path.join(cache(), actual + '.apk')
os.replace(tmp, path)
return {'apk': path, 'obb': [], 'sha256': actual, 'verified': bool(digest)}
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
def apk(url, hosts, digest=None, name=None):
name = name or _host(url)
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
with open_url(url, hosts) as r:
return save_apk(r, digest)
except urllib.error.HTTPError as e:
if e.code in (403, 429):
raise limited_error(name, throttle(url, e.headers)) from e
raise SourceError('Could not download APK: HTTP error ' + str(e.code)) from e
except (OSError, ValueError, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
if tmp and os.path.exists(tmp):
os.remove(tmp)
# IPv6 is an allow-list: only global unicast (2000::/3), which rules out everything else at
# once (loopback, unspecified, mapped/compatible IPv4, NAT64 64:ff9b::, site-local fec0::,
# unique-local fc00::, link-local, multicast, reserved). Inside it, these special blocks are
# refused too: 6to4 and Teredo carry IPv4 that may be private; the rest aren't the internet.
_GLOBAL6 = ipaddress.ip_network('2000::/3')
_SPECIAL6 = tuple(ipaddress.ip_network(n) for n in (
'2001::/32', # Teredo
'2001:2::/48', # benchmarking
'2001:10::/28', # ORCHID
'2001:20::/28', # ORCHIDv2
'2001:db8::/32', # documentation
'2002::/16', # 6to4
'3fff::/20')) # documentation (2024)
# IPv4 blocks that are never the public internet, listed in full rather than trusting
# is_global (Python 3.9 calls 192.0.0.8, .11 and .255 global).
_SPECIAL4 = tuple(ipaddress.ip_network(n) for n in (
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
'192.0.0.0/24', '192.0.2.0/24', '192.88.99.0/24', '192.168.0.0/16', '198.18.0.0/15',
'198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32'))
def public_address(ip):
"""True for an address on the public internet: never loopback, private, link-local,
site-local, shared (CGNAT), reserved, unspecified or multicast, nor an IPv6 wrapper
around IPv4. Python 3.9's is_global alone lets several of these through."""
try:
a = ipaddress.ip_address(ip.split('%', 1)[0])
except ValueError:
return False
if a.version == 6 and (a not in _GLOBAL6 or any(a in n for n in _SPECIAL6)):
return False
if a.version == 4 and any(a in n for n in _SPECIAL4):
return False
return a.is_global and not (a.is_reserved or a.is_private or a.is_loopback or a.is_link_local
or a.is_multicast or a.is_unspecified)
def resolve_public(host):
"""host's addresses for port 443, if every address it has is public; else SourceError."""
try:
addresses = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
except (OSError, UnicodeError) as e:
raise SourceError('Could not find the download server ' + host) from e
if not addresses or not all(public_address(a[4][0]) for a in addresses):
raise SourceError('The download server is on a private network address; refused')
found = []
for a in addresses:
if a[4][:2] not in found:
found.append(a[4][:2])
return found
CONNECT_BUDGET = 20 # seconds for connecting, all addresses and TLS handshakes together
def _public_connection(host, timeout, budget=CONNECT_BUDGET):
"""A verified TLS connection to host:443, made to an address checked to be public.
Each checked address is tried in turn, all within one deadline (budget, or timeout if
shorter) covering both connecting and the handshake. The one connected to is always one
that was checked, so a second lookup can't swap in another. timeout is for reads after."""
deadline = time.monotonic() + min(budget, timeout)
error = None
for address in resolve_public(host):
if time.monotonic() >= deadline:
break
try:
return _connect(host, address, timeout, deadline)
except OSError as e: # unreachable, refused, or its TLS failed: try the next one
error = e
raise error or socket.timeout('Timed out connecting to ' + host)
def _connect(host, address, timeout, deadline):
"""TLS to exactly address, verified as host, connected and handshaken before deadline
(a time.monotonic() value); reads then get timeout seconds each."""
def remaining():
left = deadline - time.monotonic()
if left <= 0:
raise socket.timeout('Timed out connecting to ' + host)
return left
sock = socket.create_connection(address, timeout=remaining())
try:
# Each step gets what is left of the deadline as its socket timeout. A handshake
# read as several receives could in theory run past it by one timeout; no thread
# is kept to cut it short, as one could fire after the connection was handed over.
sock.settimeout(remaining())
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host,
do_handshake_on_connect=False)
sock.settimeout(remaining())
sock.do_handshake()
sock.settimeout(timeout)
except BaseException:
sock.close()
raise
conn = http.client.HTTPConnection(host, 443, timeout=timeout)
conn.sock = sock
return conn
def public_apk(url, name, redirects=3, timeout=60, check=None):
"""Download an APK from a public HTTPS host that no source lists in advance (itch.io's
storage). No credentials are sent. Every hop, redirects included, is re-checked, and
check(url), if given, runs on each hop before any lookup or connection (it raises)."""
for _ in range(redirects + 1):
if check:
check(url)
try:
p = urllib.parse.urlsplit(url)
port = p.port
except (ValueError, TypeError) as e:
raise SourceError(name + ' returned an invalid download URL') from e
if p.scheme != 'https' or not p.hostname or p.username or p.password or port not in (None, 443):
raise SourceError(name + ' returned an unexpected download URL')
wait = wait_time(url)
if wait:
raise limited_error(name, wait)
try:
conn = _public_connection(p.hostname, timeout)
except OSError as e:
raise SourceError('Could not download APK: ' + str(e)) from e
try:
conn.request('GET', (p.path or '/') + ('?' + p.query if p.query else ''), headers={'User-Agent': UA})
r = conn.getresponse()
if r.status in (301, 302, 303, 307, 308):
url = urllib.parse.urljoin(url, r.getheader('Location') or '')
continue
if r.status == 429:
raise limited_error(name, throttle(url, r.headers))
if r.status != 200:
raise SourceError("%s's download server refused the file (HTTP %d); try again later"
% (name, r.status))
return save_apk(r)
except (OSError, ValueError, http.client.HTTPException, zipfile.BadZipFile) as e:
raise SourceError('Could not download APK: ' + str(e)) from e
finally:
conn.close()
raise SourceError(name + ' redirected the download too many times')
+97 -15
View File
@@ -1,17 +1,35 @@
"""Free Android VR RSS listings. Download pages stay in the publisher's UI."""
"""Free Android VR RSS listings, plus the Android builds of games the user owns.
The free feeds' download pages stay in the publisher's UI. Owned games appear only
after the user connects their itch.io account with an API key (_itch_account.py);
they download through itch.io's own API, with the download key of that purchase.
"""
import html, re, urllib.parse, xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from . import SourceError, _web
from . import SourceError, _itch_account, _web
KIND = 'itch'
FEEDS = ('openxr', 'oculus-quest')
OWNED = 'itch-owned'
OWNED_LABEL = 'Owned on itch.io'
def sources():
return [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
found = [{'id': KIND if tag == 'openxr' else 'itch-quest', 'kind': KIND,
'name': 'itch.io (' + tag + ')', 'url': 'https://itch.io', 'tag': tag,
'builtin': True, 'enabled': True, 'trust': 'community'} for tag in FEEDS]
if _itch_account.connected():
found.append({'id': OWNED, 'kind': KIND, 'name': 'itch.io (my purchases)',
'url': 'https://itch.io/my-purchases', 'owned': True,
'description': 'Android builds of games you own on itch.io',
'builtin': True, 'enabled': True, 'trust': 'user'})
return found
def _itch_page(url):
p = urllib.parse.urlsplit(url if isinstance(url, str) else '')
return p.scheme == 'https' and (p.hostname or '').endswith('.itch.io') and not p.username and ':' not in p.netloc
def _parse(source, data):
@@ -22,8 +40,7 @@ def _parse(source, data):
entries = []
for item in root.findall('./channel/item'):
page = item.findtext('link') or ''
p = urllib.parse.urlsplit(page)
if p.scheme != 'https' or not (p.hostname or '').endswith('.itch.io') or p.username or ':' in p.netloc:
if not _itch_page(page):
continue
if item.findtext('price') != '$0.00' or item.findtext('platforms/android') != 'yes':
continue
@@ -45,23 +62,81 @@ def _parse(source, data):
return entries
def _owned_entry(source, key):
"""Entry for one owned download key, or None if its game isn't for Android."""
game = key.get('game') if isinstance(key.get('game'), dict) else {}
game_id = key.get('game_id') or game.get('id')
if type(game_id) is not int or type(key.get('id')) is not int:
return None
traits = game.get('traits')
if isinstance(traits, list) and 'p_android' not in traits:
return None # the game's page offers no Android download
cover = game.get('cover_url') if isinstance(game.get('cover_url'), str) else None
if cover and not cover.startswith('https://img.itch.zone/'):
cover = None
user = game.get('user') if isinstance(game.get('user'), dict) else {}
price = game.get('min_price')
return {'source': source['id'], 'id': str(game_id), 'package': None,
'name': str(game.get('title') or 'itch.io game ' + str(game_id)),
'summary': str(game.get('short_text') or OWNED_LABEL),
'developer': user.get('display_name') or user.get('username'),
'icon': cover, 'images': {'icon': cover, 'banner': cover, 'screenshots': []},
'page': game['url'] if _itch_page(game.get('url')) else None,
'version': None, 'version_code': None, 'min_sdk': None, 'abis': None,
'vr': None, 'size': None, 'free': price == 0 if type(price) is int else None,
'owned': True, 'owned_label': OWNED_LABEL, 'license': None,
'updated': str(game.get('published_at') or '')[:10] or None,
'downloadable': None, 'download_key_id': key['id']}
def _owned(source):
entries = {}
for key in _itch_account.owned_keys():
entry = _owned_entry(source, key)
if entry:
entries.setdefault(entry['id'], entry) # keys come newest first; one entry per game
return entries
def search(source, query, limit=50):
limit = max(0, min(int(limit), 100))
if not limit:
return []
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
if source.get('owned'):
entries = _owned(source)
else:
entries = {}
tag = source.get('tag', 'openxr')
if tag not in FEEDS:
raise SourceError('Unsupported itch.io feed')
url = 'https://itch.io/games/free/platform-android/tag-' + tag + '.xml'
for entry in _parse(source, _web.read(url, ('itch.io',), name='itch.io')):
entries.setdefault(entry['id'], entry)
words = query.lower().split()
return [e for e in entries.values() if all(w in (e['name'] + ' ' + e['summary']).lower()
for w in words)][:limit]
def _owned_details(source, entry_id):
entry = _owned(source).get(str(entry_id))
if not entry:
raise SourceError('This game is not among the Android games you own on itch.io')
uploads = _itch_account.uploads(int(entry['id']), entry['download_key_id'])
versions = [{'version': str(u.get('display_name') or u.get('filename') or u['id']), 'version_code': None,
'upload_id': u['id'], 'name': u.get('filename'), 'min_sdk': None,
'demo': u.get('demo') is True or 'demo' in (u.get('traits') or []),
'size': u.get('size') if type(u.get('size')) is int else None,
'updated': str(u.get('updated_at') or u.get('created_at') or '')[:10] or None}
for u in _itch_account.android_uploads(uploads)]
entry = dict(entry, versions=versions, downloadable=bool(versions))
if versions:
entry.update({k: versions[0][k] for k in ('version', 'size', 'updated')})
return entry
def details(source, entry_id):
if source.get('owned'):
return _owned_details(source, entry_id)
entry = next((e for e in search(source, '', 100) if e['id'] == entry_id), None)
if not entry:
raise SourceError('Game is not in the current free Android VR feed; open its publisher page')
@@ -69,4 +144,11 @@ def details(source, entry_id):
def download(source, entry_id, version_code=None):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if not source.get('owned'):
raise SourceError('Open the itch.io publisher page to download; automated download pages are disallowed by robots rules')
if version_code is not None:
raise SourceError('itch.io does not publish Android version codes; download the latest upload')
entry = _owned_details(source, entry_id)
if not entry['versions']:
raise SourceError('This game has no Android download on itch.io')
return _itch_account.download(entry['versions'][0]['upload_id'], entry['download_key_id'])
+11 -3
View File
@@ -182,7 +182,10 @@ def group(entries, query='', vr=None, installable=False):
groups = {}
for entry in entries:
entry = decorate(entry)
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
# Unknown counts as flat, except the user's own purchases (itch.io doesn't say which are
# VR): those show under either choice rather than vanish from one.
if vr is not None and (entry.get('vr') is True) != vr \
and not (entry.get('owned') is True and entry.get('vr') is None):
continue
if installable and entry['fit']['installable'] is not True:
continue
@@ -215,6 +218,11 @@ def group(entries, query='', vr=None, installable=False):
return result
def obtainable(source, entry):
"""Free, or a game the user owns, listed by their own account's source (itch.io purchases)."""
return entry.get('free') is True or (source.get('owned') is True and entry.get('owned') is True)
def _launch(module, source, query, limit):
"""One search per source at a time; the newest different query runs next."""
key = source['id']
@@ -243,7 +251,7 @@ def _start(key, task):
queued = None
try:
task['entries'] = [dict(e, source=key, source_name=source['name'], trust=source.get('trust'))
for e in module.search(source, query, limit=limit) if e.get('free') is True]
for e in module.search(source, query, limit=limit) if obtainable(source, e)]
task['stale'] = bool(getattr(module, 'stale', lambda s: False)(source))
except Exception as e:
task['error'] = str(e)
@@ -300,7 +308,7 @@ def install(source_id, entry_id, version_code=None, progress=None):
if not source['enabled']:
raise SourceError('This source is disabled')
entry = module.details(source, entry_id)
if entry.get('free') is not True or entry.get('downloadable') is not True:
if not obtainable(source, entry) or entry.get('downloadable') is not True:
raise SourceError('This app must be obtained from its developer page')
if progress:
progress('Downloading', None)
+5 -28
View File
@@ -61,20 +61,13 @@ 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)
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
if p.returncode != 0:
# 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:])
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')
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
return p.stdout
@@ -143,19 +136,11 @@ 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.
timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
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 []),
@@ -163,8 +148,7 @@ 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,
errors='replace', timeout=timeout)
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
@@ -295,13 +279,6 @@ 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
+1 -4
View File
@@ -7,7 +7,6 @@ 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',
@@ -222,9 +221,7 @@ 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)
# 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:
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
for tag, attrs in elements:
+4 -15
View File
@@ -238,13 +238,6 @@ 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."""
@@ -817,9 +810,7 @@ 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), and `short`:
the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
the address that failed)."""
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth)."""
self.stage(sid, "failed", message)
with self.cond:
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
@@ -905,7 +896,7 @@ class Link:
self.learn(device, a["host"], net, None)
return True
with self.cond:
why_not = failure_detail(self.state["error"])
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
self.probe_update(i, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -955,7 +946,7 @@ class Link:
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
return True
with self.cond:
why_not = failure_detail(self.state["error"])
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
self.probe_update(pick, state="sshfailed", detail=why_not)
if outcome != "next":
return False
@@ -1278,7 +1269,6 @@ 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"
@@ -1289,8 +1279,7 @@ 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",
short="This computer's key refused")
"Connection: it pairs this computer with the headset again.", text, auth="denied")
return "stop"
if step == "connected":
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
+1 -11
View File
@@ -6,7 +6,6 @@ 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
@@ -122,16 +121,7 @@ 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"), "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]
"variant": out.get("VARIANT_ID")}
def volume():
+18 -81
View File
@@ -18,9 +18,7 @@ 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. 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
computer, and sent without person profiles or GeoIP. 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.
@@ -45,7 +43,6 @@ 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')
@@ -53,7 +50,6 @@ 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
@@ -68,7 +64,6 @@ 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'}}
@@ -109,7 +104,7 @@ def blocked():
def _defaults():
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
'frames_seen': [], 'compat_sent': []}
@@ -151,12 +146,12 @@ def update_settings(changes):
under way finishes first, so nothing leaves after this returns."""
with _send_lock, _lock:
s = settings()
if changes.get('noticeShown'):
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
if 'noticeShown' in changes:
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
for level in LEVELS:
if level in changes:
s[level] = bool(changes[level])
s['notice_shown'] = True # only the notice itself moves notice_version on
s['notice_shown'] = True
_save(s)
_drop_unwanted(s)
if changes.get('compat'):
@@ -165,16 +160,10 @@ 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': _notice_current(s),
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
'diagnostics': s['diagnostics'],
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
'sent': list(reversed(_read_lines(SENT)))[:50]}
@@ -277,23 +266,9 @@ 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|'
r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
('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')),
@@ -305,8 +280,6 @@ 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')),
@@ -314,16 +287,8 @@ 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.
message may be the exception itself: one of BUG_ERRORS is internal_error."""
if isinstance(message, BUG_ERRORS):
return 'internal_error', None
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
text = str(message or '')
for name, pattern in CATEGORIES:
m = pattern.search(text)
@@ -382,59 +347,35 @@ 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, with the country
(left out when sending if the notice this person saw came before it: flush)."""
"""Once per server start: first install, an update, and one open a day."""
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', where)
capture('app_installed')
s['installed_sent'] = True
elif s['last_version'] and s['last_version'] != version:
capture('app_updated', {'from_version': s['last_version'], **where})
capture('app_updated', {'from_version': s['last_version']})
if s['last_open_day'] != today:
capture('app_opened', where)
capture('app_opened')
s['last_open_day'] = today
s['last_version'] = version
_save(s)
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 '')
def frame_seen(build, version):
"""The Frame's SteamOS build, once per build (public build numbers)."""
key = f'{build}/{version}'
with _lock:
s = settings()
if not build or key in s['frames_seen']:
return
s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
_save(s)
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)
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
@@ -606,14 +547,10 @@ def flush(timeout=20):
if blocked() or not settings()['notice_shown']:
return 0
with _lock:
s = settings()
_drop_unwanted(s)
_drop_unwanted(settings())
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
@@ -1,547 +0,0 @@
"""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',
}
+44 -56
View File
@@ -51,10 +51,6 @@
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; }
@@ -64,7 +60,6 @@
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; }
@@ -79,7 +74,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: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
@media (max-width: 1150px) { nav kbd { display: none; } }
/* ---- offline banner ---- */
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
@@ -92,14 +87,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: 150px; flex: 0 1 auto; background: var(--btn); }
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; 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; min-width: 90px; }
.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; }
.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); }
@@ -137,7 +132,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, .dev-problem .s { white-space: normal; }
.addr .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; } }
@@ -660,7 +655,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="hdr-end">
<div class="spacer"></div>
<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>
@@ -670,7 +665,6 @@
<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>
@@ -683,8 +677,7 @@
</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, 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,
your operating system, which tabs you use, and whether installs on the Frame worked. 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>
@@ -1148,9 +1141,8 @@
<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, 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>
<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>
<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>
@@ -1216,6 +1208,7 @@
<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><button class="action">Add source</button></form>
<form id="itchAccount" class="source-repo-form"><h2>My itch.io purchases</h2><p id="itchAccountNote">Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.</p><label id="itchKeyField">itch.io API key<input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="300" placeholder="Paste your API key"></label><button class="action" id="itchConnect">Connect itch.io</button><button type="button" class="small" id="itchDisconnect" hidden>Disconnect itch.io</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -1595,7 +1588,7 @@ $("vrUtilities").onclick = async e => {
await loadUtilities();
};
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
// The Frame owns the clock. Poll independently of status and the selected tab.
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
async function localNotification(message, request = false) {
try {
@@ -1654,12 +1647,7 @@ function renderComfort(s) {
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
}
async function pollComfort() {
// 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) {
if (!comfortBusy) {
comfortBusy = true;
try { renderComfort(await api("/api/comfort", {action: "status"})); }
catch (e) {
@@ -1693,6 +1681,7 @@ $("castBtn").onclick = () => {
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
$("viewer").requestFullscreen?.().catch(() => {});
};
pollComfort();
// ---- battery ----
function battery(b, power) {
@@ -3153,15 +3142,28 @@ function sourceMark(s) { const name=storeName(s); return `<span class="offer-mar
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"); }
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
const {sources, itch} = await api("/api/sources"); sourceState.sources = sources; showItchAccount(itch);
$("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")}</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; }
}
// Owned on itch.io (the user's own purchases) or free: what the store shows instead of a price.
const storePrice = o => o.owned ? (o.owned_label || "Owned") : "Free";
function showItchAccount(itch) {
const on = !!itch?.connected;
$("itchAccountNote").textContent = on ? `Connected${itch.username ? " as " + itch.username : ""}. Android builds of games you own appear in the store, marked Owned on itch.io. Disconnecting deletes the API key from this computer.` : "Install the Android builds of games you own on itch.io. Create an API key in itch.io settings › API keys and paste it here. It stays on this computer and is only sent to itch.io.";
$("itchKeyField").hidden = on; $("itchConnect").hidden = on; $("itchDisconnect").hidden = !on;
$("itchAccount").elements.key.required = !on;
}
async function itchAccount(body) {
$("sourceError").textContent = body.action === 'itch-connect' ? "Checking the key with itch.io…" : "Disconnecting…";
try { const result=await api('/api/sources',body);$("sourceError").textContent=result.message;showItchAccount(result.itch);sourceState.source='';await loadSources();await searchSources(); }
catch(e) { $("sourceError").textContent=e.message;await loadSources(); }
}
function storeCard(app) {
const i=sourceState.apps.indexOf(app), o=app.offers[0];
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>Free</b></span></span></button>`;
return `<button class="store-card" data-app="${i}" aria-label="View ${esc(app.name)}">${storeArt(o)}<span class="store-card-content"><span class="store-card-title">${storeIcon(o)}<strong>${esc(app.name)}</strong></span><span class="store-summary">${esc(storeText(app.summary) || "A new addition to your Frame")}</span><span class="store-card-foot"><span>${o.vr ? "VR experience" : o.vr === false ? "Flat-screen app" : "Android app"}</span><b>${esc(storePrice(app.offers.find(x=>x.owned) || o))}</b></span></span></button>`;
}
function storeRow(title, subtitle, apps) { return apps.length ? `<section class="store-row"><div class="store-row-head"><h2>${esc(title)}</h2><p>${esc(subtitle)}</p></div><div class="store-grid">${apps.slice(0,5).map(storeCard).join("")}</div></section>` : ""; }
function storeEmpty() {
@@ -3206,7 +3208,7 @@ async function searchSources(quiet) {
finally { if(request===sourceState.request)$("searchGrid").setAttribute("aria-busy","false"); }
}
function currentOffer() { return sourceState.detail?.app.offers[sourceState.detail.choice]; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">Free</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
function detailBadges(o) { return `<span class="store-badge">${o.vr ? "VR experience" : o.vr===false ? "Flat-screen app" : "Android app"}</span>${o.verdict?.tone==='works' ? '<span class="store-badge works">✓ Works on Frame</span>' : ''}<span class="store-badge">${esc(storePrice(o))}</span>${storeOpenSource(o) ? '<span class="store-badge">Open source</span>' : ''}`; }
// The store's app page. (Not renderDetail: the Devices page declares that further down,
// and a later function declaration silently replaces an earlier one; issue #68.)
// True when the page rendered; false when it shows the error instead.
@@ -3222,7 +3224,7 @@ function renderAppDetail() {
function renderAppDetailBody() {
const restoreOfferFocus=document.activeElement?.name==='storeOffer';
const d=sourceState.detail;if(!d)return;const o=currentOffer(), images=o.artwork?.screenshots||[], page=sourcePage(o.page);
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">Free</div><p class="detail-small">No purchase needed.</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
$("detailBody").innerHTML=storeArt(o,"detail-banner",true)+`<div class="detail-content"><div class="detail-heading">${storeIcon(o)}<div><h1 id="detailTitle">${esc(o.name)}</h1><p>${esc(o.developer || o.author || 'From '+storeName(o.source_name))}</p></div></div><div class="store-badges">${detailBadges(o)}</div><div class="detail-layout"><div><div class="detail-gallery" id="detailGallery" aria-label="App screenshots" tabindex="0">${images.map((p,i)=>storeArt(o,"",false,p,`${o.name} screenshot ${i+1}`)).join("")}</div>${images.length ? `<div class="gallery-controls"><span>${images.length} screenshots · scroll to explore</span><div><button data-gallery="-1" aria-label="Previous screenshot">←</button> <button data-gallery="1" aria-label="Next screenshot">→</button></div></div>` : ''}<h2>About this app</h2><div class="detail-description">${esc(storeText(o.description || o.summary || "Discover this app on its developer's page."))}</div>${page ? `<p><a href="${esc(page)}" target="_blank" rel="noopener noreferrer">Meet the creators ↗</a></p>` : ''}<details class="detail-technical"><summary>Technical details</summary><dl><dt>Package</dt><dd>${esc(o.package||"Unknown")}</dd><dt>Version</dt><dd>${esc(o.version||"Unknown")} (${esc(o.version_code??"unknown")})</dd><dt>Android API</dt><dd>${esc(o.min_sdk??"Unknown")}</dd><dt>Architecture</dt><dd>${esc(o.abis?.join(', ')||"Unknown / no native code")}</dd><dt>Updated</dt><dd>${esc(o.updated||"Unknown")}</dd><dt>Compatibility</dt><dd>${(o.fit?.reasons||[]).map(esc).join(' · ')||'No known installation blockers'}</dd></dl></details></div><aside class="detail-buy"><div class="detail-price">${esc(storePrice(o))}</div><p class="detail-small">${o.owned ? "Downloads with your itch.io account." : "No purchase needed."}</p><p class="detail-verdict ${esc(o.verdict?.tone||'unknown')}">${esc(o.verdict?.label||'Not yet checked on the Frame')}</p><button id="detailInstall" class="action store-primary">Install</button><progress id="detailProgress" max="100" hidden aria-label="Installation progress"></progress><p id="detailInstallNote" class="detail-small" role="status" aria-live="polite"></p><h2>Get it from</h2><div id="detailOffers">${d.app.offers.map((v,j)=>`<label class="offer-choice"><input type="radio" name="storeOffer" value="${j}" ${j===d.choice?'checked':''}><span>${sourceMark(v.source_name)}</span><span><strong>${esc(storeName(v.source_name))}${j===0?' · Recommended':''}</strong><small>${v.verified===true?'✓ Verified download':'Verification not provided'}</small><small>${esc(v.version ? 'v'+v.version : 'Latest release')}${v.size!=null?' · '+(v.size/1048576).toFixed(0)+' MB':''}</small></span></label>`).join('')}</div></aside></div></div>`;
updateInstall();
if(restoreOfferFocus)document.querySelector('#detailOffers input:checked')?.focus();
}
@@ -3292,6 +3294,9 @@ async function addSource(body) {
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
// The key goes to the server once and leaves the page straight away: the field is cleared before the reply.
$("itchAccount").onsubmit=e=>{e.preventDefault();const input=e.target.elements.key, key=input.value.trim();input.value='';if(key)itchAccount({action:'itch-connect',key});};
$("itchDisconnect").onclick=()=>itchAccount({action:'itch-disconnect'});
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();});
@@ -4507,7 +4512,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
refresh().then(() => loadShots()); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -4536,20 +4541,6 @@ 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;
@@ -4561,8 +4552,9 @@ 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";
// 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` : "");
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` : "");
} else {
const st = activeStage(s);
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
@@ -4570,7 +4562,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}${s.phase === "failed" ? " is offline" : ""}: ${line}. Click for details.`;
$("conn").title = `${dev}: ${line}. Click for details.`;
return true;
}
window.connPill = renderPill;
@@ -4587,13 +4579,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}: ${asClause(p.detail)}`).join("; ");
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).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 ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
}
}
window.connBanner = renderBanner;
@@ -4771,10 +4763,7 @@ 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);
@@ -4786,8 +4775,7 @@ 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.short || s.error.message : "Offline", s.error && s.error.message]
: ["", "Connecting…"];
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "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"];
@@ -4797,10 +4785,10 @@ function renderDevices() {
if (!dv.data) return;
$("devCount").textContent = dv.list.length;
$("devList").innerHTML = dv.list.map(d => {
const [dot, text, full] = deviceStatus(d);
const [dot, text] = 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"${full ? ` title="${esc(full)}"` : ""}>ssh ${esc(d.alias)} · ${esc(text)}</div></div>
<div class="s">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 => {
@@ -4851,10 +4839,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 dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
const problem = err && err.hostkey ? `<div class="item" 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 dev-problem" 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" 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>
+29 -44
View File
@@ -46,6 +46,7 @@ import frame_assistant # noqa: E402
import frame_android # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
from apk_sources import _itch_account as itch_account # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_devices # noqa: E402
@@ -403,38 +404,11 @@ 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):
# 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_telemetry.frame_seen(osr.get("build"), osr.get("version"))
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)
@@ -442,14 +416,25 @@ 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.
# 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}
import hashlib
files = {name: (HERE / name).read_text() for name in
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
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))
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))
if out.get("error") and "active" not in out:
raise Failure(out["error"], 409)
return out
@@ -2226,6 +2211,13 @@ def source_manage(body):
if action == 'remove':
apk_search.manage_repo('remove_repo', source_id=source_text(body, 'source'))
return {'message': 'Repository removed'}
if action == 'itch-connect': # the key is checked with itch.io, saved 0600, never echoed
key = body.get('key')
if not isinstance(key, str) or not key.strip() or len(key) > 300:
raise Failure('Paste your itch.io API key', 400)
return itch_account.connect(key)
if action == 'itch-disconnect':
return itch_account.disconnect()
raise Failure('Unknown source action', 400)
except SourceError as e:
raise Failure(str(e), 400)
@@ -2296,10 +2288,6 @@ 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
@@ -2446,7 +2434,7 @@ class Handler(BaseHTTPRequestHandler):
except SourceError as e:
raise Failure(str(e), 400)
elif path == "/api/sources":
self.send_json({"sources": apk_search.sources()})
self.send_json({"sources": apk_search.sources(), "itch": itch_account.status()})
elif path == "/api/search":
self.send_json(source_search(url.query))
elif path == "/api/apk-versions":
@@ -2553,10 +2541,7 @@ class Handler(BaseHTTPRequestHandler):
except ClientGone:
raise
except Failure as e:
# 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:
if e.status >= 500:
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: