Compare commits

..
Author SHA1 Message Date
SAPHID 342bf78b05 Merge main and preserve tracking and Windows test guidance 2026-10-18 00:06:01 +00:00
Alex Southwell 16b37bc667 Merge pull request #50 from saphid/flat2vr-mods
VR mods: UEVR install, play in VR and remove from Frame Control
2026-10-18 11:04:17 +11:00
Alex Southwell 5b531c84fb Merge pull request #90 from saphid/feat/repo-auth
Store: repositories behind a login, and a GitHub token in source settings
2026-10-18 10:57:12 +11:00
Alex Southwell e89912d936 Merge pull request #64 from saphid/chore/windows-vm-rdp-repro
Windows test VM: a driver script and how to set one up
2026-10-18 10:52:10 +11:00
Alex Southwell 4c27a3397c Merge pull request #57 from saphid/fix/ci-failures
CI: stop recurring false failures on main and pull requests
2026-10-18 10:52:06 +11:00
SAPHID b51fab9872 chore: resolve conflicts with main 2026-10-17 22:10:01 +00:00
SAPHID 6aab62983e chore: resolve conflicts with main 2026-10-17 22:08:52 +00:00
saphidandClaude Opus 5.5 4ecc03280e Source logins: refuse addresses with a backslash and a login; decode escapes in one pass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:40 +11:00
saphidandClaude Opus 5.5 2f1aeaac72 Source logins: refuse an invalid address that holds a login; keep undecodable bytes as typed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:35:50 +11:00
saphidandClaude Opus 5.5 8347c2c3f7 Source logins: let the URL parser find a pasted login; text matching only as a fallback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:30:54 +11:00
saphidandClaude Opus 5.5 a94c45d014 Source logins: strip a pasted login even with tabs, newlines or backslashes in the address
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:26:18 +11:00
saphidandClaude Opus 5.5 e20225604d Source logins: keep repository logins inside the repository directory
- Every request made with an F-Droid repository's login (entry.jar, index,
  APKs), redirects included, now uses the scoped check: same origin and
  inside the repository directory by whole plain path segments. A
  same-origin redirect elsewhere on the server gets no login.
- _child() refuses file names a server could normalise outside the
  repository: double-encoded dots, ';' path parameters, encoded slashes or
  backslashes (anything _credentials.path_segments rejects).
- Store page: moveLogin() strips the login from the address with plain
  text matching first, then decodes with a fallback to the text as typed,
  so '100%done', '%FF' or an address the URL parser rejects can no longer
  leave the password in the field.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:46:35 +11:00
saphidandClaude Opus 5.5 73b97fedc5 windows-vm.sh: stop the input helper before giving up on a click
On a timeout or a setup error the request now stops the scheduled task and
waits for it to end before releasing the mutex, so a stalled helper can't
act later inside another request.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 21:18:58 +10:00
saphidandClaude Sonnet 5.5 8fb00b263c fix(tracking): review 7 - rescan on cleanup, non-finite Health values
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:29:56 +10:00
saphidandClaude Sonnet 5.5 4937caf310 fix(tracking): don't call an unworn eye-camera artifact a pulse
Unworn Frame runs gave a steady 'clear' 90-96 BPM. pulse now reads the
proximity sensor and refuses to report when the headset is not worn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:20:23 +10:00
saphidandClaude Sonnet 5.5 128e7a4c94 fix(tracking): never signal the eye-camera capture; finish cleanup exhaustively
Terminating eyetracking --calib left the DSP eye camera stuck streaming on the
Frame. Capture now lets the bounded run end by itself, deleting images
meanwhile, and only kills as a last resort. Also hardens Ctrl-C cleanup and
heart-check error handling.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 20:15:24 +10:00
saphidandClaude Opus 5.5 1a439f05c2 fix(tracking): make eye-image cleanup exhaustive and confirm capture ownership
Follow-up review findings: remove() now tries every capture directory and
reports any it could not delete; each cleanup step runs even if an earlier
one fails; the directory the capture tool reports (once its output is
flushed) must match the one we reduced, and is always removed. heart-check
keeps readings with an unrecognised flag and reports unreadable references
without a traceback.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:07:33 +10:00
saphidandClaude Opus 5.5 016d2b8c2d fix(tracking): address independent review of the pulse experiment
- Watch for a second capture directory on every poll; stop and remove every
  directory that appeared, and fail loudly if an eye image can't be deleted.
- Start the worker pool inside the cleanup block.
- Flat patches no longer rank first (zero-power SNR is 0, not infinity).
- Align eyes to the truly nearest frame.
- Keep patch grids as float arrays (a 300 s run no longer needs ~0.4 GB).
- heart-check: tolerate unusual flags, close the Health archive, give a clear
  error when export.xml is missing, and build the lookup index once.

Found by a SWE-2 Max read-only review. Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:59:13 +10:00
saphidandClaude Opus 5.5 42ec68ed51 fix(tracking): reduce eye images while capturing, with a backlog cap
The capture tool's stdout is block-buffered, so waiting for its directory name
left every image on disk until the capture ended (3,554 PNGs in a 20 s run on
the Frame). Detect the new capture directory instead, decode in three worker
processes, and stop the capture if more than 900 images wait. On the Frame a
30 s run now peaks at 7 images on disk.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:44:51 +10:00
saphidandClaude Opus 5.5 fa49fab5bf feat(tracking): experimental eye-camera pulse estimate and heart-rate comparison tool
Adds 'pulse', which captures the IR eye cameras through SteamVR's own
eyetracking --calib mode, reduces each image to patch averages and deletes it
immediately, then estimates pulse from skin brightness. Adds heart-check.py to
show OSC readings live and compare recordings with an Apple Health export or
CSV, and a synthetic Mac BLE strap for relay tests.

Part of #27

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:15:05 +10:00
saphid ce9e476ec7 feat(tracking): add local OpenXR OSC and BlueZ heart-rate tools 2026-09-28 22:33:02 +10:00
34 changed files with 4095 additions and 73 deletions

No files matched your search

