mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-24 04:07:59 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
342bf78b05 | ||
|
|
16b37bc667 | ||
|
|
5b531c84fb | ||
|
|
ae8cc1b4a6 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
63017e1969 | ||
|
|
b51fab9872 | ||
|
|
6aab62983e | ||
|
|
4ecc03280e | ||
|
|
2f1aeaac72 | ||
|
|
8347c2c3f7 | ||
|
|
a94c45d014 | ||
|
|
e20225604d | ||
|
|
2d81c92114 | ||
|
|
fe73906f1f | ||
|
|
84912ea4e1 | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
d6fb5e917a | ||
|
|
8051687e1e | ||
|
|
8fb00b263c | ||
|
|
4937caf310 | ||
|
|
128e7a4c94 | ||
|
|
daf30515b1 | ||
|
|
1a439f05c2 | ||
|
|
2f056dbcff | ||
|
|
016d2b8c2d | ||
|
|
e3176a2de8 | ||
|
|
42ec68ed51 | ||
|
|
fa49fab5bf | ||
|
|
416f3181cc | ||
|
|
ce9e476ec7 |
No files matched your search
@@ -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
|
||||
@@ -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: |
|
||||
|
||||
@@ -118,9 +118,8 @@ a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/ipho
|
||||
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
|
||||
From 0.4 it updates itself: when a new version is published, a banner offers
|
||||
**Update and restart**. It sends anonymous usage statistics, which you can turn
|
||||
off. Sharing compatibility results and error details is opt-in, and so is the
|
||||
mailing list it offers once on first launch (just an email address, **No
|
||||
thanks** to skip). See [docs/privacy.md](docs/privacy.md).
|
||||
off. Sharing compatibility results and error details is opt-in. See
|
||||
[docs/privacy.md](docs/privacy.md).
|
||||
Google doesn't publish `adb` for arm64 Linux, so that build uses your
|
||||
distribution's. If you already have `adb`, the app uses yours.
|
||||
|
||||
@@ -220,12 +219,13 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [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>
|
||||
|
||||
@@ -602,9 +602,6 @@ function buildMenu() {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
|
||||
else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
|
||||
} },
|
||||
{ label: "Join the Mailing List…", click: () => {
|
||||
if (win && url && linkPage === win.webContents) win.webContents.send("mailing:open");
|
||||
} },
|
||||
{ label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
|
||||
{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
|
||||
],
|
||||
|
||||
@@ -40,11 +40,6 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
ipcRenderer.removeAllListeners("report:open");
|
||||
ipcRenderer.on("report:open", () => cb());
|
||||
},
|
||||
// Help → Join the Mailing List… opens the page's mailing list setting (ui/frame_contact.py).
|
||||
onJoinMailingList: (cb) => {
|
||||
ipcRenderer.removeAllListeners("mailing:open");
|
||||
ipcRenderer.on("mailing:open", () => cb());
|
||||
},
|
||||
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
|
||||
// answer is the server's (connected, display name), never the token.
|
||||
meta: {
|
||||
|
||||
+54
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# UEVR from Frame Control, 2026-09-29
|
||||
|
||||
**Verified observations**, with limits below. Same device and software as
|
||||
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||
Frame's AEST clock. Headset tests ran under the shared
|
||||
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||
93 %, on charge throughout.
|
||||
|
||||
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||
to SteamVR, not that the image is correct.
|
||||
|
||||
## Manual injection (11:13–11:28)
|
||||
|
||||
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||
started with the game process's Wine variables, and its WPF window drew
|
||||
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||
|
||||
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||
was not restored on the next start.
|
||||
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||
`0x2000001` (the game) at the injector's coordinates, and
|
||||
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||
Inject, `/proc/<game>/maps` contained
|
||||
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||
|
||||
```text
|
||||
[11:17:32.948] Hooking D3D11
|
||||
[11:17:33.020] Hooked DirectX 11
|
||||
[11:17:34.263] Framework initialized
|
||||
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||
[11:17:39.792] is stereo enabled called!
|
||||
```
|
||||
|
||||
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||
|
||||
A second session's injector exited within about 20 seconds. The third,
|
||||
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||
files were removed.
|
||||
|
||||
## Through Frame Control (11:33–11:49)
|
||||
|
||||
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||
page's **VR mod** dialog, driven with a headless Chromium:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||
|
||||
## The GUI injector wasn't reliable (12:02–12:40)
|
||||
|
||||
After review fixes, cold starts through the API with UEVR's own injector:
|
||||
|
||||
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||
relaunched the injector didn't rescue a bad session. In both failures of
|
||||
the last batch, the first injector ignored the clicks and the next two
|
||||
never showed a window within 60 s.
|
||||
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||
with OpenVR still unselected. The injector's log had no error.
|
||||
|
||||
Lock released at 12:40 before redesigning.
|
||||
|
||||
## Frame Control's own injector (12:44–12:52)
|
||||
|
||||
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||
the game's Wine session. The old install was removed and the new one
|
||||
installed without the headset lock, since neither launches anything. The
|
||||
prefix listing after removal matched the pre-install state; the new install
|
||||
is 47 MB.
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||
|
||||
Battery 98–100 % on charge. Lock released at 12:52.
|
||||
|
||||
## After the second review (13:01–13:03)
|
||||
|
||||
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||
through the API, plus the UI run.
|
||||
|
||||
## Left on the Frame
|
||||
|
||||
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||
installed; nothing else from this work is left.
|
||||
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||
SteamVR were running at the end.
|
||||
- The test lock was released at 13:03.
|
||||
@@ -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 |
|
||||
@@ -52,6 +61,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
+85
-15
@@ -1,11 +1,14 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||
([below](#beat-saber-songs-first)).
|
||||
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
visible output, injection and failures.
|
||||
|
||||
## UEVR from Frame Control
|
||||
|
||||

|
||||
|
||||
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||
like `frame_steam.py`.
|
||||
|
||||
- **Which games.** Unreal Engine games, recognised by their
|
||||
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||
prefix, so it must have been played once.
|
||||
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||
entries that escape it, symlinks and oversized contents, then moved into
|
||||
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||
what to remove, including UEVR's own settings folders only if they didn't
|
||||
exist before. Install and remove refuse while the game is running, and only
|
||||
one mod action runs at a time.
|
||||
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||
waits for its window. It then runs a short script under that Python, with
|
||||
the game's Proton, prefix and Wine settings read from the game process, so
|
||||
both share one Wine session. The script repeats what UEVR's own injector does
|
||||
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||
no game uses it. Verified: the prefix's file listing matched its
|
||||
pre-install state afterwards.
|
||||
|
||||
Why not UEVR's own injector (verified 2026-09-29):
|
||||
|
||||
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||
of downloads) in the prefix.
|
||||
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||
clicks, raising or refocusing the injector window don't change that, so in
|
||||
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||
events straight to its window does work.
|
||||
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||
saved OpenVR choice.
|
||||
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||
new injector ignored input or never showed its window, even after
|
||||
relaunching. Its process list reads each process's main window title, a
|
||||
cross-process window message; a window that isn't answering would block its
|
||||
UI thread (inferred from the frontend's source, not proven). Without a
|
||||
`HOME` it also died in .NET at startup.
|
||||
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||
Neither stopped the injection.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||
same, and none of it is built for them:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
including the SHA-512 hashes used for the injector tests.
|
||||
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||
embeddable Python's `sha256_sum`.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
|
||||
+8
-16
@@ -218,27 +218,21 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
|
||||
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||
API key as `frame_compat_db.py sync`.
|
||||
|
||||
## Mailing list and contact email (optional)
|
||||
## Contact email (optional)
|
||||
|
||||
Frame Control never needs an email address. If you'd like to leave one, there
|
||||
are two separate choices, both off until you tick them:
|
||||
|
||||
| Choice | What it's for |
|
||||
|---|---|
|
||||
| **Join the mailing list: new features and updates** | Occasional emails about new features and updates |
|
||||
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||
|
||||
The first time Frame Control opens, a bar at the top of the page asks
|
||||
"Want to hear about new features? Join the Frame Control mailing list." with
|
||||
an empty email field, **Join** and **No thanks**. It only asks about the
|
||||
mailing list; follow-up questions are a separate choice in **Privacy &
|
||||
updates**. If the first-run privacy notice is showing, it waits until that's
|
||||
closed, so the two never appear together. It doesn't need the Frame to be set
|
||||
up. **No thanks** hides it for good, and it isn't shown again even if you
|
||||
ignore it. **Mailing list and contact email** in **Privacy & updates** (also
|
||||
**Help → Join the Mailing List…** in the desktop app) is where you join later,
|
||||
or change or remove the address and either choice, at any time. Unticking the
|
||||
mailing list is unsubscribing.
|
||||
You're asked once, in a bar at the top of the page, after the Frame has
|
||||
connected for the first time, and never in the same visit as the first-run
|
||||
privacy notice. **No thanks** hides it for good, and it isn't
|
||||
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||
is where you add, change or remove the address and either choice at any time.
|
||||
|
||||
**What's sent, and where.** The address and the two choices go privately to
|
||||
Frame Control's PostHog project, the same place as problem reports, as a
|
||||
@@ -273,9 +267,7 @@ sooner, ask the maintainer (for example in a problem report).
|
||||
Nothing sends email yet: this only records who agreed to what. The
|
||||
maintainer lists the addresses with
|
||||
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||
personal API key as `inbox`; `contacts updates` is the mailing list. If those
|
||||
addresses are ever moved to an email service to send the newsletter, this
|
||||
page will name it first.
|
||||
personal API key as `inbox`.
|
||||
|
||||
## Turning it all off
|
||||
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Executable
+161
@@ -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
|
||||
@@ -38,11 +38,10 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Privacy</span>
|
||||
<h1>Privacy</h1>
|
||||
<p>Short version: the app sends anonymous usage statistics you can turn off, your email only if you join the mailing list, and the website keeps only what you choose to send.</p>
|
||||
<p>Short version: the app collects nothing, and the website keeps only what you choose to send.</p>
|
||||
</div>
|
||||
<div class="faq notes">
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no accounts. It sends anonymous usage statistics, which you can turn off in Privacy & updates; <a href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md">privacy.md</a> lists exactly what.</p></section>
|
||||
<section><h2>The mailing list</h2><p>The first time the app opens, it offers to add you to the Frame Control mailing list for news about new features and updates. It's optional: the email field starts empty, <b>No thanks</b> skips it for good, and the app works the same either way. Only your email address is sent, privately to the maintainer, and only if you join. Unsubscribe or remove the address any time in Privacy & updates.</p></section>
|
||||
<section><h2>The app</h2><p>Frame Control talks only to your headset (over SSH on your network), to GitHub for releases, and to Steam and F-Droid for game and app listings. It has no analytics and no accounts.</p></section>
|
||||
<section><h2>The feedback form</h2><p>What you type becomes a public GitHub issue on <a href="https://github.com/saphid/frame-control/issues">saphid/frame-control</a>. To stop abuse, the form keeps a one-way hash of your IP address for about an hour to count submissions. The address itself isn't stored or published.</p></section>
|
||||
<section><h2>This website</h2><p>Hosted on Cloudflare Pages. No cookies, no analytics, no trackers. The download section asks GitHub for the latest version number. Donations go through Ko-fi, under Ko-fi's own privacy policy.</p></section>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
@@ -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')
|
||||
|
||||
+5
-16
@@ -228,9 +228,10 @@ class Contact(Base):
|
||||
|
||||
# ---- the one-time prompt
|
||||
|
||||
def test_the_prompt_is_offered_on_first_launch_then_stays_dismissed(self):
|
||||
self.assertTrue(fc.state()["showPrompt"]) # a new install, before the Frame has connected
|
||||
self.assertEqual(self.got, []) # offering it sends nothing
|
||||
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
|
||||
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
self.assertTrue(fc.state()["showPrompt"])
|
||||
fc.prompt({"prompt": "dismissed"})
|
||||
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
|
||||
self.assertEqual(fc.load()["prompt"], "dismissed")
|
||||
@@ -240,24 +241,12 @@ class Contact(Base):
|
||||
fc.prompt({"prompt": "reset"})
|
||||
|
||||
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
|
||||
tm.frame_seen("20260901.1", "3.8")
|
||||
fc.prompt({"prompt": "shown"})
|
||||
self.assertFalse(fc.state()["showPrompt"])
|
||||
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
|
||||
self.assertEqual(fc.load()["prompt"], "answered")
|
||||
|
||||
def test_joining_from_the_prompt_is_the_mailing_list_opt_in_only(self):
|
||||
fc.save({"email": " me@example.com ", "updates": True, "fromPrompt": True})
|
||||
self.assertEqual((fc.load()["prompt"], fc.state()["showPrompt"]), ("answered", False))
|
||||
props = self.events()[-1]["properties"]
|
||||
self.assertEqual((props["email"], props["updates"], props["followup"], props["action"]),
|
||||
("me@example.com", True, False, "set"))
|
||||
|
||||
def test_a_failed_send_never_blocks_joining(self):
|
||||
with self.offline():
|
||||
s = fc.save({"email": "me@example.com", "updates": True, "fromPrompt": True})
|
||||
self.assertTrue(s["updates"] and s["waiting"]) # kept here, sent later by the retrier
|
||||
self.assertFalse(s["showPrompt"])
|
||||
|
||||
# ---- reports and the maintainer's list
|
||||
|
||||
def reports(self):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_mods # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
APPID = 1067310
|
||||
|
||||
|
||||
def zip_bytes(files, links=()):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
for name in links:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
z.writestr(info, "/etc/passwd")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
|
||||
class FakeLibrary(unittest.TestCase):
|
||||
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
home = Path(tmp.name)
|
||||
self.steam = home / ".local/share/Steam"
|
||||
apps = self.steam / "steamapps"
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
|
||||
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
|
||||
self.prefix = apps / f"compatdata/{APPID}/pfx"
|
||||
for sub in ("Roaming", "Local"):
|
||||
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
|
||||
(apps / f"appmanifest_{APPID}.acf").write_text(
|
||||
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
|
||||
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
|
||||
("RECEIPTS", home / ".local/share/frame-control/mods")):
|
||||
p = mock.patch.object(frame_mods, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.running = None
|
||||
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
|
||||
"openvr_api.dll": b"MZ"})}
|
||||
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
|
||||
self.pin(self.downloads)
|
||||
|
||||
def pin(self, downloads):
|
||||
"""Serve these bytes and pin their real hashes in place of the published ones."""
|
||||
for name in ("UEVR", "PYTHON"):
|
||||
pinned = getattr(frame_mods, name)
|
||||
p = mock.patch.object(frame_mods, name,
|
||||
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
|
||||
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
|
||||
self.urlopen = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def managed(self):
|
||||
return self.prefix / "drive_c/frame-control"
|
||||
|
||||
|
||||
class Status(FakeLibrary):
|
||||
def test_unreal_game_is_eligible_and_not_installed(self):
|
||||
st = frame_mods.status(APPID)
|
||||
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
|
||||
(True, "Drop-Win64-Shipping.exe", False, False, True))
|
||||
|
||||
def test_game_without_unreal_binary_is_refused(self):
|
||||
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
|
||||
self.assertFalse(frame_mods.status(APPID)["eligible"])
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
|
||||
frame_mods.install(APPID)
|
||||
self.urlopen.assert_not_called()
|
||||
|
||||
def test_uninstalled_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.status(620)
|
||||
|
||||
|
||||
class InstallUninstall(FakeLibrary):
|
||||
def test_install_then_uninstall_leaves_prefix_as_found(self):
|
||||
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
res = frame_mods.install(APPID)
|
||||
self.assertTrue(res["installed"])
|
||||
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
|
||||
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
|
||||
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
|
||||
self.assertFalse(list(self.managed().glob(".staging-*")))
|
||||
# What UEVR writes on its first injection.
|
||||
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
roaming.mkdir(parents=True)
|
||||
(roaming / "log.txt").write_text("x")
|
||||
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
|
||||
frame_mods.uninstall(APPID)
|
||||
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
self.assertEqual(after, before)
|
||||
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_existing_uevr_settings_survive_uninstall(self):
|
||||
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "config.txt").write_text("mine")
|
||||
frame_mods.install(APPID)
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertEqual((profile / "config.txt").read_text(), "mine")
|
||||
|
||||
def test_second_install_is_a_no_op(self):
|
||||
frame_mods.install(APPID)
|
||||
calls = self.urlopen.call_count
|
||||
self.assertIn("already installed", frame_mods.install(APPID)["message"])
|
||||
self.assertEqual(self.urlopen.call_count, calls)
|
||||
|
||||
def test_refuses_while_game_runs(self):
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.install(APPID)
|
||||
self.running = None
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
def test_needs_a_prefix(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.prefix.parent)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
def test_receipt_cannot_point_outside_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
victim = self.steam / "steamapps/common/Gravitas"
|
||||
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(victim.is_dir())
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
|
||||
|
||||
|
||||
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
users = self.prefix / "drive_c/users"
|
||||
r["remove"].append(str(users))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(users.is_dir())
|
||||
|
||||
def test_failed_removal_keeps_the_receipt(self):
|
||||
frame_mods.install(APPID)
|
||||
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertFalse(self.managed().exists())
|
||||
|
||||
def test_receipt_cannot_name_another_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
r["prefix"] = "/"
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
|
||||
class Downloads(FakeLibrary):
|
||||
def test_hash_mismatch_deletes_and_installs_nothing(self):
|
||||
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
|
||||
self.assertFalse(self.managed().exists())
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_archive_escaping_its_folder_is_refused(self):
|
||||
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
|
||||
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
|
||||
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
|
||||
self.pin(downloads)
|
||||
with self.subTest(), self.assertRaises(frame_mods.Fail):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
|
||||
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
|
||||
import shutil
|
||||
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
|
||||
|
||||
def test_oversized_archive_is_refused(self):
|
||||
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
|
||||
def test_oversized_download_is_stopped(self):
|
||||
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse(list(frame_mods.CACHE.glob("*")))
|
||||
|
||||
|
||||
class Actions(FakeLibrary):
|
||||
def test_one_change_at_a_time(self):
|
||||
import fcntl
|
||||
lock = Path(tempfile.mkdtemp()) / "mods.lock"
|
||||
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
|
||||
frame_mods.main(["install", str(APPID)])
|
||||
self.assertFalse(frame_mods.status(APPID)["installed"])
|
||||
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
|
||||
|
||||
def test_zero_frames_is_an_answer(self):
|
||||
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
|
||||
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
|
||||
self.assertIsNone(frame_mods.frame_submits(APPID))
|
||||
|
||||
|
||||
class Start(FakeLibrary):
|
||||
def environ(self, text):
|
||||
"""The game's /proc/<pid>/environ; every other file reads normally."""
|
||||
real = frame_mods.Path.read_text
|
||||
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
|
||||
text if str(path).startswith("/proc/") else real(path, *a, **k))
|
||||
|
||||
def test_needs_install_first(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.start(APPID)
|
||||
|
||||
def run_start(self, reply, loaded=(False, True, True, True)):
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
|
||||
with self.environ(env), \
|
||||
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
|
||||
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
|
||||
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
|
||||
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
|
||||
mock.patch.object(frame_mods.time, "sleep"):
|
||||
return frame_mods.start(APPID), run
|
||||
|
||||
def test_injects_with_the_games_wine_settings(self):
|
||||
res, run = self.run_start('noise\n{"pid": 320}\n')
|
||||
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
|
||||
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
|
||||
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
|
||||
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
|
||||
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
|
||||
self.assertEqual(env["WINEPREFIX"], "/x")
|
||||
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
|
||||
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
|
||||
|
||||
def test_reports_the_injection_error(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
|
||||
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
|
||||
|
||||
def test_success_is_checked_in_the_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
|
||||
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
|
||||
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
|
||||
|
||||
def test_inject_script_is_valid_python(self):
|
||||
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
|
||||
|
||||
|
||||
class Helper(unittest.TestCase):
|
||||
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
|
||||
def test_unexpected_errors_are_still_json(self):
|
||||
# A receipt from another version, without "exe": a KeyError, not a traceback.
|
||||
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
|
||||
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
|
||||
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
|
||||
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
|
||||
self.assertEqual(out.returncode, 1, out.stderr)
|
||||
self.assertIn("KeyError", json.loads(out.stdout)["error"])
|
||||
|
||||
def test_bad_usage_prints_json_error(self):
|
||||
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
|
||||
class ModRoutes(test_server.ServerGuards):
|
||||
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||
|
||||
def test_mod_input_validation(self):
|
||||
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
|
||||
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
|
||||
status, payload = self.post("/api/mods", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_needs_custom_header(self):
|
||||
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
|
||||
|
||||
def test_runs_the_mods_helper_with_its_timeout(self):
|
||||
import server
|
||||
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
|
||||
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
|
||||
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
|
||||
|
||||
|
||||
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||
setattr(ModRoutes, name, None)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
|
||||
+5
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
Two separate opt-in choices, both off until ticked:
|
||||
|
||||
- updates: the mailing list, occasional emails about new features and updates
|
||||
- updates: occasional notices about Frame Control releases and updates
|
||||
- followup: the maintainer may ask follow-up questions, mainly about problem reports
|
||||
|
||||
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
|
||||
@@ -97,12 +97,12 @@ def from_report(email):
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows. showPrompt: the one-time mailing list offer hasn't been shown or
|
||||
answered yet. It's offered on first launch (the page shows it after the first-run privacy
|
||||
notice is closed, never both at once), and works whether or not the Frame is set up."""
|
||||
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
|
||||
and the Frame has connected at least once (setup worked), so it never greets a new install."""
|
||||
s = load()
|
||||
set_up = bool(frame_telemetry.settings().get('frames_seen'))
|
||||
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new'}
|
||||
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
|
||||
|
||||
|
||||
def _event(s):
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
|
||||
|
||||
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
|
||||
python3 - install APPID # download the pinned official release into the game's prefix
|
||||
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
|
||||
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
|
||||
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
||||
|
||||
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
|
||||
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
|
||||
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
|
||||
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
|
||||
|
||||
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
|
||||
or ignored input in about one session in four, and gamescope keeps real input
|
||||
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
|
||||
the game's Wine session, doing what the injector does on Inject.
|
||||
|
||||
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
|
||||
from python.org, each checked against a pinned hash. Files live in
|
||||
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
|
||||
records what to remove.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / ".local/share/Steam"
|
||||
CACHE = HOME / ".cache/frame-control/mods"
|
||||
RECEIPTS = HOME / ".local/share/frame-control/mods"
|
||||
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
|
||||
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
|
||||
|
||||
UEVR = {"version": "1.05", "dir": "uevr-1.05",
|
||||
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
|
||||
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
|
||||
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
|
||||
# release-file API (sha256_sum).
|
||||
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
|
||||
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
|
||||
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
|
||||
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
|
||||
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
|
||||
|
||||
# The game's environment a second program needs to join its Wine session. Not
|
||||
# WINESERVERSOCKET and friends: those are inherited file descriptors.
|
||||
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
|
||||
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
|
||||
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
|
||||
|
||||
|
||||
# Runs under Windows Python in the game's Wine session: the steps of UEVR
|
||||
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
|
||||
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
|
||||
INJECT_PY = r"""
|
||||
import ctypes, json, os, sys
|
||||
from ctypes import wintypes as W
|
||||
exe, uevr, profile = sys.argv[1:4]
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
|
||||
for name, res, args in (
|
||||
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
|
||||
("CloseHandle", W.BOOL, [H]),
|
||||
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
|
||||
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
|
||||
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
|
||||
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
|
||||
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
|
||||
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
|
||||
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
|
||||
("FreeLibrary", W.BOOL, [W.HMODULE]),
|
||||
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
|
||||
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
|
||||
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
|
||||
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
|
||||
fn = getattr(k32, name)
|
||||
fn.restype, fn.argtypes = res, args
|
||||
INVALID = ctypes.c_void_p(-1).value
|
||||
|
||||
class Entry(ctypes.Structure): # PROCESSENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
|
||||
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
|
||||
("flags", W.DWORD), ("name", W.WCHAR * 260)]
|
||||
|
||||
class Module(ctypes.Structure): # MODULEENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
|
||||
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
|
||||
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
|
||||
|
||||
def fail(msg):
|
||||
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
|
||||
|
||||
def snapshot(flags, pid, entry, first, next_, match):
|
||||
snap = k32.CreateToolhelp32Snapshot(flags, pid)
|
||||
if not snap or snap == INVALID:
|
||||
fail("couldn't list processes")
|
||||
try:
|
||||
entry.size = ctypes.sizeof(entry)
|
||||
ok = first(snap, ctypes.byref(entry))
|
||||
while ok:
|
||||
if match(entry):
|
||||
return entry
|
||||
ok = next_(snap, ctypes.byref(entry))
|
||||
finally:
|
||||
k32.CloseHandle(snap)
|
||||
|
||||
def remote_call(proc, fn, arg, wait_ms):
|
||||
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
|
||||
if not t:
|
||||
fail("CreateRemoteThread failed")
|
||||
try:
|
||||
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
|
||||
finally:
|
||||
k32.CloseHandle(t)
|
||||
|
||||
def inject(proc, pid, name):
|
||||
path = os.path.join(uevr, name)
|
||||
data = ctypes.create_unicode_buffer(path)
|
||||
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
|
||||
if not mem:
|
||||
fail(f"couldn't allocate in {exe}")
|
||||
try:
|
||||
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
|
||||
fail(f"couldn't write into {exe}")
|
||||
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
|
||||
if not remote_call(proc, load, mem, 30000):
|
||||
fail(f"loading {name} into {exe} didn't finish in 30 s")
|
||||
finally:
|
||||
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
|
||||
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
|
||||
lambda m: m.path.lower() == path.lower())
|
||||
if not m:
|
||||
fail(f"{name} didn't load into {exe}")
|
||||
return path, m.base
|
||||
|
||||
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
|
||||
lambda e: e.name.lower() == exe.lower())
|
||||
if not e:
|
||||
fail(f"{exe} isn't running")
|
||||
pid = e.pid
|
||||
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
|
||||
if not proc:
|
||||
fail(f"couldn't open {exe}")
|
||||
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
|
||||
local = k32.LoadLibraryW(path)
|
||||
fn = local and k32.GetProcAddress(local, b"nullify")
|
||||
if not fn:
|
||||
fail("couldn't find UEVRPluginNullifier.dll's nullify")
|
||||
offset = fn - local # same DLL, same layout in both processes
|
||||
k32.FreeLibrary(local)
|
||||
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
|
||||
nullified = remote_call(proc, base + offset, None, 2000)
|
||||
inject(proc, pid, "openvr_api.dll")
|
||||
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
|
||||
os.makedirs(profile, exist_ok=True)
|
||||
cfg = os.path.join(profile, "config.txt")
|
||||
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
|
||||
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
|
||||
open(cfg, "w").write("\n".join(lines) + "\n")
|
||||
inject(proc, pid, "UEVRBackend.dll")
|
||||
k32.CloseHandle(proc)
|
||||
print(json.dumps({"pid": pid, "nullified": nullified}))
|
||||
"""
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def libraries():
|
||||
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
|
||||
libs = [STEAM]
|
||||
try:
|
||||
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
|
||||
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
|
||||
except OSError:
|
||||
pass
|
||||
seen, out = set(), []
|
||||
for lib in libs:
|
||||
key = os.path.realpath(lib)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(lib)
|
||||
return out
|
||||
|
||||
|
||||
def game(appid):
|
||||
"""Where the game is installed and its Proton prefix, from its app manifest."""
|
||||
for lib in libraries():
|
||||
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
|
||||
try:
|
||||
text = manifest.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
m = re.search(r'"installdir"\s+"([^"]+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
name = re.search(r'"name"\s+"([^"]*)"', text)
|
||||
return {"name": name.group(1) if name else str(appid),
|
||||
"dir": lib / "steamapps/common" / m.group(1),
|
||||
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
|
||||
raise Fail(f"app {appid} isn't installed on this Frame")
|
||||
|
||||
|
||||
def shipping_exe(game_dir):
|
||||
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
|
||||
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
return max(found, key=lambda p: p.stat().st_size) if found else None
|
||||
|
||||
|
||||
def game_pid(exe_name):
|
||||
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
|
||||
want = "\\" + exe_name.lower()
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
|
||||
except OSError:
|
||||
continue
|
||||
if cmd.endswith(want):
|
||||
return int(d.name)
|
||||
return None
|
||||
|
||||
|
||||
def loaded(pid, name):
|
||||
try:
|
||||
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def receipt_path(appid):
|
||||
return RECEIPTS / f"{appid}-uevr.json"
|
||||
|
||||
|
||||
def read_receipt(appid):
|
||||
try:
|
||||
return json.loads(receipt_path(appid).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def managed_dir(g):
|
||||
return g["prefix"] / "drive_c" / MANAGED
|
||||
|
||||
|
||||
def user_dirs(g, exe):
|
||||
"""What UEVR itself writes into the prefix when it first runs."""
|
||||
user = g["prefix"] / "drive_c/users/steamuser/AppData"
|
||||
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
|
||||
"injector": user / "Local/praydog"} # the injector's own settings
|
||||
|
||||
|
||||
def status(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
r = read_receipt(appid)
|
||||
pid = game_pid(exe.name) if exe else None
|
||||
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
|
||||
"eligible": bool(exe), "exe": exe.name if exe else None,
|
||||
"prefix": g["prefix"].is_dir(), "installed": bool(r),
|
||||
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
|
||||
|
||||
|
||||
def fetch(url, digest, algo):
|
||||
"""Download into the cache once, and check its hash every time it's used."""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE / url.rsplit("/", 1)[1]
|
||||
if not path.exists():
|
||||
part = path.with_suffix(path.suffix + ".part")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
|
||||
for block in iter(lambda: r.read(1 << 20), b""):
|
||||
if f.tell() + len(block) > MAX_DOWNLOAD:
|
||||
raise Fail(f"{url} is bigger than expected; stopped the download")
|
||||
f.write(block)
|
||||
except (OSError, Fail) as e:
|
||||
part.unlink(missing_ok=True)
|
||||
raise Fail(f"download failed: {url}: {e}")
|
||||
part.rename(path)
|
||||
h = hashlib.new(algo)
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
if h.hexdigest() != digest:
|
||||
path.unlink()
|
||||
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
|
||||
return path
|
||||
|
||||
|
||||
def unpack(archive, dest, budget):
|
||||
"""Extract a ZIP into dest, refusing anything that would land outside it."""
|
||||
root = os.path.realpath(dest)
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
for info in z.infolist():
|
||||
target = os.path.realpath(os.path.join(dest, info.filename))
|
||||
if target != root and not target.startswith(root + os.sep):
|
||||
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
|
||||
if (info.external_attr >> 16) & 0o170000 == 0o120000:
|
||||
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
|
||||
budget -= info.file_size
|
||||
if budget < 0:
|
||||
raise Fail(f"{archive.name} unpacks to more than expected")
|
||||
z.extractall(dest)
|
||||
return budget
|
||||
|
||||
|
||||
def install(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
if not exe:
|
||||
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
|
||||
if not g["prefix"].is_dir():
|
||||
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
|
||||
if game_pid(exe.name):
|
||||
raise Fail(f"{g['name']} is running; quit it first")
|
||||
if read_receipt(appid):
|
||||
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
|
||||
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
|
||||
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
|
||||
base = managed_dir(g)
|
||||
dirs = user_dirs(g, exe)
|
||||
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
|
||||
stage = base / f".staging-{os.getpid()}"
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
budget = MAX_UNPACKED
|
||||
for archive, sub in archives:
|
||||
budget = unpack(archive, stage / sub, budget)
|
||||
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
|
||||
if not need.is_file():
|
||||
raise Fail(f"the download has no {need.name}")
|
||||
if game_pid(exe.name): # it may have started during the download
|
||||
raise Fail(f"{g['name']} started; quit it first")
|
||||
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
|
||||
for sub in (UEVR["dir"], PYTHON["dir"]):
|
||||
shutil.rmtree(base / sub, ignore_errors=True)
|
||||
(stage / sub).rename(base / sub)
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if str(base) in fresh:
|
||||
try:
|
||||
base.rmdir() # only succeeds if the install failed and left it empty
|
||||
except OSError:
|
||||
pass
|
||||
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
|
||||
"installed": int(time.time()), "prefix": str(g["prefix"]),
|
||||
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
|
||||
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
|
||||
"remove_if_empty": fresh,
|
||||
# UEVR creates these on first injection. Remove them on uninstall
|
||||
# only if they weren't there before, so earlier settings survive.
|
||||
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
|
||||
"sources": [UEVR["url"], PYTHON["url"]]}
|
||||
RECEIPTS.mkdir(parents=True, exist_ok=True)
|
||||
tmp = receipt_path(appid).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(receipt, indent=1))
|
||||
tmp.rename(receipt_path(appid))
|
||||
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
|
||||
|
||||
|
||||
def x11_windows(display):
|
||||
"""(id, name, width) for each named top-level window, via xwininfo."""
|
||||
try:
|
||||
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
|
||||
|
||||
|
||||
def frame_submits(appid):
|
||||
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
|
||||
try:
|
||||
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if not isinstance(stats, list):
|
||||
return None
|
||||
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
|
||||
|
||||
|
||||
def wait(what, seconds, check, step=2):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = check()
|
||||
if v is not None and v is not False: # 0 frames is still an answer
|
||||
return v
|
||||
time.sleep(step)
|
||||
raise Fail(f"timed out waiting for {what}")
|
||||
|
||||
|
||||
def start(appid):
|
||||
g = game(appid)
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for {g['name']}")
|
||||
exe = r["exe"]
|
||||
pid = game_pid(exe)
|
||||
if pid and loaded(pid, "UEVRBackend.dll"):
|
||||
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
|
||||
if not pid:
|
||||
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
|
||||
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
|
||||
try:
|
||||
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
|
||||
except OSError:
|
||||
raise Fail(f"{exe} exited") from None
|
||||
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
|
||||
display = game_env.get("DISPLAY")
|
||||
if not display or "WINEPREFIX" not in game_env:
|
||||
raise Fail(f"{exe} doesn't look like a Proton game process")
|
||||
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
|
||||
# own helper windows ("Steam", "Default IME", …) are small.
|
||||
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
|
||||
time.sleep(10)
|
||||
# Our own environment (HOME and the rest) under the game's Wine settings.
|
||||
env = {**os.environ, **game_env}
|
||||
base = f"C:\\{MANAGED}"
|
||||
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
|
||||
try:
|
||||
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
|
||||
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
|
||||
timeout=120, stdin=subprocess.DEVNULL,
|
||||
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Fail("the injection step didn't finish in 2 minutes") from None
|
||||
reply = None
|
||||
for line in reversed(out.stdout.splitlines()):
|
||||
try:
|
||||
reply = json.loads(line)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(reply, dict):
|
||||
reply = None
|
||||
if not reply or "error" in reply:
|
||||
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
|
||||
try:
|
||||
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
|
||||
except Fail:
|
||||
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
|
||||
submits = None
|
||||
try:
|
||||
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
|
||||
except Fail:
|
||||
pass
|
||||
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
|
||||
else "; SteamVR hasn't reported frames from it yet"),
|
||||
"frame_submits": submits, **status(appid)}
|
||||
|
||||
|
||||
def uninstall(appid):
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for app {appid}")
|
||||
if game_pid(r["exe"]):
|
||||
raise Fail("the game is running; quit it first")
|
||||
prefix = os.path.realpath(r["prefix"])
|
||||
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
|
||||
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
|
||||
# Only ever our folder and UEVR's own settings folders for this game, even
|
||||
# if the receipt says otherwise.
|
||||
managed = os.path.join(prefix, "drive_c", MANAGED)
|
||||
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
|
||||
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
|
||||
for t in targets:
|
||||
real = os.path.realpath(t)
|
||||
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
|
||||
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
|
||||
for t in targets:
|
||||
shutil.rmtree(t, ignore_errors=True)
|
||||
left = [t for t in targets if os.path.lexists(t)]
|
||||
if left: # keep the receipt, so Remove can be tried again
|
||||
raise Fail("couldn't remove " + ", ".join(left))
|
||||
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
|
||||
for t in r.get("remove_if_empty", []):
|
||||
if os.path.realpath(t) in parents:
|
||||
try:
|
||||
os.rmdir(t)
|
||||
except OSError:
|
||||
pass # something else lives there now
|
||||
receipt_path(appid).unlink()
|
||||
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
|
||||
shutil.rmtree(CACHE, ignore_errors=True)
|
||||
try:
|
||||
RECEIPTS.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
|
||||
raise Fail("usage: status|install|start|uninstall APPID")
|
||||
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
|
||||
if action is status:
|
||||
return status(int(argv[1]))
|
||||
import fcntl # only on the Frame; the server and its tests also import this module on Windows
|
||||
# One change at a time, or an uninstall could delete what an install is moving in.
|
||||
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
|
||||
return action(int(argv[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(main(sys.argv[1:])))
|
||||
except Fail as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
except Exception as e: # still one JSON object, for the server to show
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
||||
sys.exit(1)
|
||||
+139
-37
@@ -346,9 +346,9 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
@@ -357,7 +357,7 @@
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
@@ -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)); } }
|
||||
@@ -704,12 +706,15 @@
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<form class="notice" id="contactNotice" hidden>
|
||||
<div class="grow"><b>Want to hear about new features?</b> Join the Frame Control mailing list.
|
||||
Optional, unsubscribe any time in Privacy & updates.
|
||||
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div></div>
|
||||
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
|
||||
you can change or remove it any time in Privacy & updates.
|
||||
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
|
||||
<div class="contact-opts">
|
||||
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
|
||||
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
|
||||
<span class="sub" id="cpMsg" role="status"></span>
|
||||
<button type="button" class="small" id="cpNo">No thanks</button>
|
||||
<button type="submit" class="action small" id="cpSave">Join</button>
|
||||
<button type="submit" class="action small" id="cpSave">Save</button>
|
||||
</form>
|
||||
<div class="banner" id="offline" role="alert" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
@@ -1258,17 +1263,17 @@
|
||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||
folder, user name, addresses and keys removed.</span></label>
|
||||
<div class="hint" id="tStatus"></div>
|
||||
<h3 style="margin-top:22px" id="mailingList">Mailing list and contact email (optional)</h3>
|
||||
<h3 style="margin-top:22px">Contact email (optional)</h3>
|
||||
<form id="contactForm">
|
||||
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
|
||||
<div class="contact-opts">
|
||||
<label><input type="checkbox" id="cUpdates"> Join the mailing list: new features and updates</label>
|
||||
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
|
||||
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
|
||||
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
|
||||
<button type="button" class="small" id="cRemove">Remove my email</button></div>
|
||||
</form>
|
||||
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
|
||||
The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.</div>
|
||||
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
|
||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||
@@ -1315,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>
|
||||
|
||||
@@ -1329,6 +1334,21 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modDlg" aria-labelledby="modTitle">
|
||||
<h2 id="modTitle">Play in VR with UEVR</h2>
|
||||
<p id="modState" class="sub"></p>
|
||||
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
|
||||
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
|
||||
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
|
||||
Remove takes them out again.</p>
|
||||
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
|
||||
UEVR's own menu in the headset has the per-game settings.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="modClose">Close</button>
|
||||
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
|
||||
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
|
||||
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
|
||||
</dialog>
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
@@ -3214,7 +3234,8 @@ function gameButtons(id, name, free) {
|
||||
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
|
||||
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
|
||||
if (g && g.installed) return `<span class="tag">Installed</span>` +
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
|
||||
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
|
||||
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
|
||||
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
|
||||
}
|
||||
@@ -3286,6 +3307,45 @@ document.body.addEventListener("click", async e => {
|
||||
});
|
||||
loadOwned();
|
||||
|
||||
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
|
||||
const mod = { appid: null, name: "" };
|
||||
function showMod(st, note) {
|
||||
const busy = note === null;
|
||||
$("modTitle").textContent = `${mod.name} in VR`;
|
||||
$("modState").textContent = !st ? "Checking the game on the Frame…"
|
||||
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
|
||||
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
|
||||
: st.injected ? "UEVR is running in the game now. Put on the headset."
|
||||
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
|
||||
: `UEVR ${st.version} isn't installed for this game yet.`;
|
||||
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
|
||||
const ok = st && st.eligible && st.prefix;
|
||||
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
|
||||
$("modRemove").hidden = !st || !st.installed;
|
||||
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
|
||||
}
|
||||
async function modAction(action, label) {
|
||||
const appid = mod.appid;
|
||||
showMod(mod.st, null);
|
||||
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
|
||||
if (appid !== mod.appid) return;
|
||||
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
|
||||
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
|
||||
}
|
||||
document.body.addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-mod]"); if (!b) return;
|
||||
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
|
||||
showMod(null, "");
|
||||
$("modDlg").showModal();
|
||||
const appid = mod.appid;
|
||||
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
|
||||
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
|
||||
});
|
||||
$("modInstall").onclick = () => modAction("install", "Install UEVR");
|
||||
$("modStart").onclick = () => modAction("start", "Start UEVR");
|
||||
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
|
||||
$("modClose").onclick = () => $("modDlg").close();
|
||||
|
||||
// ---- Discover apps ----
|
||||
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||||
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||||
@@ -3303,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) {
|
||||
@@ -3436,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="";
|
||||
@@ -3444,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) {
|
||||
@@ -4298,7 +4407,7 @@ $("macview").onclick = async e => {
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", mailingList: "tools", panels: "tools" };
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -4550,6 +4659,7 @@ async function offerTest(m) {
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
@@ -4560,6 +4670,7 @@ function renderTelemetry(s) {
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) privacyNoticeShown = true;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
@@ -4573,25 +4684,24 @@ $("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; checkContactPrompt(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("noticeMore").onclick = async () => {
|
||||
$("privacyNotice").hidden = true;
|
||||
checkContactPrompt();
|
||||
await setTelemetry({ compat: true, diagnostics: true });
|
||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||
};
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; checkContactPrompt(); };
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||
const telemetryLoaded = loadTelemetry();
|
||||
|
||||
// ---- mailing list and contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
|
||||
const contact = { email: "", updates: false, followup: false };
|
||||
function renderContact(s) {
|
||||
Object.assign(contact, s);
|
||||
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
|
||||
$("cRemove").hidden = !s.email;
|
||||
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
|
||||
: s.email ? `Saved. ${s.email} may get ${[s.updates && "mailing list emails", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||||
: "Sent privately to the Frame Control maintainer, never shared or published. The mailing list is occasional news about new features and updates; follow-up questions are mainly about problem reports you send.";
|
||||
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
|
||||
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
|
||||
}
|
||||
async function saveContact(change) {
|
||||
const s = await api("/api/contact", change);
|
||||
@@ -4603,26 +4713,25 @@ async function loadContact() {
|
||||
try { renderContact(await api("/api/contact")); } catch { return; }
|
||||
checkContactPrompt();
|
||||
}
|
||||
// The mailing list offer: once, on first launch, and never on screen with the first-run privacy
|
||||
// notice (it waits for that to be closed). Checked at load, when the notice closes and when the
|
||||
// Frame connects (in case the server couldn't be asked earlier).
|
||||
// One time only, only once the Frame has connected, and never in a visit that showed the privacy
|
||||
// notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
|
||||
async function checkContactPrompt() {
|
||||
await telemetryLoaded;
|
||||
if (!$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
if (privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
let s;
|
||||
try { s = await api("/api/contact"); } catch { return; }
|
||||
if (!s.showPrompt || !$("privacyNotice").hidden || !$("contactNotice").hidden) return;
|
||||
if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
|
||||
$("contactNotice").hidden = false;
|
||||
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
|
||||
}
|
||||
$("contactNotice").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
|
||||
$("cpSave").disabled = true;
|
||||
try {
|
||||
// Joining is the mailing list only; follow-up questions keep whatever was agreed before.
|
||||
await saveContact({ email: $("cpEmail").value, updates: true, followup: contact.followup && contact.email.toLowerCase() === $("cpEmail").value.trim().toLowerCase(), fromPrompt: true });
|
||||
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
|
||||
$("contactNotice").hidden = true;
|
||||
toast("Thanks for joining! Unsubscribe any time in Privacy & updates.");
|
||||
toast("Thanks! Change or remove it any time in Privacy & updates.");
|
||||
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
|
||||
finally { $("cpSave").disabled = false; }
|
||||
};
|
||||
@@ -4645,13 +4754,6 @@ $("cRemove").onclick = async () => {
|
||||
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
|
||||
};
|
||||
loadContact();
|
||||
// Help → Join the Mailing List… (desktop app): the Privacy & updates section, where joining later lives.
|
||||
function openMailingList() {
|
||||
location.hash = "#mailingList";
|
||||
showPage(); // also when the hash was already this, so hashchange doesn't fire
|
||||
$("cEmail").focus({ preventScroll: true });
|
||||
}
|
||||
if (window.frameApp && window.frameApp.onJoinMailingList) window.frameApp.onJoinMailingList(openMailingList);
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
|
||||
+46
-9
@@ -709,13 +709,14 @@ def launch(body):
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40):
|
||||
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py"):
|
||||
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
|
||||
helper with the same JSON contract, on the Frame and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
|
||||
stdin=(HERE / script).read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# The helper prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
|
||||
try:
|
||||
@@ -766,6 +767,20 @@ def vr(body):
|
||||
return result
|
||||
|
||||
|
||||
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
|
||||
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
|
||||
|
||||
|
||||
def mods(body):
|
||||
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
|
||||
appid, action = str(body.get("appid", "")), body.get("action")
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in MOD_TIMEOUT:
|
||||
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
|
||||
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -2399,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]
|
||||
@@ -2437,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))
|
||||
@@ -2467,7 +2504,7 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
|
||||
Reference in new issue
Block a user