From 8051687e1eccb919f1832c8cdb6f5b66cf5aa191 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:18:58 +1000 Subject: [PATCH 01/63] CI: stop recurring false failures on main and pull requests - checks: the runner's packages.microsoft.com apt source now 403s, failing `apt-get update` and with it "Install zsh"; tolerate the refresh error (Ubuntu's lists still update and the install still fails if it can't). - e2e: a failed quiet image build is rebuilt with the full log, but the script exited 2 even when that rebuild succeeded; only fail if it fails. - ArtworkTests: a timed-out lookup gives its resolver slot back from its own thread, which a busy macOS runner may schedule after the next test starts. Wait for every slot before each test, and always return slots a test took, so one late thread can't cascade into three failures. - release: tagged builds no longer re-upload the installers as workflow artifacts (they are attached to the release; the artifact finalize timed out on v0.3.0). Pull requests build installers only when the Electron app, the macOS helper it compiles, or the workflow changes. - checks and release: a newer push to a pull request cancels the older run. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/checks.yml | 13 ++++++++-- .github/workflows/release.yml | 14 +++++++++-- scripts/e2e.sh | 3 ++- tests/test_frame_android_library.py | 37 ++++++++++++++++++++++------- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f131fe4..b7624f0 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04cfeaf..dcb37fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -49,7 +57,9 @@ jobs: tag="${GITHUB_REF_NAME}" gh release view "$tag" >/dev/null 2>&1 || gh release create "$tag" --draft --title "Frame Control ${tag#v}" --notes "" gh release upload "$tag" ${{ 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: | diff --git a/scripts/e2e.sh b/scripts/e2e.sh index c8d4ef0..75ad349 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -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 diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py index cdf51c0..537e46a 100644 --- a/tests/test_frame_android_library.py +++ b/tests/test_frame_android_library.py @@ -120,7 +120,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() @@ -231,10 +251,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 @@ -242,10 +259,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 From 3f20e5f9d343223bba994876d0461821e590c159 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:29:56 +1000 Subject: [PATCH 02/63] Windows test VM: a driver script and how to set one up scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's screendump, clicks and scrolls replayed by a scheduled task in the signed-in session, QEMU key names, and a tagged, removable headset key for tests against a real Frame. docs/testing.md explains setting it up (Windows from Microsoft, loopback-only ports) and the pitfalls found reproducing the Windows "RDP not working" report. docs/streaming.md records what Remote Desktop from Windows does against the Frame's xrdp (verified 2026-09-30). Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- docs/streaming.md | 9 +++ docs/testing.md | 72 ++++++++++++++++++++++++ scripts/windows-vm.sh | 128 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 1 deletion(-) create mode 100755 scripts/windows-vm.sh diff --git a/README.md b/README.md index fb40d32..d42fb43 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ Frame's software fits together, all checked against a real headset and labelled | [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing | | [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset | | [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel | -| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test | +| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM | | [Open questions](docs/open-questions.md) | What's still unchecked |
diff --git a/docs/streaming.md b/docs/streaming.md index 773e698..b810934 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -21,6 +21,15 @@ 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 is still unchecked: the Frame's log showed no +successful xrdp login up to that date. + **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. diff --git a/docs/testing.md b/docs/testing.md index 82a1826..61c4168 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 @@ -163,6 +164,77 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had accepted. The fake now refuses them the same way, and Frame Control makes ids Steam accepts. +## Windows test VM + +The unit tests run on Windows in CI, but the app itself doesn't. Anything that +depends on the Windows desktop (Remote Desktop, the installer, the bundled +Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a +[dockur/windows](https://github.com/dockur/windows) container on a Linux machine +with KVM, driven from the Mac with `scripts/windows-vm.sh`. + +Setting it up, once, on the Linux machine: + +- **Windows comes from Microsoft.** The container downloads the Windows 11 + image from Microsoft on first start. Windows runs unactivated, which is fine + for testing. Don't use activation workarounds or third-party Windows images. +- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH), + `127.0.0.1:8006:8006` (the web console) and, if you need it, + `127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use + `restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is + testing, and a `docker stop` shuts Windows down cleanly. +- **Give it SSH on first sign-in.** The container runs `/oem/install.bat` + once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell + as its default shell, and put a dedicated public key (for example + `~/.ssh/id_ed25519_winvm` on the Mac) in + `C:\ProgramData\ssh\administrators_authorized_keys`. +- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app + and the tests. + +Using it, with `WINVM_HOST` set to the Linux machine's ssh alias: + +```sh +scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes) +scripts/windows-vm.sh put Frame-Control-Setup-x64.exe +scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait' +scripts/windows-vm.sh shot screen.png # what's on its screen +scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot +scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names +scripts/windows-vm.sh down # shut Windows down +``` + +- **Clicks go through a scheduled task.** Commands over SSH run in a + session with no desktop, so `click` and `scroll` write the position to a + file, and a scheduled task running as the signed-in user replays it. The + VM's screen must be signed in; it is after `up`. 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. Set up Frame Control in the VM once, then: + +```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 +``` + +`frame-key` tags the key `windows-vm-test` in the headset's +`authorized_keys`, and `remove` deletes only that line. 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 reproduce a Remote Desktop report from +screenshots. It also took Windows-side logs, and `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, diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh new file mode 100755 index 0000000..441504d --- /dev/null +++ b/scripts/windows-vm.sh @@ -0,0 +1,128 @@ +#!/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 [args] +# up | down | status start it (waits for SSH), shut Windows down cleanly, show state +# ps '' run PowerShell as the VM's user +# put [] copy a file in (default: the user's Downloads) +# shot save the VM's screen +# click click screen pixel x,y in the signed-in session +# scroll turn the wheel n notches at x,y (negative scrolls down) +# keys ... 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 == (-|)<-> ]] || die "not a whole number: $1" } + +# 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 -EncodedCommand $b64" /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. +INPUT_PS1='$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 +[WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1]) | Out-Null +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 '$d = Join-Path $env:LOCALAPPDATA "windows-vm" +New-Item -ItemType Directory -Force $d | Out-Null +[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 +Set-Content "$d\input.txt" "'"$*"'" +Start-ScheduledTask -TaskName WindowsVmInput +foreach ($i in 1..50) { if (-not (Test-Path "$d\input.txt")) { exit 0 }; Start-Sleep -Milliseconds 200 } +Remove-Item "$d\input.txt" -ErrorAction SilentlyContinue +Write-Error "no input after 10 s: is $env:USERNAME signed in on the VM screen?"; 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 ''"; vm_ps "$1" ;; + put) + [[ -f ${1:-} ]] || die "usage: put []" + scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" " + 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 "; int $1; int $2; pointer $1 $2 click ;; + scroll) (( $# == 3 )) || die "usage: scroll "; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;; + keys) + (( $# )) || die "usage: keys ..." + 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) + pub=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r') + pub=${${(z)pub}[1,2]} + [[ $pub == ssh-ed25519\ * ]] || die "the VM has no Frame Control key yet: run Set Up Connection in the app first" + case ${1:-} in + add) ssh frame "grep -qxF '$pub $TAG' ~/.ssh/authorized_keys || echo '$pub $TAG' >> ~/.ssh/authorized_keys" \$f.tmp; chmod 600 \$f.tmp; mv \$f.tmp \$f" Date: Wed, 30 Sep 2026 20:24:48 +1000 Subject: [PATCH 03/63] Docs: signing in over RDP as steamos reaches a Plasma desktop (verified) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/streaming.md | 9 +++++++-- docs/testing.md | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/streaming.md b/docs/streaming.md index b810934..48290f4 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -27,8 +27,13 @@ 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 is still unchecked: the Frame's log showed no -successful xrdp login up to that date. +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 `; 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 diff --git a/docs/testing.md b/docs/testing.md index 61c4168..5b120a7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -231,9 +231,9 @@ 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 reproduce a Remote Desktop report from -screenshots. It also took Windows-side logs, and `frame-key` left -`authorized_keys` byte-for-byte as it was. +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 From 895cc0ebbd3b0186912ea52e90bd59906d477d02 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:32:35 +1000 Subject: [PATCH 04/63] windows-vm.sh: review fixes for the headset key and input helper - frame-key remove deletes only the exact line add wrote, through a private temporary file, and leaves authorized_keys alone if it can't read or rewrite it. add checks the VM's key is a plain ed25519 key, keeps a last line without a newline intact, and doesn't duplicate a key the headset already trusts. - The input helper stops on any error before acknowledging, refuses a second click while one is in flight, and coordinates are bounded. - Host, container and user names are checked before they reach a remote shell; PowerShell errors come back as plain text. - docs/testing.md says what frame-key does and doesn't undo. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/testing.md | 16 ++++++++++----- scripts/windows-vm.sh | 47 ++++++++++++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 5b120a7..8595f9e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -205,8 +205,9 @@ 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`. QEMU's own `mouse_move` is - relative and drifts, so the script doesn't use it. + VM's screen must be signed in; it is after `up`. One click or scroll at a + time: they share that task. 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. @@ -216,7 +217,10 @@ scripts/windows-vm.sh down # shut Windows down one `click` uses. **Testing against a real Frame.** The VM reaches the headset on the LAN like -any other computer. Set up Frame Control in the VM once, then: +any other computer. Run **Set Up Connection** in the VM's Frame Control once; +that makes the VM's key. So the VM doesn't keep access between test runs, +take that key's line out of the headset's `~/.ssh/authorized_keys` afterwards. +Then, around each run: ```sh scripts/windows-vm.sh frame-key add # let the VM's key into the headset @@ -224,8 +228,10 @@ scripts/windows-vm.sh frame-key add # let the VM's key into the headset scripts/windows-vm.sh frame-key remove # and take it out again ``` -`frame-key` tags the key `windows-vm-test` in the headset's -`authorized_keys`, and `remove` deletes only that line. Follow the shared-device +`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. diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh index 441504d..d64aa47 100755 --- a/scripts/windows-vm.sh +++ b/scripts/windows-vm.sh @@ -30,14 +30,17 @@ opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new 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 == (-|)<-> ]] || die "not a whole number: $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 -EncodedCommand $b64" /d # 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. -INPUT_PS1='$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+" +# 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 -[WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1]) | 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 @@ -65,6 +70,8 @@ pointer() { # x y click|wheel [notches] local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n') vm_ps '$d = Join-Path $env:LOCALAPPDATA "windows-vm" New-Item -ItemType Directory -Force $d | Out-Null +$busy = Get-Item "$d\input.txt" -ErrorAction SilentlyContinue +if ($busy -and $busy.LastWriteTime -gt (Get-Date).AddSeconds(-15)) { [Console]::Error.WriteLine("windows-vm: another click or scroll is in progress"); exit 1 } [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 @@ -73,7 +80,7 @@ Set-Content "$d\input.txt" "'"$*"'" Start-ScheduledTask -TaskName WindowsVmInput foreach ($i in 1..50) { if (-not (Test-Path "$d\input.txt")) { exit 0 }; Start-Sleep -Milliseconds 200 } Remove-Item "$d\input.txt" -ErrorAction SilentlyContinue -Write-Error "no input after 10 s: is $env:USERNAME signed in on the VM screen?"; exit 1' +[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" @@ -115,14 +122,26 @@ open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack( 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) - pub=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r') - pub=${${(z)pub}[1,2]} - [[ $pub == ssh-ed25519\ * ]] || die "the VM has no Frame Control key yet: run Set Up Connection in the app first" - case ${1:-} in - add) ssh frame "grep -qxF '$pub $TAG' ~/.ssh/authorized_keys || echo '$pub $TAG' >> ~/.ssh/authorized_keys" \$f.tmp; chmod 600 \$f.tmp; mv \$f.tmp \$f" > "$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 From bd24436fbc265054d00fcd69c8ee8337cc7c8316 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:39:07 +1000 Subject: [PATCH 05/63] windows-vm.sh: an atomic click lock, and setup errors fail the click input.txt is now created with CreateNew, so only one request can hold it; a lock older than 15 s is treated as left by a dead run. The outer PowerShell stops on any error, says why in one line, releases the lock and exits 1. docs/testing.md names both keys Set Up Connection can add to the headset. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/testing.md | 9 ++++++--- scripts/windows-vm.sh | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 8595f9e..3b6db77 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -218,9 +218,12 @@ scripts/windows-vm.sh down # shut Windows down **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 key. So the VM doesn't keep access between test runs, -take that key's line out of the headset's `~/.ssh/authorized_keys` afterwards. -Then, around each run: +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 diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh index d64aa47..2065d6b 100755 --- a/scripts/windows-vm.sh +++ b/scripts/windows-vm.sh @@ -68,18 +68,24 @@ Remove-Item "$PSScriptRoot\input.txt"' pointer() { # x y click|wheel [notches] local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n') - vm_ps '$d = Join-Path $env:LOCALAPPDATA "windows-vm" + vm_ps '$ErrorActionPreference = "Stop" +$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $held = $false +trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($held) { Remove-Item $req -ErrorAction SilentlyContinue }; exit 1 } New-Item -ItemType Directory -Force $d | Out-Null -$busy = Get-Item "$d\input.txt" -ErrorAction SilentlyContinue -if ($busy -and $busy.LastWriteTime -gt (Get-Date).AddSeconds(-15)) { [Console]::Error.WriteLine("windows-vm: another click or scroll is in progress"); exit 1 } +$old = Get-Item $req -ErrorAction SilentlyContinue # left behind by a run that died +if ($old -and $old.LastWriteTime -lt (Get-Date).AddSeconds(-15)) { Remove-Item $req } +# Creating input.txt is the lock: CreateNew fails if another request already has it. +try { $f = [IO.File]::Open($req, "CreateNew", "Write") } +catch { [Console]::Error.WriteLine("windows-vm: another click or scroll is in progress"); exit 1 } +$held = $true +$text = [Text.Encoding]::ASCII.GetBytes("'"$*"'"); $f.Write($text, 0, $text.Length); $f.Close() [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 -Set-Content "$d\input.txt" "'"$*"'" Start-ScheduledTask -TaskName WindowsVmInput -foreach ($i in 1..50) { if (-not (Test-Path "$d\input.txt")) { exit 0 }; Start-Sleep -Milliseconds 200 } -Remove-Item "$d\input.txt" -ErrorAction SilentlyContinue +foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 } +Remove-Item $req -ErrorAction SilentlyContinue [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' } From 924e37aa30d048f9de6640b4c5f3ba553b0ee94d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:44:35 +1000 Subject: [PATCH 06/63] windows-vm.sh: serialise clicks with a named mutex instead of a lock file Windows releases the mutex when the request's process ends, so there's no stale lock to recover and no window where two requests can take each other's. Requests sent together now run one after another. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/testing.md | 6 +++--- scripts/windows-vm.sh | 17 ++++++++--------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/testing.md b/docs/testing.md index 3b6db77..ee32c2d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -205,9 +205,9 @@ 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`. One click or scroll at a - time: they share that task. QEMU's own `mouse_move` is relative and drifts, - so the script doesn't use it. + 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. diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh index 2065d6b..be4f077 100755 --- a/scripts/windows-vm.sh +++ b/scripts/windows-vm.sh @@ -69,16 +69,15 @@ 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"; $held = $false -trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($held) { Remove-Item $req -ErrorAction SilentlyContinue }; exit 1 } +$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false +trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Remove-Item $req -ErrorAction SilentlyContinue }; 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 -$old = Get-Item $req -ErrorAction SilentlyContinue # left behind by a run that died -if ($old -and $old.LastWriteTime -lt (Get-Date).AddSeconds(-15)) { Remove-Item $req } -# Creating input.txt is the lock: CreateNew fails if another request already has it. -try { $f = [IO.File]::Open($req, "CreateNew", "Write") } -catch { [Console]::Error.WriteLine("windows-vm: another click or scroll is in progress"); exit 1 } -$held = $true -$text = [Text.Encoding]::ASCII.GetBytes("'"$*"'"); $f.Write($text, 0, $text.Length); $f.Close() +[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 From 73b97fedc535fe69b9ea67788550cc123bfeda2a Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:50:13 +1000 Subject: [PATCH 07/63] windows-vm.sh: stop the input helper before giving up on a click On a timeout or a setup error the request now stops the scheduled task and waits for it to end before releasing the mutex, so a stalled helper can't act later inside another request. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/windows-vm.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/windows-vm.sh b/scripts/windows-vm.sh index be4f077..ff6db1c 100755 --- a/scripts/windows-vm.sh +++ b/scripts/windows-vm.sh @@ -70,7 +70,16 @@ 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 -trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Remove-Item $req -ErrorAction SilentlyContinue }; exit 1 } +# 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") @@ -84,7 +93,7 @@ $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 } -Remove-Item $req -ErrorAction SilentlyContinue +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' } From 9b26c907ce277b8fb23357811214067b42091820 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:42:03 +1100 Subject: [PATCH 08/63] Telemetry: count headsets, not just installs frame_connected now carries headset_id, a one-way hash of the headset's /etc/machine-id worked out on the headset (frame_status.py), and headsets_saved, how many headsets this copy of the app has set up. It is sent once per build for each headset. The hash goes to usage statistics only: the status endpoint and MCP clients don't get it. docs/privacy.md says what is sent. Co-Authored-By: Claude Opus 5.5 --- docs/privacy.md | 8 +++++++- tests/test_telemetry.py | 32 ++++++++++++++++++++++++++++++++ ui/frame_status.py | 12 +++++++++++- ui/frame_telemetry.py | 19 ++++++++++++++----- ui/server.py | 4 +++- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index 3412850..24df6e7 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -33,6 +33,12 @@ computer, exactly as they were sent. yours. - Every event includes the app version, OS name (macOS, Windows or Linux), CPU architecture and Python version. +- `frame_connected` carries `headset_id`: the first 16 hex digits of a + SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset, + so the machine-id itself never leaves it. It is the same for one headset on + every computer, which lets us count headsets rather than installs; it can't + be turned back into the machine-id or a serial number. `headsets_saved` is how + many headsets this copy of Frame Control has set up. ## Usage events @@ -41,7 +47,7 @@ computer, exactly as they were sent. | `app_installed` | First run | | | `app_updated` | First run of a new version | `from_version` | | `app_opened` | At most once a day | | -| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` | +| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` | | `tab_viewed` | The first click on each tab in a session | `tab` | | `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below | | `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` | diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 9f5cc28..689acf4 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -164,6 +164,38 @@ class Lifecycle(Base): tm.frame_seen("20260922.1", "3.8") self.assertEqual(len(self.queued()), 1) + def test_frame_once_per_headset_with_saved_count(self): + tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2) + tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2) + tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2) + props = [e["properties"] for e in self.queued()] + self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props], + [("0123456789abcdef", 2), ("fedcba9876543210", 2)]) + + def test_frame_many_headsets_are_not_sent_twice(self): + ids = ["%016x" % n for n in range(25)] + for _ in range(2): + for h in ids: + tm.frame_seen("20260922.1", "3.8", h, 25) + self.assertEqual(len(self.queued()), 25) + + def test_frame_headset_id_must_be_the_hash(self): + tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None) + p = self.queued()[0]["properties"] + self.assertNotIn("headset_id", p) + self.assertNotIn("headsets_saved", p) + + +class HeadsetId(unittest.TestCase): + def test_hash_of_machine_id_and_none_without_one(self): + import hashlib + import frame_status + with mock.patch.object(frame_status, "read", return_value="abc123"): + self.assertEqual(frame_status.headset_id(), + hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16]) + with mock.patch.object(frame_status, "read", return_value=None): + self.assertIsNone(frame_status.headset_id()) + class Sending(Base): def serve(self, status=200): diff --git a/ui/frame_status.py b/ui/frame_status.py index 92ef7a1..0d6a1a5 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1 """ import ctypes as C import glob +import hashlib import math import json import os @@ -121,7 +122,16 @@ def os_release(): k, _, v = line.partition("=") out[k] = v.strip('"') return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"), - "variant": out.get("VARIANT_ID")} + "variant": out.get("VARIANT_ID"), "headset": headset_id()} + + +def headset_id(): + """A one-way hash of the headset's /etc/machine-id: the same for this headset on every + computer, and the machine-id itself never leaves the headset. None without one.""" + mid = read("/etc/machine-id") + if not mid: + return None + return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16] def volume(): diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py index 10f7843..68c06e0 100644 --- a/ui/frame_telemetry.py +++ b/ui/frame_telemetry.py @@ -50,6 +50,7 @@ SETTINGS = STATE / 'settings.json' OUTBOX = STATE / 'outbox.jsonl' SENT = STATE / 'sent.jsonl' SENT_KEEP = 200 +FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates OUTBOX_MAX = 2000 # events kept while offline; the oldest go first FLUSH_EVERY = 60 REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds @@ -366,16 +367,24 @@ def app_started(): _save(s) -def frame_seen(build, version): - """The Frame's SteamOS build, once per build (public build numbers).""" - key = f'{build}/{version}' +def frame_seen(build, version, headset=None, saved=None): + """The Frame's SteamOS build (public build numbers), once per build for each headset. + headset: the anonymous hash from frame_status.headset_id; saved: how many headsets + this copy of the app has set up.""" + headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None + key = f'{build}/{version}' + (f'/{headset}' if headset else '') with _lock: s = settings() if not build or key in s['frames_seen']: return - s['frames_seen'] = (s['frames_seen'] + [key])[-20:] + s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:] _save(s) - capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}) + p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]} + if headset: + p['headset_id'] = headset + if isinstance(saved, int) and saved >= 0: + p['headsets_saved'] = saved + capture('frame_connected', p) def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props): diff --git a/ui/server.py b/ui/server.py index 07ca9f3..fb4552a 100755 --- a/ui/server.py +++ b/ui/server.py @@ -403,7 +403,9 @@ def status(_body): s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20)) osr = s.get("os") if isinstance(s, dict) else None if isinstance(osr, dict): - frame_telemetry.frame_seen(osr.get("build"), osr.get("version")) + # The headset's hash is for usage statistics only; the page and MCP clients don't get it. + frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None), + len(LINK.reg.devices()) if LINK else None) frame_report.frame.update(build=osr.get("build"), version=osr.get("version")) return s From b6406a90a4b80671a16b9aa9729a3053ac70b761 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:27:44 +1100 Subject: [PATCH 09/63] Log scrubbing: find names and ssh operands in the original text (#81) Follow-up to #79. Known names and the hosts ssh names are now located as spans in the text as it came, overlapping spans merged, and replaced in one pass. A display name containing a scrubber token (" Jane Doe headset") now goes whole, and a name like "to host" can no longer hide ssh's "connect to host X" operand. Tokens on their own are still left as they are. Co-Authored-By: Claude Opus 5.5 --- tests/test_link.py | 20 ++++++++++++ ui/frame_link.py | 79 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/tests/test_link.py b/tests/test_link.py index e389e9e..ad80c2d 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -1177,6 +1177,26 @@ class FailureLog(unittest.TestCase): "Timed out talking to ") self.assertEqual(fl.hide_hosts("@: ", ["user", "host", "ip"]), "@: ") + def test_a_whole_name_can_cross_a_scrubber_token(self): # #81, first case + self.assertEqual(fl.scrub_failure(" Jane Doe headset stopped answering", [" Jane Doe headset"]), + " stopped answering") + self.assertEqual(fl.hide_hosts("seen Jane Doe headset and ", [" Jane Doe headset", "ip"]), + "seen and ") # the standalone token stays + self.assertEqual(fl.hide_hosts(" frame", ["ip> frame"]), "") # never half a token left + + def test_a_name_cant_swallow_sshs_operand_marker(self): # #81, second case + said = "ssh: connect to host bastion-jane port 22: Connection refused" + out = fl.scrub_failure(said, ["to host"]) + self.assertNotIn("bastion-jane", out) + self.assertEqual(out, "ssh: connect port 22: Connection refused") + err = quiet_log(self) + fl.log_failure("ssh", "ssh stopped", said, hosts=["to host", "host bastion"]) + self.assertNotIn("bastion", err.getvalue()) + self.assertNotIn("jane", err.getvalue()) + # a name overlapping the operand merges with it into one + self.assertEqual(fl.scrub_failure("Could not resolve hostname jane-office now", ["jane-office now"]), + "Could not resolve hostname ") + def test_windows_user_names_with_spaces_go_whole(self): err = quiet_log(self) with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()): diff --git a/ui/frame_link.py b/ui/frame_link.py index d4833c8..1207c90 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -203,17 +203,58 @@ def probes_summary(probes): PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)") +def _host_spans(text, hosts, keep=()): + """Where each of `hosts` is in text, longest first and whatever the case: (start, end) pairs. + + A name may cross a scrubber's (the whole token then goes with it), but one wholly + inside a token is the token's own word and is left. A name next to one already found sees + it as , as if that one had been replaced first.""" + keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording + names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True) + tokens = [m.span() for m in PLACEHOLDER.finditer(text)] + spans = [] + + def at(i, inside): # the character at i, with names already found standing in as + if not 0 <= i < len(text): + return "" + return inside if any(a <= i < b for a, b in spans) else text[i] + + for h in names: + pat = re.compile(re.escape(h), re.I) + m = pat.search(text) + while m: + s, e = m.span() + before, after, then = at(s - 1, ">"), at(e, "<"), at(e + 1, "<") + ok = (not re.match(r"[\w.:-]", before) and not re.match(r"[\w-]", after) + and not (after in (":", ".", "%") and re.match(r"\w", then))) + if ok and any(a <= s and e <= b for a, b in tokens): + ok = False # "ip" in "": a token already there stays as it is + if ok: + for a, b in tokens: # never cut a token in half: the whole of it goes + if a < e and s < b: + s, e = min(s, a), max(e, b) + spans.append((s, e)) + m = pat.search(text, m.end() if ok else m.start() + 1) + return spans + + +def _hide_spans(text, spans): + """text with each span replaced by , overlapping ones merged into one, in one pass.""" + out, last = [], 0 + for s, e in sorted(spans): + if s < last: # overlaps the one before: that covers this too + last = max(last, e) + continue + out += [text[last:s], ""] + last = e + return "".join(out) + text[last:] + + def hide_hosts(text, hosts, keep=()): """text with each of `hosts` (the headset's own addresses and names) replaced by , longest first and whatever the case, so a bare name like "steamdeck" that the scrubber can't recognise goes too.""" - keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording - names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True) - parts = PLACEHOLDER.split(text) # never inside a scrubber's own already there - for i in range(0, len(parts), 2): - for h in names: - parts[i] = re.sub(r"(?", parts[i], flags=re.I) - return "".join(parts) + return _hide_spans(text, _host_spans(text, hosts, keep)) # ssh names the host it was going to after these words ("Could not resolve hostname X", @@ -223,19 +264,27 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect", KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS -def hide_operands(text): - def one(m): +def _operand_spans(text): + """Where the hosts ssh names are in text, ssh's own words and any trailing dots left out.""" + spans = [] + for m in OPERAND.finditer(text): word = m.group(2).rstrip(".") - dots = m.group(2)[len(word):] - return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} {dots}" - return OPERAND.sub(one, text) + if word.lower() not in PLAIN_WORDS: + spans.append((m.start(2), m.start(2) + len(word))) + return spans + + +def hide_operands(text): + return _hide_spans(text, _operand_spans(text)) def scrub_failure(text, hosts=()): """Free text about a failed attempt, for the log: the attempt's own names, whole and - longest first (case-insensitively, never ssh's own words), then anything ssh names as a - host, then the shared scrubber (addresses, paths, user names).""" - return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600) + longest first (case-insensitively, never ssh's own words), and anything ssh names as a + host, both found in the text as it came so neither can hide the other, then the shared + scrubber (addresses, paths, user names).""" + text = str(text or "") + return frame_telemetry.scrub(_hide_spans(text, _host_spans(text, hosts) + _operand_spans(text)), 600) def failure_category(message, raw=""): From 7c1d6984007325fb8b38275e2090444210254b2b Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:29:32 +1100 Subject: [PATCH 10/63] Comfort: don't report the poll meeting an unreachable Frame, or poll while it is The page polled POST /api/comfort status every 5 s whatever the connection, and every POST Failure >= 500 went to diagnostics. ssh already marks link failures, but those are still sent once per session per category, so each session with a sleeping, away or re-keyed headset sent up to three reports from this poll (most of 0.4.2's error reports). GET /api/status never reports Failures at all. - server: a polled POST read (POLLS: comfort status) that couldn't reach the headset isn't a diagnostic, as with GET /api/status. Real comfort errors, and link failures from starting or cancelling a session, still are. - page: the comfort poll starts after the first status check and skips its ssh while the Frame is offline (online === false); status checks bring it back. Co-Authored-By: Claude Opus 5.5 --- tests/test_comfort_ui.py | 29 +++++++++++++++++++++++++++++ tests/test_telemetry.py | 40 +++++++++++++++++++++++++++++++++++----- ui/index.html | 12 ++++++++---- ui/server.py | 9 ++++++++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index ef008d6..1866540 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -59,3 +59,32 @@ const log = ()=>{}, toast = ()=>{}; ''' result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) + + def test_poll_waits_while_the_frame_is_unreachable(self): + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + start = page.index('async function pollComfort()') + code = page[start:page.index('$("comfortForm").onsubmit', start)] + js = r''' +const assert = require('node:assert/strict'); +const el = {textContent: 'Checking session…'}; +const $ = () => el; +let online = false, comfortBusy = false, calls = 0, timers = 0; +const api = async () => { calls++; return {}; }; +const renderComfort = () => {}; +const setTimeout = () => { timers++; }; +''' + code + r''' +(async () => { + await pollComfort(); + assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame + assert.match(el.textContent, /when the Frame is connected/); + assert.equal(timers, 1); // still scheduled, to resume when it's back + online = true; + await pollComfort(); + assert.equal(calls, 1); + online = null; // a status check that failed for another reason: keep polling + await pollComfort(); + assert.equal(calls, 2); +})().catch(e => { console.error(e); process.exitCode = 1; }); +''' + result = subprocess.run(['node', '-e', js], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 689acf4..221cd42 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -408,14 +408,17 @@ class LinkFailureProvenance(Base): c.close() return result - def test_routes_that_rewrap_a_link_failure_keep_its_mark(self): - # Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a - # Failure; after the status poll's first connection failure, theirs are held back too, - # also past the 10-minute window. + def serve(self): self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler) threading.Thread(target=self.httpd.serve_forever, daemon=True).start() self.addCleanup(self.httpd.server_close) self.addCleanup(self.httpd.shutdown) + + def test_routes_that_rewrap_a_link_failure_keep_its_mark(self): + # Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a + # Failure; after the first connection failure, theirs are held back too, also past the + # 10-minute window. + self.serve() down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n") meta = {"label": "Test App", "game_id": 5, "shortcut": None} with mock.patch.object(frame_host, "run_ssh", return_value=down), \ @@ -431,7 +434,34 @@ class LinkFailureProvenance(Base): self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502) sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued() if e["event"] == "$exception"] - self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")]) + # The comfort poll's own failure isn't one (see the next test). + self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")]) + + def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self): + # 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or + # refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled + # by hand still is, once, and a real failure of the poll is too. + self.serve() + for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n", + "banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n", + "ssh: Could not resolve hostname frame: nodename nor servname provided\r\n", + "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n", + "No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict " + "checking.\r\nHost key verification failed.\r\n", + "steamos@192.168.1.9: Permission denied (publickey,password).\r\n"): + down = subprocess.CompletedProcess(["ssh"], 255, "", stderr) + with mock.patch.object(frame_host, "run_ssh", return_value=down): + self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr) + self.assertEqual(self.queued(), []) + down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n") + with mock.patch.object(frame_host, "run_ssh", return_value=down): + self.request("/api/comfort", {"action": "cancel"}) + broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n") + with mock.patch.object(frame_host, "run_ssh", return_value=broken): + self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502) + sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued() + if e["event"] == "$exception"] + self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")]) def test_download_failures_after_a_poll_failure_are_still_sent(self): # Review round 3: these file names, in the real checksum message through the web-link worker, diff --git a/ui/index.html b/ui/index.html index 8c5a705..2b00dfe 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1587,7 +1587,7 @@ $("vrUtilities").onclick = async e => { await loadUtilities(); }; -// The Frame owns the clock. Poll independently of status and the selected tab. +// The Frame owns the clock. Poll independently of the selected tab, from the first status check on. let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false; async function localNotification(message, request = false) { try { @@ -1646,7 +1646,12 @@ function renderComfort(s) { if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history; } async function pollComfort() { - if (!comfortBusy) { + // Not while the Frame can't be reached: each poll would be another ssh attempt waiting to + // time out, and the banner already says why. Status checks bring it back (setOnline). + if (online === false) { + const message = "Session status appears when the Frame is connected."; + if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message; + } else if (!comfortBusy) { comfortBusy = true; try { renderComfort(await api("/api/comfort", {action: "status"})); } catch (e) { @@ -1680,7 +1685,6 @@ $("castBtn").onclick = () => { // Fullscreen is a direct user gesture; iPhone falls back to its inline viewer. $("viewer").requestFullscreen?.().catch(() => {}); }; -pollComfort(); // ---- battery ---- function battery(b, power) { @@ -4495,7 +4499,7 @@ if (window.frameApp && window.frameApp.onInstallLink) { } setView("headset"); -refresh().then(() => loadShots()); // after status, so app names resolve +refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); }); ", "blob:https://evil.example/1", + "javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/", + "chrome://settings", "not a url"]) { + assert.strictEqual(frameNav(wc, bad, false), true, bad); + } + // The window itself: Meta's sites only. + assert.strictEqual(frameNav(wc, "about:blank", true), true); assert.strictEqual(frameNav(wc, "https://evil.example/", true), true); + assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true); assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false); - assert.deepStrictEqual(f.opened, []); + assert.deepStrictEqual(f.opened, []); // stopped, never opened outside f.windows[0].destroy(); }); diff --git a/docs/meta-library.md b/docs/meta-library.md index cf1855f..7b9309b 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -45,9 +45,14 @@ closes the window. The window: or check (camera, notifications…) refused; - only goes to `https://` pages on `oculus.com`, `meta.com` and `facebook.com` (and their subdomains). A link anywhere else opens in your system browser; a - redirect anywhere else is stopped. Frames inside the page are held to the - same list (plus `about:blank`), and one going elsewhere is just stopped. - Pop-ups on those sites open in the same window; + redirect anywhere else is stopped. Pop-ups on those sites open in the same + window. Frames inside the page may load any `https://` page (Meta's login + can embed captcha and CDN frames from other sites, such as Arkose Labs, + hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`, + `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`, + `blob:`, `javascript:`, `file:`…) is just stopped. Frames can't reach the + token: the cookie is read by Frame Control's main process, and the window + has no IPC; - sends a cookie value on only if it looks like a user token (`OC` or `FRL`, mixed case, no `%`, `|` or `:`), and each value once. If the server refuses it, the window stays open; close it to see why; @@ -306,7 +311,8 @@ and cookies (`app/test/meta-signin.test.js`): the window's options (its own partition, sandbox, context isolation, no Node, no preload) and Meta's login address, no developer tools; permission requests and checks refused; the navigation allow-list (links elsewhere open in the system browser, redirects -and frames going elsewhere are stopped, pop-ups never become windows); a +elsewhere are stopped, pop-ups never become windows); frames allowed any +HTTPS page but no other scheme, the window itself only Meta's sites; a page's beforeunload not keeping the window open; the cookie read only from `persist:meta-signin` and only for `oculus.com`, even when the default session and another partition hold one; signed-out values ignored; a value @@ -331,8 +337,7 @@ working token. Meta account was used, and nothing was sent to Meta): whether Meta's login still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether it uses pop-ups (they open in the same window here, which would break a -pop-up that reports back to its opener), whether it needs frames from other -sites such as a captcha (those are stopped), whether `oc_ac_at` is still set on +pop-up that reports back to its opener), whether `oc_ac_at` is still set on `oculus.com` after login, and whether Meta accepts the window (Frame Control removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was available; nothing was sent to Meta), real downloads and their redirects, From 9d92396d49e6a2a5b75a1778066c987257d635a1 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:54:38 +1100 Subject: [PATCH 49/63] My Meta library: Sign in with Meta cancels by in-flight attempt and saved owner - frame_meta keeps only in-flight sign-in attempts (removed when their connect finishes) instead of a bounded list of cancelled ids, so other cancels can't push one out, and records which attempt saved the account. Cancelling an attempt being checked stops its save; cancelling the attempt that just saved deletes that token; a later sign-in's or a pasted token's account is never touched; unknown attempts change nothing. - will-redirect applies the per-frame rule: frames may be redirected to any https: page, the window only to Meta's sites. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 8 +++- app/test/meta-signin.test.js | 29 ++++++++++++ docs/meta-library.md | 22 ++++++--- tests/test_meta.py | 90 +++++++++++++++++++++++++++--------- ui/frame_meta.py | 73 ++++++++++++++++++----------- 5 files changed, 166 insertions(+), 56 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index 90c55ee..c49bd17 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -164,7 +164,13 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return; e.preventDefault(); }); - wc.on("will-redirect", (e, target) => { if (!allowedUrl(target)) e.preventDefault(); }); + // Redirects fire for frames too: the same rule per frame (details on the event in + // current Electron, positional arguments in older ones; unknown counts as the window). + wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => { + const to = e && e.url !== undefined ? e.url : target; + const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false; + if (!(main ? allowedUrl(to) : allowedSubframeUrl(to))) e.preventDefault(); + }); // A page's beforeunload never keeps the window open. wc.on("will-prevent-unload", (e) => e.preventDefault()); wc.setWindowOpenHandler(({ url: target }) => { diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index 8b27b26..f2f481a 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -312,6 +312,35 @@ test("the window stays on Meta's sites; frames inside it may load any HTTPS page f.windows[0].destroy(); }); +function redirect(wc, url, isMainFrame, style) { + let prevented = false; + const e = { preventDefault: () => { prevented = true; } }; + if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame })); + else wc.emit("will-redirect", e, url, false, isMainFrame); + return prevented; +} + +test("redirects follow the same rule per frame", async () => { + const f = fakes(); + f.flow.signIn(); + const wc = f.windows[0].webContents; + for (const style of ["details", "positional"]) { + // A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style); + assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style); + assert.strictEqual(redirect(wc, "data:text/html,x", false, style), true, style); + assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style); + assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style); + // The window: Meta's sites only. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style); + assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style); + } + // Without frame information it is treated as the window. + assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true); + assert.deepStrictEqual(f.opened, []); + f.windows[0].destroy(); +}); + test("a page's beforeunload can't keep the window open", async () => { const f = fakes(); const done = f.flow.signIn(); diff --git a/docs/meta-library.md b/docs/meta-library.md index 7b9309b..6458de5 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -50,7 +50,8 @@ closes the window. The window: can embed captcha and CDN frames from other sites, such as Arkose Labs, hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`, `about:srcdoc`); a frame going to any other scheme (`http:`, `data:`, - `blob:`, `javascript:`, `file:`…) is just stopped. Frames can't reach the + `blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same + rule for the frame they happen in. Frames can't reach the token: the cookie is read by Frame Control's main process, and the window has no IPC; - sends a cookie value on only if it looks like a user token (`OC` or `FRL`, @@ -60,8 +61,11 @@ closes the window. The window: - when it is closed, times out or is signed out of, stops reading cookies first, then is destroyed (a page's "leave this page?" can't keep it open), and, if it had sent a token, tells the server to drop that sign-in: each - sign-in has a random attempt id, and a connect from a cancelled attempt is - never saved, even if Meta is still answering when the cancel arrives. + sign-in has a random attempt id. The server lists an attempt only while its + connect is being checked: cancelled then, it isn't saved; cancelled just + after its token was saved, that token is deleted (as a disconnect). A cancel + never removes an account a later sign-in or a pasted token saved, and one + for an unknown attempt changes nothing. The page never sees the token: the desktop app hands it straight to the local server's connect path and tells the page only the answer (connected, and your @@ -140,7 +144,8 @@ test return a background job (`/api/job`). Sign in with Meta uses the same `connect` action: the desktop app's main process (`app/meta-signin.js`) posts it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or `-`) once the page's acknowledgement box is ticked. A connect whose attempt -was cancelled answers 409 and saves nothing. +was cancelled while it was checked answers 409 and saves nothing; +`cancel-signin` for an attempt whose token is the saved account deletes it. ## What an install does @@ -322,9 +327,12 @@ logged, and a server error that echoed the token scrubbed; closing the window or signing out ending the wait; closing, timing out or signing out during a connect cancelling that attempt (with its own id) after cookie intake stops; sign-out destroying the window before clearing only that partition. The -server side (`tests/test_meta.py`): a cancelled attempt refused before -anything is sent to Meta, and also when cancelled while Meta is answering; -attempt ids checked; other attempts and pasted tokens unaffected. +frame redirects held to the frame rule, the window's to Meta's sites. The +server side (`tests/test_meta.py`): an attempt cancelled while Meta is +answering not saved, even after 300 other cancels; a cancel just after the +save deleting that token; a cancel never removing a later sign-in's or a +pasted token's account; unknown attempts changing nothing; no attempt left +listed after a connect, refused or not; attempt ids checked. **Differences from BSManager:** BSManager uses its app's default session (and clears only `.oculus.com` cookies afterwards); Frame Control uses a session of diff --git a/tests/test_meta.py b/tests/test_meta.py index f794fef..3a2cf07 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -201,31 +201,20 @@ class Tokens(unittest.TestCase): class SignInAttempts(unittest.TestCase): - """Sign in with Meta (app/meta-signin.js): an attempt the app gave up on is never saved.""" + """Sign in with Meta (app/meta-signin.js): a token from an attempt the app gave up on is + never kept, whether the cancel comes while it is checked or just after it was saved.""" def tearDown(self): meta.disconnect() - def test_a_cancelled_attempt_is_refused_before_anything_is_sent(self): - fake = FakeMeta(self) - meta.cancel_signin('attempt-before-0001') - with self.assertRaises(meta.SignInCancelled): - meta.connect(TOKEN, attempt='attempt-before-0001') - self.assertEqual(fake.requests, []) - self.assertFalse(meta.account_path().exists()) - # Another attempt, and a pasted token (no attempt), still connect. - self.assertTrue(meta.connect(TOKEN, attempt='attempt-other-0002')['connected']) - meta.disconnect() - self.assertTrue(meta.connect(TOKEN)['connected']) - - def test_cancelling_while_meta_answers_wins(self): - fake = FakeMeta(self) + def held_connect(self, fake, attempt): + """Start connect(TOKEN, attempt) and wait until Meta is answering "who am I".""" fake.hold = threading.Event() errors = [] def connect(): try: - meta.connect(TOKEN, attempt='attempt-during-0003') + meta.connect(TOKEN, attempt=attempt) except meta.MetaError as e: errors.append(e) thread = threading.Thread(target=connect) @@ -234,6 +223,12 @@ class SignInAttempts(unittest.TestCase): if fake.requests: break threading.Event().wait(0.01) + return thread, errors + + def test_cancelling_while_meta_answers_wins(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-during-0003') + self.assertIn('attempt-during-0003', meta._attempts) meta.cancel_signin('attempt-during-0003') # the window closed while Meta was answering fake.hold.set() thread.join(10) @@ -241,6 +236,53 @@ class SignInAttempts(unittest.TestCase): self.assertIsInstance(errors[0], meta.SignInCancelled) self.assertFalse(meta.account_path().exists()) self.assertFalse(meta.status()['connected']) + self.assertEqual(meta._attempts, {}) + + def test_other_cancels_cant_push_out_one_in_flight(self): + fake = FakeMeta(self) + thread, errors = self.held_connect(fake, 'attempt-held-0004') + meta.cancel_signin('attempt-held-0004') + for i in range(300): + meta.cancel_signin(f'attempt-noise-{i:04d}') + fake.hold.set() + thread.join(10) + self.assertIsInstance(errors[0], meta.SignInCancelled) + self.assertFalse(meta.account_path().exists()) + self.assertEqual(meta._attempts, {}) # unknown ids left nothing behind + + def test_a_cancel_just_after_the_save_removes_that_token(self): + FakeMeta(self) + self.assertTrue(meta.connect(TOKEN, attempt='attempt-saved-0005')['connected']) + self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected']) + self.assertFalse(meta.account_path().exists()) + + def test_a_cancel_never_removes_a_later_account(self): + FakeMeta(self) + meta.connect(TOKEN, attempt='attempt-first-0006') + meta.connect(TOKEN, attempt='attempt-second-0007') # a later sign-in owns the account now + meta.cancel_signin('attempt-first-0006') + self.assertTrue(meta.status()['connected']) + meta.connect(TOKEN) # a pasted token + meta.cancel_signin('attempt-second-0007') + self.assertTrue(meta.status()['connected']) + meta.disconnect() + meta.connect(TOKEN, attempt='attempt-third-0008') + meta.disconnect() + meta.connect(TOKEN) + meta.cancel_signin('attempt-third-0008') # its account was disconnected already + self.assertTrue(meta.status()['connected']) + + def test_unknown_attempts_change_nothing(self): + FakeMeta(self) + meta.cancel_signin('attempt-unknown-0009') + self.assertTrue(meta.connect(TOKEN, attempt='attempt-unknown-0009')['connected']) + self.assertEqual(meta._attempts, {}) + + def test_a_rejected_token_leaves_no_attempt_behind(self): + FakeMeta(self, token=OTHER_TOKEN) + with self.assertRaises(meta.TokenRejected): + meta.connect(TOKEN, attempt='attempt-rejected-0010') + self.assertEqual(meta._attempts, {}) def test_attempt_ids_are_checked(self): FakeMeta(self) @@ -1158,7 +1200,7 @@ class Server(unittest.TestCase): def test_sign_in_attempts_through_the_server(self): s = self.server - fake = FakeMeta(self) + FakeMeta(self) self.addCleanup(meta.disconnect) for body in ({'action': 'cancel-signin'}, {'action': 'cancel-signin', 'attempt': 'no'}, {'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 5}, @@ -1166,15 +1208,19 @@ class Server(unittest.TestCase): with self.assertRaises(s.Failure) as e: s.meta(body) self.assertEqual(e.exception.status, 400, body) + self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, + 'attempt': 'server-attempt-0001'})['connected']) result = s.meta({'action': 'cancel-signin', 'attempt': 'server-attempt-0001'}) self.assertFalse(result['connected']) - with self.assertRaises(s.Failure) as e: - s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0001'}) + self.assertNotIn(TOKEN, json.dumps(result)) + # A cancel caught while Meta answers is a 409, without the token. + with patch.object(meta, 'whoami', side_effect=lambda t: meta.cancel_signin('server-attempt-0002') or + {'id': '1', 'name': 'player_one'}): + with self.assertRaises(s.Failure) as e: + s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, 'attempt': 'server-attempt-0002'}) self.assertEqual(e.exception.status, 409) self.assertNotIn(TOKEN, str(e.exception)) - self.assertEqual(fake.requests, []) - self.assertTrue(s.meta({'action': 'connect', 'token': TOKEN, 'acknowledged': True, - 'attempt': 'server-attempt-0002'})['connected']) + self.assertFalse(meta.status()['connected']) if __name__ == '__main__': diff --git a/ui/frame_meta.py b/ui/frame_meta.py index 7778098..894e8ae 100644 --- a/ui/frame_meta.py +++ b/ui/frame_meta.py @@ -26,7 +26,6 @@ Python stdlib only. CLI: python3 ui/frame_meta.py connect --token-stdin | status | disconnect | list [--refresh] | download APP_ID install APP_ID [--test] [--allow-downgrade] | test PACKAGE [--wait SECONDS] [--no-report] """ -import collections import concurrent.futures import hashlib import http.client @@ -325,11 +324,15 @@ def status(): 'connectedAt': (account or {}).get('connected')} -# Sign in with Meta (app/meta-signin.js) names each of its attempts; one it gave up on is -# remembered here, so its connect is refused whether the cancel arrives before or after it. +# Sign in with Meta (app/meta-signin.js) names each of its attempts. While a connect from an +# attempt is being checked it is listed in _attempts (only then, so this never grows); a +# cancel marks it, and it is then not saved. _saved_by is the attempt whose token is the +# saved account (None for a pasted token), set together with the save, so a cancel that +# arrives just after the save still removes that token, and never a later one. SIGNIN_ATTEMPT = re.compile(r'[A-Za-z0-9-]{8,64}') SIGNIN_CANCELLED = 'Sign in with Meta was cancelled, so that sign-in was not saved.' -_cancelled = collections.deque(maxlen=256) +_attempts = {} # in-flight attempt id -> {'cancelled': bool, 'running': connects under way} +_saved_by = [None] def _check_attempt(attempt): @@ -339,13 +342,16 @@ def _check_attempt(attempt): def cancel_signin(attempt): - """Refuse to save any connect made under this Sign in with Meta attempt, now or later.""" - _check_attempt(attempt) + """Never keep a token from this Sign in with Meta attempt: one still being checked isn't + saved, and one already saved is deleted. Unknown attempts change nothing.""" if attempt is None: raise MetaError('Invalid sign-in attempt id.') + _check_attempt(attempt) with _lock: - if attempt not in _cancelled: - _cancelled.append(attempt) + if attempt in _attempts: + _attempts[attempt]['cancelled'] = True + elif _saved_by[0] == attempt: + _forget_locked() return status() @@ -356,31 +362,46 @@ def connect(token, attempt=None): _check_attempt(attempt) token = check_token(token) with _lock: - if attempt is not None and attempt in _cancelled: - raise SignInCancelled(SIGNIN_CANCELLED) generation = _state['generation'] - user = whoami(token) - with _lock: - if attempt is not None and attempt in _cancelled: - raise SignInCancelled(SIGNIN_CANCELLED) - if _state['generation'] != generation: - raise MetaError('The Meta account was disconnected (or another token connected) while this one ' - 'was being checked, so it was not saved.') - _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')}) - _state['generation'] += 1 - _library.update(generation=None, at=0, apps=None) + if attempt is not None: + entry = _attempts.setdefault(attempt, {'cancelled': False, 'running': 0}) + entry['running'] += 1 + try: + user = whoami(token) + with _lock: + if attempt is not None and _attempts[attempt]['cancelled']: + raise SignInCancelled(SIGNIN_CANCELLED) + if _state['generation'] != generation: + raise MetaError('The Meta account was disconnected (or another token connected) while this one ' + 'was being checked, so it was not saved.') + _write_account({'token': token, 'user': user, 'connected': time.strftime('%Y-%m-%dT%H:%M:%S')}) + _saved_by[0] = attempt + _state['generation'] += 1 + _library.update(generation=None, at=0, apps=None) + finally: + if attempt is not None: + with _lock: + entry = _attempts[attempt] + entry['running'] -= 1 + if not entry['running']: + del _attempts[attempt] return status() +def _forget_locked(): + _state['generation'] += 1 + _saved_by[0] = None + _library.update(generation=None, at=0, apps=None) + try: + os.remove(str(account_path())) + except FileNotFoundError: + pass + + def disconnect(): """Forget the token: delete the file and the library in memory. Downloads under way stop.""" with _lock: - _state['generation'] += 1 - _library.update(generation=None, at=0, apps=None) - try: - os.remove(str(account_path())) - except FileNotFoundError: - pass + _forget_locked() return status() From 9c33711e6f9b9f0b366f42cc1e7334dac87edee0 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:00:36 +1100 Subject: [PATCH 50/63] My Meta library: Sign in with Meta cancels only after its connect settles When the sign-in window is closed, times out or is signed out of while a connect is under way, the page is answered at once, but cancel-signin is posted only after that connect settles (saved, refused or failed), so the server always sees the cancel after the connect and deletes the token if that connect saved it. Nothing sent means no cancel. Sign-out, Disconnect's first step, waits for any such cancel (also from a window closed earlier) before the page posts its disconnect. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 22 +++++--- app/test/meta-signin.test.js | 99 +++++++++++++++++++++++++++++------- docs/meta-library.md | 15 ++++-- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index c49bd17..a4cf0ee 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -65,6 +65,7 @@ function scrub(text, token) { // cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) } function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) { let active = null; // { win, promise, finish } + const pending = new Set(); // cancels still to reach the server, from flows already finished const openOutside = (target) => { if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target)); @@ -102,9 +103,12 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare if (timer.unref) timer.unref(); // Stops cookie intake first, then the window (destroy: a page's beforeunload can't - // keep it open), then, unless it connected, tells the server not to save a connect - // that may still be under way. Resolves once that is done. + // keep it open), and answers the page. Unless it connected, and if it sent a token, it + // then has the server drop this attempt: only once the connect under way has settled, + // so the server always sees the cancel after the connect (and deletes the token if + // that connect saved it). The returned promise settles when that is done. let finished = null; + let inflight = null; // the last connect sent function finish(result) { if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed" done = true; @@ -112,9 +116,11 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare clearTimeout(timer); if (active && active.win === win) active = null; if (!win.isDestroyed()) win.destroy(); - const sent = tried.size > 0; // a connect is sent synchronously after its value is added - finished = (result.ok || !sent ? Promise.resolve() : Promise.resolve().then(() => cancel(attemptId))) - .catch(() => {}).then(() => resolve(result)); + resolve(result); + if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel + finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {}); + pending.add(finished); + finished.then(() => pending.delete(finished)); return finished; } @@ -134,7 +140,8 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare tried.add(key); let r; try { - r = await connect(c.value, attemptId); + inflight = connect(c.value, attemptId); + r = await inflight; } catch { lastError = "Frame Control's local server didn't answer."; continue; @@ -201,6 +208,9 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare // Clear only once nothing can write to the session again. if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); } + // Any connect still under way settles and is cancelled before the caller (Disconnect) + // deletes the account, so it can't be saved afterwards. + await Promise.all([...pending]); const ses = session.fromPartition(PARTITION); await ses.clearStorageData(); await ses.clearCache(); diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index f2f481a..2213f62 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -355,35 +355,97 @@ test("a page's beforeunload can't keep the window open", async () => { assert.ok(w.isDestroyed()); }); -test("closing the window while a connect is under way has the server drop that attempt", async () => { +test("closing during a connect: the cancel goes to the server only after that connect settles", async () => { + for (const outcome of ["saved", "refused", "failed"]) { + const f = fakes(); + let release, fail; + f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; })); + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + assert.deepStrictEqual(f.calls, [TOKEN]); + f.windows[0].close(); // the user gives up while the server and Meta are answering + const r = await done; // the page hears at once + assert.strictEqual(r.ok, false); + await settle(); + assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled + if (outcome === "saved") release({ status: 200, data: { connected: true } }); + else if (outcome === "refused") release({ status: 400, data: { error: "no" } }); + else fail(new Error("socket hang up")); + await settle(); + // The same attempt id the connect carried, after it settled and after cookie intake stopped. + assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome); + assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/); + assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome); + assert.deepStrictEqual(f.calls, [TOKEN]); + } +}); + +test("closing before any connect needs no cancel", async () => { + const f = fakes(); + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable + await settle(); + f.windows[0].close(); + assert.strictEqual((await done).ok, false); + await f.flow.signOut(); + await settle(); + assert.deepStrictEqual(f.calls, []); + assert.deepStrictEqual(f.cancels, []); +}); + +test("a timeout during a connect cancels after it settles", async () => { + const f = fakes({ timeout: 50 }); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow + const done = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + const r = await done; + assert.match(r.error, /timed out/); + assert.ok(f.windows[0].isDestroyed()); + assert.deepStrictEqual(f.cancels, []); + release({ status: 400, data: { error: "no" } }); + await settle(); + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); +}); + +test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => { const f = fakes(); let release; f.setAnswer(() => new Promise((r) => { release = r; })); const done = f.flow.signIn(); f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); await settle(); - assert.deepStrictEqual(f.calls, [TOKEN]); - f.windows[0].close(); // the user gives up while Meta is answering - const r = await done; - assert.strictEqual(r.ok, false); - // The same attempt id the connect carried, cancelled after cookie intake stopped. - assert.strictEqual(f.cancels.length, 1); - assert.strictEqual(f.cancels[0], f.attempts[0]); - assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/); - assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"]); - release({ status: 200, data: { connected: true } }); // the late answer changes nothing here + f.log.length = 0; + let signedOut = false; + const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one + assert.strictEqual((await done).ok, false); await settle(); - assert.deepStrictEqual(f.calls, [TOKEN]); + assert.strictEqual(signedOut, false); // still waiting for the connect + assert.deepStrictEqual(f.cancels, []); + release({ status: 200, data: { connected: true } }); // it saved, just now + await out; + // Only now would the page post its disconnect: the cancel (which deletes what that + // connect saved) has reached the server, and the session is cleared. + assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]); + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); }); -test("a timeout drops the attempt too", async () => { - const f = fakes({ timeout: 50 }); - f.setAnswer(() => new Promise(() => {})); // Meta never answers +test("a later disconnect still waits for a closed window's connect", async () => { + const f = fakes(); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); const done = f.flow.signIn(); f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); - const r = await done; - assert.match(r.error, /timed out/); - assert.ok(f.windows[0].isDestroyed()); + await settle(); + f.windows[0].close(); + await done; + let signedOut = false; + const out = f.flow.signOut().then(() => { signedOut = true; }); + await settle(); + assert.strictEqual(signedOut, false); + release({ status: 400, data: { error: "no" } }); + await out; assert.deepStrictEqual(f.cancels, [f.attempts[0]]); }); @@ -425,6 +487,7 @@ test("each sign-in has its own attempt id", async () => { await settle(); f.windows[1].close(); await b; + await settle(); assert.strictEqual(f.attempts.length, 2); assert.notStrictEqual(f.attempts[0], f.attempts[1]); assert.deepStrictEqual(f.cancels, f.attempts); diff --git a/docs/meta-library.md b/docs/meta-library.md index 6458de5..30fa2f0 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -61,7 +61,10 @@ closes the window. The window: - when it is closed, times out or is signed out of, stops reading cookies first, then is destroyed (a page's "leave this page?" can't keep it open), and, if it had sent a token, tells the server to drop that sign-in: each - sign-in has a random attempt id. The server lists an attempt only while its + sign-in has a random attempt id. The cancel is sent only once the connect + under way has finished (saved, refused or failed), so the server always + gets it after the connect; with nothing sent, there is nothing to cancel. + The server lists an attempt only while its connect is being checked: cancelled then, it isn't saved; cancelled just after its token was saved, that token is deleted (as a disconnect). A cancel never removes an account a later sign-in or a pasted token saved, and one @@ -253,8 +256,9 @@ turn that on. - Disconnect deletes the file and the library Frame Control holds in memory, and stops a download under way. A connect still being checked when you disconnect is not saved. In the desktop app, before the token is deleted, it - also stops any sign-in under way (cancelling its attempt), destroys the - sign-in window, and only then clears the `persist:meta-signin` session + also stops any sign-in under way, destroys the sign-in window, waits for a + connect still under way to finish and cancels its attempt (deleting its + token if it was just saved), and only then clears the `persist:meta-signin` session (cookies, storage, cache); no other session is touched. - With Sign in with Meta, a second copy of your Meta sign-in lives in that session's storage, in Electron's per-user data folder (`Partitions/meta-signin`), @@ -325,7 +329,10 @@ sent once, a refused one keeping the window open; the window closing after a connect, with the page getting the answer and never the token; nothing logged, and a server error that echoed the token scrubbed; closing the window or signing out ending the wait; closing, timing out or signing out during a -connect cancelling that attempt (with its own id) after cookie intake stops; +connect cancelling that attempt (with its own id) after cookie intake stops +and only after that connect settles (saved, refused or failed), and no cancel +when nothing was sent; sign-out (Disconnect's first step) waiting for a +connect under way, even from a window closed earlier, before it finishes; sign-out destroying the window before clearing only that partition. The frame redirects held to the frame rule, the window's to Meta's sites. The server side (`tests/test_meta.py`): an attempt cancelled while Meta is From c7125bc3de80dc6b2416e34570c1ee3441bddf33 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:10:59 +1100 Subject: [PATCH 51/63] My Meta library: Sign in with Meta closes two sign-out races - cancel_signin marks an in-flight attempt and, independently, deletes the account if that attempt saved it, so a cancel landing after the save but before the connect's cleanup still removes the token. - Sign-out runs one at a time (a second call joins it) and refuses new sign-ins while it runs; it goes round until no window, connect or cancel is left. The page keeps Sign in with Meta off from pressing Disconnect until the server has deleted the token. Co-Authored-By: Claude Opus 5.5 --- app/meta-signin.js | 46 ++++++++++++++++++--------- app/test/meta-signin.test.js | 60 ++++++++++++++++++++++++++++++++++++ docs/meta-library.md | 12 ++++++-- tests/test_meta.py | 38 +++++++++++++++++++++++ ui/frame_meta.py | 4 ++- ui/index.html | 24 +++++++++------ 6 files changed, 156 insertions(+), 28 deletions(-) diff --git a/app/meta-signin.js b/app/meta-signin.js index a4cf0ee..b4205ae 100644 --- a/app/meta-signin.js +++ b/app/meta-signin.js @@ -66,12 +66,15 @@ function scrub(text, token) { function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) { let active = null; // { win, promise, finish } const pending = new Set(); // cancels still to reach the server, from flows already finished + let signingOut = null; // the sign-out under way; no sign-in starts while it runs + const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment."; const openOutside = (target) => { if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target)); }; function signIn() { + if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT }); if (active) { active.win.focus(); return active.promise; } const ses = session.fromPartition(PARTITION); ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); @@ -201,24 +204,37 @@ function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, pare // Signs out of Meta in Frame Control: stops any sign-in under way (and has the server // refuse to save it), destroys the window, then clears everything // the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh. - async function signOut() { - if (active) { - const { win, finish } = active; - await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." }); - // Clear only once nothing can write to the session again. - if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); + // One at a time (a second call joins the first), and no sign-in can start meanwhile. + // It goes round until no window is open and no connect or cancel is left, so nothing + // started before it can set a token after it (or after the Disconnect that follows). + function signOut() { + if (!signingOut) { + signingOut = (async () => { + for (;;) { + if (active) { + const { win, finish } = active; + await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." }); + // Clear only once nothing can write to the session again. + if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again."); + } else if (pending.size) { + // A connect under way settles and is cancelled before the caller (Disconnect) + // deletes the account, so it can't be saved afterwards. + await Promise.all([...pending]); + } else { + break; + } + } + const ses = session.fromPartition(PARTITION); + await ses.clearStorageData(); + await ses.clearCache(); + if (ses.clearAuthCache) await ses.clearAuthCache(); + return true; + })().finally(() => { signingOut = null; }); } - // Any connect still under way settles and is cancelled before the caller (Disconnect) - // deletes the account, so it can't be saved afterwards. - await Promise.all([...pending]); - const ses = session.fromPartition(PARTITION); - await ses.clearStorageData(); - await ses.clearCache(); - if (ses.clearAuthCache) await ses.clearAuthCache(); - return true; + return signingOut; } - return { signIn, signOut, isOpen: () => !!active }; + return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut }; } module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED }; diff --git a/app/test/meta-signin.test.js b/app/test/meta-signin.test.js index 2213f62..10a522c 100644 --- a/app/test/meta-signin.test.js +++ b/app/test/meta-signin.test.js @@ -492,3 +492,63 @@ test("each sign-in has its own attempt id", async () => { assert.notStrictEqual(f.attempts[0], f.attempts[1]); assert.deepStrictEqual(f.cancels, f.attempts); }); + +test("no sign-in can start while a sign-out waits on an earlier connect", async () => { + const f = fakes(); + let release; + f.setAnswer(() => new Promise((r) => { release = r; })); + const first = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + const out = f.flow.signOut(); // Disconnect pressed while that connect is under way + assert.strictEqual((await first).ok, false); + assert.ok(f.flow.isSigningOut()); + // The page answered, so the user tries again at once: refused, no window, nothing sent. + const second = await f.flow.signIn(); + assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." }); + assert.strictEqual(f.windows.length, 1); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + await settle(); + assert.deepStrictEqual(f.calls, [TOKEN]); + // A second sign-out joins the first. + assert.strictEqual(f.flow.signOut(), out); + release({ status: 200, data: { connected: true } }); + await out; + assert.deepStrictEqual(f.cancels, [f.attempts[0]]); + assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1); + assert.ok(!f.flow.isSigningOut()); + // Once it is done, signing in works again. + f.setAnswer(() => ({ status: 200, data: { connected: true } })); + const third = f.flow.signIn(); + assert.strictEqual(f.windows.length, 2); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + assert.strictEqual((await third).ok, true); +}); + +test("sign-out goes round until no window and no connect or cancel is left", async () => { + const f = fakes(); + let releaseA; + f.setAnswer(() => new Promise((r) => { releaseA = r; })); + const a = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN }); + await settle(); + f.windows[0].close(); // window A closed; its cancel waits on its connect + await a; + // Window B is open, with its own connect under way, when sign-out starts. + let releaseB; + f.setAnswer(() => new Promise((r) => { releaseB = r; })); + const b = f.flow.signIn(); + f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER }); + await settle(); + let finished = false; + const out = f.flow.signOut().then(() => { finished = true; }); + await b; + releaseB({ status: 400, data: { error: "no" } }); + await settle(); + assert.strictEqual(finished, false); // A's connect is still under way + releaseA({ status: 200, data: { connected: true } }); + await out; + assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort()); + assert.strictEqual(f.attempts.length, 2); + assert.strictEqual(f.log[f.log.length - 1], "clear"); +}); diff --git a/docs/meta-library.md b/docs/meta-library.md index 30fa2f0..cc3f543 100644 --- a/docs/meta-library.md +++ b/docs/meta-library.md @@ -66,7 +66,8 @@ closes the window. The window: gets it after the connect; with nothing sent, there is nothing to cancel. The server lists an attempt only while its connect is being checked: cancelled then, it isn't saved; cancelled just - after its token was saved, that token is deleted (as a disconnect). A cancel + after its token was saved (even before the connect has finished tidying + up), that token is deleted (as a disconnect). A cancel never removes an account a later sign-in or a pasted token saved, and one for an unknown attempt changes nothing. @@ -259,7 +260,9 @@ turn that on. also stops any sign-in under way, destroys the sign-in window, waits for a connect still under way to finish and cancels its attempt (deleting its token if it was just saved), and only then clears the `persist:meta-signin` session - (cookies, storage, cache); no other session is touched. + (cookies, storage, cache); no other session is touched. Sign in with Meta + stays off from the moment you press Disconnect until the token is deleted, + and the app refuses a sign-in while it is signing out. - With Sign in with Meta, a second copy of your Meta sign-in lives in that session's storage, in Electron's per-user data folder (`Partitions/meta-signin`), until you disconnect or sign out of Meta in Frame Control. @@ -333,7 +336,10 @@ connect cancelling that attempt (with its own id) after cookie intake stops and only after that connect settles (saved, refused or failed), and no cancel when nothing was sent; sign-out (Disconnect's first step) waiting for a connect under way, even from a window closed earlier, before it finishes; -sign-out destroying the window before clearing only that partition. The +sign-out destroying the window before clearing only that partition; no +sign-in starting while a sign-out runs (it is refused: "try again in a +moment"), a second sign-out joining the first, and a sign-out waiting for +every window and connect started before it. The frame redirects held to the frame rule, the window's to Meta's sites. The server side (`tests/test_meta.py`): an attempt cancelled while Meta is answering not saved, even after 300 other cancels; a cancel just after the diff --git a/tests/test_meta.py b/tests/test_meta.py index 3a2cf07..9caf106 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -256,6 +256,44 @@ class SignInAttempts(unittest.TestCase): self.assertFalse(meta.cancel_signin('attempt-saved-0005')['connected']) self.assertFalse(meta.account_path().exists()) + def test_a_cancel_between_the_save_and_the_cleanup_removes_that_token(self): + # The connect has saved and let go of the lock, but hasn't yet dropped its attempt + # from the in-flight list when the cancel arrives. + FakeMeta(self) + real_status = meta.status + seen = {} + + def cancel_in_the_gap(): + # status() runs after the save; put the attempt back as still listed, as it is + # in the moment before connect's cleanup, and cancel there. + if 'done' not in seen and meta._saved_by[0] == 'attempt-gap-0011': + seen['done'] = True + with meta._lock: + meta._attempts['attempt-gap-0011'] = {'cancelled': False, 'running': 1} + meta.cancel_signin('attempt-gap-0011') + seen['listed'] = 'attempt-gap-0011' in meta._attempts + with meta._lock: + del meta._attempts['attempt-gap-0011'] + return real_status() + with patch.object(meta, 'status', side_effect=cancel_in_the_gap): + meta.connect(TOKEN, attempt='attempt-gap-0011') + self.assertTrue(seen.get('listed')) + self.assertFalse(meta.account_path().exists()) + self.assertFalse(meta.status()['connected']) + + def test_a_cancel_while_still_listed_marks_and_never_touches_another_account(self): + FakeMeta(self) + meta.connect(TOKEN) # a pasted token owns the account + with meta._lock: + meta._attempts['attempt-listed-0012'] = {'cancelled': False, 'running': 1} + try: + meta.cancel_signin('attempt-listed-0012') + self.assertTrue(meta._attempts['attempt-listed-0012']['cancelled']) + finally: + with meta._lock: + meta._attempts.pop('attempt-listed-0012', None) + self.assertTrue(meta.status()['connected']) + def test_a_cancel_never_removes_a_later_account(self): FakeMeta(self) meta.connect(TOKEN, attempt='attempt-first-0006') diff --git a/ui/frame_meta.py b/ui/frame_meta.py index 894e8ae..fecd174 100644 --- a/ui/frame_meta.py +++ b/ui/frame_meta.py @@ -348,9 +348,11 @@ def cancel_signin(attempt): raise MetaError('Invalid sign-in attempt id.') _check_attempt(attempt) with _lock: + # Both, independently: a connect that has just saved may still be listed until + # its cleanup runs, and its token must go all the same. if attempt in _attempts: _attempts[attempt]['cancelled'] = True - elif _saved_by[0] == attempt: + if _saved_by[0] == attempt: _forget_locked() return status() diff --git a/ui/index.html b/ui/index.html index 2afb885..35d1968 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3492,11 +3492,13 @@ async function loadMeta(refresh = false) { // Sign in with Meta: the desktop app only (app/meta-signin.js); a plain browser pastes the token. const metaSignInOk = !!(window.frameApp && window.frameApp.meta && window.frameApp.meta.signIn); $("metaSignInRow").hidden = !metaSignInOk; +let metaDisconnecting = false; // from pressing Disconnect until the server has deleted the token $("metaAck").onchange = e => { - $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = !e.target.checked; + $("metaToken").disabled = $("metaConnect").disabled = !e.target.checked; + $("metaSignIn").disabled = !e.target.checked || metaDisconnecting; }; $("metaSignIn").onclick = async () => { - if (!$("metaAck").checked) return; + if (!$("metaAck").checked || metaDisconnecting) return; $("metaSignIn").disabled = true; $("metaState").textContent = "Sign in to Meta in the window that opened…"; try { @@ -3504,7 +3506,7 @@ $("metaSignIn").onclick = async () => { if (r && r.ok) { toast(r.message || "Connected"); loadMeta(); } else { await loadMeta(); $("metaState").textContent = (r && r.error) || "Sign-in didn't finish."; } } catch (e) { $("metaState").textContent = e.message; } - finally { $("metaSignIn").disabled = !$("metaAck").checked; } + finally { $("metaSignIn").disabled = !$("metaAck").checked || metaDisconnecting; } }; $("metaSignOut").onclick = async () => { try { await window.frameApp.meta.signOut(); toast("Signed out of Meta in Frame Control"); } @@ -3524,12 +3526,16 @@ $("metaConnect").onclick = async () => { $("metaDisconnect").onclick = async () => { if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay." + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) return; - // Sign-out first: it stops the sign-in window taking a new token before the account is forgotten. - if (metaSignInOk) { - try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } - } - try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } - catch (e) { toast(e.message, true); } + // No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in + // window (and any connect it started) before the account is forgotten. + metaDisconnecting = true; $("metaSignIn").disabled = true; + try { + if (metaSignInOk) { + try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } + } + try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } + catch (e) { toast(e.message, true); } + } finally { metaDisconnecting = false; } $("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true; metaLib.results.clear(); loadMeta(); }; From c060df4b5066234233990125b299f18a1054224d Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:17:39 +1100 Subject: [PATCH 52/63] My Meta library: one Disconnect at a time, guarded from the confirm until the delete returns Co-Authored-By: Claude Opus 5.5 --- ui/index.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui/index.html b/ui/index.html index 35d1968..85db1ee 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3524,18 +3524,24 @@ $("metaConnect").onclick = async () => { finally { $("metaConnect").disabled = !$("metaAck").checked; } }; $("metaDisconnect").onclick = async () => { + // One Disconnect at a time, guarded from before the confirm until the delete returns. + if (metaDisconnecting) return; + metaDisconnecting = true; $("metaDisconnect").disabled = $("metaSignIn").disabled = true; if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay." - + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) return; + + (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) { + metaDisconnecting = false; $("metaDisconnect").disabled = false; + $("metaSignIn").disabled = !$("metaAck").checked; + return; + } // No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in // window (and any connect it started) before the account is forgotten. - metaDisconnecting = true; $("metaSignIn").disabled = true; try { if (metaSignInOk) { try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); } } try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); } catch (e) { toast(e.message, true); } - } finally { metaDisconnecting = false; } + } finally { metaDisconnecting = false; $("metaDisconnect").disabled = false; } $("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true; metaLib.results.clear(); loadMeta(); }; From 5db963157159249f115527ad2287886d331209aa Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:41:19 +1100 Subject: [PATCH 53/63] Usage statistics: send the time zone, and show the notice again when it lists something new Opens, installs and updates carry the computer's time zone name (from Electron, which knows it on every OS), so we can see roughly which countries Frame Control is used in without ever seeing an address. The first-run notice now has a version. Copies that saw the older notice see it again, and keep sending their usual statistics, but leave the time zone out until they've seen the new one. Co-Authored-By: Claude Opus 5.5 --- app/main.js | 5 +++-- docs/privacy.md | 13 ++++++++---- tests/test_telemetry.py | 35 ++++++++++++++++++++++++++++++++ ui/frame_telemetry.py | 45 ++++++++++++++++++++++++++++++++--------- ui/index.html | 4 ++-- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/app/main.js b/app/main.js index 7d77675..84e88d4 100644 --- a/app/main.js +++ b/app/main.js @@ -115,10 +115,11 @@ function ping(target) { } async function startServer() { - // The version and whether this is a built app go to ui/frame_telemetry.py, which - // sends nothing from a source checkout. + // The version, the time zone and whether this is a built app go to ui/frame_telemetry.py, + // which sends nothing from a source checkout. const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1", FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG, + FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "", ...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}), ...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) }; python = await findPython(env); diff --git a/docs/privacy.md b/docs/privacy.md index 8bf434d..b7c5e0f 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -33,7 +33,12 @@ computer, exactly as they were sent. yours. - Every event includes the app version, OS name (macOS, Windows or Linux), CPU architecture and Python version. -- `frame_connected` carries `headset_id`: the first 16 hex digits of a +- `app_installed`, `app_updated` and `app_opened` carry `timezone`: your + computer's time zone setting, such as `Europe/Berlin`, so we can see roughly + which countries Frame Control is used in without knowing your address. + Copies that showed the first-run notice before it mentioned time zones + (before 0.4.6) show it again, and don't send the time zone until it has been + seen.- `frame_connected` carries `headset_id`: the first 16 hex digits of a SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset, so the machine-id itself never leaves it. It is the same for one headset on every computer, which lets us count headsets rather than installs; it can't @@ -44,9 +49,9 @@ computer, exactly as they were sent. | Event | When | Properties besides the common ones | |---|---|---| -| `app_installed` | First run | | -| `app_updated` | First run of a new version | `from_version` | -| `app_opened` | At most once a day | | +| `app_installed` | First run | `timezone` | +| `app_updated` | First run of a new version | `from_version`, `timezone` | +| `app_opened` | At most once a day | `timezone` | | `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` | | `tab_viewed` | The first click on each tab in a session | `tab` | | `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below | diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 64bf976..42938e2 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -159,6 +159,34 @@ class Lifecycle(Base): e = self.queued()[-1] self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9")) + def test_opens_installs_and_updates_carry_the_time_zone(self): + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}): + tm.app_started() + with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}): + tm.app_started() + self.assertEqual([(e["event"], e["properties"].get("timezone")) for e in self.queued()], + [("app_installed", "Europe/Berlin"), ("app_opened", "Europe/Berlin"), + ("app_updated", "Europe/Berlin")]) + + def test_time_zone_names_only(self): + for tz, want in (("America/Argentina/Buenos_Aires", "America/Argentina/Buenos_Aires"), ("Etc/GMT+10", "Etc/GMT+10"), + ("UTC", "UTC"), ("../etc/passwd", None), ("/home/alex/tz", None), ("A/" + "b" * 40, None)): + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}): + self.assertEqual(tm.timezone(), want, tz) + + def test_an_older_notice_is_shown_again_and_holds_back_the_time_zone(self): + tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True, + "last_version": "9.9.9"})) # as 0.4.5 saved it + self.assertFalse(tm.state()["noticeShown"]) + with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}): + tm.app_started() + self.assertEqual([(e["event"], e["properties"].get("timezone")) for e in self.queued()], [("app_opened", None)]) + tm.update_settings({"noticeShown": True}) + self.assertTrue(tm.state()["noticeShown"]) + tm._save({**tm.settings(), "last_open_day": None}) # the next day + tm.app_started() + self.assertEqual(self.queued()[-1]["properties"]["timezone"], "Europe/Berlin") + def test_frame_build_once(self): tm.frame_seen("20260922.1", "3.8") tm.frame_seen("20260922.1", "3.8") @@ -230,6 +258,13 @@ class Sending(Base): self.assertEqual(self.queued(), []) self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"]) + def test_an_older_notice_still_sends(self): + got = self.serve() + tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1}) + tm.capture("app_opened") + self.assertEqual(tm.flush(), 1) + self.assertEqual(len(got), 1) + def test_a_failed_send_keeps_the_events(self): self.serve(status=500) tm.update_settings({"noticeShown": True}) diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py index faa115d..8a8dd62 100644 --- a/ui/frame_telemetry.py +++ b/ui/frame_telemetry.py @@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the same project whatever is chosen here. Events are identified by a random id made on first run, not by the person or -computer, and sent without person profiles or GeoIP. Nothing is sent without a +computer, and sent without person profiles or GeoIP. Opens, installs and updates +carry the computer's time zone setting (e.g. Europe/Berlin) instead, to see +roughly where Frame Control is used. Nothing is sent without a project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or $FRAME_CONTROL_TELEMETRY=0. @@ -65,6 +67,7 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth') DEFAULT_HOST = 'https://us.i.posthog.com' LEVELS = ('usage', 'compat', 'diagnostics') +NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: time zone) # Events the page may send through /api/telemetry, and the properties each may carry. PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'}, 'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}} @@ -105,7 +108,7 @@ def blocked(): def _defaults(): return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False, - 'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None, + 'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None, 'frames_seen': [], 'compat_sent': []} @@ -147,12 +150,12 @@ def update_settings(changes): under way finishes first, so nothing leaves after this returns.""" with _send_lock, _lock: s = settings() - if 'noticeShown' in changes: - s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown'] + if changes.get('noticeShown'): + s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION for level in LEVELS: if level in changes: s[level] = bool(changes[level]) - s['notice_shown'] = True + s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION _save(s) _drop_unwanted(s) if changes.get('compat'): @@ -161,10 +164,16 @@ def update_settings(changes): return state() +def _notice_current(s): + """Whether the notice this person saw lists everything that's sent now. If not, the page + shows it again, and what it added waits until it has been seen.""" + return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION + + def state(): """What the page shows: the choices, why sending is blocked, and what was sent.""" s = settings() - return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'], + return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s), 'diagnostics': s['diagnostics'], 'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)), 'sent': list(reversed(_read_lines(SENT)))[:50]} @@ -372,20 +381,36 @@ def page_event(body): return {'queued': capture(name, props)} +def timezone(): + """The computer's time zone name (e.g. Europe/Berlin), or None. The app passes it from + Electron, which knows it on every OS; from a checkout it's read from /etc/localtime.""" + tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':') + if not tz: + try: + tz = os.path.realpath('/etc/localtime').split('zoneinfo/', 1)[1] + except (OSError, IndexError): + return None + return tz if len(tz) <= 40 and re.fullmatch(r'[A-Za-z][A-Za-z0-9_+-]*(/[A-Za-z0-9_+-]+){0,2}', tz) else None + + def app_started(): - """Once per server start: first install, an update, and one open a day.""" + """Once per server start: first install, an update, and one open a day. They carry the time + zone, unless the notice this person saw came before it was sent; a first run's events wait + for the current notice anyway (flush).""" if blocked(): return with _lock: s = settings() version, today = app_version(), time.strftime('%Y-%m-%d') + tz = timezone() if _notice_current(s) or not s['notice_shown'] else None + where = {'timezone': tz} if tz else {} if not s['installed_sent']: - capture('app_installed') + capture('app_installed', where) s['installed_sent'] = True elif s['last_version'] and s['last_version'] != version: - capture('app_updated', {'from_version': s['last_version']}) + capture('app_updated', {'from_version': s['last_version'], **where}) if s['last_open_day'] != today: - capture('app_opened') + capture('app_opened', where) s['last_open_day'] = today s['last_version'] = version _save(s) diff --git a/ui/index.html b/ui/index.html index dd191fb..bad2773 100644 --- a/ui/index.html +++ b/ui/index.html @@ -683,7 +683,7 @@