+11 -2
View File
@@ -5,6 +5,11 @@ on:
branches: [main]
pull_request:
# A newer push to the same pull request supersedes a run still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
checks:
runs-on: ubuntu-latest
@@ -17,7 +22,9 @@ jobs:
with:
node-version: "24"
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: Script syntax
run: |
sh -n ui/local-bin/ssh
@@ -84,6 +91,8 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install zsh
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
- name: End-to-end tests
run: scripts/e2e.sh
+12 -2
View File
@@ -2,14 +2,22 @@ name: release
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
# the installers to that tag's GitHub release (created as a draft if missing).
# Pull requests that touch the app build the same installers as artifacts.
# Pull requests that change how the installers are built (the Electron app, the
# macOS helper it compiles, or this workflow) build the same installers as
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
# are tested by the checks workflow; run this one by hand to package them early.
on:
push:
tags: ["v*"]
pull_request:
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: write
@@ -94,7 +102,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
with:
name: frame-control-${{ matrix.os }}
path: |
+2 -1
View File
@@ -223,8 +223,9 @@ Frame's software fits together, all checked against a real headset and labelled
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [Eye tracking and heart rate](docs/tracking.md) | Our OpenXR → OSC bridge, BlueZ heart-rate panel and optional local session log; SlimeVR feasibility notes |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
+54 -1
View File
@@ -58,8 +58,61 @@ form shows the pinned one ("Trusted on first use: …") so you can compare it.
Re-adding an existing URL preserves its pin; changing it requires deliberately
removing and re-adding it.
### Repositories behind a login
Some self-hosted repositories sit behind HTTP basic auth. As in F-Droid's own
client, the login can be part of the address
(`https://user:pass@example.org/fdroid/repo`), or given separately: in the
store's **Add a source** form under "This repository needs a login", in the
server API (`username` and `password` beside `url`), or on the command line,
where the password is read from standard input so it stays out of shell history
and `ps`:
```sh
printf '%s\n' "$PASSWORD" | python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo' --username me --password-stdin
```
How the login is kept and used:
- **Kept out of the address.** The `user:pass@` part is removed before anything
is saved, so the saved repository URL, its id, the source list, job results
and the page never contain it. The store only says "Uses a login".
- **Stored separately.** Logins live in
`frame_host.data_dir('source-credentials.json')`
(`~/Library/Application Support/Frame Control/source-credentials.json` on
macOS), written with mode 0600 and keyed by source id. They are stored in
plain text there, protected by file permissions only (on Windows,
the folder's normal ACLs). Removing the repository deletes its login, and
the file goes when the last login is removed. Each change holds a lock
file (`source-credentials.lock`), so the app and the command line can't undo
each other's saves or removals.
- **HTTPS only.** An `http://` address with a login is refused before any request.
- **Inside the repository only.** The `Authorization` header is decided
separately for every request, redirects included: index, `entry.jar`,
APKs and the store's artwork proxy. It is sent only when the request's
scheme, host and port match the repository's and its path is inside the
repository's directory by whole path segments. A redirect to another host
or port, or to another path on the same server, gets no login, and that
request's own `Authorization` header is removed. Paths with `.` or `..`
segments (plain or percent-encoded), encoded `/` or `\`, double encoding
or `;` get no login, since a server could normalise them to a path
outside the repository. Index file names like that are refused outright.
- **Never echoed.** Logins are not written to logs, error messages, API
responses or the cache. The CLI prints only the saved source.
- **Checked before saving.** As with any repository, the whole index must
download and verify before anything is saved. HTTP 401 (or 403 when a login
was sent) stops the add with "check the username and password" and saves
nothing: no repository, no login, no rollback state. A 403 with a login is
not treated as rate limiting.
- **Same verification.** The login changes only who may download. Signature,
fingerprint pin or TOFU, index hash and rollback checks apply exactly as for
any other repository.
Re-adding the same address with a new login replaces the saved one. Re-adding
it without a login keeps the saved one.
The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
`add_repo(url, fingerprint=None, name=None, username=None, password=None)`, `remove_repo(source_id)`,
`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
`sources`, `search`, `details`, and `download` from the shared source contract.
This change supplies the CLI and API; the integrated source-management UI is
+17 -4
View File
@@ -45,10 +45,23 @@ search. Results outside the curated list stay page-only, even if a repository
claims an open-source license. This prevents an arbitrary tagged mirror from
becoming a trusted downloader. Extend the curated JSON after provenance review.
Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
quota. Tokens are sent only to `api.github.com`, never written to the cache,
never sent to asset hosts, and removed on redirects. The adapter does not
read `gh` credentials automatically. Metadata is cached for one hour under
For a higher API quota, add a GitHub access token in the store's source
settings (the GitHub row's token field) or through the server API
(`POST /api/sources` with `action: "set-token"` or `"clear-token"` and
`source: "github"`). `FRAME_GITHUB_TOKEN` in the process environment still
works and wins over a saved token. A saved token is kept with repository
logins in `source-credentials.json` (mode 0600, see
[APK repositories](apk-repos.md#repositories-behind-a-login)). The source list
reports only whether a token is set, where it came from and whether GitHub
rejected it, never its value. Tokens are sent as `Authorization: Bearer` only to
`api.github.com`, never written to the cache, never sent to asset hosts (public
release downloads don't need them), and removed on redirects. If GitHub answers
401 to the token, the request is retried once without it, as Obtainium does.
The rejection is logged without the token and shown in source settings. The
token is not tried again until it is saved again or the app restarts. Rate-limit
backoff is kept per login (anonymous, or each token), so saving a token after
anonymous requests were limited lets the next request go out with it. The
adapter does not read `gh` credentials automatically. Metadata is cached for one hour under
`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
fetches at most ten releases. Rate-limit errors are surfaced without retry
loops. Asset IDs and release tags are not Android version codes: metadata
+9
View File
@@ -20,6 +20,15 @@ SteamVR (vrserver, vrcompositor, vrdashboard) ← renders the room + pane
Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 3056000
```
## Tracking additions (verified 2026-09-28)
On SteamOS 0.4.1, build `20260925.6191901`, SteamVR exposes combined gaze
through `XR_EXT_eye_gaze_interaction` in a headless OpenXR 1.0 session. Our
reader obtained valid tracked samples and sent OSC to a configured loopback
receiver. BlueZ LE discovery works; GTK4/GI can render our heart-rate panel.
No BLE strap or SlimeVR trackers were attached. See [tracking](tracking.md)
for the evidence, privacy defaults and untested integration boundaries.
## Facts worth knowing
| Fact | Where it matters |
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+14
View File
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
Remote Desktop never asks for a user or password. It warns that the certificate
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
frame" box with the username blank. Any user but `steamos` gets "User does not
exist, or could not be authenticated". Signing in as `steamos` with the
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
It's a new session on display `:10`, separate from what the headset shows,
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
it running, and the next sign-in reconnects to it. To end it, find its
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
and run `loginctl terminate-session <id>`; the headset's own session keeps
running.
**Recommendation for A:** start with Steam Link for macOS, because Valve
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
+88
View File
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
## Unit tests
@@ -186,6 +187,93 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## Tracking protocols and fake BlueZ
`tests/test_tracking.py` exercises our gaze conversion, OSC sender, HRS parser
and BlueZ lifecycle with an in-memory fake object tree. It runs in the normal
unit suite without Bluetooth, GTK or OpenXR. Real Frame results and the absent
strap/tracker boundaries are recorded in [tracking](tracking.md).
## Windows test VM
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
depends on the Windows desktop (Remote Desktop, the installer, the bundled
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
Setting it up, once, on the Linux machine:
- **Windows comes from Microsoft.** The container downloads the Windows 11
image from Microsoft on first start. Windows runs unactivated, which is fine
for testing. Don't use activation workarounds or third-party Windows images.
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
`127.0.0.1:8006:8006` (the web console) and, if you need it,
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
testing, and a `docker stop` shuts Windows down cleanly.
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
as its default shell, and put a dedicated public key (for example
`~/.ssh/id_ed25519_winvm` on the Mac) in
`C:\ProgramData\ssh\administrators_authorized_keys`.
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
and the tests.
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
```sh
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
scripts/windows-vm.sh shot screen.png # what's on its screen
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
scripts/windows-vm.sh down # shut Windows down
```
- **Clicks go through a scheduled task.** Commands over SSH run in a
session with no desktop, so `click` and `scroll` write the position to a
file, and a scheduled task running as the signed-in user replays it. The
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
the same time run one after another. QEMU's own `mouse_move` is relative
and drifts, so the script doesn't use it.
- **Screenshots may not show the pointer.** Check the result of a click (a
menu that opens, a button that changes) rather than the pointer's position.
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
Do the same if you run `ssh` in the VM by hand.
- **Starting the app.** Run Frame Control in the signed-in session, not over
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
one `click` uses.
**Testing against a real Frame.** The VM reaches the headset on the LAN like
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
that makes the VM's keys. So the VM doesn't keep access between test runs,
afterwards take the lines it added out of the headset's
`~/.ssh/authorized_keys`: the ones matching the VM's
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
in the VM now fails. Then, around each run:
```sh
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
# ... test ...
scripts/windows-vm.sh frame-key remove # and take it out again
```
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
already trusts that key through another line. `remove` deletes only the exact
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
the file alone if it can't rewrite it. Follow the shared-device
procedure in [Headset smoke test](#headset-smoke-test) before installing or
launching anything on the headset.
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
+329
View File
@@ -0,0 +1,329 @@
# Eye tracking and heart rate
Frame Control's own tools run on the Frame, using OpenXR and BlueZ. No
VRCFaceTracking, LunaHR, Pulsoid or other tracking app is required. This is a
command-line first version; it does not add a desktop app tab.
## What was checked
**Verified 2026-09-28**, on a real aarch64 Frame running SteamOS 0.4.1,
BUILD_ID `20260925.6191901`:
| Check | Result |
|---|---|
| OpenXR gaze | SteamVR advertises `XR_EXT_eye_gaze_interaction`, `XR_MND_headless` and `XR_KHR_convert_timespec_time`. `supportsEyeGazeInteraction=1`. A headless session reached FOCUSED and produced 269 valid, tracked orientations in the first ten-second probe. |
| Our gaze → OSC bridge | A separate ten-second run produced 280 valid samples and 280 correctly padded 44-byte `/tracking/eye/CenterPitchYaw` messages at an explicitly configured loopback receiver. Only counters and packet-layout checks were retained. |
| Bluetooth stack | BlueZ active, adapter powered, central/peripheral roles available. LE discovery started and stopped successfully. No pairing or adapter power settings changed. |
| Our heart-rate panel | GTK4/GI runs on the stock image. A **synthetic 72 BPM** notification displayed in our X11 window, tagged `STEAM_GAME=2000000027`. Window capture checked; no real heart-rate measurement was taken. |
| SlimeVR, separate feasibility check | Native aarch64 server v21.1.0 ran with an isolated Temurin 21 JRE, created its driver sockets and accepted a local TCP connection on port 21110. Driver v6.0.0 loaded with all shared libraries resolved; `HmdDriverFactory("IServerTrackedDeviceProvider_004")` returned a non-null provider and error 0. |
![Our heart-rate panel on the Frame, showing synthetic 72 BPM](img/heart-rate-panel.png)
The image is a capture of our own Frame window using a fake notification,
not a real sensor reading.
**Untested:** a real BLE strap's notifications, physical fit/contact behaviour,
end-to-end heart-rate display/OSC/log with a strap, avatar response in VRChat,
gaze accuracy/calibration, coexistence with every immersive app, in-headset
panel placement, SlimeVR tracker/calibration data and the SlimeVR driver running
inside SteamVR. No trackers or strap are attached. The driver was loaded in a
separate process; it was **not registered or activated in SteamVR**. Steam and
SteamVR were not stopped or restarted.
**Verified blocker resolved:** importing `tkinter` fails because `libtk8.6.so`
is absent. The panel uses the installed GTK4/GI bindings instead. The Frame's
OpenXR headers advertise a newer API version than the runtime accepts; our
reader requests OpenXR 1.0 explicitly.
## Install our tools
From this checkout on your computer, while the Frame is awake:
```sh
python3 scripts/tracking-on-frame.py install
```
This copies our Python code and compiles our small C OpenXR reader into
`~/.local/share/frame-control/tracking/` on the Frame. It uses the Frame's
existing compiler, OpenXR headers/loader, Python, dbus-python, GI and GTK4.
Nothing is downloaded, and no sudo, driver registration, system setting,
service or autostart is added. `FRAME_ALIAS` can select another SSH alias.
The desktop app/server keeps its existing stdlib-only dependency set.
## Eye tracking → OSC
Start with a ten-second capability/data-availability check:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 10
```
This prints support, session-state numbers and sample counters. It opens no
OSC socket and prints no gaze coordinates. Exit 0 means at least one valid
sample, 3 means no valid sample was observed, and 1 means an API/runtime error.
If there are no valid samples, wake/wear the headset and check its tracking
setup; a successful capability check alone does not prove usable gaze.
To send to VRChat running **on the Frame**, explicitly enable OSC in VRChat
and choose its local UDP endpoint:
```sh
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
```
For a receiver on another computer, replace `127.0.0.1` with that computer's
IP address and choose its listening port. Addresses are IP literals (IPv4 or
IPv6); there is no discovery or default destination. Loopback here always
means **the Frame**, not the computer running the SSH command. OSC uses
unencrypted UDP: configure only a receiver you intend to receive this data.
**Documented:** [VRChat's eye OSC interface](https://docs.vrchat.com/docs/osc-eye-tracking)
accepts `/tracking/eye/CenterPitchYaw` with two floats in degrees, positive down
and right. We locate OpenXR's combined gaze pose relative to VIEW (the head),
rotate its -Z forward vector and convert that direction to these angles.
Only active, orientation-valid **and tracked** samples are sent, at up to
30 Hz. No eyelid/blink, individual-eye or face values are invented. We do not
send neutral gaze on tracking loss; VRChat's documented timeout restores its
automatic eye behaviour after input stops.
**Privacy:** gaze is personal data. It stays in process memory and a private
pipe between our reader and bridge. There is no gaze log option, telemetry,
OSC receiver or raw gaze on stdout/stderr. Only an explicit `--osc IP PORT`
opens an output socket. Runtime diagnostics and validity counters are not
measurements. Stop with Ctrl-C or let `--seconds` expire (maximum 24 hours).
A lost headless session ends the run; it does not silently reconnect.
## BLE heart rate → our panel, OSC and optional log
First discover/pair your strap in SteamOS's Bluetooth settings. Select that
strap's Bluetooth address explicitly; our tool does not scan for or connect
to arbitrary nearby devices.
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600
```
This uses BlueZ's standard Heart Rate Service (`180d`) and Heart Rate
Measurement (`2a37`) notifications. It finds the characteristic only beneath
the selected device's HRS service. The reader handles 8- and 16-bit BPM,
contact flags and optional energy/RR fields; energy and RR intervals are
validated for length but discarded. Zero BPM, reported loss of skin contact,
malformed packets and readings older than five seconds are not shown as a
current measurement. A disconnect stops the run; reconnect and start again.
This is a social/fitness readout, not a medical monitor.
The panel is our GTK4 window on gamescope's X display. Use SteamVR's panel
controls to float/dock it (see [panels](panels.md)). **Stop**, closing the panel,
Ctrl-C, SSH hangup or the duration limit ends our subscription. A connection
that was already open when we started is preserved; a connection we opened
is disconnected on exit. No Bluetooth power or pairing state is changed.
Add either output explicitly:
```sh
python3 scripts/tracking-on-frame.py heart \
--device AA:BB:CC:DD:EE:FF --panel --seconds 3600 \
--osc 127.0.0.1 9000 --address /avatar/parameters/HeartRate \
--log /home/steamos/heart-session.csv
```
The OSC value is integer BPM. `HeartRate` is a chosen avatar parameter, **not a
built-in VRChat heart-rate feature**; your avatar/receiver must define the
matching parameter. `--address` can select another literal OSC path. The local
panel works without OSC, a log or an avatar integration.
The optional CSV contains only `unix_seconds,bpm`. It is created privately
(mode 0600), refuses existing files/symlinks, and lives **on the Frame** at the
path you specify. Nothing is logged by default, and heart-rate values are not
printed to the terminal. Delete your session file when you no longer need it.
### Checking heart rate against a reference
`scripts/heart-check.py` runs on your computer. `listen` shows our OSC
readings live as they arrive, so you can watch them next to another device:
```sh
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
```
Point the Frame at it with `--osc <your computer's IP> 9000`. `compare` lines
up two recordings by time and reports the mean difference, bias, the share
within ±5 BPM and the delay between them. It passes when the mean difference
is at most 5 BPM, at least 80% of reference readings are matched and nothing
was shown while the sensor reported lost skin contact:
```sh
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
```
The reference can be a CSV (`time,bpm[,flags]`, time in unix seconds or ISO
8601) or an Apple Health export (`export.zip` or `export.xml`). Only heart-rate
records within the recording's time range are read. Everything stays on your
computer.
`scripts/heart-test-strap.swift` turns a Mac into a synthetic strap. It
advertises the standard Heart Rate Service and sends a fixed, known sequence
(8-bit and 16-bit values and a skin-contact loss), printing each sent value, so
`compare` can check that the Frame shows exactly what was sent. It needs
Bluetooth permission for the process that runs it. **Untested on 2026-09-29:**
it compiled, but on this Mac, launched from an agent session, macOS never
delivered a Bluetooth state and no permission prompt appeared, so it never
advertised.
## Pulse from the eye cameras (experimental)
The Frame has no heart-rate sensor. **Verified 2026-09-29** (SteamOS 0.4.1,
build `20260925.6191901`): its sensors are an ambient light/proximity sensor
(`vcnl4000`), a hall sensor (`als31300`), two passthrough cameras
(`arcimx616`), two tracking cameras (`og01a1b`) and two IR eye cameras
(`og0ve10`). There is no optical heart-rate (PPG) sensor.
The experiment asks whether the eye cameras can see a pulse anyway. With each
heartbeat, the blood volume in the skin around the eye changes slightly and
its IR reflectance changes with it. This is camera-based photoplethysmography;
near-IR works, though the signal is weaker than in green light.
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show
```
How it works:
- **Capture (verified).** SteamVR ships `eyetracking --calib N`, which saves
both eye cameras for N seconds as 400×400 8-bit IR PNGs with a monotonic
timestamp per frame, at about 90 fps per eye. SteamVR's live eye tracker,
part of `steamvr.service`, gets its frames from the DSP and stops its
cameras when the headset is off. Unworn captures ran alongside it: its PID
and log were unchanged and our OpenXR gaze session still started
afterwards. **Untested:** whether the capture and the live tracker coexist
while the headset is worn and tracking.
- **Privacy.** Each image is reduced to a 16×16 grid of patch averages as
soon as it is complete, then deleted. Three worker processes do this beside
the capture. If more than 900 images (about five seconds) ever wait, we stop
reading them and delete them undecoded until the capture ends, and report
an error. The capture directory is removed on exit, even after errors. No image is kept or leaves the Frame. The estimate
is printed only with `--show`, and sent or saved only with `--osc` or
`--log`, as for the strap.
- **Estimate.** Patch traces are averaged down to 15 Hz and turned into
relative change. A 2-second moving median removes drift and blinks.
Patches with frequent spikes (the eyeball and eyelid) are dropped, as are
dark or saturated ones. The 20% of patches with the clearest rhythm between
42 and 180 BPM are combined in the frequency domain. Output is an overall
estimate plus one estimate per second over 15-second windows. A result
counts as **clear** only when the top patches agree and the combined signal
stands out from the noise. Otherwise the command exits 3 and sends no OSC.
`--log` still records the per-second estimates, so a comparison shows how
far off an unclear result was.
The thresholds are provisional until checked on real wearers.
**Verified on the Frame, unworn, 2026-09-29:** captures of 3,600-5,400 eye
frames never had more than 8 images on disk, finished a few seconds after the
capture ended and left no capture directory. **The estimator alone gave a
false "clear" pulse.** With nobody wearing the headset, five runs reported a
steady, self-consistent rhythm (90, 90, 93, 94 and 96 BPM; patch agreement
100%, signal/noise 0.63-0.70), and earlier runs reported 127-129 BPM at lower
signal/noise. It is a periodic camera or illumination artifact, and its
frequency drifts between runs. A wearer-less scene cannot contain a pulse, so
the signal/noise gate cannot tell this artifact from one. Because of that,
`pulse` reads the Frame's proximity sensor (`vcnl4000`) before and after the
capture. It reads about 3 unworn (**verified**). If either reading is below 20
the result is never called clear, nothing is sent over OSC, and the command
exits 3. **Inferred, unmeasured:** that a worn reading is well above 20; the
cut-off is provisional until someone wears the headset. If the sensor can't be
read, the guard is skipped. Confirming a real pulse also needs a reference
(below).
**Do not interrupt the capture. Verified on the Frame, 2026-09-29:** sending
SIGTERM to `eyetracking --calib` left the DSP service's eye camera (OV6211)
stuck "streaming": its log had no "Stopping streaming" line, and every later
request failed with "Failed to start streaming". Head tracking kept working.
Clearing it needs the DSP service restarted or the Frame rebooted, so `pulse`
never signals the tool. After Ctrl-C or a failure it keeps deleting images
until the tool ends by itself (at most the `--seconds` plus a few seconds),
and only kills a tool that overruns by 30 s, with a warning that the eye
cameras may need a reboot. So an interrupted run can take a while to return.
**Verified on synthetic data** (unit tests): a 0.3% brightness pulse in a
third of the patches, with noise, drift, blinks and eye movement, is
recovered within 1.5 BPM at 58, 72 and 115 BPM; noise and blinks alone are
not reported as a pulse. **Not yet verified:** whether a real wearer's eye
images contain a usable pulse, and how accurate it is. That needs someone
wearing the headset and a reference, as below.
### Comparing with an Apple Watch
1. On the watch, start a workout (for example **Other**) so it measures heart
rate every few seconds rather than occasionally.
2. Put the Frame on, sit still and look ahead. Run:
```sh
python3 scripts/tracking-on-frame.py pulse --seconds 120 --show \
--log /home/steamos/pulse.csv
```
The per-second estimates print at the end. Compare them with what the
watch showed.
3. End the workout. On the iPhone, open Health → your picture → **Export All
Health Data**, and AirDrop `export.zip` to the Mac.
4. On the Mac:
```sh
scp frame:pulse.csv . && ssh frame rm pulse.csv
python3 scripts/heart-check.py compare pulse.csv ~/Downloads/export.zip
```
This first version analyses after the capture ends, because the method must
prove itself before a live panel is worth building. The Apple Watch is a
reference, not ground truth: in workouts it is typically within a few BPM of
a chest strap when you are still.
## SlimeVR: feasibility only
SlimeVR is an independent application stack. Neither of our features installs,
launches or depends on it. Users who want it can follow
[SlimeVR's setup documentation](https://docs.slimevr.dev/server/index.html).
The consented upstream releases tested were
[server v21.1.0](https://github.com/SlimeVR/SlimeVR-Server/releases/tag/v21.1.0)
and [driver v6.0.0](https://github.com/SlimeVR/SlimeVR-OpenVR-Driver/releases/tag/v6.0.0),
under SlimeVR's MIT/Apache-2.0 licensing.
**Verified layout, read-only:** the Frame's registered runtime is `/opt/steamvr`;
its native driver is `drivers/cv/bin/linuxarm64/driver_cv.so`, with a
`drivers/cv/driver.vrdrivermanifest`. Frame controller manifests/resources are
under `drivers/frame_controller/`. Configuration is under
`~/.config/openvr/config/`, not the Steam client's config directory. The
SlimeVR release also uses `slimevr/bin/linuxarm64/driver_slimevr.so` plus its
manifest. Nothing in those installed SteamVR directories was changed.
**Inferred:** the matching ABI/layout and standalone factory success make
SteamVR integration plausible. They do not prove successful driver `Init`,
server/driver IPC, tracking, or calibration. That needs a separate integration
check with hardware and an agreed SteamVR restart. No Java executable was on
PATH for this check, so an isolated JRE was used. SlimeVR's server opens LAN
listeners; our temporary server was stopped and the temporary downloads,
configuration and logs were removed. It is not left installed or running.
## Tests and remaining checks
```sh
python3 -m unittest discover -s tests
```
`tests/test_tracking.py` covers HRS packet parsing, contact/staleness, OSC
padding/types and a real loopback socket, quaternion signs, opt-in networking,
private/exclusive logging and a fake BlueZ object tree. The fake checks service
ownership, notification routing, delayed GATT discovery and connection cleanup.
It does not pretend to be a physical strap or a real OpenXR runtime.
Before calling hardware support complete, attach a strap and check BPM against
its own display/reference, loss of contact, disconnect/reconnect, Stop, OSC and
CSV together. Check avatar eyes while looking up/down/left/right in a supported
VRChat session. No third-party tracking app is needed for either test.
Independent review attempt: `devin -p --model swe-2-max` with the frozen diff,
contribution standards and read-only instructions returned no output for ten
minutes. It was terminated with exit 143. No completed review or actual model
identity was returned; hardware checks and independent review remain follow-up
work before making the draft ready.
+135
View File
@@ -0,0 +1,135 @@
/* Frame Control's OpenXR gaze source. No values on stdout/stderr or disk.
* The Python bridge supplies a private pipe with --fd; standalone probes only
* report counters. Uses a headless session, never submits frames or takes focus. */
#define XR_USE_TIMESPEC
#include <time.h>
#include <openxr/openxr.h>
#include <openxr/openxr_platform.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t stopped;
static void stop(int sig) { (void)sig; stopped = 1; }
#define CHECK(call) do { result = (call); if (XR_FAILED(result)) { \
fprintf(stderr, "%s failed (%d)\n", #call, result); goto cleanup; } } while (0)
int main(int argc, char **argv) {
int seconds = 10, fd = -1, running = 0, rc = 1;
unsigned active = 0, valid = 0, samples = 0;
for (int i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--seconds") && i+1 < argc) seconds = atoi(argv[++i]);
else if (!strcmp(argv[i], "--fd") && i+1 < argc) fd = atoi(argv[++i]);
else { fprintf(stderr, "usage: gaze [--seconds 1..86400] [--fd private-pipe]\n"); return 2; }
}
if (seconds < 1 || seconds > 86400 || (fd != -1 && fd < 3)) return 2;
FILE *out = fd == -1 ? NULL : fdopen(fd, "w");
if (fd != -1 && !out) return 2;
signal(SIGINT, stop); signal(SIGTERM, stop); signal(SIGHUP, stop); signal(SIGPIPE, SIG_IGN);
XrResult result;
XrInstance instance = XR_NULL_HANDLE;
XrSession session = XR_NULL_HANDLE;
XrActionSet set = XR_NULL_HANDLE;
XrSpace gaze = XR_NULL_HANDLE, view = XR_NULL_HANDLE;
const char *extensions[] = {"XR_EXT_eye_gaze_interaction", "XR_MND_headless", "XR_KHR_convert_timespec_time"};
XrInstanceCreateInfo create = {.type = XR_TYPE_INSTANCE_CREATE_INFO};
strcpy(create.applicationInfo.applicationName, "Frame Control gaze");
create.applicationInfo.apiVersion = XR_MAKE_VERSION(1, 0, 0);
create.enabledExtensionCount = 3; create.enabledExtensionNames = extensions;
CHECK(xrCreateInstance(&create, &instance));
XrSystemGetInfo get = {.type = XR_TYPE_SYSTEM_GET_INFO, .formFactor = XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY};
XrSystemId system;
CHECK(xrGetSystem(instance, &get, &system));
XrSystemEyeGazeInteractionPropertiesEXT eye = {.type = XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT};
XrSystemProperties props = {.type = XR_TYPE_SYSTEM_PROPERTIES, .next = &eye};
CHECK(xrGetSystemProperties(instance, system, &props));
printf("supportsEyeGazeInteraction=%u\n", eye.supportsEyeGazeInteraction);
if (!eye.supportsEyeGazeInteraction) goto cleanup;
XrSessionCreateInfo sc = {.type = XR_TYPE_SESSION_CREATE_INFO, .systemId = system};
CHECK(xrCreateSession(instance, &sc, &session));
XrActionSetCreateInfo asc = {.type = XR_TYPE_ACTION_SET_CREATE_INFO};
strcpy(asc.actionSetName, "gaze"); strcpy(asc.localizedActionSetName, "Gaze");
CHECK(xrCreateActionSet(instance, &asc, &set));
XrActionCreateInfo ac = {.type = XR_TYPE_ACTION_CREATE_INFO, .actionType = XR_ACTION_TYPE_POSE_INPUT};
strcpy(ac.actionName, "gaze_pose"); strcpy(ac.localizedActionName, "Gaze pose");
XrAction action;
CHECK(xrCreateAction(set, &ac, &action));
XrPath profile, input;
CHECK(xrStringToPath(instance, "/interaction_profiles/ext/eye_gaze_interaction", &profile));
CHECK(xrStringToPath(instance, "/user/eyes_ext/input/gaze_ext/pose", &input));
XrActionSuggestedBinding binding = {action, input};
XrInteractionProfileSuggestedBinding suggested = {.type = XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING,
.interactionProfile = profile, .countSuggestedBindings = 1, .suggestedBindings = &binding};
CHECK(xrSuggestInteractionProfileBindings(instance, &suggested));
XrSessionActionSetsAttachInfo attach = {.type = XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO, .countActionSets = 1, .actionSets = &set};
CHECK(xrAttachSessionActionSets(session, &attach));
XrActionSpaceCreateInfo space = {.type = XR_TYPE_ACTION_SPACE_CREATE_INFO, .action = action, .poseInActionSpace.orientation.w = 1};
CHECK(xrCreateActionSpace(session, &space, &gaze));
XrReferenceSpaceCreateInfo ref = {.type = XR_TYPE_REFERENCE_SPACE_CREATE_INFO, .referenceSpaceType = XR_REFERENCE_SPACE_TYPE_VIEW,
.poseInReferenceSpace.orientation.w = 1};
CHECK(xrCreateReferenceSpace(session, &ref, &view));
PFN_xrConvertTimespecTimeToTimeKHR convert;
CHECK(xrGetInstanceProcAddr(instance, "xrConvertTimespecTimeToTimeKHR", (PFN_xrVoidFunction *)&convert));
struct timespec start, now;
clock_gettime(CLOCK_MONOTONIC, &start);
while (!stopped) {
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - start.tv_sec >= seconds) break;
XrEventDataBuffer event = {.type = XR_TYPE_EVENT_DATA_BUFFER};
while ((result = xrPollEvent(instance, &event)) == XR_SUCCESS) {
if (event.type == XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED) {
XrSessionState state = ((XrEventDataSessionStateChanged *)&event)->state;
printf("sessionState=%d\n", state); fflush(stdout);
if (state == XR_SESSION_STATE_READY && !running) {
XrSessionBeginInfo begin = {.type = XR_TYPE_SESSION_BEGIN_INFO, .primaryViewConfigurationType = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO};
CHECK(xrBeginSession(session, &begin)); running = 1;
} else if (state == XR_SESSION_STATE_STOPPING) {
CHECK(xrEndSession(session)); running = 0; stopped = 1;
} else if (state == XR_SESSION_STATE_EXITING || state == XR_SESSION_STATE_LOSS_PENDING) stopped = 1;
} else if (event.type == XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING) stopped = 1;
event.type = XR_TYPE_EVENT_DATA_BUFFER;
}
if (XR_FAILED(result)) goto cleanup;
if (running && !stopped) {
XrActiveActionSet activeSet = {set, XR_NULL_PATH};
XrActionsSyncInfo sync = {.type = XR_TYPE_ACTIONS_SYNC_INFO, .countActiveActionSets = 1, .activeActionSets = &activeSet};
CHECK(xrSyncActions(session, &sync));
if (result != XR_SUCCESS) {
struct timespec delay = {.tv_nsec = 33333333};
nanosleep(&delay, NULL);
continue; /* No stale gaze when the runtime denies focus. */
}
XrActionStateGetInfo ag = {.type = XR_TYPE_ACTION_STATE_GET_INFO, .action = action};
XrActionStatePose pose = {.type = XR_TYPE_ACTION_STATE_POSE};
CHECK(xrGetActionStatePose(session, &ag, &pose));
samples++;
if (pose.isActive) {
active++;
XrTime time; CHECK(convert(instance, &now, &time));
XrSpaceLocation location = {.type = XR_TYPE_SPACE_LOCATION};
CHECK(xrLocateSpace(gaze, view, time, &location));
XrSpaceLocationFlags needed = XR_SPACE_LOCATION_ORIENTATION_VALID_BIT | XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT;
if ((location.locationFlags & needed) == needed) {
valid++;
if (out) {
XrQuaternionf q = location.pose.orientation;
if (fprintf(out, "%g %g %g %g\n", q.x, q.y, q.z, q.w) < 0 || fflush(out)) goto cleanup;
}
}
}
}
struct timespec delay = {.tv_nsec = 33333333}; nanosleep(&delay, NULL);
}
printf("samples=%u active=%u valid=%u\n", samples, active, valid);
rc = valid ? 0 : 3; /* Distinguish a working session from observed gaze. */
cleanup:
if (view) xrDestroySpace(view);
if (gaze) xrDestroySpace(gaze);
if (session) xrDestroySession(session);
if (set) xrDestroyActionSet(set);
if (instance) xrDestroyInstance(instance);
if (out) fclose(out);
return rc;
}
+481
View File
@@ -0,0 +1,481 @@
"""Experimental pulse estimate from the Frame's IR eye-tracking cameras.
Skin brightens and darkens very slightly with each heartbeat as blood volume
changes (photoplethysmography). The eye cameras film the skin around each eye
under steady IR light at about 90 frames per second, so that rhythm may be
visible in the average brightness of small patches of skin.
Capture uses SteamVR's own `eyetracking --calib` mode, which writes PNG pairs
to /tmp. Each image is reduced to a grid of patch averages the moment it is
complete, then deleted; the capture directory is removed on exit. No image
leaves the Frame or outlives the run. This is an experiment, not a medical
measurement.
"""
from array import array
import bisect
import cmath
import json
import math
import os
from pathlib import Path
import re
import shutil
import subprocess
import time
ET_DIR = Path("/opt/steamvr/tools/eyetracking")
ET_BIN = ET_DIR / "bin/linuxarm64/eyetracking"
ET_WEIGHTS = ET_DIR / "resources/et_dsp_20250610_03136.weights"
GRID = 16 # 16 × 16 patches of 25 × 25 pixels on the 400 × 400 image
RATE = 15.0 # analysis sample rate, Hz; the band of interest ends at 3 Hz
LOW, HIGH = 42.0, 180.0 # BPM search band
# The Frame's proximity sensor (vcnl4000) reads about 3 with nobody wearing it.
# A worn reading has not been measured yet, so the cut-off is provisional.
IIO = Path("/sys/bus/iio/devices")
WORN_MIN = 20.0
WINDOW = 15.0 # seconds per windowed estimate
# ---------------------------------------------------------------- capture ---
def grid_means(pixels, width, height, stride, channels, grid=GRID):
"""Average of channel 0 in each of grid × grid equal patches."""
bw, bh = width // grid, height // grid
sums = [0] * (grid * grid)
for y in range(bh * grid):
start = y * stride
row = pixels[start:start + width * channels:channels]
base = (y // bh) * grid
for bx in range(grid):
sums[base + bx] += sum(row[bx * bw:(bx + 1) * bw])
area = bw * bh
return [s / area for s in sums]
def load_grid(path):
import gi
gi.require_version("GdkPixbuf", "2.0")
from gi.repository import GdkPixbuf
image = GdkPixbuf.Pixbuf.new_from_file(str(path))
return grid_means(image.read_pixel_bytes().get_data(), image.get_width(), image.get_height(),
image.get_rowstride(), image.get_n_channels())
def reduce_file(loader, path):
"""Reduce one image to its patch grid and delete it, whatever happens."""
try:
return loader(path)
finally:
os.unlink(path)
class Capture:
"""Run SteamVR's eye-camera capture and reduce frames as they arrive."""
NAME = re.compile(r"^(left|right)_(\d+)\.png$")
# Only SteamVR's own capture directories are read and removed.
PREFIX = "/tmp/etcalib_"
# Printed by the capture tool; its output is only flushed when it exits.
WRITING = re.compile(r"Writing capture to: (\S+)")
# About five seconds of frames (~65 MB in RAM-backed /tmp). If reduction
# falls further behind than this, the capture stops rather than letting
# eye images pile up.
MAX_BACKLOG = 900
def __init__(self, seconds, runner=subprocess.Popen, loader=load_grid, workers=3):
self.seconds, self.runner, self.loader, self.workers = seconds, runner, loader, workers
self.grids = {"left": {}, "right": {}}
self.directory = None
self.pool = None
self.submitted = set()
self.futures = {}
self.new = set() # every capture directory that appeared during our run
self.before = None # capture directories that existed before the run
self.log = None
def candidates(self):
parent, stem = os.path.split(self.PREFIX)
return {Path(entry.path) for entry in os.scandir(parent)
if entry.name.startswith(stem) and entry.is_dir(follow_symlinks=False)}
def run(self):
# The capture tool's output goes to a private temporary file, read for
# failure messages. It is block-buffered, so the capture directory is
# found by watching for a new one rather than waiting for its name.
import tempfile
self.before = before = self.candidates()
process = None
with tempfile.TemporaryFile("w+") as log:
self.log = log
try:
if self.workers:
# SteamVR pins its eye tracker to cores 0-1; decoding runs beside it.
import concurrent.futures
import multiprocessing
self.pool = concurrent.futures.ProcessPoolExecutor(
self.workers, mp_context=multiprocessing.get_context("fork"))
process = self.runner([str(ET_BIN), "-b", "CDSP", "-w", str(ET_WEIGHTS), "--calib", str(self.seconds)],
cwd=str(ET_BIN.parent), stdout=log, stderr=subprocess.STDOUT)
deadline = time.monotonic() + self.seconds + 30
while True:
# Checked on every poll: a second capture directory means
# we can't tell which images are ours, so stop.
self.new |= self.candidates() - before
if len(self.new) > 1:
raise RuntimeError("another eye-camera capture is running")
if not self.directory and self.new:
self.directory = next(iter(self.new))
finished = process.poll() is not None
self.reduce(final=finished)
if finished:
break
if time.monotonic() > deadline:
raise RuntimeError("eye-camera capture did not finish")
time.sleep(0.02)
log.seek(0)
text = log.read()
if process.returncode or not self.directory:
reason = "cameras unavailable" if "Failed to" in text else f"exit {process.returncode}"
raise RuntimeError(f"eye-camera capture failed ({reason})")
named = self.written(log)
if named and named != self.directory:
raise RuntimeError("the capture wrote somewhere else; not using those images")
return self.frames()
finally:
# Each step runs even if an earlier one failed: eye images must
# be removed whatever else went wrong.
# Ctrl-C and SIGTERM (raised as KeyboardInterrupt) are held
# until the images are gone, then re-raised.
interrupted, failed = None, None
for step in (lambda: self.finish(process),
lambda: self.pool and self.pool.shutdown(wait=True, cancel_futures=True),
self.adopt_reported):
try:
step()
except BaseException as error:
if isinstance(error, Exception):
failed = failed or error
else:
interrupted = interrupted or error
self.log = None
self.remove()
if interrupted:
raise interrupted
if failed:
raise RuntimeError(f"the eye-camera capture did not stop cleanly: {failed}")
def finish(self, process):
"""Let the capture tool end by itself, deleting its images meanwhile.
Never signal it: stopping the tool early leaves the headset's eye
camera stuck streaming until the DSP service restarts (verified on the
Frame with SIGTERM). Its run is bounded by `seconds`, so waiting is
short. Only if it overruns by a wide margin is it killed."""
if not process:
return
interrupted = None
give_up = time.monotonic() + self.seconds + 30
while True:
try:
if process.poll() is not None:
break
self.discard()
if time.monotonic() > give_up:
process.kill()
process.wait()
raise RuntimeError("the eye-camera capture had to be killed; "
"the eye cameras may need a reboot to stream again")
time.sleep(0.05)
except KeyboardInterrupt as error: # keep cleaning up until it ends
interrupted = interrupted or error
if interrupted:
raise interrupted
def discard(self):
"""Delete the eye images written so far, without reading them."""
if self.before is not None:
self.new |= self.candidates() - self.before
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
for entry in os.scandir(directory):
if self.NAME.match(entry.name):
try:
os.unlink(entry.path)
except OSError:
pass
def adopt_reported(self):
"""The directory the tool reported is ours by its own account."""
named = self.written(self.log)
if named and str(named).startswith(self.PREFIX):
self.new.add(named)
def written(self, log):
"""The directory the capture tool reported, once its output is flushed."""
log.seek(0)
match = self.WRITING.search(log.read())
return Path(match.group(1)) if match else None
def reduce(self, final=False):
"""Reduce and delete every complete image; an image is complete once a
later one of the same eye exists, or the capture has ended."""
if not self.directory or not self.directory.is_dir():
return
pending = {"left": [], "right": []}
for entry in os.scandir(self.directory):
match = self.NAME.match(entry.name)
if match:
pending[match.group(1)].append((int(match.group(2)), entry.path))
if sum(len(files) for files in pending.values()) > self.MAX_BACKLOG:
raise RuntimeError("eye-image processing fell behind; capture abandoned")
for eye, files in pending.items():
files.sort()
ready = files if final else files[:-1]
for index, path in ready:
if path in self.submitted:
continue
self.submitted.add(path)
if self.pool:
self.futures[(eye, index)] = self.pool.submit(reduce_file, self.loader, path)
else:
self.grids[eye][index] = array("f", reduce_file(self.loader, path))
for key, future in list(self.futures.items()):
if final or future.done():
self.grids[key[0]][key[1]] = array("f", future.result())
del self.futures[key]
def frames(self):
"""[(monotonic seconds, eye, grid)] joined with the capture metadata."""
meta = json.loads((self.directory / "meta.json").read_text())
frames = []
for pair in meta["frames"]:
for eye in ("left", "right"):
info = pair.get(eye) or {}
match = self.NAME.match(Path(info.get("fname", "")).name)
grid = self.grids[eye].get(int(match.group(2))) if match else None
if info.get("valid") and grid is not None:
frames.append((float(info["tsMono"]), eye, grid))
return frames
def remove(self):
"""Remove every capture directory that appeared during the run. Eye
images must not outlive it, so a failure to delete is an error."""
left = []
if self.before is not None:
try:
self.new |= self.candidates() - self.before # even if interrupted before the first poll
except OSError:
pass
for directory in self.new | ({self.directory} if self.directory else set()):
if str(directory).startswith(self.PREFIX) and directory.is_dir() and not directory.is_symlink():
try:
shutil.rmtree(directory)
except OSError:
left.append(str(directory))
if left:
raise RuntimeError("could not delete eye images in " + ", ".join(sorted(left)))
# --------------------------------------------------------------- analysis ---
def fft(values):
"""In-place iterative radix-2 FFT of a list whose length is a power of 2."""
n = len(values)
a = list(values)
j = 0
for i in range(1, n):
bit = n >> 1
while j & bit:
j ^= bit
bit >>= 1
j |= bit
if i < j:
a[i], a[j] = a[j], a[i]
size = 2
while size <= n:
step = cmath.exp(-2j * math.pi / size)
half = size // 2
for start in range(0, n, size):
w = 1
for k in range(start, start + half):
t = w * a[k + half]
a[k + half] = a[k] - t
a[k] += t
w *= step
size *= 2
return a
def resample(times, values, rate=RATE):
"""Average samples into 1/rate bins from the first sample; empty bins are
filled from the previous bin."""
if not times:
return []
start = times[0]
count = int((times[-1] - start) * rate) + 1
sums, counts = [0.0] * count, [0] * count
for t, v in zip(times, values):
i = min(int((t - start) * rate), count - 1)
sums[i] += v
counts[i] += 1
out, last = [], None
for s, c in zip(sums, counts):
last = s / c if c else last
out.append(last)
first = next(v for v in out if v is not None)
return [first if v is None else v for v in out]
def clean(signal, rate=RATE):
"""Relative change with slow drift removed; None if the patch is mostly
blinks or eye movement. Spikes (blinks, saccades) become gaps at the
local level rather than clipped steps, which would add false rhythm."""
mean = sum(signal) / len(signal)
x = [v / mean - 1 for v in signal]
half = int(rate) # 2-second centred moving median removes drift and blinks
trend = []
for i in range(len(x)):
chunk = sorted(x[max(0, i - half):i + half + 1])
trend.append(chunk[len(chunk) // 2])
residual = [v - m for v, m in zip(x, trend)]
mad = sorted(abs(v) for v in residual)[len(residual) // 2] or 1e-9
limit = 4 * 1.4826 * mad
spikes = sum(1 for v in residual if abs(v) > limit)
if spikes > 0.05 * len(residual):
return None
return [v if abs(v) <= limit else 0.0 for v in residual]
def spectrum(signal, rate=RATE):
"""[(bpm, power)] within the search band, Hann-windowed and zero-padded."""
n = len(signal)
size = 1
while size < max(n * 4, 256):
size *= 2
window = [0.5 - 0.5 * math.cos(2 * math.pi * i / (n - 1)) for i in range(n)] if n > 1 else [1.0]
padded = [s * w for s, w in zip(signal, window)] + [0.0] * (size - n)
result = fft(padded)
out = []
for k in range(size // 2):
bpm = k * rate / size * 60
if LOW <= bpm <= HIGH:
out.append((bpm, abs(result[k]) ** 2))
return out
def peak(spec):
"""Peak BPM with parabolic interpolation between spectral bins."""
i = max(range(len(spec)), key=lambda k: spec[k][1])
if 0 < i < len(spec) - 1:
a, b, c = spec[i - 1][1], spec[i][1], spec[i + 1][1]
denominator = a - 2 * b + c
offset = 0.5 * (a - c) / denominator if denominator else 0.0
return spec[i][0] + offset * (spec[1][0] - spec[0][0])
return spec[i][0]
def snr(spec, bpm, width=4.0):
"""Power near the pulse and its first harmonic against the rest of the band."""
near = sum(p for f, p in spec if abs(f - bpm) <= width or abs(f - 2 * bpm) <= width)
rest = sum(p for f, p in spec) - near
if near <= 0:
return 0.0
return near / rest if rest > 0 else float("inf")
def normalised(spec):
total = sum(p for _, p in spec) or 1.0
return [p / total for _, p in spec]
def usable(values):
"""Patches that are neither dark nor saturated for the whole recording."""
mean = sum(values) / len(values)
return 8 <= mean <= 245
def estimate(times, patches, rate=RATE, share=0.2, window=WINDOW):
"""Estimate pulse from patch brightness traces.
times: monotonic seconds per frame. patches: {name: [brightness per frame]}.
Selects the share of usable patches with the clearest periodic signal,
combines their spectra and reports the overall and windowed estimates.
"""
cleaned = {}
for name, values in patches.items():
if usable(values):
signal = clean(resample(times, values, rate), rate)
if signal is not None and any(signal): # flat patches carry no rhythm
cleaned[name] = signal
if not cleaned or len(next(iter(cleaned.values()))) < rate * 8:
raise ValueError("need at least 8 seconds of usable eye-camera frames")
quality = []
for name, signal in cleaned.items():
spec = spectrum(signal, rate)
own = peak(spec)
quality.append((snr(spec, own), name, own, spec))
quality.sort(reverse=True)
chosen = quality[:max(4, int(len(quality) * share))]
combined = [sum(values) for values in zip(*(normalised(spec) for _, _, _, spec in chosen))]
bins = [f for f, _ in chosen[0][3]]
bpm = peak(list(zip(bins, combined)))
top = chosen[:8]
agree = sum(1 for _, _, own, _ in top if abs(own - bpm) <= 5) / len(top)
series = []
samples = int(window * rate)
length = len(next(iter(cleaned.values())))
for end in range(samples, length + 1, int(rate)):
specs = [spectrum(cleaned[name][end - samples:end], rate) for _, name, _, _ in chosen]
total = [sum(values) for values in zip(*(normalised(s) for s in specs))]
window_bins = [f for f, _ in specs[0]]
series.append((times[0] + end / rate, peak(list(zip(window_bins, total)))))
return {
"bpm": bpm,
"agreement": agree,
"patches": len(chosen),
"usable": len(cleaned),
"snr": snr(list(zip(bins, combined)), bpm),
"series": series,
}
def analyse(frames):
"""Estimate from Capture.frames(); both eyes' patches are analysed together
on a shared time base, each sample taken from that eye's nearest frame."""
times = sorted({t for t, _, _ in frames})
patches = {}
for eye in ("left", "right"):
eye_frames = sorted((t, grid) for t, e, grid in frames if e == eye)
if not eye_frames:
continue
eye_times = [t for t, _ in eye_frames]
nearest = []
for t in times:
i = bisect.bisect_left(eye_times, t)
if i == len(eye_times) or (i > 0 and t - eye_times[i - 1] <= eye_times[i] - t):
i -= 1
nearest.append(i)
for k in range(len(eye_frames[0][1])):
patches[f"{eye}{k}"] = [eye_frames[i][1][k] for i in nearest]
return estimate(times, patches)
def proximity(root=None):
"""The headset's proximity reading, or None if it can't be read."""
try:
for device in sorted(Path(root or IIO).glob("iio:device*")):
if (device / "name").read_text().strip() == "vcnl4000":
return float((device / "in_proximity_raw").read_text())
except (OSError, ValueError):
pass
return None
def worn(reading):
"""False only when the sensor says the headset is not on a face."""
return reading is None or reading >= WORN_MIN
def reliable(result):
"""Whether the estimate is clear enough to show as a reading. Thresholds
are provisional until checked against a reference on a real wearer."""
return result["agreement"] >= 0.75 and result["snr"] >= 0.5
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""Frame-local tracking tools. No network destination or data log by default."""
import argparse
import math
import os
from pathlib import Path
import signal
import socket
import struct
import subprocess
import time
class TrackingError(RuntimeError):
"""A safe, actionable status message containing no sensor data."""
HRS = "0000180d-0000-1000-8000-00805f9b34fb"
MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
DEVICE = "org.bluez.Device1"
SERVICE = "org.bluez.GattService1"
CHARACTERISTIC = "org.bluez.GattCharacteristic1"
def heart_rate(data):
"""Validate the Bluetooth HRS measurement, returning BPM/contact only.
Energy and RR intervals are checked for length but never retained.
None means contact is supported and the strap reports no skin contact.
"""
data = bytes(data)
if len(data) < 2 or data[0] & 0xe0:
raise ValueError("invalid HRS measurement")
flags = data[0]
size = 2 if flags & 1 else 1
end = 1 + size + (2 if flags & 8 else 0)
if len(data) < end:
raise ValueError("truncated HRS measurement")
extra = len(data) - end
if (flags & 16 and (extra < 2 or extra % 2)) or (not flags & 16 and extra):
raise ValueError("invalid HRS optional fields")
if flags & 4 and not flags & 2:
return None
bpm = int.from_bytes(data[1:1 + size], "little")
return bpm if bpm else None
def gaze_angles(quaternion):
"""OpenXR head-relative -Z forward → VRChat degrees, down/right positive."""
if len(quaternion) != 4 or not all(math.isfinite(v) for v in quaternion):
raise ValueError("invalid gaze orientation")
norm = math.sqrt(sum(v * v for v in quaternion))
if not 0.9 < norm < 1.1:
raise ValueError("invalid gaze orientation")
x, y, z, w = (v / norm for v in quaternion)
# Rotate OpenXR's forward vector (0, 0, -1) into VIEW space.
dx, dy, dz = -2 * (x*z + w*y), 2 * (w*x - y*z), 2 * (x*x + y*y) - 1
return math.degrees(math.atan2(-dy, math.hypot(dx, dz))), math.degrees(math.atan2(dx, -dz))
def osc_message(address, values):
if not address.startswith("/") or any(c.isspace() or c in '\0#*,?[]{}' for c in address):
raise ValueError("OSC address must be a literal path")
def string(value):
encoded = value.encode("utf-8") + b"\0"
return encoded + b"\0" * (-len(encoded) % 4)
tags, payload = ",", b""
for value in values:
if type(value) is int:
tags += "i"
payload += struct.pack(">i", value)
else:
if not math.isfinite(value):
raise ValueError("OSC value must be finite")
tags += "f"
payload += struct.pack(">f", value)
return string(address) + string(tags) + payload
class Osc:
def __init__(self, endpoint=None):
self.sock = None
self.target = None
if endpoint:
import ipaddress
address = ipaddress.ip_address(endpoint[0])
port = int(endpoint[1])
if address.is_unspecified or address.is_multicast or not 1 <= port <= 65535:
raise ValueError("OSC needs a unicast IP address and port 1..65535")
self.sock = socket.socket(socket.AF_INET6 if address.version == 6 else socket.AF_INET, socket.SOCK_DGRAM)
self.target = (str(address), port)
def send(self, address, values):
if self.sock:
self.sock.sendto(osc_message(address, values), self.target)
def close(self):
if self.sock:
self.sock.close()
class HeartSession:
def __init__(self, osc, address, log=None, clock=time.monotonic):
self.osc, self.address, self.clock = osc, address, clock
self.bpm, self.updated = None, None
self.log = None
if log:
# Exclusive creation refuses existing files and symlinks; mode is
# private even with a permissive process umask.
fd = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
self.log = os.fdopen(fd, "w")
self.log.write("unix_seconds,bpm\n")
def notification(self, data):
self.bpm = heart_rate(data)
self.updated = self.clock()
if self.bpm is not None:
self.osc.send(self.address, [self.bpm])
if self.log:
self.log.write(f"{time.time():.3f},{self.bpm}\n")
self.log.flush()
def current(self):
if self.updated is None or self.clock() - self.updated > 5:
return None
return self.bpm
def close(self):
if self.log:
self.log.close()
class BluezHeart:
"""One explicitly selected, already discovered strap; no ambient scan."""
def __init__(self, bus, interface, address, on_value):
self.bus, self.interface, self.on_value = bus, interface, on_value
self.device = self.characteristic = None
self.connected_here = False
self.notifying = False
self.match = None
objects = self.objects()
matches = [path for path, interfaces in objects.items()
if str(interfaces.get(DEVICE, {}).get("Address", "")).upper() == address.upper()]
if len(matches) != 1:
raise TrackingError("Strap not found uniquely in BlueZ; pair/discover it in SteamOS Bluetooth settings first")
self.device = matches[0]
self.match = bus.add_signal_receiver(self.changed, signal_name="PropertiesChanged",
dbus_interface="org.freedesktop.DBus.Properties",
bus_name="org.bluez", path_keyword="path")
try:
if not objects[self.device][DEVICE].get("Connected"):
self.call(self.device, DEVICE).Connect(timeout=20)
self.connected_here = True
except Exception:
self.close()
raise
def objects(self):
return self.call("/", "org.freedesktop.DBus.ObjectManager").GetManagedObjects()
def call(self, path, kind):
return self.interface(self.bus.get_object("org.bluez", path), kind)
def subscribe(self):
objects = self.objects()
if not objects.get(self.device, {}).get(DEVICE, {}).get("ServicesResolved"):
return False
services = {p for p, obj in objects.items() if str(obj.get(SERVICE, {}).get("UUID", "")).lower() == HRS
and obj[SERVICE].get("Device") == self.device}
for path, obj in objects.items():
props = obj.get(CHARACTERISTIC, {})
if props.get("Service") in services and str(props.get("UUID", "")).lower() == MEASUREMENT:
if "notify" not in props.get("Flags", []):
raise TrackingError("Heart-rate characteristic does not support notifications")
self.characteristic = path
self.call(path, CHARACTERISTIC).StartNotify()
self.notifying = True
return True
raise TrackingError("Selected device has no standard Heart Rate Service measurement")
def changed(self, kind, changes, invalidated, path=None):
if kind == CHARACTERISTIC and path == self.characteristic and "Value" in changes:
self.on_value(changes["Value"])
elif kind == DEVICE and path == self.device and "Connected" in changes and not changes["Connected"]:
self.on_value(None)
def close(self):
try:
if self.notifying:
self.call(self.characteristic, CHARACTERISTIC).StopNotify()
finally:
if self.match:
self.match.remove()
if self.connected_here:
self.call(self.device, DEVICE).Disconnect()
def run_gaze(args, osc):
binary = Path(__file__).with_name("gaze")
command = [str(binary), "--seconds", str(args.seconds)]
if not args.osc:
return subprocess.call(command)
read_fd, write_fd = os.pipe()
process = None
try:
process = subprocess.Popen(command + ["--fd", str(write_fd)], pass_fds=(write_fd,))
os.close(write_fd)
write_fd = None
with os.fdopen(read_fd) as source:
read_fd = None
for line in source:
try:
angles = gaze_angles([float(v) for v in line.split()])
except ValueError:
continue
osc.send("/tracking/eye/CenterPitchYaw", angles)
return process.wait()
finally:
if read_fd is not None:
os.close(read_fd)
if write_fd is not None:
os.close(write_fd)
if process and process.poll() is None:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
class HeartPanel:
"""Our GTK panel, using the Frame's existing GTK4/GI platform libraries."""
def __init__(self):
os.environ["GDK_BACKEND"] = "x11"
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkX11", "4.0")
from gi.repository import Gtk, Gdk, GdkX11, GLib
Gtk.init()
self.running = True
self.window = Gtk.Window(title="Frame Control · Heart rate")
self.window.set_default_size(480, 320)
self.window.connect("close-request", self.stop)
Gtk.Settings.get_default().set_property("gtk-application-prefer-dark-theme", True)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=16)
box.set_valign(Gtk.Align.CENTER)
box.set_halign(Gtk.Align.CENTER)
box.set_size_request(440, -1)
for side in ("top", "bottom", "start", "end"):
getattr(box, "set_margin_" + side)(24)
self.window.set_child(box)
title = Gtk.Label(label="Heart rate")
title.add_css_class("title-2")
box.append(title)
self.reading = Gtk.Label(label="—")
self.reading.add_css_class("reading")
box.append(self.reading)
self.status = Gtk.Label(label="Waiting for strap")
box.append(self.status)
button = Gtk.Button(label="Stop")
button.connect("clicked", self.stop)
box.append(button)
css = Gtk.CssProvider()
css.load_from_data(b".reading { font-size: 144px; font-weight: 700; }")
Gtk.StyleContext.add_provider_for_display(Gdk.Display.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.window.present()
context = GLib.MainContext.default()
while context.pending():
context.iteration(False)
try:
xid = GdkX11.X11Surface.get_xid(self.window.get_surface())
subprocess.run(["xprop", "-id", str(xid), "-f", "STEAM_GAME", "32c", "-set", "STEAM_GAME", "2000000027"],
check=True, stdout=subprocess.DEVNULL)
except Exception:
self.window.destroy()
raise
def stop(self, *args):
self.running = False
return True
def update(self, bpm):
self.reading.set_label(str(bpm) if bpm is not None else "—")
self.status.set_label("beats per minute" if bpm is not None else "Waiting for strap")
def close(self):
self.window.destroy()
def run_heart(args, osc):
import dbus
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
DBusGMainLoop(set_as_default=True)
session = HeartSession(osc, args.address, args.log)
reader, root = None, None
failure = []
def value(data):
if data is None:
session.bpm = None
failure.append("Strap disconnected; reconnect and start again")
return
try:
session.notification(data)
except ValueError:
session.bpm = None
except OSError:
failure.append("OSC or session log write failed")
try:
reader = BluezHeart(dbus.SystemBus(), dbus.Interface, args.device, value)
context = GLib.MainContext.default()
deadline = time.monotonic() + 20
while not reader.subscribe():
if time.monotonic() > deadline:
raise TrackingError("Timed out waiting for the strap's GATT services")
while context.pending():
context.iteration(False)
time.sleep(0.1)
end = time.monotonic() + args.seconds
print("Heart-rate notifications started; readings stay local unless OSC or a log was selected.")
if args.panel:
root = HeartPanel()
running = lambda: root.running if root else True
while running() and time.monotonic() < end and not failure:
while context.pending():
context.iteration(False)
if root:
root.update(session.current())
time.sleep(0.05)
if failure:
raise TrackingError(failure[0])
return 0
finally:
if root:
root.close()
try:
if reader:
reader.close()
finally:
session.close()
def run_pulse(args, osc):
"""Experimental: estimate pulse from the IR eye cameras (see pulse.py)."""
import pulse
if not pulse.ET_BIN.exists():
raise TrackingError("SteamVR's eye-tracking tool is not installed on this Frame")
print(f"Capturing {args.seconds} s from the eye cameras. Wear the headset and keep still.", flush=True)
readings = [pulse.proximity()]
try:
frames = pulse.Capture(args.seconds).run()
readings.append(pulse.proximity())
except RuntimeError as error: # capture status only; no image data
raise TrackingError(str(error))
print(f"Captured {len(frames)} eye frames; images already deleted. Analysing...", flush=True)
try:
result = pulse.analyse(frames)
except ValueError as error:
raise TrackingError(str(error))
on_face = all(pulse.worn(reading) for reading in readings)
if not on_face:
# Unworn, the cameras still show a steady periodic artifact that looks
# like a pulse (verified on the Frame), so it is never called clear.
print("The proximity sensor says the headset is not being worn; no pulse can be read.")
clear = on_face and pulse.reliable(result)
offset = time.time() - time.monotonic() # the capture's timestamps are CLOCK_MONOTONIC
if args.log:
fd = os.open(args.log, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as log:
log.write("unix_seconds,bpm\n")
for when, bpm in result["series"]:
log.write(f"{when + offset:.3f},{bpm:.1f}\n")
if clear:
osc.send(args.address, [round(result["bpm"])])
if args.show:
print(f"Estimate: {result['bpm']:.1f} BPM ({'clear' if clear else 'NOT clear'}; "
f"patch agreement {result['agreement']:.0%}, signal/noise {result['snr']:.2f}, "
f"{result['patches']} of {result['usable']} usable patches)")
print("Per-second estimates (15 s windows): "
+ " ".join(str(round(bpm)) for _, bpm in result["series"]))
else:
print("A clear pulse was found." if clear else "No clear pulse was found.")
return 0 if clear else 3
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
gaze = commands.add_parser("gaze", help="headless OpenXR; prints counters only without --osc")
heart = commands.add_parser("heart", help="standard BLE HRS from an explicitly selected strap")
pulse = commands.add_parser("pulse", help="experimental pulse estimate from the IR eye cameras")
for command in (gaze, heart, pulse):
command.add_argument("--osc", nargs=2, metavar=("IP", "PORT"), help="explicit UDP destination; no default")
for command in (gaze, heart):
command.add_argument("--seconds", type=int, default=10, help="bounded run, 1..86400 seconds (default: 10)")
pulse.add_argument("--seconds", type=int, default=60, help="capture length, 20..300 seconds (default: 60)")
heart.add_argument("--device", required=True, help="strap Bluetooth address already discovered by BlueZ")
heart.add_argument("--panel", action="store_true", help="show our panel on gamescope DISPLAY=:0")
for command in (heart, pulse):
command.add_argument("--address", default="/avatar/parameters/HeartRate", help="integer BPM OSC parameter")
command.add_argument("--log", help="new private CSV file; disabled by default")
pulse.add_argument("--show", action="store_true", help="print the estimate and per-second series")
args = parser.parse_args()
if not 1 <= args.seconds <= 86400:
parser.error("--seconds must be 1..86400")
if args.command == "pulse" and not 20 <= args.seconds <= 300:
parser.error("pulse --seconds must be 20..300")
def interrupted(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, interrupted)
if hasattr(signal, "SIGHUP"):
signal.signal(signal.SIGHUP, interrupted)
osc = None
try:
if args.command in ("heart", "pulse"):
osc_message(args.address, [0])
if getattr(args, "panel", False):
os.environ["DISPLAY"] = ":0"
osc = Osc(args.osc)
run = {"gaze": run_gaze, "heart": run_heart, "pulse": run_pulse}[args.command]
return run(args, osc)
except TrackingError as error:
print(str(error))
return 1
except KeyboardInterrupt:
return 130
except Exception as error:
# Never dump notifications, gaze, BLE addresses or exception payloads.
print(f"Tracking stopped ({type(error).__name__}). Check the device, runtime and selected output.")
return 1
finally:
if osc:
osc.close()
if __name__ == "__main__":
raise SystemExit(main())
+2 -1
View File
@@ -23,7 +23,8 @@ started=$SECONDS
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
# That second build also rides out a flaky package mirror: only its failure is fatal.
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Check Frame Control's heart-rate readings against a reference, on your computer.
python3 scripts/heart-check.py listen --port 9000 --out ours.csv
python3 scripts/heart-check.py compare ours.csv reference.csv
python3 scripts/heart-check.py compare ours.csv ~/Downloads/export.zip
`listen` receives our integer-BPM OSC messages (send them here with
`tracking-on-frame.py heart --osc <this computer's IP> 9000`) and shows each
reading live, so you can watch it next to a reference such as your Apple
Watch. `--out` also records `unix_seconds,bpm` to a new private file.
`compare` lines up two recordings by time and reports how far apart they are.
The reference can be:
- a CSV whose first column is a time (unix seconds or ISO 8601) and whose
second is BPM, such as our own log, `listen --out`, or the test strap's
output (a third `flags` column marks skin-contact loss as "no reading");
- an Apple Health export (`export.zip` or `export.xml`, from Health → your
profile → Export All Health Data). Only heart-rate records within the
recording's time range are read.
Everything stays on this computer. Nothing is uploaded.
"""
import argparse
import bisect
import csv
from datetime import datetime
import io
import os
from pathlib import Path
import re
import socket
import struct
import sys
import time
import zipfile
from xml.etree import ElementTree
ADDRESS = "/avatar/parameters/HeartRate"
def parse_osc(packet):
"""Return (address, values) for a single OSC message with i/f arguments."""
def string(offset):
end = packet.index(b"\0", offset)
return packet[offset:end].decode("utf-8"), (end + 4) & ~3
address, offset = string(0)
tags, offset = string(offset)
if not tags.startswith(","):
raise ValueError("not an OSC message")
values = []
for tag in tags[1:]:
if tag not in "if" or offset + 4 > len(packet):
raise ValueError("unsupported OSC argument")
values.append(struct.unpack(">i" if tag == "i" else ">f", packet[offset:offset + 4])[0])
offset += 4
return address, values
def listen(port, address, out, seconds, clock=time.time, stream=sys.stdout):
log = None
if out:
log = os.fdopen(os.open(out, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "w")
log.write("unix_seconds,bpm\n")
received = 0
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.bind(("0.0.0.0", port))
sock.settimeout(0.5)
print(f"Listening for {address} on UDP port {port}. Ctrl-C stops.", file=stream, flush=True)
end = clock() + seconds if seconds else None
try:
while end is None or clock() < end:
try:
packet = sock.recv(1024)
except socket.timeout:
continue
try:
path, values = parse_osc(packet)
except (ValueError, UnicodeDecodeError):
continue
if path != address or len(values) != 1:
continue
now = clock()
bpm = int(values[0])
received += 1
print(f"{time.strftime('%H:%M:%S', time.localtime(now))} {bpm:3d} bpm", file=stream, flush=True)
if log:
log.write(f"{now:.3f},{bpm}\n")
log.flush()
except KeyboardInterrupt:
pass
finally:
if log:
log.close()
return received
def parse_time(text):
text = text.strip()
if re.fullmatch(r"\d+(\.\d+)?", text):
return float(text)
# Apple Health uses "2026-09-29 09:12:03 +1000".
text = re.sub(r" ([+-]\d{2}):?(\d{2})$", r"\1\2", text).replace("Z", "+0000")
for pattern in ("%Y-%m-%d %H:%M:%S%z", "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z"):
try:
return datetime.strptime(text, pattern).timestamp()
except ValueError:
pass
raise ValueError(f"unrecognised time {text!r}")
def read_csv(path):
"""[(time, bpm or None)], sorted. A header row is skipped."""
samples = []
with open(path, newline="") as source:
for row in csv.reader(source):
if len(row) < 2:
continue
try:
when, bpm = parse_time(row[0]), int(float(row[1]))
except (ValueError, OverflowError):
continue # header or unparseable line
try:
flags = int(float(row[2])) if len(row) > 2 else None
except (ValueError, OverflowError):
flags = None # an unrecognised flag leaves the reading as it is
if flags is not None and flags & 4 and not flags & 2:
bpm = None # contact supported and not detected: no reading
samples.append((when, bpm))
return sorted(samples, key=lambda s: s[0])
def read_health(path, start, end):
"""Heart-rate records from an Apple Health export between start and end."""
samples = []
def scan(source):
for _, element in ElementTree.iterparse(source):
if element.tag == "Record" and element.get("type") == "HKQuantityTypeIdentifierHeartRate":
try:
when = parse_time(element.get("startDate") or "")
value = round(float(element.get("value") or ""))
except (ValueError, OverflowError):
continue
if start <= when <= end:
samples.append((when, value))
element.clear()
if zipfile.is_zipfile(path):
with zipfile.ZipFile(path) as archive:
names = [n for n in archive.namelist() if n.endswith("/export.xml") or n == "export.xml"]
if not names:
raise ValueError("no export.xml in that archive; use Health's Export All Health Data")
with archive.open(names[0]) as source:
scan(source)
else:
with open(path, "rb") as source:
scan(source)
return sorted(samples)
def read_any(path, start=None, end=None):
path = str(path)
if path.endswith((".zip", ".xml")):
return read_health(path, start, end)
return read_csv(path)
def value_at(samples, when, hold, times=None):
"""Our reading at a moment: the latest sample no older than `hold` seconds.
`times` is the samples' time column, if the caller has already built it."""
times = times if times is not None else [s[0] for s in samples]
i = bisect.bisect_right(times, when) - 1
if i < 0 or when - times[i] > hold:
return None
return samples[i][1]
def compare(ours, reference, max_lag=10.0, hold=5.0):
"""Compare our readings with the reference at each reference moment.
`lag` is the delay added to reference times before looking up ours (our
readings arrive after the sensor's). The best lag within ±max_lag is used.
"""
real = [(t, b) for t, b in reference if b is not None]
if not real or not any(b is not None for _, b in ours):
raise ValueError("both recordings need at least one reading")
best = None
ours_times = [t for t, _ in ours]
steps = int(max_lag * 4)
for step in range(-steps, steps + 1):
lag = step / 4
pairs = [(value_at(ours, t + lag, hold, ours_times), b) for t, b in real]
pairs = [(o, r) for o, r in pairs if o is not None]
if not pairs:
continue
error = sum(abs(o - r) for o, r in pairs) / len(pairs)
key = (-len(pairs), error, abs(lag))
if best is None or key < best[0]:
best = (key, lag, pairs)
if best is None:
raise ValueError("the recordings do not overlap in time")
_, lag, pairs = best
differences = [o - r for o, r in pairs]
# While the reference says "no reading" (lost skin contact), we must not
# produce new readings. Count ours that arrive during such a stretch.
gaps = [t for t, b in reference if b is None]
reference_times = [t for t, _ in reference]
shown_in_gaps = 0
for t, bpm in ours:
i = bisect.bisect_right(reference_times, t - lag) - 1
if bpm is not None and i >= 0 and reference[i][1] is None:
shown_in_gaps += 1
return {
"reference_readings": len(real),
"matched": len(pairs),
"lag_seconds": lag,
"mean_abs_error": sum(abs(d) for d in differences) / len(differences),
"bias": sum(differences) / len(differences),
"max_abs_error": max(abs(d) for d in differences),
"within_5": sum(1 for d in differences if abs(d) <= 5) / len(differences),
"exact": sum(1 for d in differences if d == 0) / len(differences),
"no_contact_moments": len(gaps),
"shown_during_no_contact": shown_in_gaps,
}
def report(result, tolerance, stream=sys.stdout):
print(f"Reference readings: {result['reference_readings']}, matched: {result['matched']}", file=stream)
print(f"Best alignment: ours {result['lag_seconds']:+.2f} s after the reference", file=stream)
print(f"Mean absolute difference: {result['mean_abs_error']:.2f} BPM "
f"(bias {result['bias']:+.2f}, worst {result['max_abs_error']})", file=stream)
print(f"Within ±5 BPM: {result['within_5']:.0%}; identical: {result['exact']:.0%}", file=stream)
if result["no_contact_moments"]:
print(f"No-contact moments: {result['no_contact_moments']}, where we showed a stale "
f"reading: {result['shown_during_no_contact']}", file=stream)
coverage = result["matched"] / result["reference_readings"]
passed = (result["mean_abs_error"] <= tolerance and coverage >= 0.8
and not result["shown_during_no_contact"])
print(("PASS" if passed else "FAIL") + f" (mean difference ≤ {tolerance} BPM, ≥80% of reference "
f"readings matched, nothing shown without contact)", file=stream)
return passed
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
heard = commands.add_parser("listen", help="show and optionally record our OSC readings live")
heard.add_argument("--port", type=int, default=9000)
heard.add_argument("--address", default=ADDRESS)
heard.add_argument("--out", help="new private CSV file")
heard.add_argument("--seconds", type=float, default=0, help="stop after this long (default: until Ctrl-C)")
check = commands.add_parser("compare", help="compare our recording with a reference")
check.add_argument("ours", type=Path)
check.add_argument("reference", type=Path)
check.add_argument("--tolerance", type=float, default=5.0, help="allowed mean difference in BPM (default 5)")
check.add_argument("--max-lag", type=float, default=10.0, help="largest time offset to search, seconds")
args = parser.parse_args(argv)
if args.command == "listen":
count = listen(args.port, args.address, args.out, args.seconds)
print(f"Received {count} readings.")
return 0 if count else 3
try:
ours = read_csv(args.ours)
if not ours:
raise ValueError("our recording has no readings")
reference = read_any(args.reference, ours[0][0] - 60, ours[-1][0] + 60)
result = compare(ours, reference, args.max_lag)
except (ValueError, OSError, csv.Error, ElementTree.ParseError, zipfile.BadZipFile) as error:
print(f"Could not compare: {error}")
return 1
return 0 if report(result, args.tolerance) else 1
if __name__ == "__main__":
raise SystemExit(main())
+100
View File
@@ -0,0 +1,100 @@
// A synthetic Bluetooth heart-rate strap for testing, run on the Mac.
//
// swiftc -O scripts/heart-test-strap.swift -o /tmp/heart-test-strap
// /tmp/heart-test-strap [seconds] # default 60
//
// Advertises the standard Heart Rate Service (180d) as "FC Test Strap" and,
// while a central is subscribed, sends one Heart Rate Measurement (2a37) per
// second from a fixed, known sequence. Each sent value is printed to stdout as
// `unix_seconds,bpm,flags` so scripts/heart-check.py can compare what the
// Frame received with what was sent. Every value is synthetic; this is not a
// sensor. macOS asks for Bluetooth permission the first time it runs.
import CoreBluetooth
import Foundation
let hrs = CBUUID(string: "180D")
let measurement = CBUUID(string: "2A37")
/// The known sequence: an 8-bit ramp, 16-bit encodings, a skin-contact loss
/// (which must show as no reading) and a recovery.
func packet(_ second: Int) -> (bpm: Int, flags: UInt8) {
switch second % 60 {
case 0..<30: return (60 + second % 60 * 4, 0x06) // 60…176, contact detected
case 30..<40: return (180 - (second % 60 - 30) * 3, 0x07) // 16-bit value
case 40..<45: return (150, 0x04) // contact lost
default: return (72 + (second % 60 - 45), 0x06)
}
}
final class Strap: NSObject, CBPeripheralManagerDelegate {
let seconds: Int
var manager: CBPeripheralManager!
var characteristic: CBMutableCharacteristic!
var subscribed = false
var sent = 0
init(seconds: Int) {
self.seconds = seconds
super.init()
manager = CBPeripheralManager(delegate: self, queue: nil)
}
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
guard peripheral.state == .poweredOn else {
FileHandle.standardError.write("Bluetooth state \(peripheral.state.rawValue)\n".data(using: .utf8)!)
if peripheral.state == .unauthorized || peripheral.state == .unsupported || peripheral.state == .poweredOff {
FileHandle.standardError.write("Bluetooth unavailable (state \(peripheral.state.rawValue))\n".data(using: .utf8)!)
exit(1)
}
return
}
characteristic = CBMutableCharacteristic(type: measurement, properties: [.notify], value: nil, permissions: [])
let service = CBMutableService(type: hrs, primary: true)
service.characteristics = [characteristic]
peripheral.add(service)
}
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
if let error { fail("add service: \(error.localizedDescription)") }
peripheral.startAdvertising([CBAdvertisementDataLocalNameKey: "FC Test Strap",
CBAdvertisementDataServiceUUIDsKey: [hrs]])
}
func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
if let error { fail("advertise: \(error.localizedDescription)") }
FileHandle.standardError.write("Advertising FC Test Strap\n".data(using: .utf8)!)
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in self.tick() }
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
subscribed = true
FileHandle.standardError.write("Central subscribed\n".data(using: .utf8)!)
}
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
subscribed = false
FileHandle.standardError.write("Central unsubscribed\n".data(using: .utf8)!)
}
func tick() {
guard subscribed else { return }
if sent >= seconds { exit(0) }
let (bpm, flags) = packet(sent)
var bytes: [UInt8] = [flags, UInt8(bpm & 0xff)]
if flags & 1 != 0 { bytes.append(UInt8(bpm >> 8)) }
if manager.updateValue(Data(bytes), for: characteristic, onSubscribedCentrals: nil) {
print(String(format: "%.3f,%d,%d", Date().timeIntervalSince1970, bpm, flags))
fflush(stdout)
sent += 1
}
}
func fail(_ message: String) -> Never {
FileHandle.standardError.write("\(message)\n".data(using: .utf8)!)
exit(1)
}
}
let seconds = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) ?? 60 : 60
let strap = Strap(seconds: seconds)
RunLoop.main.run()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Install or run our local-only tracking tools on the Frame.
python3 scripts/tracking-on-frame.py install
python3 scripts/tracking-on-frame.py gaze --seconds 10
python3 scripts/tracking-on-frame.py gaze --seconds 3600 --osc 127.0.0.1 9000
python3 scripts/tracking-on-frame.py heart --device AA:BB:CC:DD:EE:FF --panel
python3 scripts/tracking-on-frame.py pulse --seconds 60 --show # experimental
FRAME_ALIAS overrides the SSH alias (default: frame). Runs in the foreground;
Ctrl-C stops the reader. No service, autostart, sudo or SteamVR settings changes.
"""
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
REMOTE = '"$HOME/.local/share/frame-control/tracking"'
INSTALL = '''set -eu
base="$HOME/.local/share/frame-control/tracking"
mkdir -p "$base"
stage=$(mktemp -d "$base/.install.XXXXXX")
trap 'rm -rf "$stage"' EXIT
tar -xf - -C "$stage"
cc -O2 -Wall -Wextra -Werror "$stage/gaze.c" \\
-L/opt/steamvr/bin/linuxarm64 -Wl,-rpath,/opt/steamvr/bin/linuxarm64 \\
-lopenxr_loader -o "$stage/gaze"
chmod 700 "$stage/gaze" "$stage/tracking.py"
chmod 600 "$stage/pulse.py"
mv "$stage/gaze" "$stage/tracking.py" "$stage/pulse.py" "$base/"
echo 'Installed Frame Control tracking tools (no service started).'
'''
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("install", "gaze", "heart", "pulse"):
print(__doc__)
return 2
host = os.environ.get("FRAME_ALIAS", "frame")
if not host or host.startswith("-"):
raise ValueError("invalid SSH alias")
ssh = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", host]
if sys.argv[1] == "install":
import tempfile
source = Path(__file__).resolve().parents[1] / "frame" / "tracking"
with tempfile.TemporaryFile() as archive:
with tarfile.open(fileobj=archive, mode="w") as tar:
for name in ("gaze.c", "tracking.py", "pulse.py"):
tar.add(source / name, arcname=name)
archive.seek(0)
return subprocess.call(ssh + ["bash -c " + shlex.quote(INSTALL)], stdin=archive)
# Allocate a tty so SSH forwards Ctrl-C and hangup to the foreground process.
command = 'exec python3 ' + REMOTE + '/tracking.py ' + shlex.join(sys.argv[1:])
return subprocess.call(ssh[:-1] + ["-tt", host, command])
if __name__ == "__main__":
raise SystemExit(main())
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env zsh
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
# The VM is a dockur/windows container on another machine; this reaches it over
# SSH through that machine. See docs/testing.md#windows-test-vm.
#
# Usage: scripts/windows-vm.sh <command> [args]
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
# ps '<PowerShell>' run PowerShell as the VM's user
# put <file> [<dir>] copy a file in (default: the user's Downloads)
# shot <out.png> save the VM's screen
# click <x> <y> click screen pixel x,y in the signed-in session
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
# alias) for a test run, then take it out again
#
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
set -euo pipefail
setopt extendedglob
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
ctr=${WINVM_CONTAINER:-frame-winvm}
user=${WINVM_USER:-frame}
port=${WINVM_PORT:-2222}
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
die() { print -u2 "windows-vm: $*"; exit 1 }
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
# These go into commands run by a shell on the other machine, so keep them plain.
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
vm_ps() {
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
}
# QEMU's monitor inside the container: one command per line on stdin.
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
# Input has to come from the signed-in desktop session, not SSH's session 0, so
# a scheduled task running as the user replays one click or wheel turn written
# to input.txt, then deletes the file to say it's done. Any error stops it
# before that, so the caller times out instead of reporting a click.
INPUT_PS1='$ErrorActionPreference = "Stop"
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
"@
[WinVm.Input]::SetProcessDPIAware() | Out-Null
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
Start-Sleep -Milliseconds 150
if ($a[2] -eq "click") {
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
Remove-Item "$PSScriptRoot\input.txt"'
pointer() { # x y click|wheel [notches]
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
vm_ps '$ErrorActionPreference = "Stop"
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
# Giving up: end the helper first, or it could wake up and act in a later request.
function Abandon {
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
foreach ($i in 1..25) {
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
Start-Sleep -Milliseconds 200
}
Remove-Item $req -ErrorAction SilentlyContinue
}
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
# One request at a time: the helper, the task and input.txt are shared. Windows
# frees the mutex when this process ends, however it ends.
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
New-Item -ItemType Directory -Force $d | Out-Null
[IO.File]::WriteAllText($req, "'"$*"'")
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
Start-ScheduledTask -TaskName WindowsVmInput
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
Abandon
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
}
(( $# )) || die "usage: see the top of $0"
cmd=$1; shift
case $cmd in
up)
ssh $host "docker start $ctr" >/dev/null
for i in {1..60}; do
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
sleep 5
done
die "Windows didn't answer on SSH within 5 minutes" ;;
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
put)
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
shot)
(( $# == 1 )) || die "usage: shot <out.png>"
print "screendump /tmp/windows-vm-shot.ppm" | monitor
sleep 1
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
import re, struct, sys, zlib
d = sys.stdin.buffer.read()
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
w, h = int(m[1]), int(m[2]); px = d[m.end():]
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
' $1
print $1 ;;
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
keys)
(( $# )) || die "usage: keys <key>..."
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
for k; do print "sendkey $k"; done | monitor ;;
frame-key)
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
f=~/.ssh/authorized_keys
if [ "$1" = add ]; then
if grep -qxF "$3" "$f"; then exit 0; fi
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
echo "$3" >> "$f"
else
t=$(mktemp "$f.XXXXXX") || exit 1
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
fi
fi
EOF
print "frame-key $1: done" ;;
*) die "unknown command: $cmd (see the top of $0)" ;;
esac
+79
View File
@@ -0,0 +1,79 @@
// The store's "Add a source" login handling and GitHub token field, run in node against
// the real functions from ui/index.html. A login typed into the address must leave the
// address field (into the login fields) before anything is sent, and stay out of it.
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const { moveLogin, sourceToken } = new Function(
"const esc = s => String(s).replace(/[&<>\"']/g, c => `&#${c.charCodeAt(0)};`); const storeName = s => s;\n" +
grab("decodeLenient") + grab("moveLogin") + grab("sourceToken") + "return { moveLogin, sourceToken };")();
function form(url, username = "", password = "") {
const details = { open: false };
const field = value => ({ value, closest: () => details });
return { details, elements: { url: field(url), username: field(username), password: field(password) } };
}
let f = form("https://alice:s3cr%40t@repo.example/fdroid/repo");
moveLogin(f);
if (f.elements.url.value !== "https://repo.example/fdroid/repo") fail("address still holds the login: " + f.elements.url.value);
if (f.elements.username.value !== "alice" || f.elements.password.value !== "s3cr@t") fail("login not moved into its fields");
if (!f.details.open) fail("login fields not shown");
f = form("https://alice:fromurl@repo.example/repo", "typed", "typedpw");
moveLogin(f);
if (f.elements.url.value.includes("fromurl") || f.elements.username.value !== "typed" || f.elements.password.value !== "typedpw")
fail("typed login replaced, or address kept the login");
// Undecodable logins and unusual spellings the URL parser accepts still lose their login; undecodable bytes stay as typed.
for (const [url, address, user, pass] of [
["https://alice:100%done@repo.example/private/repo", "https://repo.example/private/repo", "alice", "100%done"],
["https:\t//alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["ht\ntps://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https:alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["\u0000https://alice:s3cret@repo.example/repo", "https://repo.example/repo", "alice", "s3cret"],
["https://alice:%FF@repo.example/private/repo", "https://repo.example/private/repo", "alice", "%FF"],
["https://al%20ice:p%40ss:word@repo.example/r", "https://repo.example/r", "al ice", "p@ss:word"],
["https://alice:p%FF\u00e4@repo.example/repo", "https://repo.example/repo", "alice", "p%FF\u00e4"],
["fdroidrepos://alice:pw@repo.example/repo?fingerprint=AB", "fdroidrepos://repo.example/repo?fingerprint=AB", "alice", "pw"],
["https://alice@repo.example/repo", "https://repo.example/repo", "alice", ""],
]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== address) fail(`address kept the login: ${url} -> ${f.elements.url.value}`);
if (f.elements.username.value !== user || f.elements.password.value !== pass)
fail(`login for ${url}: ${f.elements.username.value} / ${f.elements.password.value}`);
}
// An address the parser rejects that may hold a login is refused and left as typed, never sent.
for (const url of ["https://alice\\name:pw@repo.example/repo", "https:\\\\alice:s3cret@repo.example/repo", "fdroidrepos://alice:p\\w@repo.example/repo", "https://alice:s3cret@repo example/repo", "https:alice:s3cret@repo example/repo", "https://repo.example:bad\\a@b"]) {
f = form(url);
if (moveLogin(f) !== false) fail("accepted an invalid address with a login: " + url);
if (f.elements.username.value || f.elements.password.value) fail("guessed a login from an invalid address: " + url);
}
for (const url of ["https://repo.example/repo", "not a url", "https://repo.example/a@b", "fdroidrepos:///a@b", "fdroidrepos:/a@b", "https://repo.example/r?x=a@b#c@d"]) {
f = form(url);
moveLogin(f);
if (f.elements.url.value !== url || f.elements.username.value) fail("changed an address without a login: " + url);
}
const html = sourceToken({ id: "github", name: "GitHub releases", token: { set: true, from: "saved", rejected: true } });
if (!html.includes("rejected") || !html.includes('data-clear-token="github"')) fail("token row: " + html);
if (sourceToken({ id: "fdroid", name: "F-Droid" }) !== "") fail("token field on a source without one");
const slow = "https://a:" + "%FF".repeat(4000) + "@repo.example/r", t0 = Date.now();
f = form(slow); moveLogin(f);
if (Date.now() - t0 > 200 || f.elements.password.value !== "%FF".repeat(4000)) fail("decoding malformed escapes is slow or wrong");
console.log("ok");
+1 -1
View File
@@ -325,7 +325,7 @@ class EndpointTests(SettingsTest):
mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://repo.example:x/'):
self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
mod.remove_repo.assert_called_once_with(source_id='fdroid')
+6 -6
View File
@@ -81,7 +81,7 @@ class Repositories(unittest.TestCase):
state.clear()
self.addCleanup(state.clear)
def fetch(self, url, path, maximum):
def fetch(self, url, path, maximum, auth=None):
name = url.rsplit('/', 1)[-1]
if self.v1 and name == 'entry.jar':
raise urllib.error.HTTPError(url, 404, 'missing', None, None)
@@ -311,7 +311,7 @@ class Repositories(unittest.TestCase):
source = self.add()
other = dict(source, id='other-repo')
started, release = threading.Event(), threading.Event()
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if threading.current_thread().name == 'slow':
started.set()
release.wait(5)
@@ -355,7 +355,7 @@ class Repositories(unittest.TestCase):
self.files['entry.jar'], _ = entry_jar(50)
source = fdroid.add_repo(URL)
jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
name = threading.current_thread().name
if name in jars and url.endswith('entry.jar'):
Path(path).write_bytes(jars[name])
@@ -401,7 +401,7 @@ class Repositories(unittest.TestCase):
source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
self.v1 = True
inner = []
def fetch(url, path, maximum):
def fetch(url, path, maximum, auth=None):
if url.endswith('index-v1.jar') and not inner:
inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
self.v1 = False
@@ -471,7 +471,7 @@ class Repositories(unittest.TestCase):
source = self.add()
self.expire(source)
release = threading.Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
@@ -528,7 +528,7 @@ class Repositories(unittest.TestCase):
self.expire(source)
before = self.fetch_mock.call_count
release = __import__('threading').Event()
def slow(url, path, maximum):
def slow(url, path, maximum, auth=None):
release.wait(5)
self.fetch(url, path, maximum)
self.fetch_mock.side_effect = slow
+29 -8
View File
@@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase):
FIXTURES = ROOT / 'tests/fixtures/library'
def wait_for_idle_resolvers(timeout=10):
# Name lookups that outlast their deadline give their slot back from their own thread,
# which a busy runner may not schedule before the next test; wait until all are back.
from apk_sources import _images
held = []
try:
end = time.monotonic() + timeout
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
held.append(1)
if len(held) == 4:
return
raise AssertionError('artwork name lookups from an earlier test are still running')
finally:
for _ in held:
_images._resolvers.release()
class ArtworkTests(unittest.TestCase):
def setUp(self):
wait_for_idle_resolvers()
def test_source_inputs_and_url(self):
from apk_sources import _images
data = (FIXTURES / 'icon.png').read_bytes()
@@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
self.assertEqual(sum('Too many' in e for e in errors), 2)
finally:
gate.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
time.sleep(0.01)
_images._resolvers.release() # the stuck lookups finished and gave their slots back
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
def test_resolver_slot_released_when_thread_cannot_start(self):
from apk_sources import _images
@@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
for _ in range(6):
with self.assertRaises(RuntimeError):
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
for _ in range(4): # every slot came back
self.assertTrue(_images._resolvers.acquire(blocking=False))
for _ in range(4):
_images._resolvers.release()
held = 0
try:
while held < 4 and _images._resolvers.acquire(blocking=False):
held += 1
self.assertEqual(held, 4) # every slot came back
finally:
for _ in range(held): # even on failure, so later tests don't inherit the leak
_images._resolvers.release()
def test_deadline_covers_name_resolution(self):
import threading
+472
View File
@@ -0,0 +1,472 @@
"""Eye-camera pulse estimate and the heart-rate comparison tool; no headset needed."""
import importlib.util
import io
import json
import math
import os
from pathlib import Path
import random
import socket
import struct
import sys
import tempfile
import unittest
import zipfile
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
def load(name, path):
spec = importlib.util.spec_from_file_location(name, ROOT / path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
pulse = load("pulse", "frame/tracking/pulse.py")
check = load("heart_check", "scripts/heart-check.py")
def synthetic(bpm, pulsing, seconds=40, fps=30, patches=48, noise=0.004, seed=7):
"""Patch brightness with a faint pulse in `pulsing` patches, plus sensor
noise, slow drift, blinks in some patches and eye movement in others."""
rng = random.Random(seed)
times = [i / fps for i in range(seconds * fps)]
values = {}
for k in range(patches):
base, amplitude, trace = 40 + k, 0.003 if k < pulsing else 0.0, []
for t in times:
v = base * (1 + amplitude * math.sin(2 * math.pi * bpm / 60 * t)
+ noise * rng.gauss(0, 1) + 0.02 * math.sin(0.1 * t + k))
if k % 8 == 7 and int(t * 10) % 47 == 0:
v *= 0.5 # blink
if k % 8 == 6 and int(t * 10) % 23 == 0:
v *= 1.3 # frequent eye movement
trace.append(v)
values[k] = trace
return times, values
class Estimate(unittest.TestCase):
def test_finds_pulse(self):
for bpm in (58, 72, 115):
with self.subTest(bpm=bpm):
result = pulse.estimate(*synthetic(bpm, 16))
self.assertAlmostEqual(result["bpm"], bpm, delta=1.5)
self.assertTrue(pulse.reliable(result))
self.assertTrue(all(abs(b - bpm) <= 2 for _, b in result["series"]))
def test_noise_and_blinks_are_not_a_pulse(self):
result = pulse.estimate(*synthetic(72, 0))
self.assertFalse(pulse.reliable(result))
def test_frequent_spike_patches_are_dropped(self):
times, values = synthetic(72, 16)
result = pulse.estimate(times, values)
self.assertLess(result["usable"], len(values))
def test_needs_enough_frames(self):
with self.assertRaises(ValueError):
pulse.estimate(*synthetic(72, 16, seconds=5))
times, values = synthetic(72, 16)
with self.assertRaises(ValueError):
pulse.estimate(times, {k: [0.0] * len(times) for k in values}) # all dark
def test_series_times(self):
times, values = synthetic(72, 16, seconds=20)
series = pulse.estimate(times, values)["series"]
self.assertAlmostEqual(series[0][0], pulse.WINDOW, delta=0.1)
self.assertEqual(len(series), 20 - int(pulse.WINDOW) + 1)
def test_flat_patches_do_not_rank_first(self):
times, values = synthetic(72, 16)
values["flat"] = [100.0] * len(times)
result = pulse.estimate(times, values)
self.assertAlmostEqual(result["bpm"], 72, delta=1.5)
self.assertEqual(pulse.snr([(60.0, 0.0), (61.0, 0.0)], 60), 0.0)
def test_analyse_uses_nearest_frame(self):
times, values = synthetic(72, 4, patches=4, seconds=10)
# Right-eye frames 1 ms before each left frame: nearest is that frame.
frames = [(t, "left", [values[k][i] for k in range(4)]) for i, t in enumerate(times)]
frames += [(t - 0.001, "right", [float(i)] * 4) for i, t in enumerate(times)]
seen = {}
original = pulse.estimate
with patch.object(pulse, "estimate", lambda t, p: seen.update(p) or original(t, p)):
pulse.analyse(frames)
self.assertEqual(seen["right0"][:5], [0.0, 0.0, 1.0, 1.0, 2.0])
def test_fft_matches_dft(self):
signal = [math.sin(i) + (i % 3) for i in range(16)]
fast = pulse.fft(signal)
for k in range(16):
slow = sum(signal[n] * complex(math.cos(2 * math.pi * k * n / 16), -math.sin(2 * math.pi * k * n / 16))
for n in range(16))
self.assertAlmostEqual(abs(fast[k] - slow), 0, places=9)
def test_grid_means(self):
# 4 × 4 image in RGB with padding: left half 10, right half 30 (channel 0).
width, height, channels, stride = 4, 4, 3, 16
pixels = bytearray(stride * height)
for y in range(height):
for x in range(width):
pixels[y * stride + x * channels] = 10 if x < 2 else 30
pixels[y * stride + x * channels + 1] = 255 # other channels ignored
self.assertEqual(pulse.grid_means(bytes(pixels), width, height, stride, channels, grid=2),
[10, 30, 10, 30])
def test_analyse_combines_both_eyes(self):
times, values = synthetic(80, 16, patches=16)
frames = []
for i, t in enumerate(times):
frames.append((t, "left", [values[k][i] for k in range(16)]))
frames.append((t + 0.001, "right", [values[k][i] for k in range(16)]))
result = pulse.analyse(frames)
self.assertAlmostEqual(result["bpm"], 80, delta=1.5)
self.assertEqual(result["usable"] % 2, 0)
class FakeCaptureTool:
"""Stands in for `eyetracking --calib`: writes PNG names over a few polls."""
def __init__(self, directory, frames=6, fail=False):
self.directory, self.frames, self.fail = Path(directory), frames, fail
self.polls = 0
self.returncode = None
self.stdout = None
self.announce = None # a different directory to report, if set
def __call__(self, command, cwd, stdout, stderr):
self.command = command
self.stdout = stdout
if self.fail:
stdout.write("Failed to initialize cameras\n")
stdout.flush()
self.returncode = 1
return self
# Real output is block-buffered until exit, so nothing is printed here.
stdout.flush()
self.directory.mkdir()
return self
def poll(self):
if self.returncode is not None:
return self.returncode
if self.polls < self.frames:
for eye in ("left", "right"):
(self.directory / f"{eye}_{self.polls}.png").write_bytes(b"png")
self.polls += 1
return None
meta = {"frames": [{eye: {"fname": str(self.directory / f"{eye}_{i}.png"), "frameNum": i + 10,
"tsMono": 100 + i / 90, "valid": i != 2} for eye in ("left", "right")}
for i in range(self.frames)]}
(self.directory / "meta.json").write_text(json.dumps(meta))
# The real tool's buffered output only appears once it exits.
self.stdout.write(f"Writing capture to: {self.announce or self.directory}\nCaptured images\n")
self.stdout.flush()
self.returncode = 0
return 0
def terminate(self):
raise AssertionError("the capture tool must never be signalled")
kill = terminate
def wait(self, timeout=None):
return self.returncode
class WornCheck(unittest.TestCase):
def sensor(self, name, raw):
root = Path(self.enterContext(tempfile.TemporaryDirectory()))
device = root / "iio:device2"
device.mkdir()
(device / "name").write_text(name + "\n")
(device / "in_proximity_raw").write_text(raw + "\n")
return root
def test_reads_the_proximity_sensor(self):
self.assertEqual(pulse.proximity(self.sensor("vcnl4000", "3.250000000")), 3.25)
def test_unreadable_sensor_does_not_block(self):
self.assertIsNone(pulse.proximity(self.sensor("other", "9")))
self.assertIsNone(pulse.proximity(self.sensor("vcnl4000", "junk")))
self.assertIsNone(pulse.proximity(Path("/nonexistent")))
self.assertTrue(pulse.worn(None))
def test_low_reading_means_unworn(self):
self.assertFalse(pulse.worn(3.1))
self.assertTrue(pulse.worn(pulse.WORN_MIN))
class CaptureLifecycle(unittest.TestCase):
def setUp(self):
self.root = Path(tempfile.mkdtemp())
self.directory = self.root / "etcalib_test"
(self.root / "etcalib_older").mkdir() # an earlier capture is not ours
self.loaded = []
def tearDown(self):
import shutil
shutil.rmtree(self.root, ignore_errors=True)
def loader(self, path):
self.loaded.append(Path(path).name)
self.assertTrue(Path(path).exists())
return [float(len(self.loaded))]
def capture(self, tool):
class TestCapture(pulse.Capture):
# A temporary directory stands in for /tmp/etcalib_*.
PREFIX = str(self.root / "etcalib_")
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
with patch.object(pulse.time, "sleep", lambda s: None):
return capture, capture.run()
def test_reduces_deletes_and_joins_metadata(self):
tool = FakeCaptureTool(self.directory)
capture, frames = self.capture(tool)
self.assertEqual(sorted(self.loaded), sorted(f"{e}_{i}.png" for e in ("left", "right") for i in range(6)))
self.assertFalse(self.directory.exists()) # images and metadata removed
self.assertTrue((self.root / "etcalib_older").exists())
self.assertEqual(len(frames), 10) # frame 2 invalid in both eyes
self.assertEqual(tool.command[-2:], ["--calib", "20"])
self.assertTrue(all(isinstance(t, float) and eye in ("left", "right") for t, eye, _ in frames))
def test_camera_failure(self):
tool = FakeCaptureTool(self.directory, fail=True)
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("cameras unavailable", str(raised.exception))
def test_backlog_abandons_capture_and_removes_images(self):
class Stalled(FakeCaptureTool):
def poll(self):
if self.polls > 8:
self.returncode = 0 # the bounded run ends by itself
return 0
for i in range(20):
for eye in ("left", "right"):
(self.directory / f"{eye}_{self.polls * 20 + i}.png").write_bytes(b"png")
self.polls += 1
return None
tool = Stalled(self.directory)
class TestCapture(pulse.Capture):
PREFIX = str(self.root / "etcalib_")
MAX_BACKLOG = 50
capture = TestCapture(20, runner=tool, loader=self.loader, workers=0)
capture.reduce = lambda final=False, original=capture.reduce: (
original(final) if tool.polls > 3 else None) # reduction stalls
with patch.object(pulse.time, "sleep", lambda s: None), self.assertRaises(RuntimeError) as raised:
capture.run()
self.assertIn("fell behind", str(raised.exception))
self.assertEqual(tool.returncode, 0) # left to end by itself, never signalled
self.assertFalse(self.directory.exists()) # no image left behind
def test_second_capture_directory_stops_and_removes_both(self):
foreign = self.root / "etcalib_foreign"
class Racing(FakeCaptureTool):
def poll(self):
if self.polls == 2:
foreign.mkdir()
(foreign / "left_0.png").write_bytes(b"png")
return super().poll()
tool = Racing(self.directory)
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("another eye-camera capture", str(raised.exception))
self.assertFalse(self.directory.exists())
self.assertFalse(foreign.exists()) # no eye image outlives the run
self.assertTrue((self.root / "etcalib_older").exists())
def test_images_elsewhere_are_not_used_but_are_removed(self):
tool = FakeCaptureTool(self.directory)
tool.announce = self.root / "etcalib_reported"
tool.announce.mkdir()
(tool.announce / "left_0.png").write_bytes(b"png")
with self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("somewhere else", str(raised.exception))
self.assertFalse(tool.announce.exists())
self.assertFalse(self.directory.exists())
def test_interrupt_during_cleanup_still_removes_images(self):
class Stubborn(FakeCaptureTool):
interrupts = 0
def poll(self):
if self.polls == 2 and self.interrupts < 2:
self.interrupts += 1
raise KeyboardInterrupt # Ctrl-C mid-capture, and again while waiting
return super().poll()
tool = Stubborn(self.directory)
with self.assertRaises(KeyboardInterrupt):
self.capture(tool)
self.assertEqual(tool.returncode, 0) # still let finish, never signalled
self.assertFalse(self.directory.exists())
def test_overrunning_tool_is_killed_as_a_last_resort(self):
class Hung(FakeCaptureTool):
killed = False
def poll(self):
(self.directory / "left_0.png").write_bytes(b"png")
return None if not self.killed else -9
def kill(self):
self.killed = True
tool = Hung(self.directory)
clock = iter(range(0, 10000, 20))
with patch.object(pulse.time, "monotonic", lambda: next(clock)), \
self.assertRaises(RuntimeError) as raised:
self.capture(tool)
self.assertIn("may need a reboot", str(raised.exception))
self.assertTrue(tool.killed)
self.assertFalse(self.directory.exists())
def test_cleanup_tries_every_directory(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
first, second = self.root / "etcalib_a", self.root / "etcalib_b"
for directory in (first, second):
directory.mkdir()
(directory / "left_0.png").write_bytes(b"png")
capture.new = {first, second}
real = pulse.shutil.rmtree
def flaky(path):
if Path(path) == first:
raise OSError("busy")
real(path)
with patch.object(pulse.shutil, "rmtree", flaky), self.assertRaises(RuntimeError) as raised:
capture.remove()
self.assertIn("etcalib_a", str(raised.exception))
self.assertFalse(second.exists())
def test_removes_a_directory_seen_only_at_the_end(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
capture.before = capture.candidates()
late = self.root / "etcalib_late"
late.mkdir()
(late / "left_0.png").write_bytes(b"png")
capture.remove()
self.assertFalse(late.exists())
self.assertTrue((self.root / "etcalib_older").exists())
def test_never_adopts_directories_without_a_baseline(self):
capture = pulse.Capture(20)
capture.PREFIX = str(self.root / "etcalib_")
capture.remove()
self.assertTrue((self.root / "etcalib_older").exists())
def test_only_removes_capture_directories(self):
capture = pulse.Capture(20)
capture.directory = self.root
capture.remove()
self.assertTrue(self.root.exists())
class HeartCheck(unittest.TestCase):
def write(self, name, text):
path = Path(self.enterContext(tempfile.TemporaryDirectory())) / name
path.write_text(text)
return path
def test_osc_round_trip(self):
tracking = load("tracking", "frame/tracking/tracking.py")
self.assertEqual(check.parse_osc(tracking.osc_message("/avatar/parameters/HeartRate", [72])),
("/avatar/parameters/HeartRate", [72]))
address, values = check.parse_osc(tracking.osc_message("/x", [1.5, -2.0]))
self.assertEqual((address, values), ("/x", [1.5, -2.0]))
with self.assertRaises(ValueError):
check.parse_osc(b"/x\0\0,s\0\0abc\0")
def test_listen_records_readings(self):
tracking = load("tracking", "frame/tracking/tracking.py")
with tempfile.TemporaryDirectory() as directory:
out = Path(directory) / "ours.csv"
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
probe.bind(("127.0.0.1", 0))
port = probe.getsockname()[1]
import threading
def send():
import time
time.sleep(0.3)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
sender.sendto(tracking.osc_message(check.ADDRESS, [72]), ("127.0.0.1", port))
sender.sendto(tracking.osc_message("/other", [1]), ("127.0.0.1", port))
threading.Thread(target=send).start()
shown = io.StringIO()
count = check.listen(port, check.ADDRESS, str(out), 1.5, stream=shown)
self.assertEqual(count, 1)
self.assertIn("72 bpm", shown.getvalue())
self.assertEqual(out.read_text().splitlines()[1].split(",")[1], "72")
self.assertEqual(out.stat().st_mode & 0o777, 0o600)
def test_compare_finds_lag_and_contact_loss(self):
reference = [(1000.0 + i, 60 + i, 6) for i in range(40)] + [(1040.0 + i, 150, 4) for i in range(5)]
ours = [(1002.0 + i, 60 + i) for i in range(40)] # 2 s late, nothing during contact loss
ref = self.write("ref.csv", "unix_seconds,bpm,flags\n" + "".join(f"{t},{b},{f}\n" for t, b, f in reference))
mine = self.write("ours.csv", "unix_seconds,bpm\n" + "".join(f"{t},{b}\n" for t, b in ours))
result = check.compare(check.read_csv(mine), check.read_any(ref))
self.assertEqual(result["lag_seconds"], 2.0)
self.assertEqual(result["mean_abs_error"], 0)
self.assertEqual(result["shown_during_no_contact"], 0)
self.assertTrue(check.report(result, 5, stream=io.StringIO()))
# A stale reading sent during contact loss fails the check.
stale = check.read_csv(mine) + [(1043.0, 99)]
result = check.compare(stale, check.read_any(ref))
self.assertEqual(result["shown_during_no_contact"], 1)
self.assertFalse(check.report(result, 5, stream=io.StringIO()))
def test_compare_against_apple_health_export(self):
records = "".join(
f'<Record type="HKQuantityTypeIdentifierHeartRate" unit="count/min" '
f'startDate="2026-09-29 12:00:{s:02d} +1000" endDate="2026-09-29 12:00:{s:02d} +1000" value="{70 + s % 3}"/>'
for s in range(0, 60, 5))
other = '<Record type="HKQuantityTypeIdentifierStepCount" startDate="2026-09-29 12:00:00 +1000" value="9"/>'
xml = f'<?xml version="1.0"?><HealthData>{other}{records}</HealthData>'
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory) / "export.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("apple_health_export/export.xml", xml)
start = check.parse_time("2026-09-29 12:00:00 +1000")
samples = check.read_any(archive, start - 60, start + 120)
self.assertEqual(len(samples), 12)
self.assertEqual(samples[0], (start, 70))
ours = [(start + i + 1.0, 71) for i in range(60)]
result = check.compare(ours, samples)
self.assertLessEqual(result["mean_abs_error"], 1)
def test_unusual_flags_and_missing_export(self):
path = self.write("ref.csv", "time,bpm,flags\n1000,70,6.0\n1001,71,yes\n1002,72,4\n")
self.assertEqual(check.read_csv(path), [(1000.0, 70), (1001.0, 71), (1002.0, None)])
with tempfile.TemporaryDirectory() as directory:
archive = Path(directory) / "other.zip"
with zipfile.ZipFile(archive, "w") as z:
z.writestr("notes.txt", "x")
with self.assertRaises(ValueError):
check.read_any(archive, 0, 1)
def test_compare_reports_bad_input_without_traceback(self):
good = self.write("ref.csv", "1000,70\n")
shown = io.StringIO()
with patch("sys.stdout", shown):
self.assertEqual(check.main(["compare", str(good.parent / "missing.csv"), str(good)]), 1)
self.assertEqual(check.main(["compare", str(self.write("ours.csv", "1000,inf\n1001,70\n")),
str(self.write("bad.xml", "<not closed"))]), 1)
self.assertEqual(shown.getvalue().count("Could not compare"), 2)
def test_health_records_with_non_finite_values_are_skipped(self):
record = '<Record type="HKQuantityTypeIdentifierHeartRate" startDate="2026-09-29 12:00:00 +1000" value="%s"/>'
path = self.write("export.xml", "<HealthData>" + record % "inf" + record % "72" + "</HealthData>")
start = check.parse_time("2026-09-29 12:00:00 +1000")
self.assertEqual(check.read_health(path, start - 1, start + 1), [(start, 72)])
def test_time_formats(self):
self.assertEqual(check.parse_time("1700000000.5"), 1700000000.5)
self.assertEqual(check.parse_time("2023-11-14T22:13:20Z"), 1700000000)
self.assertEqual(check.parse_time("2023-11-15 08:13:20 +1000"), 1700000000)
with self.assertRaises(ValueError):
check.parse_time("yesterday")
if __name__ == "__main__":
unittest.main()
+566
View File
@@ -0,0 +1,566 @@
"""Repositories and sources behind a login: F-Droid basic auth and GitHub tokens.
A fake HTTPS layer stands in for the network (urllib's real redirect, error and
request processing still run), so these tests see every request and its headers.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import contextlib
import http.client
import io
import json
import os
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import Mock, patch
import urllib.request
import urllib.response
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _images, _web, fdroid, github, search
import server
FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
REPO = 'https://repo.example/fdroid/repo/'
USER, PASSWORD = 'alice', 's3cr@t-pässword'
TOKEN = 'ghp_frameControlTestToken0123456789'
SECRETS = (PASSWORD, TOKEN, base64.b64encode((USER + ':' + PASSWORD).encode()).decode())
REAL_BUILD_OPENER = urllib.request.build_opener
def basic(user, password):
return 'Basic ' + base64.b64encode((user + ':' + password).encode()).decode()
class FakeHTTPS(urllib.request.HTTPSHandler):
"""Answers https requests from route(url, authorization) -> (status, headers, body)."""
def __init__(self, route):
super().__init__()
self.route, self.seen = route, []
def https_open(self, req):
auth = req.get_header('Authorization')
self.seen.append((req.full_url, auth))
status, headers, body = self.route(req.full_url, auth)
message = http.client.HTTPMessage()
for key, value in headers.items():
message[key] = value
response = urllib.response.addinfourl(io.BytesIO(body), message, req.full_url, status)
response.msg = 'fake'
return response
class Base(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
mock = patch.object(fdroid.frame_host, name, value)
mock.start()
self.addCleanup(mock.stop)
self.web = FakeHTTPS(self.route)
mock = patch('urllib.request.build_opener', lambda *handlers: REAL_BUILD_OPENER(*handlers, self.web))
mock.start()
self.addCleanup(mock.stop)
mock = patch.dict(os.environ, {github.ENV: ''})
mock.start()
self.addCleanup(mock.stop)
for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing, github._rejected):
state.clear()
self.addCleanup(state.clear)
self.stderr = io.StringIO()
mock = patch('sys.stderr', self.stderr)
mock.start()
self.addCleanup(mock.stop)
def route(self, url, auth):
raise AssertionError('unexpected request ' + url)
def assertNoSecrets(self, *texts):
for text in texts:
for secret in SECRETS + (USER + ':',):
self.assertNotIn(secret, text)
def disk(self):
"""Everything saved under the data and cache folders except the credentials file."""
out = []
for path in self.root.rglob('*'):
if path.is_file() and path.name != 'source-credentials.json':
out.append(path.read_bytes().decode('utf-8', 'replace'))
return '\n'.join(out)
def credentials_file(self):
return self.root / 'data' / 'source-credentials.json'
class Repository(Base):
"""A repository at REPO that wants USER and PASSWORD."""
def setUp(self):
super().setUp()
self.login = basic(USER, PASSWORD)
self.redirect = None # file name -> absolute URL it redirects to
self.forbidden = 401
def serve(self, name):
return (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
def route(self, url, auth):
for mirror in ('https://mirror.example/fdroid/repo/', 'https://repo.example/elsewhere/'):
if url.startswith(mirror): # serves anyone; a login must never get here unless same-origin
return 200, {}, self.serve(url[len(mirror):])
self.assertTrue(url.startswith(REPO), url)
if auth != self.login:
return self.forbidden, {'WWW-Authenticate': 'Basic realm="repo"'}, b'no'
name = url[len(REPO):]
if self.redirect and name in self.redirect:
return 302, {'Location': self.redirect[name]}, b''
return 200, {}, self.serve(name)
def add(self, **kw):
return fdroid.add_repo('https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo', PIN, **kw)
class RepositoryLogin(Repository):
def test_add_search_download_with_login_in_the_url(self):
source = self.add()
self.assertEqual(source['url'], REPO)
self.assertEqual(source['fingerprint'], PIN)
saved = fdroid.user_repos()[0]
self.assertEqual(saved['url'], REPO)
self.assertNotIn('login', saved)
self.assertEqual(len(fdroid.search(source, 'example offline')), 1)
downloaded = fdroid.download(source, 'org.example.app', 1)
self.assertTrue(downloaded['verified'])
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
self.assertTrue(all('@' not in url for url, auth in self.web.seen)) # never in the URL
self.assertTrue(next(s for s in fdroid.sources() if s['id'] == source['id'])['login'])
self.assertNoSecrets(json.dumps(source), json.dumps(fdroid.sources()), self.disk(), self.stderr.getvalue())
# The settings file holds no part of the login; the credentials file holds it, privately.
settings = (self.root / 'data' / 'apk-repos.json').read_text()
self.assertNotIn(USER, settings)
self.assertTrue(self.credentials_file().is_file())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_login_given_apart_and_saved_login_reused_on_refresh(self):
source = fdroid.add_repo(REPO, PIN, username=USER, password=PASSWORD)
self.assertEqual(source['url'], REPO)
self.web.seen.clear()
fdroid._load(fdroid.user_repos()[0], force=True) # as a background refresh would
self.assertTrue(self.web.seen)
self.assertTrue(all(auth == self.login for url, auth in self.web.seen))
fdroid.add_repo(REPO) # re-adding without a login keeps the saved one
self.assertTrue(_credentials.has(source['id']))
def test_wrong_or_missing_login_saves_nothing(self):
for kw, message in [({'username': USER, 'password': 'wrong'}, 'check the username and password'),
({}, 'needs a username and password')]:
with self.subTest(kw=kw):
with self.assertRaisesRegex(SourceAuthError, message) as caught:
fdroid.add_repo(REPO, PIN, **kw)
self.assertNoSecrets(str(caught.exception), repr(caught.exception))
self.assertNotIn('wrong', str(caught.exception))
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
self.assertFalse((self.root / 'data' / 'apk-repo-state.json').exists())
self.assertEqual(_web._limited, {}) # an auth failure isn't a rate limit
def test_403_with_a_login_is_a_login_error_not_a_rate_limit(self):
self.forbidden = 403
with self.assertRaisesRegex(SourceAuthError, 'check the username and password'):
fdroid.add_repo(REPO, PIN, username=USER, password='wrong')
self.assertEqual(_web._limited, {})
self.assertEqual(fdroid.user_repos(), [])
def test_signature_checks_still_apply(self):
with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
fdroid.add_repo(REPO, '0' * 64, username=USER, password=PASSWORD)
self.assertEqual(fdroid.user_repos(), [])
self.assertFalse(self.credentials_file().exists())
def test_cross_origin_redirect_never_gets_the_login(self):
self.redirect = {'entry.jar': 'https://mirror.example/fdroid/repo/entry.jar',
'index-v2.json': 'https://repo.example/elsewhere/index-v2.json'}
source = self.add()
self.assertEqual(source['fingerprint'], PIN) # the mirror's copy still had to verify
mirror = [auth for url, auth in self.web.seen if url.startswith('https://mirror.example/')]
same_origin = [auth for url, auth in self.web.seen if url.startswith('https://repo.example/elsewhere/')]
self.assertEqual(mirror, [None])
self.assertEqual(same_origin, [None]) # same origin but outside the repository: no login either
def test_traversal_names_are_refused_and_never_get_the_login(self):
for name in ('%252e%252e/%252e%252e/evil/collect.apk', '..;/..;/evil/collect.apk', 'a%2f..%2f..%2fevil.apk',
'a%5c..%5cevil.apk', '%2e%2e/evil.apk', 'x;y.apk', '100%25.apk'):
with self.subTest(name=name), self.assertRaisesRegex(SourceError, 'unsafe|outside'):
fdroid._child(REPO, name)
self.assertEqual(fdroid._child(REPO, '/app/en-US/phoneScreenshots/#0 a.png'),
REPO + 'app/en-US/phoneScreenshots/%230%20a.png')
# Even if such a URL reached the downloader directly, the login stays home.
login = _credentials.basic(REPO, USER, PASSWORD)
for url in (REPO + '%252e%252e/%252e%252e/evil/collect.apk', REPO + '..;/..;/evil/collect.apk'):
with self.subTest(url=url), tempfile.TemporaryDirectory() as tmp:
self.web.seen.clear()
with contextlib.suppress(SourceError): # the repository answers 401 without the login
fdroid._fetch(url, Path(tmp) / 'out', 1 << 20, auth=login)
self.assertEqual(self.web.seen, [(url, None)])
def test_same_origin_redirect_outside_the_repository_gets_no_login(self):
self.redirect = {'example1.apk': 'https://repo.example/elsewhere/example1.apk'}
source = self.add()
self.web.seen.clear()
self.assertTrue(fdroid.download(source, 'org.example.app', 1)['verified'])
self.assertEqual(self.web.seen, [(REPO + 'example1.apk', self.login),
('https://repo.example/elsewhere/example1.apk', None)])
def test_other_ports_and_http_never_get_the_login(self):
login = _credentials.basic(REPO, USER, PASSWORD)
for url in ('https://repo.example:8443/fdroid/repo/x', 'http://repo.example/fdroid/repo/x',
'https://other.example/fdroid/repo/x', 'https://repo.example.evil/x'):
self.assertFalse(login.matches(url), url)
self.assertTrue(login.matches('https://REPO.example:443/anything'))
self.assertNoSecrets(repr(login))
handler = _credentials.Handler(login)
req = urllib.request.Request('https://other.example/x', headers={'Authorization': 'Basic stale'})
self.assertIsNone(handler.https_request(req).get_header('Authorization'))
def test_http_and_bad_logins_are_refused_before_any_request(self):
for url, kw in [('http://alice:pw@repo.example/fdroid/repo', {}),
('http://repo.example/fdroid/repo', {'username': USER, 'password': PASSWORD}),
(REPO, {'username': 'a:b', 'password': PASSWORD}),
(REPO, {'username': USER, 'password': 'x\r\nX-Evil: 1'}),
(REPO, {'password': PASSWORD}),
('https://alice:pw@repo.example/fdroid/repo', {'username': 'bob'})]:
with self.subTest(url=url, kw=kw), self.assertRaises(SourceError) as caught:
fdroid.add_repo(url, PIN, **kw)
self.assertNotIn('pw', str(caught.exception).replace('password', ''))
self.assertNoSecrets(str(caught.exception))
self.assertEqual(self.web.seen, [])
def test_remove_deletes_the_login(self):
source = self.add()
other = _credentials.bearer('https://api.github.com/', TOKEN)
_credentials.put('github', other)
fdroid.remove_repo(source['id'])
self.assertFalse(_credentials.has(source['id']))
self.assertNotIn(PASSWORD, self.credentials_file().read_text())
_credentials.remove('github')
self.assertFalse(self.credentials_file().exists()) # nothing left: the file goes
def test_cli_reads_the_password_from_stdin(self):
out = io.StringIO()
argv = ['fdroid.py', 'add', REPO, '--fingerprint', PIN, '--username', USER, '--password-stdin']
with patch.object(sys, 'argv', argv), patch.object(sys, 'stdin', io.StringIO(PASSWORD + '\n')), \
contextlib.redirect_stdout(out):
fdroid.main()
self.assertEqual(json.loads(out.getvalue())['url'], REPO)
self.assertNoSecrets(out.getvalue())
self.assertTrue(_credentials.has(fdroid.user_repos()[0]['id']))
def test_artwork_from_the_repository_gets_the_login_and_only_there(self):
source = self.add()
self.assertEqual(_credentials.for_url(REPO + 'icons/a.png').header(), self.login)
self.assertTrue(_credentials.has(source['id']))
self.assertIsNone(_credentials.for_url('https://repo.example/other/a.png')) # outside the repository
self.assertIsNone(_credentials.for_url('https://cdn.example/fdroid/repo/a.png'))
class GitHubToken(Base):
def setUp(self):
super().setUp()
self.accept = {TOKEN}
self.releases = json.dumps([]).encode()
self.anonymous_limited = False
def route(self, url, auth):
if url.startswith('https://api.github.com/'):
if auth is not None and auth not in {'Bearer ' + t for t in self.accept}:
return 401, {}, b'{"message": "Bad credentials"}'
if auth is None and self.anonymous_limited:
return 403, {'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(time.time()) + 1800)}, b'{}'
if url.endswith('/redirected'):
return 302, {'Location': 'https://github.com/landed'}, b''
return 200, {}, self.releases
if url == 'https://github.com/landed':
return 200, {}, b'{"ok": true}'
raise AssertionError('unexpected request ' + url)
def test_saved_token_goes_only_to_the_api(self):
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual(self.web.seen, [('https://api.github.com/repos/a/b/releases', 'Bearer ' + TOKEN)])
auth = github._credential(github._token()[0])
self.assertEqual(_web.read('https://api.github.com/redirected', ('api.github.com', 'github.com'), auth=auth),
b'{"ok": true}')
self.assertEqual(self.web.seen[-2:], [('https://api.github.com/redirected', 'Bearer ' + TOKEN),
('https://github.com/landed', None)]) # redirect: no token
status = github.sources()[0]['token']
self.assertEqual(status, {'set': True, 'from': 'saved', 'rejected': False})
self.assertNoSecrets(json.dumps(github.sources()), self.disk(), self.stderr.getvalue())
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
def test_rejected_token_retries_once_without_it(self):
github.set_token(TOKEN)
self.accept = set()
self.assertEqual(github._api('/repos/a/b/releases'), [])
self.assertEqual([auth for _, auth in self.web.seen], ['Bearer ' + TOKEN, None])
self.assertTrue(github.token_status()['rejected'])
self.assertIn('rejected', self.stderr.getvalue())
self.assertNoSecrets(self.stderr.getvalue())
self.web.seen.clear()
github._api('/repos/a/b/other')
self.assertEqual([auth for _, auth in self.web.seen], [None]) # not tried again this run
github.set_token(TOKEN) # saving it again gives it a fresh chance
self.assertFalse(github.token_status()['rejected'])
def test_environment_variable_wins(self):
github.set_token(TOKEN)
env = 'ghp_fromTheEnvironment'
self.accept = {env}
with patch.dict(os.environ, {github.ENV: env}):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], 'Bearer ' + env)
self.assertEqual(github.token_status(), {'set': True, 'from': 'env', 'rejected': False})
self.assertEqual(github.token_status()['from'], 'saved')
def test_clear_removes_it_from_disk(self):
github.set_token(TOKEN)
github.clear_token()
self.assertFalse(self.credentials_file().exists())
self.assertEqual(github.token_status(), {'set': False, 'from': None, 'rejected': False})
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
def test_saving_a_token_escapes_the_anonymous_rate_limit(self):
self.anonymous_limited = True
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/releases')
self.assertEqual(self.web.seen[-1][1], None)
github.set_token(TOKEN)
self.assertEqual(github._api('/repos/a/b/other'), []) # tried, with the token's own quota
self.assertEqual(self.web.seen[-1], ('https://api.github.com/repos/a/b/other', 'Bearer ' + TOKEN))
github.clear_token()
count = len(self.web.seen)
with self.assertRaisesRegex(SourceLimited, 'GitHub is limiting requests'):
github._api('/repos/a/b/third')
self.assertEqual(len(self.web.seen), count) # anonymous is still backing off: no request
def test_bad_tokens_are_refused(self):
for token in ('', ' ', 'two words', 'x\nAuthorization: y'):
with self.subTest(token=token), self.assertRaises(SourceError):
github.set_token(token)
self.assertFalse(self.credentials_file().exists())
PNG = b'\x89PNG\r\n\x1a\nfixture'
PRIVATE = 'https://repo.example/private/repo/'
TRAVERSALS = [PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', PRIVATE + '%2E%2E/x.png', PRIVATE + '../../evil/x.png',
PRIVATE + './icons/a.png', PRIVATE + '..%2f..%2fevil/x.png', PRIVATE + 'a%5c..%5c..%5cevil.png',
PRIVATE + 'a;b/../../evil.png', PRIVATE + '%252e%252e/%252e%252e/evil.png',
'https://repo.example/private/repository/x.png', 'https://repo.example/private/repo%2fx.png']
class ArtworkScope(Base):
"""The artwork proxy: actual outgoing headers, through _images.get's own HTTP code."""
def setUp(self):
super().setUp()
self.sent, self.redirects = [], {}
_credentials.put('fdroid-user-private', _credentials.basic(PRIVATE, USER, PASSWORD))
test = self
class Response:
def __init__(self, status, location=''):
self.status, self.location, self.body = status, location, PNG if status == 200 else b''
def getheader(self, name, default=''):
return self.location if name == 'Location' else default
def read1(self, n):
chunk, self.body = self.body[:n], self.body[n:]
return chunk
class Connection:
def __init__(self, host, port, timeout=None):
self.host, self.port = host, port
def request(self, method, path, headers):
url = 'https://%s%s' % (self.host, path)
test.sent.append((url, headers.get('Authorization')))
self.url = url
def getresponse(self):
if self.url in test.redirects:
return Response(302, test.redirects[self.url])
return Response(200)
def close(self):
pass
# A public address, no real socket or TLS, and Connection in place of http.client's.
for target, name, value in [(_images, '_resolve', lambda host, port, timeout: [(2, 1, 6, '', ('93.184.216.34', port))]),
(_images.socket, 'create_connection', lambda *a, **k: Mock()),
(_images.ssl, 'create_default_context', lambda: Mock()),
(_images.http.client, 'HTTPConnection', Connection)]:
mock = patch.object(target, name, value)
mock.start()
self.addCleanup(mock.stop)
def test_artwork_in_the_repository_gets_the_login(self):
self.assertEqual(_images.get(PRIVATE + 'icons/a%20b.png'), PNG)
self.assertEqual(self.sent, [(PRIVATE + 'icons/a%20b.png', basic(USER, PASSWORD))])
def test_paths_a_server_could_normalise_outside_the_repository_get_nothing(self):
for url in TRAVERSALS:
with self.subTest(url=url):
self.sent.clear()
self.assertEqual(_images.get(url), PNG)
self.assertEqual(self.sent, [(url, None)])
self.assertIsNone(_credentials.for_url(url))
def test_redirect_to_a_traversal_gets_nothing(self):
start = PRIVATE + 'icons/a.png'
self.redirects[start] = PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'
self.assertEqual(_images.get(start), PNG)
self.assertEqual(self.sent, [(start, basic(USER, PASSWORD)), (self.redirects[start], None)])
class ScopedHandler(Base):
"""Handler(scoped=True), for callers (artwork-like) that must stay inside a scope, redirects included."""
def route(self, url, auth):
if url == PRIVATE + 'icons/a.png':
return 302, {'Location': PRIVATE + '%2e%2e/%2e%2e/evil/collect.png'}, b''
return 200, {}, PNG
def test_redirect_into_a_traversal_is_stripped(self):
login = _credentials.basic(PRIVATE, USER, PASSWORD)
opener = urllib.request.build_opener(_credentials.Handler(login, scoped=True))
with opener.open(PRIVATE + 'icons/a.png') as r:
self.assertEqual(r.read(), PNG)
self.assertEqual(self.web.seen, [(PRIVATE + 'icons/a.png', basic(USER, PASSWORD)),
(PRIVATE + '%2e%2e/%2e%2e/evil/collect.png', None)])
for url in TRAVERSALS:
self.assertFalse(_credentials.in_scope(login, url), url)
self.assertTrue(_credentials.in_scope(login, PRIVATE + 'a/b%20c.png?x=1'))
self.assertTrue(_credentials.in_scope(login, PRIVATE.rstrip('/')))
CHURN = '''
import sys
sys.path.insert(0, sys.argv[1])
from apk_sources import _credentials
for i in range(int(sys.argv[3])):
key = sys.argv[2] + str(i)
_credentials.put(key, _credentials.basic('https://h.example/', 'u', 'p'))
if i % 2:
_credentials.remove(key)
'''
class TwoProcesses(Base):
def test_interleaved_saves_and_removals_are_not_lost(self):
ui = str(Path(__file__).resolve().parents[1] / 'ui')
env = dict(os.environ, FRAME_CONTROL_DATA_DIR=str(self.root / 'data'))
count = 60
procs = [subprocess.Popen([sys.executable, '-c', CHURN, ui, prefix, str(count)], env=env,
stdout=subprocess.PIPE, stderr=subprocess.PIPE) for prefix in ('a', 'b')]
for p in procs:
out, err = p.communicate(timeout=120)
self.assertEqual(p.returncode, 0, err.decode())
saved = set(json.loads(self.credentials_file().read_text()))
self.assertEqual(saved, {p + str(i) for p in 'ab' for i in range(0, count, 2)})
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(self.credentials_file().stat().st_mode), 0o600)
@unittest.skipUnless(shutil.which('node'), 'needs node')
class PageLogin(unittest.TestCase):
def test_login_leaves_the_address_field_before_sending(self):
"""tests/page/source_login.mjs runs the real page functions from index.html."""
r = subprocess.run(['node', str(Path(__file__).parent / 'page' / 'source_login.mjs')],
capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
self.assertIn('ok', r.stdout)
class Endpoints(Repository):
"""The server API: logins in, never out, in responses or the server log."""
def setUp(self):
super().setUp()
mock = patch.object(search, 'modules', return_value=([fdroid, github], []))
mock.start()
self.addCleanup(mock.stop)
self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
self.addCleanup(self.httpd.server_close)
self.addCleanup(self.httpd.shutdown)
def request(self, method, path, body=None):
c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
c.request(method, path, json.dumps(body) if body is not None else None,
{'X-Frame-UI': '1', 'Content-Type': 'application/json'})
r = c.getresponse()
raw = r.read().decode()
c.close()
self.replies.append(raw)
return r.status, json.loads(raw)
def job(self, reply):
for _ in range(500):
job = self.request('GET', '/api/job?id=' + reply['job'])[1]
if job['done']:
return job
time.sleep(.01)
self.fail('job did not finish')
def test_add_with_login_and_token_settings(self):
self.replies = []
bad = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': 'wrong'})[1])
self.assertIn('check the username and password', bad['error'])
self.assertEqual(fdroid.user_repos(), [])
code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': REPO, 'fingerprint': PIN,
'username': USER, 'password': PASSWORD})
self.assertEqual(code, 200)
job = self.job(reply)
self.assertIsNone(job['error'])
listed = self.request('GET', '/api/sources')[1]['sources']
repo = next(s for s in listed if s['id'] == job['result']['source']['id'])
self.assertEqual((repo['url'], repo['login']), (REPO, True))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'github',
'token': TOKEN})[0], 200)
github_row = next(s for s in self.request('GET', '/api/sources')[1]['sources'] if s['id'] == 'github')
self.assertEqual(github_row['token'], {'set': True, 'from': 'saved', 'rejected': False})
self.assertEqual(self.request('POST', '/api/sources', {'action': 'set-token', 'source': 'fdroid',
'token': TOKEN})[0], 400)
self.assertEqual(self.request('POST', '/api/sources', {'action': 'clear-token', 'source': 'github'})[0], 200)
self.assertFalse(_credentials.has('github'))
self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': repo['id']})[0], 200)
self.assertFalse(self.credentials_file().exists())
self.assertNoSecrets(*self.replies, self.stderr.getvalue(), self.disk())
def test_login_in_the_url_is_accepted(self):
self.replies = []
url = 'https://alice:s3cr%40t-p%C3%A4ssword@repo.example/fdroid/repo'
job = self.job(self.request('POST', '/api/sources', {'action': 'add', 'url': url, 'fingerprint': PIN})[1])
self.assertIsNone(job['error'])
self.assertEqual(fdroid.user_repos()[0]['url'], REPO)
self.assertNoSecrets(*self.replies, self.stderr.getvalue())
if __name__ == '__main__':
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
"""Tracking protocols and fake-Frame BlueZ lifecycle; no headset or strap needed."""
import importlib.util
import math
import os
from pathlib import Path
import socket
import stat
import struct
import tempfile
import unittest
from unittest.mock import Mock, patch
SPEC = importlib.util.spec_from_file_location("tracking", Path(__file__).resolve().parents[1] / "frame/tracking/tracking.py")
t = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(t)
class Protocols(unittest.TestCase):
def test_hrs_formats(self):
self.assertEqual(t.heart_rate(b"\x00\x48"), 72)
self.assertEqual(t.heart_rate(b"\x01\x2c\x01"), 300)
self.assertEqual(t.heart_rate(b"\x1e\x48\x01\x00\x00\x04\x00\x04"), 72)
self.assertEqual(t.heart_rate(b"\x02\x48"), 72) # contact not supported
self.assertIsNone(t.heart_rate(b"\x04\x48")) # no contact
self.assertIsNone(t.heart_rate(b"\x00\x00"))
def test_hrs_malformed(self):
for packet in (b"", b"\x00", b"\x01\x48", b"\x08\x48\x00", b"\x10\x48",
b"\x10\x48\x00", b"\x00\x48\x01", b"\xe0\x48"):
with self.subTest(packet=packet), self.assertRaises(ValueError):
t.heart_rate(packet)
def test_gaze_coordinates(self):
self.assertEqual(t.gaze_angles([0, 0, 0, 1]), (0, 0))
angle = math.radians(15)
pitch, yaw = t.gaze_angles([math.sin(angle), 0, 0, math.cos(angle)])
self.assertAlmostEqual(pitch, -30) # OpenXR +X rotation looks up
self.assertAlmostEqual(yaw, 0)
pitch, yaw = t.gaze_angles([0, -math.sin(angle), 0, math.cos(angle)])
self.assertAlmostEqual(pitch, 0)
self.assertAlmostEqual(yaw, 30) # right
def test_invalid_gaze(self):
for pose in ([0, 0, 0, 0], [math.nan, 0, 0, 1], [0, 0, 0], [0, 0, 0, math.inf]):
with self.assertRaises(ValueError):
t.gaze_angles(pose)
def test_osc_wire(self):
self.assertEqual(t.osc_message('/x', [72]), b'/x\0\0,i\0\0' + struct.pack('>i', 72))
self.assertEqual(t.osc_message('/x', [1.0, -2.0]), b'/x\0\0,ff\0' + struct.pack('>ff', 1, -2))
for address in ('x', '/x\0y', '/x y', '/x*'):
with self.assertRaises(ValueError):
t.osc_message(address, [1])
def test_default_never_opens_socket(self):
with patch.object(t.socket, 'socket') as create:
osc = t.Osc()
osc.send('/x', [72])
osc.close()
create.assert_not_called()
def test_only_configured_endpoint(self):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
receiver.bind(('127.0.0.1', 0))
receiver.settimeout(1)
osc = t.Osc(receiver.getsockname())
try:
osc.send('/tracking/eye/CenterPitchYaw', [0.0, 30.0])
self.assertEqual(receiver.recv(1024), t.osc_message('/tracking/eye/CenterPitchYaw', [0.0, 30.0]))
finally:
osc.close()
def test_endpoint_validation(self):
for endpoint in [('example.org', 9000), ('0.0.0.0', 9000), ('224.0.0.1', 9000), ('127.0.0.1', 0), ('::1', 65536)]:
with self.assertRaises(ValueError):
t.Osc(endpoint)
def test_heart_staleness_contact_and_log(self):
now = [0]
osc = Mock()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'session.csv'
session = t.HeartSession(osc, '/hr', path, lambda: now[0])
self.assertIsNone(session.current())
session.notification(b'\x00\x48')
self.assertEqual(session.current(), 72)
osc.send.assert_called_once_with('/hr', [72])
now[0] = 6
self.assertIsNone(session.current())
session.notification(b'\x04\x48')
self.assertIsNone(session.current())
self.assertEqual(osc.send.call_count, 1)
session.close()
self.assertEqual(path.read_text().splitlines()[0], 'unix_seconds,bpm')
self.assertEqual(len(path.read_text().splitlines()), 2)
if os.name != 'nt':
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
with self.assertRaises(FileExistsError):
t.HeartSession(osc, '/hr', path)
def test_no_log_by_default(self):
with patch.object(t.os, 'open') as create:
session = t.HeartSession(Mock(), '/hr')
session.notification(b'\x00\x48')
session.close()
create.assert_not_called()
class FakeBluez(unittest.TestCase):
def setUp(self):
self.device = '/org/bluez/hci0/dev_TEST'
self.service = self.device + '/service1'
self.char = self.service + '/char1'
self.objects = {
self.device: {t.DEVICE: {'Address': 'AA:BB:CC:DD:EE:FF', 'Connected': False, 'ServicesResolved': True}},
self.service: {t.SERVICE: {'UUID': t.HRS, 'Device': self.device}},
self.char: {t.CHARACTERISTIC: {'UUID': t.MEASUREMENT, 'Service': self.service, 'Flags': ['notify']}},
}
self.api = Mock()
self.api.GetManagedObjects.side_effect = lambda: self.objects
self.bus = Mock()
self.interface = Mock(return_value=self.api)
self.values = Mock()
def reader(self):
return t.BluezHeart(self.bus, self.interface, 'AA:BB:CC:DD:EE:FF', self.values)
def test_subscribe_receive_and_cleanup(self):
reader = self.reader()
self.api.Connect.assert_called_once()
self.assertTrue(reader.subscribe())
self.api.StartNotify.assert_called_once()
reader.changed(t.CHARACTERISTIC, {'Value': [0, 72]}, [], self.char)
self.values.assert_called_once_with([0, 72])
reader.changed(t.CHARACTERISTIC, {'Value': [0, 73]}, [], '/other/strap')
self.assertEqual(self.values.call_count, 1)
reader.close()
self.api.StopNotify.assert_called_once()
self.api.Disconnect.assert_called_once()
self.bus.add_signal_receiver.return_value.remove.assert_called_once()
def test_preserve_existing_connection(self):
self.objects[self.device][t.DEVICE]['Connected'] = True
reader = self.reader()
reader.subscribe()
reader.close()
self.api.Connect.assert_not_called()
self.api.Disconnect.assert_not_called()
def test_only_selected_device_service(self):
self.objects[self.service][t.SERVICE]['Device'] = '/other/device'
reader = self.reader()
try:
with self.assertRaises(RuntimeError):
reader.subscribe()
finally:
reader.close()
self.api.StartNotify.assert_not_called()
def test_wait_for_services(self):
self.objects[self.device][t.DEVICE]['ServicesResolved'] = False
reader = self.reader()
self.assertFalse(reader.subscribe())
reader.close()
self.api.StartNotify.assert_not_called()
self.api.StopNotify.assert_not_called()
def test_disconnect_notification(self):
reader = self.reader()
reader.changed(t.DEVICE, {'Connected': 0}, [], self.device) # dbus.Boolean behaves as int
self.values.assert_called_once_with(None)
reader.close()
def test_failed_notify_cleans_connection(self):
reader = self.reader()
self.api.StartNotify.side_effect = RuntimeError('failure')
with self.assertRaises(RuntimeError):
reader.subscribe()
reader.close()
self.api.StopNotify.assert_not_called()
self.api.Disconnect.assert_called_once()
def test_unknown_device_does_not_connect_or_scan(self):
self.objects.clear()
with self.assertRaises(RuntimeError):
self.reader()
self.api.Connect.assert_not_called()
self.api.StartDiscovery.assert_not_called()
+4
View File
@@ -45,6 +45,10 @@ class SourceError(Exception):
"""User-readable failure from a source (network, format, verification)."""
class SourceAuthError(SourceError):
"""The server refused our login (HTTP 401, or 403 when a login was sent)."""
class SourceLimited(SourceError):
"""The source's host asked us to slow down; retry_after is in seconds."""
+270
View File
@@ -0,0 +1,270 @@
"""Logins for sources behind one: HTTP basic auth and access tokens.
Saved apart from the source settings, in frame_host.data_dir('source-credentials.json')
(mode 0600), keyed by a caller-chosen name: an F-Droid source id, 'github'. Each
credential belongs to one exact origin (https, host, port) and is only ever sent
to a request for that origin: Handler adds it to each request, redirects
included, and removes any Authorization header from every other request.
Scoped use (for_url, and Handler(..., scoped=True), which F-Droid repositories and
artwork use) is stricter: the URL must also be under the credential's scope by whole
path segments, with no dot segments, encoded slashes or ';' a server could normalise
into a path outside it. Origin-only matching suits a host-wide token (GitHub's API).
Secrets leave this module only as an Authorization header value. Credential's
repr hides them, and nothing here puts them in an exception message.
Each change is a read-modify-write under an inter-process lock (the CLI and the
app may both save), so one process can't undo another's save or removal.
"""
import base64
import contextlib
import errno
import json
import os
import tempfile
import threading
import urllib.parse
import urllib.request
import frame_host
_lock = threading.RLock()
MAX = 4096 # longest username, password or token accepted
def _path():
return frame_host.data_dir('source-credentials.json')
@contextlib.contextmanager
def _changing():
"""This thread, then this process, holds the credentials file for a read-modify-write."""
path = _path().with_name('source-credentials.lock')
with _lock:
path.parent.mkdir(parents=True, exist_ok=True)
with open(str(path), 'a+b') as f:
if os.name == 'nt':
import msvcrt
while True:
try:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_LOCK, 1)
break
except OSError as e: # LK_LOCK gives up after ~10 s of contention; keep waiting
if e.errno not in (errno.EACCES, errno.EDEADLK):
raise
try:
yield
finally:
f.seek(0)
msvcrt.locking(f.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
def path_segments(path):
"""A URL path's decoded segments, or None if a server might read it as another path:
'.' or '..' (plain or percent-encoded), encoded '/' or '\\', '%' left after decoding
(double encoding), or ';' path parameters."""
if ';' in path or '\\' in path:
return None
parts = [urllib.parse.unquote(part) for part in path.split('/')]
if any(part in ('.', '..') or '/' in part or '\\' in part or '%' in part or ';' in part for part in parts):
return None
return parts
def in_scope(credential, url):
"""True if url is on credential's origin and under its scope by whole, plain path segments."""
if credential is None or not credential.matches(url):
return False
try:
path, scope = urllib.parse.urlsplit(url).path, urllib.parse.urlsplit(credential.scope).path
except ValueError:
return False
parts, base = path_segments(path), path_segments(scope)
if parts is None or base is None:
return False
if base and base[-1] == '':
base = base[:-1] # 'https://h/repo/' covers '/repo/x', not '/repository'
return parts[:len(base)] == base
def origin(url):
"""'https://host:port' for an https URL; ValueError for anything else."""
p = urllib.parse.urlsplit(url)
port = p.port # raises ValueError on a bad port
if p.scheme.lower() != 'https' or not p.hostname:
raise ValueError('credentials are only sent over HTTPS')
host = p.hostname.lower()
return 'https://%s:%d' % ('[%s]' % host if ':' in host else host, port or 443)
def _check(value, what, allow_empty=False):
if not isinstance(value, str) or (not value and not allow_empty) or len(value) > MAX:
raise ValueError('invalid ' + what)
if any(ord(c) < 32 or ord(c) == 127 for c in value):
raise ValueError(what + ' must not contain control characters')
return value
class Credential:
"""A username and password (basic) or a token (bearer) for one origin.
scope is the URL prefix it belongs under: in_scope checks it (by path segments),
matches() checks only the origin."""
__slots__ = ('origin', 'scope', 'username', '_secret')
def __init__(self, scope, secret, username=None):
self.origin = origin(scope)
self.scope = scope
if username is not None:
_check(username, 'username')
if ':' in username:
raise ValueError('username must not contain ":"')
_check(secret, 'password', allow_empty=True)
else:
_check(secret, 'token')
if any(c.isspace() for c in secret):
raise ValueError('token must not contain spaces')
self.username, self._secret = username, secret
@property
def kind(self):
return 'bearer' if self.username is None else 'basic'
def header(self):
if self.username is None:
return 'Bearer ' + self._secret
return 'Basic ' + base64.b64encode((self.username + ':' + self._secret).encode('utf-8')).decode('ascii')
def matches(self, url):
"""True only for a URL on exactly this credential's origin."""
try:
return origin(url) == self.origin
except ValueError:
return False
def same(self, other):
return isinstance(other, Credential) and (self.origin, self.username, self._secret) == (
other.origin, other.username, other._secret)
def __repr__(self):
return '<Credential %s for %s>' % (self.kind, self.origin)
def _json(self):
return {'scope': self.scope, 'username': self.username, 'secret': self._secret}
def basic(scope, username, password):
"""Basic auth for scope's origin, e.g. basic('https://example.org/fdroid/repo/', 'me', 'pw')."""
return Credential(scope, password, username)
def bearer(scope, token):
"""An access token for scope's origin, sent as 'Authorization: Bearer <token>'."""
return Credential(scope, token)
def _read():
try:
with open(str(_path()), encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError
return data
except FileNotFoundError:
return {}
except (OSError, ValueError):
return {} # unreadable: as if none were saved; the next save rewrites it
def _save(data):
path = _path()
if not data:
try:
os.remove(str(path))
except FileNotFoundError:
pass
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix='.credentials-', suffix='.part') # created 0600
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
json.dump(data, f, separators=(',', ':'))
os.chmod(tmp, 0o600)
os.replace(tmp, str(path))
finally:
if os.path.exists(tmp):
os.unlink(tmp)
def get(key):
"""The saved Credential for key, or None."""
with _lock:
item = _read().get(key)
try:
return Credential(item['scope'], item['secret'], item.get('username'))
except (TypeError, KeyError, ValueError):
return None
def has(key):
return get(key) is not None
def put(key, credential):
if not isinstance(credential, Credential):
raise TypeError('put() needs a Credential')
with _changing():
data = _read()
data[key] = credential._json()
_save(data)
def remove(key):
"""Delete key's credential from disk; True if there was one."""
with _changing():
data = _read()
if key not in data:
return False
del data[key]
_save(data)
return True
def for_url(url):
"""A saved credential url is in the scope of (see in_scope), or None. Call it again for
each redirect hop: the answer depends only on the URL being requested."""
with _lock:
keys = list(_read())
for key in keys:
credential = get(key)
if in_scope(credential, url):
return credential
return None
class Handler(urllib.request.BaseHandler):
"""Adds the credential to requests for its origin (redirects included) and strips
Authorization from every other request, so a redirect elsewhere never gets it.
scoped=True also requires each request, redirects included, to be in_scope."""
handler_order = 900 # after the default processors
def __init__(self, credential, scoped=False):
self.credential, self.scoped = credential, scoped
def _request(self, req):
req.remove_header('Authorization')
allowed = in_scope if self.scoped else lambda c, url: c is not None and c.matches(url)
if allowed(self.credential, req.full_url):
req.add_unredirected_header('Authorization', self.credential.header())
return req
http_request = https_request = _request
+6 -2
View File
@@ -9,7 +9,7 @@ import threading
import time
from urllib.parse import urljoin, urlsplit
from apk_sources import SourceError
from apk_sources import SourceError, _credentials
_lock = threading.Lock()
_urls = OrderedDict()
@@ -141,7 +141,11 @@ def get(url, headers=None, redirects=3, deadline=None, limit=MAX_IMAGE):
path = p.path or '/'
if p.query:
path += '?' + p.query
conn.request('GET', path, headers={'User-Agent': 'FrameControl/0.3.1', **(headers or {})})
sent = {'User-Agent': 'FrameControl/0.3.1', **(headers or {})}
login = _credentials.for_url(url) # a repository behind a login: its own artwork only, per hop
if login:
sent['Authorization'] = login.header()
conn.request('GET', path, headers=sent)
live[0].settimeout(min(10, left()))
response = conn.getresponse()
if response.status in (301, 302, 303, 307, 308) and redirects:
+28 -13
View File
@@ -3,7 +3,7 @@ import hashlib, os, shutil, tempfile, threading, time, urllib.error, urllib.pars
from email.utils import parsedate_to_datetime
import frame_host
from . import SourceError, SourceLimited
from . import SourceAuthError, SourceError, SourceLimited, _credentials
UA = 'FrameControl/0.1'
APK_CAP = 2 * 1024 ** 3 # cached APKs across all sources, least recently used go first
@@ -38,7 +38,7 @@ def release(path):
else:
_in_use.pop(path, None)
BACKOFF = 600 # seconds to leave a host alone after 403/429 without Retry-After
_limited = {} # host -> time.time() before which we don't contact it
_limited = {} # host (+ login identity) -> time.time() before which we don't ask it with that login
_limited_lock = threading.Lock()
@@ -46,8 +46,16 @@ def _host(url):
return (urllib.parse.urlsplit(url).hostname or '').lower()
def throttle(url, headers=None):
"""Remember that url's host asked us to back off; return the delay in seconds."""
def _who(url, auth=None):
"""Backoff key: the host, per login. A token has its own quota, so anonymous
rate limiting mustn't hold back requests made with one (or the reverse)."""
if auth is None:
return _host(url)
return _host(url) + ' ' + hashlib.sha256(auth.header().encode()).hexdigest()[:16]
def throttle(url, headers=None, auth=None):
"""Remember that url's host asked us (with this login) to back off; return the delay in seconds."""
headers = headers or {}
now, delay = time.time(), None
value = (headers.get('Retry-After') or '').strip()
@@ -62,14 +70,15 @@ def throttle(url, headers=None):
if delay is None and headers.get('X-RateLimit-Remaining') == '0' and reset.isdigit():
delay = int(reset) - now # GitHub
delay = min(max(delay if delay is not None else BACKOFF, 1), 6 * 3600)
key = _who(url, auth)
with _limited_lock:
_limited[_host(url)] = max(_limited.get(_host(url), 0), now + delay)
_limited[key] = max(_limited.get(key, 0), now + delay)
return delay
def wait_time(url):
def wait_time(url, auth=None):
with _limited_lock:
return max(0, _limited.get(_host(url), 0) - time.time())
return max(0, _limited.get(_who(url, auth), 0) - time.time())
def limited_error(name, seconds, hint=''):
@@ -136,6 +145,8 @@ def checked_url(url, hosts):
class Redirect(urllib.request.HTTPRedirectHandler):
"""Only to the allowed hosts, and never carrying an Authorization header along:
a credential passed to open_url() is added again, by its Handler, only on its origin."""
def __init__(self, hosts):
self.hosts = hosts
@@ -147,13 +158,15 @@ class Redirect(urllib.request.HTTPRedirectHandler):
return result
def open_url(url, hosts, headers=None):
def open_url(url, hosts, headers=None, auth=None):
"""auth: an _credentials.Credential, sent only to requests on its own origin."""
checked_url(url, hosts)
return urllib.request.build_opener(Redirect(hosts)).open(
handlers = [Redirect(hosts)] + ([_credentials.Handler(auth)] if auth else [])
return urllib.request.build_opener(*handlers).open(
urllib.request.Request(url, headers={'User-Agent': UA, **(headers or {})}), timeout=60)
def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
def read(url, hosts, headers=None, ttl=3600, name=None, hint='', auth=None):
path = os.path.join(cache(), hashlib.sha256(url.encode()).hexdigest() + '.data')
name = name or _host(url) or 'The source'
@@ -165,13 +178,13 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
if os.path.isfile(path) and time.time() - os.path.getmtime(path) < ttl:
with open(path, 'rb') as f:
return f.read()
wait = wait_time(url)
wait = wait_time(url, auth)
if wait:
data = cached()
if data is None:
raise limited_error(name, wait, hint)
return data
with open_url(url, hosts, headers) as r:
with open_url(url, hosts, headers, auth) as r:
data = r.read(8 * 1024 * 1024 + 1)
if len(data) > 8 * 1024 * 1024:
raise SourceError('Source index is too large')
@@ -185,8 +198,10 @@ def read(url, hosts, headers=None, ttl=3600, name=None, hint=''):
os.remove(tmp)
return data
except urllib.error.HTTPError as e:
if e.code == 401: # not 403: GitHub also uses that for rate limits, token or not
raise SourceAuthError('%s refused the login (HTTP 401)' % name) from e
if e.code in (403, 429):
delay = throttle(url, e.headers)
delay = throttle(url, e.headers, auth)
data = cached()
if data is None:
raise limited_error(name, delay, hint) from e
+82 -16
View File
@@ -20,7 +20,7 @@ import zipfile
if __package__ in (None, ''):
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from apk_sources import SourceError, SourceLimited, _web
from apk_sources import SourceAuthError, SourceError, SourceLimited, _credentials, _web
import frame_host
from frame_apk_sign import _der_parts, _cert_key, der
from frame_catalog import _IndexReader, _reduce_index, _sha256
@@ -71,6 +71,39 @@ def _url(url, fingerprint=None):
return urllib.parse.urlunsplit(('https', p.netloc.lower(), p.path.rstrip('/') + '/', '', '')), pin
def _split_login(url, username=None, password=None):
"""(url without user:pass, credential or None). The address may carry the login, as
F-Droid's client accepts (https://user:pass@host/fdroid/repo), or it may be given apart."""
try:
p = urllib.parse.urlsplit(url)
p.port
except ValueError as e:
raise SourceError('invalid repository URL') from e
if '@' in p.netloc:
if p.username is None:
raise SourceError('invalid repository URL')
linked = urllib.parse.unquote(p.username), None if p.password is None else urllib.parse.unquote(p.password)
if username not in (None, '', linked[0]) or (linked[1] is not None and password not in (None, '', linked[1])):
raise SourceError('the address and the username/password fields disagree')
username = linked[0]
password = linked[1] if linked[1] is not None else password
url = urllib.parse.urlunsplit((p.scheme, p.netloc.rpartition('@')[2], p.path, p.query, p.fragment))
if username in (None, ''):
if password:
raise SourceError('a password needs a username')
return url, None
clean, _ = _url(url)
try:
return url, _credentials.basic(clean, username, password or '')
except ValueError as e:
raise SourceError('cannot use that login: ' + str(e)) from None
def _login(source):
"""The saved login for a user repository, or None."""
return None if source.get('builtin') else _credentials.get(source['id'])
def _child(base, name):
name = str(name).lstrip('/')
decoded = urllib.parse.unquote(name)
@@ -81,6 +114,9 @@ def _child(base, name):
url = urllib.parse.urljoin(base, urllib.parse.quote(decoded, safe='/')) # names may contain '#' or spaces
if not url.startswith(base) or urllib.parse.urlsplit(url).query or urllib.parse.urlsplit(url).fragment:
raise SourceError('repository file is outside its repository')
# Nothing a server could normalise elsewhere: double-encoded dots, ';' parameters, encoded slashes.
if _credentials.path_segments(urllib.parse.urlsplit(url).path) is None:
raise SourceError('unsafe repository file name')
return url
@@ -91,14 +127,17 @@ class _HTTPSRedirect(urllib.request.HTTPRedirectHandler):
return super().redirect_request(req, fp, code, msg, headers, newurl)
def _fetch(url, path, maximum):
def _fetch(url, path, maximum, auth=None):
"""auth: the repository's login, sent only inside the repository (its origin and directory,
by whole plain path segments), on every request including redirects."""
host = urllib.parse.urlsplit(url).hostname
wait = _web.wait_time(url)
wait = _web.wait_time(url, auth)
if wait:
raise _web.limited_error(host, wait)
request = urllib.request.Request(url, headers={'User-Agent': 'FrameControl/1.0'})
handlers = [_HTTPSRedirect()] + ([_credentials.Handler(auth, scoped=True)] if auth else [])
try:
with urllib.request.build_opener(_HTTPSRedirect()).open(request, timeout=60) as r, open(path, 'wb') as f:
with urllib.request.build_opener(*handlers).open(request, timeout=60) as r, open(path, 'wb') as f:
total = 0
while True:
chunk = r.read(1 << 20)
@@ -109,8 +148,11 @@ def _fetch(url, path, maximum):
raise SourceError('repository file exceeds size limit')
f.write(chunk)
except urllib.error.HTTPError as e:
if e.code == 401 or (e.code == 403 and auth):
raise SourceAuthError('The repository refused the login (HTTP %d); check the username and password'
% e.code if auth else 'This repository needs a username and password') from None
if e.code in (403, 429):
raise _web.limited_error(host, _web.throttle(url, e.headers)) from e
raise _web.limited_error(host, _web.throttle(url, e.headers, auth)) from e
raise
@@ -342,7 +384,7 @@ def sources():
return [dict(id=i, kind=KIND, name=n, url=u, fingerprint=p, builtin=True,
# The archive only holds superseded versions; it clutters search unless asked for.
enabled=settings['enabled'].get(i, i != 'fdroid-archive'), trust='community')
for i, n, u, p in builtins] + settings['repos']
for i, n, u, p in builtins] + [dict(s, login=_credentials.has(s['id'])) for s in settings['repos']]
def _text(value):
@@ -514,10 +556,12 @@ def _source_lock(source_id):
return _load_locks.setdefault(source_id, threading.Lock())
def _load(source, force=False):
def _load(source, force=False, auth=None):
"""auth: a login to use instead of the saved one (while adding, before anything is saved)."""
if not re.fullmatch(r'[a-z0-9-]+', source['id']):
raise SourceError('invalid source id')
_url(source['url'], source.get('fingerprint'))
auth = auth or _login(source)
cache = frame_host.cache_dir('apk-sources', source['id'] + '.json')
found = None if force else _cached(source, cache)
if found:
@@ -535,7 +579,7 @@ def _load(source, force=False):
jar, raw = Path(tmp) / 'index.jar', Path(tmp) / 'index.json'
v2 = True
try:
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024)
_fetch(source['url'] + 'entry.jar', jar, 8 * 1024 * 1024, auth=auth)
except urllib.error.HTTPError as e:
if e.code not in (404, 410):
raise
@@ -543,7 +587,7 @@ def _load(source, force=False):
raise SourceError('repository no longer serves its signed v2 index; '
'refusing to fall back to the older v1 index') from e
v2 = False
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024)
_fetch(source['url'] + 'index-v1.jar', jar, 256 * 1024 * 1024, auth=auth)
content, pin = _jar(jar, 'index-v1.json', source.get('fingerprint'))
timestamp = _v1(content, raw)
_check_timestamp(source, timestamp)
@@ -552,7 +596,7 @@ def _load(source, force=False):
signed = json.loads(content)
timestamp, entry = signed.get('timestamp'), signed['index']
_check_timestamp(source, timestamp)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024)
_fetch(_child(source['url'], entry['name']), raw, 512 * 1024 * 1024, auth=auth)
if _sha256(raw) != entry['sha256'] or (entry.get('size') is not None and raw.stat().st_size != entry['size']):
raise SourceError('index SHA-256 or size mismatch')
apps = _reduce(raw, source)
@@ -572,7 +616,10 @@ def _load(source, force=False):
raise SourceError('cannot load repository: ' + str(e)) from e
def add_repo(url, fingerprint=None, name=None):
def add_repo(url, fingerprint=None, name=None, username=None, password=None):
"""Check and save a repository. A login (in the URL or apart) is kept out of the
saved URL, in _credentials, and saved only once the repository's index verified."""
url, login = _split_login(url, username, password)
url, pin = _url(url, fingerprint)
with _LOCK:
existing = next((s for s in _read()['repos'] if s['url'] == url), None)
@@ -583,15 +630,26 @@ def add_repo(url, fingerprint=None, name=None):
source = dict(id='fdroid-user-' + hashlib.sha256(url.encode()).hexdigest()[:20], kind=KIND,
name=name or (existing or {}).get('name') or urllib.parse.urlsplit(url).hostname,
url=url, builtin=False, enabled=True, trust='user', fingerprint=pin)
_, source['fingerprint'] = _load(source, force=True) # network work outside the settings lock
# Network work outside the settings lock; without a new login, a saved one (re-adding) is used.
_, source['fingerprint'] = _load(source, force=True, auth=login)
source['trust_on_first_use'] = existing.get('trust_on_first_use', False) if existing else pin is None
with _LOCK:
settings = _read()
current = next((s for s in settings['repos'] if s['id'] == source['id']), None)
if current and current['fingerprint'] != source['fingerprint']:
raise SourceError('repository already has a different pinned fingerprint; remove it first')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
previous = _credentials.get(source['id'])
if login:
_credentials.put(source['id'], login)
try:
settings['repos'] = [s for s in settings['repos'] if s['id'] != source['id']] + [source]
_write(_storage(), settings)
except BaseException:
if login and previous: # not saved after all: leave the login as it was
_credentials.put(source['id'], previous)
elif login:
_credentials.remove(source['id'])
raise
return source
@@ -602,6 +660,7 @@ def remove_repo(source_id):
raise SourceError('unknown user repository')
settings['repos'] = [s for s in settings['repos'] if s['id'] != source_id]
_write(_storage(), settings)
_credentials.remove(source_id)
with _state_file_lock(), _LOCK: # lock order: state file, then _LOCK
states = _states()
if states.pop(source_id, None) is not None: # re-adding is a deliberate new trust decision
@@ -663,7 +722,7 @@ def download(source, entry_id, version_code=None):
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix='.part')
os.close(fd)
try:
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3)
_fetch(_child(source['url'], version['name']), tmp, 4 * 1024 ** 3, auth=_login(source))
if _sha256(tmp) != sha:
raise SourceError('APK SHA-256 mismatch; download discarded')
os.replace(tmp, path)
@@ -684,6 +743,8 @@ def main():
add.add_argument('url')
add.add_argument('--fingerprint')
add.add_argument('--name')
add.add_argument('--username', help='for a repository behind a login (HTTP basic auth)')
add.add_argument('--password-stdin', action='store_true', help='read the password from the first line of stdin')
sub.add_parser('list')
remove = sub.add_parser('remove')
remove.add_argument('source')
@@ -702,7 +763,12 @@ def main():
def _cli(parser, args):
try:
if args.command == 'add':
result = add_repo(args.url, args.fingerprint, args.name)
password = None
if args.password_stdin:
if not args.username:
raise SourceError('--password-stdin needs --username')
password = sys.stdin.readline().rstrip('\r\n')
result = add_repo(args.url, args.fingerprint, args.name, args.username, password)
elif args.command == 'list':
result = sources()
elif args.command == 'remove':
+88 -8
View File
@@ -1,17 +1,77 @@
"""Curated publisher releases; optional topic discovery is page-only."""
import fnmatch, json, os, re, urllib.parse
import fnmatch, hashlib, json, os, re, sys, threading, urllib.parse
from . import SourceError, _web
from . import SourceAuthError, SourceError, _credentials, _web
KIND = 'github'
API = 'https://api.github.com'
ENV = 'FRAME_GITHUB_TOKEN' # wins over a token saved in source settings
CREDENTIAL = 'github' # _credentials key; only ever sent to api.github.com
_rejected = set() # SHA-256 of tokens GitHub answered 401 to (this run only)
_rejected_lock = threading.Lock()
TOPICS = ('oculus-quest', 'openxr', 'quest')
HOSTS = ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'GitHub releases', 'url': 'https://github.com',
'builtin': True, 'enabled': True, 'trust': 'community'}]
'builtin': True, 'enabled': True, 'trust': 'community', 'token': token_status()}]
def _token():
"""(token, where it came from) or (None, None). The environment variable wins."""
env = os.environ.get(ENV)
if env:
return env, 'env'
saved = _credentials.get(CREDENTIAL)
if saved and saved.kind == 'bearer':
return saved, 'saved'
return None, None
def _credential(token):
if isinstance(token, _credentials.Credential):
return token
try:
return _credentials.bearer(API + '/', token)
except ValueError as e:
raise SourceError('%s is not a usable GitHub token: %s' % (ENV, e)) from None
def _digest(credential):
return hashlib.sha256(credential.header().encode()).hexdigest()
def token_status():
"""Whether a token is set, where from, and whether GitHub rejected it; never the token."""
token, where = _token()
rejected = False
if token is not None:
try:
with _rejected_lock:
rejected = _digest(_credential(token)) in _rejected
except SourceError:
rejected = True
return {'set': token is not None, 'from': where, 'rejected': rejected}
def set_token(token):
"""Save a personal access token for api.github.com (FRAME_GITHUB_TOKEN still wins)."""
if not isinstance(token, str) or not token.strip():
raise SourceError('Paste a GitHub token')
try:
credential = _credentials.bearer(API + '/', token.strip())
except ValueError as e:
raise SourceError('That is not a usable GitHub token: ' + str(e)) from None
_credentials.put(CREDENTIAL, credential)
with _rejected_lock:
_rejected.discard(_digest(credential)) # a fresh chance
return {'message': 'GitHub token saved' + (' (%s is set and is used instead)' % ENV if os.environ.get(ENV) else '')}
def clear_token():
_credentials.remove(CREDENTIAL)
return {'message': 'GitHub token removed'}
def _curated():
@@ -20,13 +80,33 @@ def _curated():
def _api(path):
"""GET from the GitHub API, with the user's token if there is one. If GitHub rejects
the token (401), retry once without it, like Obtainium, and remember the rejection."""
headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}
token = os.environ.get('FRAME_GITHUB_TOKEN')
if token:
headers['Authorization'] = 'Bearer ' + token
token, _ = _token()
auth = _credential(token) if token is not None else None
with _rejected_lock:
if auth and _digest(auth) in _rejected:
auth = None # already refused this run: don't spend a request on it again
hint = ' (add a GitHub token in the store\'s source settings, or set %s, to raise the limit)' % ENV
if token is not None and not auth:
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
def get(auth):
return _web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if auth else hint, auth=auth)
try:
return json.loads(_web.read(API + path, ('api.github.com',), headers, name='GitHub',
hint='' if token else ' (set FRAME_GITHUB_TOKEN to raise the limit)'))
try:
data = get(auth)
except SourceAuthError:
if not auth:
raise
with _rejected_lock:
_rejected.add(_digest(auth))
print('GitHub rejected the access token (HTTP 401); continuing without it', file=sys.stderr)
hint = ' (GitHub rejected your token; replace it in the store\'s source settings)'
data = get(None)
return json.loads(data)
except (ValueError, TypeError) as e:
raise SourceError('Invalid GitHub response') from e
+8
View File
@@ -113,6 +113,14 @@ def manage_repo(action, **kwargs):
return getattr(module, action)(**kwargs)
def set_token(source_id, token):
"""Save (token) or remove (None) a source's access token; status is in sources()."""
module, _ = resolve(source_id)
if not hasattr(module, 'set_token'):
raise SourceError('This source does not use an access token')
return module.set_token(token) if token is not None else module.clear_token()
def normalise(name):
return ' '.join(re.findall(r'\w+', unicodedata.normalize('NFKC', name or '').casefold()))
+55 -4
View File
@@ -626,6 +626,8 @@
.source-repo-form input { margin-top:7px; display:block; border-radius:6px; }
.source-repo-form details { font-size:11px; color:var(--muted); }
.source-repo-form button { justify-self:start; border-radius:6px; }
.source-token { display:flex; flex-wrap:wrap; align-items:center; gap:7px; margin:8px 0 4px; }
.source-token input { flex:1; min-width:150px; border-radius:6px; font-size:12px; }
#appStore :focus-visible, .store-dialog :focus-visible, .store-tabs :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
.store-search input:focus-visible { outline:0!important; }
@media(min-width:1400px) { .store-grid { grid-template-columns:repeat(5,minmax(0,1fr)); } }
@@ -1318,7 +1320,7 @@
<button class="store-close" data-close="sourcesDialog" aria-label="Close source settings">×</button>
<div class="sources-intro"><div class="store-eyebrow">Your app shelves</div><h1 id="sourcesTitle">A world of apps.</h1><p>Choose where you discover games and apps. We bring them together for you.</p></div>
<div id="sourcesList"></div>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><button class="action">Add source</button></form>
<form id="addSource" class="source-repo-form"><h2>Add a source</h2><p>Have a favorite F-Droid repository? Add its address to browse its apps here.</p><label>Repository address<input name="url" type="url" required placeholder="https://example.org/fdroid/repo"></label><details><summary>Verify this source (optional)</summary><label>Repository fingerprint<input name="fingerprint" type="text" autocomplete="off" placeholder="Paste the fingerprint from the repository owner"></label></details><details><summary>This repository needs a login (optional)</summary><label>Username<input name="username" type="text" autocomplete="off" autocapitalize="none" spellcheck="false"></label><label>Password<input name="password" type="password" autocomplete="new-password"></label><p>Kept on this computer, apart from the address, and only sent to this repository over HTTPS.</p></details><button class="action">Add source</button></form>
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
</dialog>
@@ -3361,12 +3363,18 @@ function storeIcon(o) { return `<span class="store-icon" style="--hue:${storeHue
document.addEventListener("error", e => { if (e.target.matches?.(".store-art img, .store-icon img")) e.target.remove(); }, true);
function sourceMark(s) { const name=storeName(s); return `<span class="offer-mark" style="--hue:${storeHue(name)}" aria-hidden="true">${esc(name === "SideQuest" ? "SQ" : name === "F-Droid" ? "Fd" : name.slice(0,2))}</span>`; }
function sourceDescription(s) { return s.description || ({fdroid:"Independent, open-source apps",sidequest:"Discover something new in VR",itch:"Games from independent creators",github:"Direct from the people who make it"})[s.kind] || (s.name.includes("GitHub") ? "Direct from the people who make it" : s.name.includes("Droid") ? "Independent, open-source apps" : s.name.includes("SideQuest") ? "Discover something new in VR" : s.name.includes("itch") ? "Games from independent creators" : "Apps shared by this community"); }
// GitHub's optional access token: only whether one is set is ever shown, never its value.
function sourceToken(s) {
const t=s.token;if(!t)return "";
const note=t.rejected ? "GitHub rejected this token, so searches continue without it. Replace or clear it." : t.from==="env" ? "Using the token from FRAME_GITHUB_TOKEN." : t.set ? "A token is saved." : "Optional: a token raises GitHub's limit of 60 requests an hour.";
return `<form class="source-token" data-token="${esc(s.id)}"><input name="token" type="password" autocomplete="off" spellcheck="false" aria-label="${esc(storeName(s.name))} access token" placeholder="${t.set ? "Replace access token" : "Access token"}"><button class="small">Save</button>${t.from==="saved" ? `<button type="button" class="small" data-clear-token="${esc(s.id)}">Clear</button>` : ""}</form><small>${esc(note)}</small>`;
}
async function loadSources() {
try {
const {sources} = await api("/api/sources"); sourceState.sources = sources;
$("sourceFilter").innerHTML = `<option value="">All sources</option>` + sources.filter(s=>s.enabled).map(s=>`<option value="${esc(s.id)}">${esc(storeName(s.name))}</option>`).join("");
$("sourceFilter").value = sourceState.source;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}</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>`;
$("sourcesList").innerHTML = sources.map(s=>`<div class="source-setting">${sourceMark(s.name)}<div class="grow"><strong>${esc(storeName(s.name))}</strong><p>${esc(sourceDescription(s))}</p><small>${esc(({official:"Official store",community:"Community",user:"Added by you"})[s.trust] || "Community")}${s.login ? " · Uses a login" : ""}</small>${sourceToken(s)}${s.builtin === false ? `<button class="small" data-remove="${esc(s.id)}" aria-label="Remove ${esc(s.name)}">Remove</button>` : ""}</div><input class="source-toggle" role="switch" type="checkbox" aria-label="Enable ${esc(storeName(s.name))}" data-enable="${esc(s.id)}" ${s.enabled ? "checked" : ""}></div>`).join("") || `<p class="store-notice">Your sources will appear here when they are available.</p>`;
} catch (e) { $("sourceError").textContent = e.message; }
}
function storeCard(app) {
@@ -3494,7 +3502,8 @@ $("detailBody").onclick=e=>{if(e.target.closest('[data-close]'))return $("appDet
$("detailBody").onchange=e=>{if(e.target.name==='storeOffer'){sourceState.detail.choice=+e.target.value;renderAppDetail();loadOfferDetails();}};
$("sourcesOpen").onclick=()=>{loadSources();$('sourcesDialog').showModal();};
$("sourcesList").onchange=e=>{if(e.target.matches('[data-enable]'))changeSource({action:'enable',source:e.target.dataset.enable,enabled:e.target.checked});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});};
$("sourcesList").onclick=e=>{const b=e.target.closest('[data-remove]');if(b)changeSource({action:'remove',source:b.dataset.remove});const c=e.target.closest('[data-clear-token]');if(c)changeSource({action:'clear-token',source:c.dataset.clearToken});};
$("sourcesList").onsubmit=e=>{const f=e.target.closest('[data-token]');if(!f)return;e.preventDefault();const token=f.elements.token.value.trim();f.reset();if(token)changeSource({action:'set-token',source:f.dataset.token,token});};
async function addSource(body) {
$("sourceError").textContent="Checking the repository. Large ones can take a minute…";
let error="";
@@ -3502,7 +3511,49 @@ async function addSource(body) {
$("sourceError").textContent=result ? result.message : error || "We couldn't add that repository.";
if(result){$("addSource").reset();sourceState.source='';await loadSources();await searchSources();}
}
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
// A login typed into the address moves into the login fields, on the page too, before anything
// is sent: the address is shown and logged, the login never. It stays there if adding fails.
// Plain text, not the URL parser: an address it rejects (or a login it can't decode) must
// still lose its login first. Decoding falls back to the text as typed.
// Percent-decode what decodes; keep any byte sequence that isn't UTF-8 as typed.
// One forward pass: each lead byte gives its sequence length (at most 4 bytes).
function decodeLenient(s) {
return s.replace(/(?:%[0-9a-f]{2})+/gi, run => {
const b=run.match(/%[0-9a-f]{2}/gi); let out='', i=0;
while(i<b.length){
const v=parseInt(b[i].slice(1),16);
const n=v<0x80 ? 1 : v>=0xC2&&v<0xE0 ? 2 : v>=0xE0&&v<0xF0 ? 3 : v>=0xF0&&v<0xF5 ? 4 : 0;
if(n){ try { out+=decodeURIComponent(b.slice(i,i+n).join('')); i+=n; continue; } catch {} }
out+=b[i]; i++;
}
return out;
});
}
// Moves a login typed into the address into the login fields. Returns false for an
// address the URL parser rejects that may hold a login: it is refused, not sent.
function moveLogin(form) {
// Normalise the way URL parsers do: trim C0 controls and spaces, drop tabs and newlines.
const el=form.elements;
el.url.value=el.url.value.replace(/^[\u0000- ]+|[\u0000- ]+$/g,'').replace(/[\t\n\r]/g,'');
let u;
// A backslash and an '@' together are read differently by browsers and by the
// server, so the address can't be split safely: refuse it rather than guess.
if(el.url.value.includes('@') && el.url.value.includes('\\'))return false;
try { u=new URL(el.url.value); } catch { return !el.url.value.includes('@'); }
if(!u.username && !u.password)return true;
const user=decodeLenient(u.username), pass=decodeLenient(u.password);
u.username=''; u.password=''; el.url.value=u.href;
if(!el.username.value)el.username.value=user;
if(!el.password.value)el.password.value=pass;
el.username.closest('details').open=true;
return true;
}
$("addSource").onsubmit=e=>{
e.preventDefault();
if(!moveLogin(e.target)){ $("sourceError").textContent="That address isn't valid. Put the username and password in the login fields below, not in the address."; return; }
const f=new FormData(e.target), url=f.get('url'), username=f.get('username'), password=f.get('password');
addSource({action:'add',url,fingerprint:f.get('fingerprint'),...(username ? {username,password} : {})});
};
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
function showAndroidTab(tab) {
+26 -4
View File
@@ -2414,6 +2414,16 @@ def source_text(body, key, optional=False):
return value.strip()
def login_text(body, key):
"""A username, password or token: kept exactly (no stripping) and never echoed back."""
value = body.get(key)
if value in (None, ''):
return None
if not isinstance(value, str) or len(value) > 4096:
raise Failure('Provide a valid ' + key, 400)
return value
def source_search(query):
args = parse_qs(query)
q = args.get('q', [''])[0]
@@ -2452,19 +2462,31 @@ def source_manage(body):
if action == 'add':
url = source_text(body, 'url')
fingerprint, name = source_text(body, 'fingerprint', True), source_text(body, 'name', True)
parts = urlparse(url)
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname or parts.username:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL without credentials', 400)
username, password = login_text(body, 'username'), login_text(body, 'password')
try:
parts = urlparse(url)
parts.port
except ValueError:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400) from None
if parts.scheme not in ('https', 'fdroidrepos') or not parts.hostname:
raise Failure('Use an HTTPS or fdroidrepos:// repository URL', 400)
apk_search.repo_module() # fail now if this build can't manage repositories
def add(): # downloads and verifies the whole index: a job, not a request
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name)
# A login (here or in the URL) is stored apart and never comes back in a response.
login = {'username': username, 'password': password} if username or password else {}
source = apk_search.manage_repo('add_repo', url=url, fingerprint=fingerprint, name=name, **login)
message = 'Added ' + source['name']
if source.get('trust_on_first_use'):
message += '. Trusted on first use: ' + source['fingerprint'].upper()
return {'message': message, 'source': {k: source.get(k) for k in
('id', 'name', 'fingerprint', 'trust_on_first_use')}}
return start_job('Add repository', add)
if action in ('set-token', 'clear-token'):
token = login_text(body, 'token') if action == 'set-token' else None
if action == 'set-token' and not token:
raise Failure('Paste a token', 400)
return apk_search.set_token(source_text(body, 'source'), token)
if action == 'game-data':
package = source_text(body, 'package')
return start_job('Add game data', lambda: apk_search.add_game_data(package))