Compare commits

..
Author SHA1 Message Date
SAPHID c728e64fbd Merge remote-tracking branch 'origin/main' into coord/resolve-133
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:11:32 +00:00
Alex Southwell 0626c52e12 Merge pull request #132 from saphid/t3/fix-log-scrubber-edge-cases
Fix display-name overlaps in optional log redaction
2026-10-24 04:10:58 +00:00
SAPHID b9b4a00485 Merge remote-tracking branch 'origin/main' into coord/resolve-132
# Conflicts:
#	CHANGELOG.md
2026-10-24 04:00:26 +00:00
Alex Southwell eb125b59c7 Merge pull request #131 from saphid/fix/issue-87-recovery-polish
Fix remaining host-key recovery UI clipping and punctuation
2026-10-24 03:59:21 +00:00
SAPHID 7e53fe32cb Show Android compatibility reports without blocking search 2026-10-24 03:58:37 +00:00
SAPHID ebae409632 Fix overlapping display-name redaction in optional logs 2026-10-24 03:56:45 +00:00
SAPHID 08cb267ce4 Fix remaining host-key recovery clipping and tooltip punctuation 2026-10-24 03:52:38 +00:00
Alex Southwell 0119544c3e Merge pull request #130 from saphid/release/0.4.11
Frame Control 0.4.11
2026-10-24 03:42:24 +00:00
SAPHID 2a9e7dcabf Bump Frame Control to 0.4.11 2026-10-24 03:34:25 +00:00
Alex Southwell 5a9099dddb Merge pull request #129 from saphid/t3/test-windows-vm-headset
Fix Windows PC screen capture with the bundled GStreamer libraries
2026-10-24 03:33:31 +00:00
SAPHID ec4086db69 Probe capture properties in initialized native adapter 2026-10-24 03:25:28 +00:00
SAPHID 5a7c3541f8 Merge current main into Windows capture fix 2026-10-24 03:20:54 +00:00
SAPHID 525040162e Fix Windows PC screen capture with DXGI-only GStreamer bundles 2026-10-24 03:17:11 +00:00
Alex Southwell 21e771d16e Merge pull request #128 from saphid/t3/headset-game-storage
Show headset space and read-only Steam game storage
2026-10-24 03:15:37 +00:00
SAPHID 2cd095a8c4 Docs: align testing summary with real backup evidence 2026-10-24 03:08:37 +00:00
SAPHID bb9236b2b7 Merge main into storage and retain release notes 2026-10-24 03:08:15 +00:00
Alex Southwell 1bc090d00d Merge pull request #127 from saphid/feat/frame-doctor-read-only
Add read-only Frame doctor for setup and pairing diagnostics
2026-10-24 03:07:35 +00:00
SAPHID 63ba25ffdd Merge main into doctor and retain both changelog entries 2026-10-24 02:58:52 +00:00
Alex Southwell 98e6e90d84 Merge pull request #126 from saphid/t3/fix-smoke-process-cleanup
fix: stop test-owned smoke processes before removing titles
2026-10-24 02:58:08 +00:00
SAPHID 7c061846bd Tests: run doctor Bash wrapper only on POSIX 2026-10-24 02:55:40 +00:00
Alex Southwell a91d55151f Merge pull request #125 from saphid/feature/battery-health-v0410
Improve read-only Frame battery health monitoring
2026-10-24 02:51:38 +00:00
SAPHID 7f25e87b42 Docs: record real Frame doctor safety stop 2026-10-24 02:49:35 +00:00
SAPHID ff245953ac Docs: record read-only storage probe on Frame 2026-10-24 02:49:33 +00:00
Alex Southwell 46665d51c0 Merge pull request #124 from saphid/fleet/mppkd9qczt
Docs: record real Frame clipboard check and remaining test gaps
2026-10-24 02:48:12 +00:00
SAPHID 67615e85c3 Docs: record real Proton smoke cleanup verification 2026-10-24 02:47:26 +00:00
SAPHID 9e3c3cd31d Add privacy-safe read-only Frame doctor diagnostics 2026-10-24 02:45:44 +00:00
SAPHID 537754ebce Add read-only headset game storage inventory 2026-10-24 02:44:45 +00:00
SAPHID 573a9e2730 test: keep Linux-only signal checks off other desktop platforms 2026-10-24 02:42:53 +00:00
SAPHID 8b91b58336 Docs: record read-only battery parser check on Frame 2026-10-24 02:42:45 +00:00
SAPHID 12c60ec024 fix: inspect available process evidence without touching protected peers 2026-10-24 02:41:50 +00:00
SAPHID 60261f093f fix: stop owned smoke processes before removing titles 2026-10-24 02:40:26 +00:00
SAPHID 3120a3cacf Add read-only Frame battery health and capacity monitoring 2026-10-24 02:40:06 +00:00
SAPHID fecb226e43 Docs: record round-2 Frame verification and remaining gaps 2026-10-24 02:39:25 +00:00
Alex Southwell e9cbb8fd55 Merge pull request #122 from saphid/release/0.4.10
Frame Control 0.4.10
2026-10-24 01:22:05 +00:00
SAPHIDandClaude Opus 5.5 d72be95f25 Frame Control 0.4.10
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:15:19 +00:00
Alex Southwell 65fedbf01e Merge pull request #121 from saphid/feat/windows-pc-in-headset
PC in the headset on Windows (Experimental)
2026-10-24 01:15:04 +00:00
SAPHIDandClaude Opus 5.5 311ee70783 CI: test the installed Windows app's PC libraries, after the installer check
Running the bundled Python against win-unpacked wrote __pycache__ into
the copy the installer check compares with, so the check failed. The
test now runs after that check, on the installed app, without writing
bytecode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:50:36 +00:00
SAPHIDandClaude Opus 5.5 f832d0b912 CI: retry the MSYS2 package install for the Windows PC host
A mirror answered HTTP 429 mid-install and failed the Windows installer
build. setup-msys2 still does the system update; the package install is
now its own step with three attempts, in release.yml and pc-host.yml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:42:47 +00:00
SAPHIDandClaude Opus 5.5 361bcf8a1b Merge origin/main into feat/windows-pc-in-headset
ui/index.html: main added the Back up tool link beside the Mac one;
both links and both host checks are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:34:09 +00:00
SAPHIDandClaude Opus 5.5 10f86d3e7a Docs: PC in the headset is experimental, Windows only, and unverified on a desktop
Rewrites docs/pc-in-headset.md for the Windows scope, with #38's
evidence kept under the revisions it was measured at. README marks it
Experimental; testing.md, mac-in-headset.md and THIRD_PARTY_NOTICES.md
(the bundled GStreamer) follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
SAPHIDandClaude Opus 5.5 5fea1526ca Bundle the PC host in the Windows installer only, without x264
#38 built and bundled the native host into the Windows and both Linux
installers (adding a Linux ARM64 installer). Only the Windows installer
gets it now; the Linux and macOS installer jobs are as on main.

x264 is GPL and Windows encodes with Media Foundation, so the shipped
bundle leaves it out. desktop/build.py --with-x264 adds it for the
native CI jobs' headless protocol tests, which no longer upload their
bundles. The release job tests the packaged app's own bundle with its
bundled Python and checks x264 isn't in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
SAPHIDandClaude Opus 5.5 7a7dca70de PC in the headset: Windows only, Experimental and off until turned on
host_view now picks the PC host on Windows only; Linux and macOS keep
the Mac view as on main, so #38's Linux adapter is never offered. The PC
host starts nothing, loads no native library and lists no windows until
the person ticks "Turn on PC in the headset" on the card (action
"enable" on /api/macview). Turning it off ends streams, viewers and the
helper. The choice is a client preference, like mvSeparate.

Tools lists "PC in the headset" on Windows only, beside the macOS-only
Mac link, with an Experimental tag and a short note on what hasn't been
tried. The Mac card's wording and Linux-portal tweaks from #38 go back
to main's, apart from the shared "your computer" hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:12 +00:00
Alex Southwell bf8c500a87 Merge pull request #118 from saphid/feat/backup-recovery-kit
Backup and recovery kit: saves, settings and Android data, restore after a reset, Return to Game Mode
2026-10-24 00:23:51 +00:00
SAPHIDandClaude Opus 5.5 71fe208fea Keep the Mac helper on its own Swift rate controller
#38 swapped the Mac agent's Swift RateController for a binding to
desktop/controller.c. That is a change to a shipped, headset-verified
feature that this Windows-only PR can't test on a Mac, so the Mac agent,
its build script and the staleness check go back to main. The C port
stays for the Windows host and is still checked against the frozen trace
of the Swift original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:17:05 +00:00
SAPHIDandClaude Opus 5.5 2b838ede9e Backup docs: what was tested on a real Frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:16:38 +00:00
SAPHIDandClaude Opus 5.5 8402a671ef Merge origin/pc-in-headset (PR #38) into feat/windows-pc-in-headset
Brings in #38's native PC host. The only conflict was ui/index.html: main
moved the macview card under Tools, so this keeps main's layout; #38's
loadMacView changes applied cleanly. Scoping to Windows and the
Experimental opt-in follow in separate commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:14:24 +00:00
SAPHID 310e778d00 Merge remote-tracking branch 'origin/main' into feat/backup-recovery-kit
# Conflicts:
#	ui/index.html
2026-10-24 00:13:26 +00:00
SAPHIDandClaude Opus 5.5 a909beb39e Return to Game Mode: recognise gamescope-wl, and switch only when Desktop Mode is seen
On a Frame (build 20261015.6141207) gamescope runs as gamescope-wl, so the session
check said unknown, and the button would have restarted a headset that was in
Game Mode. Game Mode now also counts gamescope-wl and start-gamescope-session, and
the switch runs only when Plasma is running without gamescope.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:13:17 +00:00
Alex Southwell e2601965bd Merge pull request #120 from saphid/fix/record-stop
Recording: Stop actually stops ffmpeg on the Frame
2026-10-24 00:11:10 +00:00
SAPHID 96502e6e4d Merge remote-tracking branch 'origin/main' into fix/record-stop 2026-10-24 00:03:53 +00:00
Alex Southwell d5f26efc1a Merge pull request #119 from saphid/feat/auto-sync
Copy new screenshots and Steam clips to the computer automatically
2026-10-24 00:03:43 +00:00
SAPHIDandClaude Opus 5.5 eac9557286 Recording: stop ffmpeg with q on a FIFO, not SIGTERM, which SteamOS's ffmpeg ignores
On a Frame (SteamOS 0.5.6, build 20261015.6141207) Stop left ffmpeg recording:
ffmpeg n7.0 there catches SIGTERM and SIGINT and carries on. The recording shell
now gives ffmpeg a FIFO as stdin, types q into it on Stop or a dropped channel, and
SIGKILLs it if it's still there 10 s later. Tested end to end on that Frame: an
8 s 1080p H.264 + AAC .mp4 saved 1.3 s after Stop, nothing left on the Frame.

Also records the read-only charge-limit checks run on the same Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:39 +00:00
Alex Southwell 4aa9f18fd1 Merge pull request #117 from saphid/feat/force-quit
Force quit a running game from Games and the Control Center
2026-10-23 23:47:42 +00:00
SAPHIDandClaude Opus 5.5 26206e85b4 Copy new screenshots and Steam clips to the computer automatically
Settings → Copy to this computer, off by default, with a switch each for
screenshots and Steam clips. While a headset is connected, a worker in the
server looks about once a minute and copies what's new with save_shots and
frame_clips.export_clip: only what's newer than turning it on, never what it
copied before (remembered per headset in auto-sync.json, so deleted files stay
deleted), never Steam's background recording or a clip written to in the last
30 s. It waits for other work on the headset, backs off to 15 minutes while
the headset is unreachable, and pauses when this computer is out of space.
Copy everything now copies the rest once.

The Frame-side clip listing now reports each recording's idle time, by the
Frame's clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:33:37 +00:00
SAPHIDandClaude Opus 5.5 30cbe1c901 Backup and recovery kit: saves, settings and Android data off the Frame and back
Tools → Backup & recovery copies game saves (Proton prefixes' AppData,
Documents and Saved Games, Unity and Godot saves), Steam controller layouts,
desktop settings and Android app data (the existing backup-data helper) to
~/Documents/Frame Control Backups/<time>/ with a manifest of every file's size
and SHA-256 and the headset's BUILD_ID. Backing up only reads the headset;
sign-in files, keys and keyrings are refused by name wherever they are.

Restore checks each part against the headset first and skips, rather than
fails, games not installed yet, prefixes Proton hasn't made, a missing Steam
account and closed or missing Android apps. It asks before replacing anything,
writes only inside each part's folders and keeps every replaced file under
~/.local/share/frame-control/restore-previous/.

Return to Game Mode reads the SDDM session config and runs the switch Valve's
devkit tools use (steamos-session-select / holo-session-select gamescope),
only when the headset isn't already in Game Mode and only after confirmation.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:28:35 +00:00
Alex Southwell f4efefa958 Merge pull request #116 from saphid/feat/clips-record-export
Record the headset with its sound, and export Steam clips of any length
2026-10-23 23:23:33 +00:00
SAPHIDandClaude Opus 5.5 e179616ad0 Record the headset with its sound, and export Steam clips of any length
Record on Capture & Live runs ffmpeg on the Frame (x264 from /dev/video99, the
default output's monitor through PipeWire's PulseAudio server), stops it when the
SSH channel closes, remuxes to .mp4 and copies it to ~/Videos/SteamFrame. If the
audio can't be read, it records video only and says so. 20-minute limit, free
space checked on both ends.

Steam clips lists Steam's game recordings (DASH sessions under
userdata/<account>/gamerecordings) and Export joins each stream's init segment
and chunks, then has the Frame's ffmpeg copy them into one .mp4, so clips longer
than Steam's own 60 s export come out whole.

The logic is in ui/frame_clips.py for reuse. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:13:16 +00:00
Alex Southwell 8f245cede7 Merge pull request #115 from saphid/feat/typing-and-clipboard
Type on the headset: one place for the keyboard and clipboard, and copy the headset's clipboard back
2026-10-23 23:12:24 +00:00
Alex Southwell 359407bd06 Merge pull request #114 from saphid/docs/charge-limit-research
Docs: research on an 80% battery charge limit for the Frame
2026-10-23 23:07:53 +00:00
SAPHIDandClaude Opus 5.5 810ef1822d Type on the headset: keyboard and clipboard in one place, and the headset's clipboard back to the PC
Home, Tools and the Control Center now call the keyboard "Type on the
headset", and T opens it from anywhere. The clipboard moves from Send to
Frame to sit beside the keyboard, with a new Copy the headset's clipboard
button: the server reads Klipper's getClipboardContents on the headset
desktop's bus (base64 back, 1 MB limit, empty and image clipboards say so),
and the desktop app, the iPhone app or frame_host.set_clipboard_text puts
it on the clipboard here. Clipboard text is never logged.

Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:04:41 +00:00
SAPHIDandClaude Opus 5.5 3dd8e982a3 Docs: research on an 80% battery charge limit for the Frame
Steam's charge limit goes through SteamOS Manager's BatteryChargeLimit1,
and upstream steam-frame.toml configures no backend for it. The Frame's
battery is a max1720x fuel gauge with no charge_control_end_threshold.
Conclusion: don't build it yet; read-only headset checks are listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 22:58:13 +00:00
SAPHID a13f4fafab fix(ui): reconcile PC host controls with current main 2026-10-18 00:36:47 +00:00
SAPHID ea0b6a80bd Merge main and resolve native host documentation and build triggers 2026-10-18 00:06:01 +00:00
SAPHID b351fe7ad6 chore: resolve conflicts with main 2026-10-17 22:09:58 +00:00
saphid 3cb3defaa7 Record blocked shared-Frame verification and portal limits 2026-09-29 11:13:08 +10:00
saphidandClaude Opus 5.5 a992f6d896 PC host: review fixes (idle watchdog, controller close race, malformed input, loader path)
- The 10 s watchdog no longer ends idle sessions. PipeWire and WGC deliver
  frames only on damage; fail only when a frame entered the encoder and never
  came out, or the pipeline never produced its initial frame.
- Controller.state/call/update are inert after close(), so a /status poll
  racing Stop cannot dereference the freed native controller.
- Malformed viewer fields drop the event instead of ending the session.
- PATH/LD_LIBRARY_PATH prepends add no empty (current directory) entry.
- portal.c: document fd ownership. pipewiresrc dups the fd it is given
  (gstpipewirecore.c, F_DUPFD_CLOEXEC), so the portal closing its own fd is
  correct; clear it after close.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:44:12 +10:00
saphidandClaude Opus 5.5 d9d538690e Merge origin/main into pc-in-headset
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:38:37 +10:00
saphid 391ecbeac5 Record final local test coverage and desktop verification limits 2026-09-28 23:14:36 +10:00
saphid 1dc2a99617 Map the exported PipeWire video adapter factory 2026-09-28 23:13:19 +10:00
saphid d6f3d7c380 Set Windows DPI coordinates before native plugin initialization 2026-09-28 23:10:00 +10:00
saphid 838eaa4e6b Cover the available PipeWire converter and its D-Bus support 2026-09-28 23:09:04 +10:00
saphid 6104c3f192 Refuse window input when Windows cannot focus its target 2026-09-28 23:07:38 +10:00
saphid da5c954ce4 Retain the final idle-window update while the stream gate is closed 2026-09-28 23:05:23 +10:00
saphid 2466c5aa14 Include and smoke-test PipeWire video adapter factories 2026-09-28 23:00:13 +10:00
saphid 060cf9cd57 Bundle PipeWire client modules and record the real Frame probe 2026-09-28 22:58:41 +10:00
saphid cd202433bb Verify shared adaptation and harden PC stream lifecycle 2026-09-28 22:50:58 +10:00
saphid c5fc552c60 Add native PC capture adapters behind the shared panel viewer 2026-09-28 22:29:34 +10:00
saphid 559caa2dd0 Share the desktop stream rate controller across host platforms 2026-09-28 22:23:10 +10:00
104 changed files with 15829 additions and 201 deletions

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
name: PC host libraries
# PC in the headset (Experimental). The Windows job builds the native adapter
# and runs the real-encoder protocol tests; the installer's own bundle is
# tested in release.yml. The Linux adapter from PR #38 isn't shipped or offered
# in the app, but shares capture.c and controller.c, so it's still built here.
# These builds add the GPL x264 encoder for headless tests only: they're never
# uploaded or shipped.
on:
pull_request:
paths: ["desktop/**", "ui/**", "tests/**", "scripts/macview-bench.py", ".github/workflows/pc-host.yml"]
workflow_dispatch:
jobs:
linux:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Native libraries
run: |
sudo apt-get update -qq
sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev libglib2.0-dev gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-pipewire libpipewire-0.3-modules libspa-0.2-modules
python3 desktop/build.py --with-x264
- name: Tests (real x264, fake capture consent/input)
env:
FRAME_PC_REQUIRE_NATIVE: "1"
FRAME_PC_REQUIRE_X264: "1"
run: python3 -m unittest discover -s tests -p 'test_pc*.py' -v
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: msys2/setup-msys2@v2
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Native build and tests (x264; WGC and MF need a desktop/GPU)
shell: msys2 {0}
run: |
python desktop/build.py --with-x264
FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_REQUIRE_X264=1 python -m unittest discover -s tests -p 'test_pc*.py' -v
+37 -2
View File
@@ -3,14 +3,14 @@ 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 change how the installers are built (the Electron app, the
# macOS helper it compiles, or this workflow) build the same installers as
# Windows PC helper libraries, the macOS helper, 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/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
workflow_dispatch:
# A newer push to the same pull request supersedes a build still in progress.
@@ -75,6 +75,28 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: "24"
# PC in the headset (Experimental, Windows only): our capture adapter and
# the GStreamer libraries it uses, bundled into resources/desktop/bundle.
- uses: msys2/setup-msys2@v2
if: runner.os == 'Windows'
with:
msystem: UCRT64
update: true
# A mirror's HTTP 429 once failed this install (2026-10-24): retry, so a
# flaky mirror can't break a release build.
- name: MSYS2 packages for the PC host
if: runner.os == 'Windows'
shell: msys2 {0}
run: |
for attempt in 1 2 3; do
pacman -S --needed --noconfirm mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkgconf mingw-w64-ucrt-x86_64-python mingw-w64-ucrt-x86_64-gstreamer mingw-w64-ucrt-x86_64-gst-plugins-base mingw-w64-ucrt-x86_64-gst-plugins-good mingw-w64-ucrt-x86_64-gst-plugins-bad mingw-w64-ucrt-x86_64-gst-plugins-ugly && exit 0
sleep $((attempt * 30))
done
exit 1
- name: Windows PC streaming libraries
if: runner.os == 'Windows'
shell: msys2 {0}
run: python desktop/build.py --out app/build/desktop/win-x64
- name: Build
working-directory: app
shell: bash
@@ -95,6 +117,19 @@ jobs:
for ($i = 0; $i -lt 60 -and -not (Test-Path (Join-Path $installed 'Uninstall Frame Control.exe')); $i++) { Start-Sleep 2 }
node app/build/check-resources.js (Join-Path $installed 'resources') app/dist/win-unpacked/resources
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# From the installed app, so this also proves the installer carries the
# libraries. After the check above, and without writing __pycache__,
# so it can't change what that check compares.
- name: Test the installed app's Python with the Windows PC libraries
if: runner.os == 'Windows'
shell: bash
env:
PYTHONDONTWRITEBYTECODE: "1"
run: |
resources="$(cygpath -u "$LOCALAPPDATA")/Programs/Frame Control/resources"
python="$resources/python/python.exe"
native="$(cygpath -w "$resources/desktop/bundle")"
FRAME_PC_NATIVE="$native" FRAME_PC_REQUIRE_NATIVE=1 FRAME_PC_INSTALLER_BUNDLE=1 "$python" -B -m unittest discover -s tests -p 'test_pc*.py' -v
- name: Upload to the release
if: startsWith(github.ref, 'refs/tags/')
shell: bash
+10
View File
@@ -7,5 +7,15 @@ compat-db/.lakebed/
tests/smoke/results/
mac/bin/
# Shared desktop streaming controller build products
desktop/controller.dll
desktop/controller.dylib
desktop/controller.so
desktop/*.obj
desktop/*.lib
desktop/*.exp
desktop/bundle/
app/build/desktop/
# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
frame/kdeconnect/packages/
+45
View File
@@ -0,0 +1,45 @@
# Changelog
## Unreleased
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
VM streamed a real desktop to the Frame; physical input remains unverified.
- Add read-only battery health in Tools: charging state, temperature, cycle count,
and available capacity with explicit unknown states. The parser was checked
on a real Frame; the connected desktop UI was tested with synthetic readings.
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
library/game install-size breakdown, with unknown sizes, partial inventories
and permission/missing-drive states. Includes instructions for Steam's own
Move UI; Frame Control does not move or delete game files. The read-only
parser was checked on a real Frame; the connected UI and game moves were not.
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
with verified SSH identity, private summaries and actionable next steps
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
manual; restart-loop evidence stops deeper diagnostics. The real Frame
verified the SSH connection and safety stop; deeper checks were skipped.
- Fixed headset smoke cleanup reporting success while a smoke-title process
could remain running after its files were removed. Cleanup now stops and
rechecks processes tied to exact reserved test-title paths, uses PID handles
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
+17 -6
View File
@@ -63,17 +63,17 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
<tr>
<td valign="top">
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
**📁 Files and games**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you.
</td>
<td valign="top">
**📸 Screenshots**<br>
Browse the shots you take in the headset and save them to your Pictures folder.
**📸 Screenshots and recordings**<br>
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
**⌨️ Keyboard and trackpad**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
**⌨️ Type on the headset**<br>
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
</td>
</tr>
@@ -89,6 +89,14 @@ Install desktop apps like Moonlight or VLC, and set each Android app's resolutio
**⚡ One-click tools**<br>
SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</td>
</tr>
<tr>
<td colspan="2" valign="top">
**💾 Backup and recovery**<br>
Copy game saves, settings and Android app data to your computer, and restore them after a factory reset. Sign-in files and keys are never copied. A Return to Game Mode button helps when the headset is stuck in Desktop Mode. Not tested on a headset yet. [How it works](docs/backup-and-recovery.md).
</td>
</tr>
</table>
@@ -218,6 +226,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
@@ -266,3 +275,5 @@ into a draft release, which reaches users once published. See
licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
See [Game storage](docs/game-storage.md) for read-only headset space and Steam install sizes, and [the changelog](CHANGELOG.md) for unreleased changes.
+3 -1
View File
@@ -10,6 +10,8 @@ ship other people's software, unchanged, each under its own licence:
| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
| GStreamer 1.x, a few of its plugins and the libraries they need, as [MSYS2](https://packages.msys2.org/) builds them | Windows app only, for the experimental [PC in the headset](docs/pc-in-headset.md) | LGPL-2.1-or-later and others (per package); the GPL x264 encoder is left out | `libraries.json` (exact package versions) and the `licenses` folder in the app's `desktop/bundle` folder |
Frame Control starts KDE Connect and talks to it over its network protocol;
it doesn't link to it or include its code.
it doesn't link to it or include its code. On Windows, Frame Control's own
`pc-host.dll` links to GStreamer dynamically; the libraries are unmodified.
+6
View File
@@ -282,6 +282,12 @@ ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// Text from the headset's clipboard onto this computer's.
ipcMain.handle("clipboard:write", (e, text) => {
if (!fromUi(e) || typeof text !== "string") return false;
clipboard.writeText(text);
return true;
});
ipcMain.handle("preferences:read", (e) => fromUi(e) ? preferences.read() : {});
ipcMain.handle("preferences:write", (e, key, value) => fromUi(e) ? preferences.write(key, value) : false);
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.9",
"version": "0.4.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.9",
"version": "0.4.11",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+11 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.9",
"version": "0.4.11",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -198,7 +198,16 @@
"zip"
],
"icon": "build/icon.png",
"artifactName": "Frame-Control-win-${arch}.${ext}"
"artifactName": "Frame-Control-win-${arch}.${ext}",
"extraResources": [
{
"from": "build/desktop/${os}-${arch}",
"to": "desktop/bundle",
"filter": [
"**/*"
]
}
]
},
"nsis": {
"oneClick": false,
+1 -1
View File
@@ -1,7 +1,7 @@
// Client choices live outside the local server's changing URL/origin.
const fs = require("node:fs");
const path = require("node:path");
const KEYS = ["mvSeparate", "padUsed"];
const KEYS = ["mvSeparate", "padUsed", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
+3 -2
View File
@@ -1,5 +1,5 @@
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// Lets the page read and write this computer's clipboard through Electron, so sending
// it to the Frame, and copying the Frame's back, needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
@@ -17,6 +17,7 @@ contextBridge.exposeInMainWorld("frameApp", {
},
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeClipboard: (text) => ipcRenderer.invoke("clipboard:write", text),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
+9 -1
View File
@@ -36,7 +36,7 @@ test("client choices survive a new native session and local server origin", t =>
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
test("only the two non-sensitive binary choices can enter the native store", t => {
test("only the non-sensitive binary choices can enter the native store", t => {
const file = fixture(t), store = preferenceStore(file);
for (const [key, value] of [["token", "secret"], ["__proto__", "1"], ["padUsed", true], ["mvSeparate", "yes"]]) {
assert.throws(() => store.write(key, value), /Invalid preference/);
@@ -58,3 +58,11 @@ test("a missing or damaged preference file retains usable defaults", t => {
store.write("padUsed", "1");
assert.deepEqual(store.read(), { padUsed: "1" });
});
test("the PC in the headset opt-in is remembered and off unless saved", t => {
const file = fixture(t), store = preferenceStore(file);
assert.equal(store.read().pcView, undefined);
store.write("pcView", "1");
assert.deepEqual(preferenceStore(file).read(), { pcView: "1" });
assert.throws(() => store.write("pcView", "on"), /Invalid preference/);
});
@@ -0,0 +1,311 @@
{
"label": "pc-agent-frame-arm64-hairpin",
"date": "2026-09-28T22:36:19",
"commit": "c5fc552+dirty",
"config": {
"quality": "balanced",
"mode": "separate",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 351,
"frames_drawn": 351,
"frames_shown": 324,
"duration_s": 10.1,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.5,
"n": 351
},
"queue": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 351
},
"encode": {
"p50": 2.0,
"p95": 2.9,
"p99": 3.5,
"n": 351
},
"socket": {
"p50": 0.1,
"p95": 0.4,
"p99": 0.6,
"n": 351
},
"network": {
"p50": 11.6,
"p95": 75.7,
"p99": 130.1,
"n": 351
},
"decode": {
"p50": 0.7,
"p95": 2.1,
"p99": 4.1,
"n": 351
},
"draw": {
"p50": 0.4,
"p95": 0.8,
"p99": 1.1,
"n": 351
},
"present": {
"p50": 0.8,
"p95": 18.1,
"p99": 24.9,
"n": 324
},
"content": {
"p50": 15.1,
"p95": 80.3,
"p99": 134.2,
"n": 351
},
"content_shown": {
"p50": 21.0,
"p95": 71.8,
"p99": 146.3,
"n": 324
}
},
"fps": 34.8,
"fps_shown": 32.0,
"late_pct": 53.41,
"stall_max": 226.8,
"stalls_over_100ms": 7,
"mbps": 0.26,
"keyframes": 6,
"size": "1280x720",
"captured": 607,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 43.3,
"p95": 98.4,
"p99": 110.1,
"n": 18
},
"input_shown_ms": {
"p50": 54.7,
"p95": 98.9,
"p99": 114.0,
"n": 18
},
"input_parts_ms": {
"uplink": {
"p50": 17.1,
"p95": 48.3,
"p99": 53.1,
"n": 18
},
"mac": {
"p50": 12.5,
"p95": 32.9,
"p99": 35.7,
"n": 18
},
"back": {
"p50": 16.8,
"p95": 50.0,
"p99": 55.9,
"n": 18
}
},
"inputs": {
"asked": 18,
"sent": 18,
"seen": 18
},
"timeline": [
{
"t": 0,
"fps": 38,
"mbps": 0.3,
"content_p50": 14.8,
"content_p95": 25.3,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 33,
"mbps": 0.24,
"content_p50": 15.6,
"content_p95": 103.5,
"bitrate": 3845448,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 39,
"mbps": 0.28,
"content_p50": 15.6,
"content_p95": 29.9,
"bitrate": 5283790,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 25,
"mbps": 0.21,
"content_p50": 17.8,
"content_p95": 175.9,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 34,
"mbps": 0.22,
"content_p50": 15.4,
"content_p95": 69.8,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 38,
"mbps": 0.29,
"content_p50": 17.7,
"content_p95": 68.7,
"bitrate": 2764800,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 38,
"mbps": 0.26,
"content_p50": 15.0,
"content_p95": 18.2,
"bitrate": 3091280,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 30,
"mbps": 0.22,
"content_p50": 19.2,
"content_p95": 111.0,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 35,
"mbps": 0.26,
"content_p50": 13.4,
"content_p95": 102.6,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.28,
"content_p50": 12.3,
"content_p95": 20.9,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 3,
"mbps": 0.02,
"content_p50": 20.9,
"content_p95": 24.7,
"bitrate": 1725204,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 80.3,
"input_p50": 43.3,
"input_p95": 98.4,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "acceptable",
"input_p50": "local",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "acceptable"
},
"controller": {
"target": 3442081,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 13.334,
"inFlight": 1,
"slack": 71.738,
"adapt": true
},
"events": [
{
"t": 3.77,
"e": "target 2764800 bit/s; tier 0"
},
{
"t": 7.42,
"e": "target 1725204 bit/s; tier 0"
}
],
"source_fps": 60.1,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
"show_s": 1.88,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
@@ -0,0 +1,302 @@
{
"label": "pc-agent-frame-arm64-final",
"date": "2026-09-28T22:55:27",
"commit": "cd20243",
"config": {
"quality": "balanced",
"mode": "native",
"net": "none",
"delay_ms": 0,
"buffer_ms": 250,
"host": "frame",
"usb": false,
"ssh_opts": [],
"encoder": "",
"duration_s": 10.0,
"browser_flags": []
},
"frame_build": "20260925.6191901",
"mac": "26.5.2",
"host_platform": "Linux aarch64, SteamOS Frame, BUILD_ID 20260925.6191901 (synthetic host only)",
"source": "",
"pc_host": true,
"headset": "0:03.056203 [Info] - 1 - leaving standby",
"scenarios": [
{
"scenario": "test",
"frames_sent": 400,
"frames_drawn": 400,
"frames_shown": 339,
"duration_s": 10.2,
"stages_ms": {
"capture": {
"p50": 0.1,
"p95": 0.1,
"p99": 0.2,
"n": 400
},
"queue": {
"p50": 0.1,
"p95": 0.2,
"p99": 0.4,
"n": 400
},
"encode": {
"p50": 2.0,
"p95": 2.7,
"p99": 3.1,
"n": 400
},
"socket": {
"p50": 0.1,
"p95": 0.3,
"p99": 0.4,
"n": 400
},
"network": {
"p50": 11.8,
"p95": 25.2,
"p99": 37.8,
"n": 400
},
"decode": {
"p50": 0.7,
"p95": 1.5,
"p99": 2.0,
"n": 400
},
"draw": {
"p50": 0.3,
"p95": 0.7,
"p99": 1.0,
"n": 400
},
"present": {
"p50": 0.8,
"p95": 19.3,
"p99": 26.8,
"n": 339
},
"content": {
"p50": 15.1,
"p95": 28.6,
"p99": 41.3,
"n": 400
},
"content_shown": {
"p50": 17.6,
"p95": 39.0,
"p99": 47.2,
"n": 339
}
},
"fps": 39.3,
"fps_shown": 33.4,
"late_pct": 50.25,
"stall_max": 69.3,
"stalls_over_100ms": 0,
"mbps": 0.35,
"keyframes": 6,
"size": "1280x720",
"captured": 612,
"viewer_never_drawn": 0,
"input_ms": {
"p50": 76.6,
"p95": 98.2,
"p99": 127.8,
"n": 19
},
"input_shown_ms": {
"p50": 80.5,
"p95": 107.8,
"p99": 128.1,
"n": 16
},
"input_parts_ms": {
"uplink": {
"p50": 36.5,
"p95": 57.6,
"p99": 83.2,
"n": 19
},
"mac": {
"p50": 14.6,
"p95": 31.3,
"p99": 31.7,
"n": 19
},
"back": {
"p50": 15.1,
"p95": 32.8,
"p99": 36.2,
"n": 19
}
},
"inputs": {
"asked": 19,
"sent": 19,
"seen": 19
},
"timeline": [
{
"t": 0,
"fps": 40,
"mbps": 0.34,
"content_p50": 11.7,
"content_p95": 27.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 1,
"fps": 39,
"mbps": 0.37,
"content_p50": 11.9,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 2,
"fps": 41,
"mbps": 0.35,
"content_p50": 11.4,
"content_p95": 16.6,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 3,
"fps": 39,
"mbps": 0.37,
"content_p50": 12.7,
"content_p95": 28.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 4,
"fps": 38,
"mbps": 0.33,
"content_p50": 15.3,
"content_p95": 30.0,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 5,
"fps": 39,
"mbps": 0.37,
"content_p50": 16.7,
"content_p95": 28.5,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 6,
"fps": 41,
"mbps": 0.38,
"content_p50": 15.9,
"content_p95": 29.4,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 7,
"fps": 41,
"mbps": 0.31,
"content_p50": 15.6,
"content_p95": 28.2,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 8,
"fps": 38,
"mbps": 0.38,
"content_p50": 15.5,
"content_p95": 25.9,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 9,
"fps": 38,
"mbps": 0.29,
"content_p50": 16.7,
"content_p95": 28.8,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
},
{
"t": 10,
"fps": 6,
"mbps": 0.03,
"content_p50": 17.6,
"content_p95": 23.1,
"bitrate": 5529600,
"tier": 0,
"w": 1280,
"net": null
}
],
"adapt": [],
"content_p50": 15.1,
"content_p95": 28.6,
"input_p50": 76.6,
"input_p95": 98.2,
"grades": {
"input_replies": "local",
"content_p50": "local",
"content_p95": "local",
"input_p50": "acceptable",
"input_p95": "acceptable",
"fps": "bad",
"late_pct": "bad",
"stall_max": "local"
},
"controller": {
"target": 5529600,
"ceiling": 5529600,
"tier": 0,
"fps": 60,
"scale": 1.0,
"baseRtt": 12.332,
"inFlight": 0,
"slack": 74.12,
"adapt": true
},
"events": [],
"source_fps": 60.0,
"cpu": {
"host": "not sampled"
},
"viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
"show_s": 1.75,
"panel": "valve.steam.desktopgame.2001932457",
"src": "test",
"route": "network"
}
],
"proof": "Synthetic x264 PC agent runs on the Frame itself, forwarded via Mac and back. Not Windows/Linux desktop capture or laser input."
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Build the common controller for the Python PC host (a C11 compiler needed)."""
import os
from pathlib import Path
import subprocess
import sys
root = Path(__file__).resolve().parent
suffix = '.dll' if sys.platform == 'win32' else '.dylib' if sys.platform == 'darwin' else '.so'
out = root / ('controller' + suffix)
if sys.platform == 'win32' and os.environ.get('CC', 'cl') == 'cl':
cmd = ['cl', '/nologo', '/O2', '/LD', '/std:c11', str(root / 'controller.c'), '/link', '/OUT:' + str(out)]
else:
cmd = [os.environ.get('CC', 'cc'), '-O2', '-std=c11', '-shared', '-fPIC', str(root / 'controller.c'), '-o', str(out)]
subprocess.run(cmd, cwd=root, check=True)
print(out)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Build our native PC adapter and bundle its ordinary shared libraries.
Run on the target architecture after installing GStreamer development packages.
No GStreamer executable or desktop streaming application is shipped or invoked.
Linux libc, display/GPU drivers and the portal service remain platform pieces.
x264 is GPL, so the bundle the Windows installer ships leaves it out: Windows
encodes with Media Foundation. --with-x264 adds it for CI's headless protocol
tests, which have no GPU or desktop to encode with.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
ROOT = Path(__file__).resolve().parent
def output(*args):
return subprocess.check_output(args, text=True).strip()
def build(destination, with_x264=False):
win = sys.platform == 'win32'
destination.mkdir(parents=True, exist_ok=True)
modules = ['gstreamer-1.0', 'gstreamer-app-1.0', 'gstreamer-video-1.0']
if not win:
modules += ['gio-unix-2.0']
import shlex
flags = shlex.split(output('pkg-config', '--cflags', '--libs', *modules))
lib = destination / ('pc-host.dll' if win else 'pc-host.so')
cmd = [os.environ.get('CC', 'gcc' if win else 'cc'), '-std=c11', '-O2', '-shared', '-Wall', '-Wextra']
if not win:
cmd += ['-fPIC', '-Wl,-rpath,$ORIGIN/lib']
subprocess.run(cmd + [str(ROOT / f) for f in ('controller.c', 'capture.c', 'portal.c')] + flags + ['-o', str(lib)], check=True)
prefix = Path(output('pkg-config', '--variable=prefix', 'gstreamer-1.0'))
plugin_dir = Path(output('pkg-config', '--variable=pluginsdir', 'gstreamer-1.0'))
plugins_out, libs_out = destination / 'lib' / 'gstreamer-1.0', destination / ('bin' if win else 'lib')
plugins_out.mkdir(parents=True, exist_ok=True)
libs_out.mkdir(parents=True, exist_ok=True)
names = ['coreelements', 'videotestsrc', 'videoconvertscale', 'app', 'jpeg', 'videoparsersbad']
if with_x264:
names.append('x264')
names += ['d3d11', 'mediafoundation'] if win else ['pipewire', 'va']
pending = [lib]
for name in names:
matches = list(plugin_dir.glob('*gst' + name + ('.dll' if win else '.so')))
if not matches:
raise SystemExit('Missing GStreamer library: ' + name)
for source in matches:
target = plugins_out / source.name
shutil.copy2(source, target)
pending.append(source)
if not win:
# PipeWire dynamically loads support and protocol modules. ldd cannot
# discover those; bundle them with a private client-only config instead
# of accidentally loading a different distro's client modules/config.
libdir = plugin_dir.parent
for relative in ('pipewire-0.3/libpipewire-module-protocol-native.so',
'pipewire-0.3/libpipewire-module-client-node.so',
'pipewire-0.3/libpipewire-module-adapter.so',
'spa-0.2/support/libspa-support.so',
'spa-0.2/support/libspa-dbus.so',
'spa-0.2/videoconvert/libspa-videoconvert.so'):
source = libdir / relative
if not source.is_file():
raise SystemExit('Missing PipeWire runtime library: ' + str(source))
target = destination / 'lib' / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
pending.append(source)
config = destination / 'share' / 'pipewire'
config.mkdir(parents=True, exist_ok=True)
(config / 'client.conf').write_text(
'context.spa-libs = { support.* = support/libspa-support video.* = videoconvert/libspa-videoconvert }\n'
'context.modules = [\n'
' { name = libpipewire-module-protocol-native }\n'
' { name = libpipewire-module-client-node }\n'
' { name = libpipewire-module-adapter }\n'
']\n')
copied = set()
while pending:
binary = pending.pop()
if win:
imported = re.findall(r'DLL Name:\s*(\S+)', output('objdump', '-p', str(binary)))
dependencies = [prefix / 'bin' / name for name in imported]
else:
dependencies = [Path(p) for p in re.findall(r'=>\s+(/\S+)', output('ldd', str(binary)))]
for dep in dependencies:
if not dep.is_file() or dep.name in copied:
continue
if not win and re.match(r'lib(c|m|dl|rt|pthread|resolv)\.so', dep.name):
continue
shutil.copy2(dep, libs_out / dep.name)
copied.add(dep.name)
pending.append(dep)
# License texts and package provenance accompany the dynamically linked
# libraries. Distribution builders keep the upstream package/source URLs.
licenses = destination / 'licenses'
licenses.mkdir(exist_ok=True)
if win:
source = prefix / 'share' / 'licenses'
if source.exists():
shutil.copytree(source, licenses, dirs_exist_ok=True)
packages = output('pacman', '-Q') if shutil.which('pacman') else 'See MSYS2 build log'
else:
packages = output('dpkg-query', '-W', '-f=${Package} ${Version}\n') if shutil.which('dpkg-query') else ''
# Debian copyright files contain licenses and source homepage details.
for path in Path('/usr/share/doc').glob('*/copyright'):
shutil.copy2(path, licenses / (path.parent.name + '.copyright'))
(destination / 'libraries.json').write_text(json.dumps(dict(gstreamer=output('pkg-config', '--modversion', 'gstreamer-1.0'),
plugins=names, libraries=sorted(copied), packages=packages), indent=2))
print(lib)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--out', type=Path, default=ROOT / 'bundle')
parser.add_argument('--with-x264', action='store_true', help='add the GPL x264 encoder (CI tests only, never shipped)')
args = parser.parse_args()
build(args.out.resolve(), args.with_x264)
+134
View File
@@ -0,0 +1,134 @@
/* Frame Control's capture/encode adapter. GStreamer is a bundled library;
* capture uses WGC on Windows and the consented PipeWire fd on Linux. */
#include "controller.h"
#include <gst/gst.h>
#include <gst/app/gstappsink.h>
#include <gst/video/video-event.h>
#include <string.h>
typedef int (*Gate)(int stage, int64_t pts, int64_t capture, int64_t arrived);
typedef struct {
GstElement *pipeline, *encoder, *sink, *raw_queue;
Gate gate;
GstSample *sample;
GstMapInfo map;
int mapped;
char error[512];
} Capture;
typedef struct { const unsigned char *data; int size, key, width, height; int64_t pts; } Encoded;
FC_API int64_t fc_now(void) {return g_get_monotonic_time();}
FC_API void fc_gst_init(void) {gst_init(NULL,NULL);}
FC_API int fc_has_element(const char *name) {
GstElementFactory *f=gst_element_factory_find(name);
if(!f)return 0;gst_object_unref(f);return 1;
}
FC_API int fc_has_property(const char *element, const char *property) {
/* Probe the same initialized GStreamer instance that capture uses. The
* bundled MSYS2 plugin can omit optional WGC properties entirely. */
GstElement *source=gst_element_factory_make(element,NULL);
if(!source)return 0;
int found=g_object_class_find_property(G_OBJECT_GET_CLASS(source),property)!=NULL;
gst_object_unref(source);
return found;
}
static GstPadProbeReturn probe(GstPad *pad,GstPadProbeInfo *info,gpointer data) {
Capture *c=data; GstBuffer *b=GST_PAD_PROBE_INFO_BUFFER(info);
if(!b)return GST_PAD_PROBE_OK;
int64_t now=fc_now(), cap=now;
GstClock *clock=gst_element_get_clock(c->pipeline);
if(clock && GST_BUFFER_PTS_IS_VALID(b)) {
GstClockTime current=gst_clock_get_time(clock),origin=gst_element_get_base_time(c->pipeline);
if(current>=origin && current-origin>=GST_BUFFER_PTS(b))
cap-= (int64_t)((current-origin-GST_BUFFER_PTS(b))/1000);
}
if(clock)gst_object_unref(clock);
int stage=GPOINTER_TO_INT(g_object_get_data(G_OBJECT(pad),"stage"));
if(stage==1) return c->gate(stage,(int64_t)GST_BUFFER_PTS(b),cap,now)>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
int phase=0;
for(;;) {
/* While congested, prefer a newer raw picture queued upstream. If
* nothing changed, retain this last picture until the gate opens;
* an idle window must not stay stale after a dropped final update. */
guint queued=0;
if(phase && c->raw_queue)g_object_get(c->raw_queue,"current-level-buffers",&queued,NULL);
if(queued)return GST_PAD_PROBE_DROP;
int decision=c->gate(phase,(int64_t)GST_BUFFER_PTS(b),cap,now);
if(decision)return decision>0 ? GST_PAD_PROBE_OK : GST_PAD_PROBE_DROP;
phase=2;g_usleep(2000);
}
}
FC_API Capture *fc_capture_open(const char *pipeline,Gate gate,char *error,int capacity) {
GError *e=NULL;Capture *c=g_new0(Capture,1);c->gate=gate;
c->pipeline=gst_parse_launch(pipeline,&e);
if(e || !c->pipeline) {
g_strlcpy(error,e?e->message:"No pipeline",capacity);
if(e)g_error_free(e);if(c->pipeline)gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->encoder=gst_bin_get_by_name(GST_BIN(c->pipeline),"enc");
c->sink=gst_bin_get_by_name(GST_BIN(c->pipeline),"out");
GstElement *raw=gst_bin_get_by_name(GST_BIN(c->pipeline),"gate");
if(!c->encoder || !c->sink || !raw) {
g_strlcpy(error,"Pipeline is missing enc, out or gate",capacity);
if(raw)gst_object_unref(raw);if(c->encoder)gst_object_unref(c->encoder);
if(c->sink)gst_object_unref(c->sink);gst_object_unref(c->pipeline);g_free(c);return NULL;
}
c->raw_queue=gst_bin_get_by_name(GST_BIN(c->pipeline),"raw_queue");
GstPad *p=gst_element_get_static_pad(raw,"src");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(0));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);gst_object_unref(raw);
p=gst_element_get_static_pad(c->encoder,"sink");
g_object_set_data(G_OBJECT(p),"stage",GINT_TO_POINTER(1));
gst_pad_add_probe(p,GST_PAD_PROBE_TYPE_BUFFER,probe,c,NULL);gst_object_unref(p);
gst_element_set_state(c->pipeline,GST_STATE_PLAYING);
return c;
}
FC_API int fc_capture_pull(Capture *c,Encoded *out) {
if(c->mapped) {gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);c->mapped=0;}
if(c->sample){gst_sample_unref(c->sample);c->sample=NULL;}
GstBus *bus=gst_element_get_bus(c->pipeline);
GstMessage *m=gst_bus_pop_filtered(bus,GST_MESSAGE_ERROR);gst_object_unref(bus);
if(m) {
if(GST_MESSAGE_TYPE(m)==GST_MESSAGE_ERROR) {
GError *e=NULL;char *debug=NULL;gst_message_parse_error(m,&e,&debug);
g_strlcpy(c->error,e->message,sizeof(c->error));g_error_free(e);g_free(debug);
} else g_strlcpy(c->error,"The capture source closed",sizeof(c->error));
gst_message_unref(m);return -1;
}
c->sample=gst_app_sink_try_pull_sample(GST_APP_SINK(c->sink),100*GST_MSECOND);
if(!c->sample) {
if(gst_app_sink_is_eos(GST_APP_SINK(c->sink))) {
g_strlcpy(c->error,"The capture source closed",sizeof(c->error));return -1;
}
return 0;
}
GstBuffer *b=gst_sample_get_buffer(c->sample);
if(!gst_buffer_map(b,&c->map,GST_MAP_READ))return 0;
c->mapped=1;out->data=c->map.data;out->size=(int)c->map.size;
out->key=!GST_BUFFER_FLAG_IS_SET(b,GST_BUFFER_FLAG_DELTA_UNIT);out->pts=(int64_t)GST_BUFFER_PTS(b);
const GstStructure *s=gst_caps_get_structure(gst_sample_get_caps(c->sample),0);
gst_structure_get_int(s,"width",&out->width);gst_structure_get_int(s,"height",&out->height);return 1;
}
FC_API const char *fc_capture_error(Capture *c) {return c->error;}
FC_API void fc_capture_bitrate(Capture *c,int bps) {
g_object_set(c->encoder,"bitrate",(guint)MAX(1,bps/1000),NULL);
}
FC_API void fc_capture_test(Capture *c,uint32_t input) {
GstElement *source=gst_bin_get_by_name(GST_BIN(c->pipeline),"source");
if(source) {
/* Each benchmark click visibly changes the ball, before injection is
* timestamped. This is a response, not merely a protocol input echo. */
guint color=0xff000000u | ((input*2654435761u)&0x00ffffffu);
g_object_set(source,"foreground-color",color,NULL);gst_object_unref(source);
}
}
FC_API void fc_capture_key(Capture *c) {
GstPad *p=gst_element_get_static_pad(c->encoder,"src");
gst_pad_send_event(p,gst_video_event_new_upstream_force_key_unit(GST_CLOCK_TIME_NONE,TRUE,0));gst_object_unref(p);
}
FC_API void fc_capture_close(Capture *c) {
gst_element_set_state(c->pipeline,GST_STATE_NULL);
if(c->mapped)gst_buffer_unmap(gst_sample_get_buffer(c->sample),&c->map);
if(c->sample)gst_sample_unref(c->sample);
if(c->raw_queue)gst_object_unref(c->raw_queue);
gst_object_unref(c->sink);gst_object_unref(c->encoder);gst_object_unref(c->pipeline);g_free(c);
}
+130
View File
@@ -0,0 +1,130 @@
/* A C port of the Mac agent's RateController (mac/frame-mac-view/Sources/
* Controller.swift) for the Windows host. Same gate, demand protection, bitrate
* recovery and tier hysteresis; bounded sample buffers. The Mac agent keeps its
* Swift original, so a change to either must be made to both:
* tests/test_pc_controller.py checks this one against the Swift trace. */
#include "controller.h"
#include <stdlib.h>
#include <string.h>
#define CAP 1024
#define MIN(a,b) ((a)<(b)?(a):(b))
#define MAX(a,b) ((a)>(b)?(a):(b))
typedef struct { int64_t t, v; uint32_t seq; } Sample;
typedef struct { Sample a[CAP]; int n; } Samples;
struct FCController {
int max_fps, enabled, ceiling, target, tier, frame_bytes, held, signal, saw_ack;
int64_t decrease, increase, below, above, slack;
Samples flight, rtts, acked, sent, captures;
};
static const int fps[] = {60,45,30,30,30}, scale[] = {100,100,100,75,50};
static const double floors[] = {.45,.28,.16,.08,0};
static void remove_first(Samples *s, int n) {
s->n -= n; memmove(s->a, s->a+n, (size_t)s->n*sizeof(Sample));
}
static void add(Samples *s, Sample v, int cap) {
if (s->n >= cap) remove_first(s, 1);
s->a[s->n++] = v;
}
static void expire(Samples *s, int64_t oldest) {
int n=0; while(n<s->n && s->a[n].t<oldest) n++;
remove_first(s,n);
}
static int64_t base(FCController *c) {
int64_t v=0;
for(int i=0;i<c->rtts.n;i++) if(!i || c->rtts.a[i].v<v) v=c->rtts.a[i].v;
return v;
}
static int compare(const void *a, const void *b) {
int64_t x=*(const int64_t*)a, y=*(const int64_t*)b;
return (x>y)-(x<y);
}
static int64_t quantile(Samples *s,int64_t since,int numerator,int denominator) {
int64_t a[CAP]; int n=0;
for(int i=0;i<s->n;i++) if(s->a[i].t>since) a[n++]=s->a[i].v;
if(!n) return 0;
qsort(a,(size_t)n,sizeof(int64_t),compare);
return a[n*numerator/denominator];
}
static int rate(Samples *s) {
int64_t total=0; for(int i=0;i<s->n;i++) total+=s->a[i].v;
return (int)MIN(total*16,2147483647);
}
FCController *fc_new(int max_fps,int enabled) {
FCController *c=calloc(1,sizeof(*c));
if(c) {c->max_fps=MAX(1,max_fps);c->enabled=enabled;c->slack=40000;}
return c;
}
void fc_free(FCController *c) {free(c);}
void fc_ceiling(FCController *c,int bps) {
if(!c->target || c->target>bps) c->target=bps;
c->ceiling=bps;
}
int fc_gate(FCController *c,int64_t now,int counts) {
if(!c->enabled || !c->saw_ack) return 1;
expire(&c->flight,now-2000000);
if(!c->flight.n) return 1;
int64_t interval=1000000/MIN(c->max_fps,fps[c->tier]);
int window=MAX(3,(int)((base(c)+c->slack)/interval)+1);
if(c->flight.n<window && now-c->flight.a[0].t<=base(c)+c->slack) return 1;
if(counts) c->held++;
return 0;
}
void fc_capture(FCController *c,int64_t now) {add(&c->captures,(Sample){now,0,0},256);}
void fc_sent(FCController *c,uint32_t seq,int bytes,int64_t now) {
Sample s={now,bytes,seq};add(&c->flight,s,512);add(&c->sent,s,CAP);
}
int fc_ack(FCController *c,uint32_t seq,int64_t now) {
c->saw_ack=1;
for(int i=0;i<c->flight.n;i++) if(c->flight.a[i].seq==seq) {
Sample s=c->flight.a[i];remove_first(&c->flight,i+1);
add(&c->rtts,(Sample){now,now-s.t,0},CAP);
add(&c->acked,(Sample){now,s.v,0},CAP);return 1;
}
return 0;
}
int fc_update(FCController *c,int64_t now) {
expire(&c->rtts,now-10000000);expire(&c->acked,now-500000);
expire(&c->sent,now-500000);expire(&c->flight,now-2000000);
expire(&c->captures,now-1000000);
if(!c->enabled || c->ceiling<=0) return 0;
int64_t baseline=base(c), spread=quantile(&c->rtts,now-2000000,9,10);
int64_t jitter=spread ? spread-baseline : 0;
c->slack=1000000/MIN(c->max_fps,fps[c->tier])+MIN(MAX(jitter*3/2,25000),80000);
int64_t recent=quantile(&c->rtts,now-300000,1,2);
int64_t queue=recent ? recent-baseline : 0;
int64_t age=c->flight.n ? now-c->flight.a[0].t : 0;
int delivered=rate(&c->acked),sending=rate(&c->sent);
if(c->sent.n) c->frame_bytes=sending/16/c->sent.n;
int demand=(int)MIN((int64_t)MIN(c->captures.n,MIN(c->max_fps,fps[c->tier]))*c->frame_bytes*8,2147483647);
int signal=(sending>=c->target/2 && queue>40000)||c->held>=3||age>baseline+100000;
int congested=signal && c->signal;c->signal=signal;c->held=0;
if(congested && now-c->decrease>300000) {
int next=MAX(300000,MIN((int64_t)c->target*4/5,MAX((int64_t)delivered*9/10,c->target/2)));
if(demand>0 && (int64_t)demand*2<=(int64_t)c->target*5/4) next=MAX(next,MIN(c->target,(int64_t)demand*2));
c->target=next;c->decrease=now;
} else if(!congested && now-c->decrease>1000000 && now-c->increase>250000 && c->target<c->ceiling &&
(sending>(int64_t)c->target*6/10 || now-c->decrease>3000000)) {
c->target=MIN(c->ceiling,(int64_t)(c->target*1.1)+50000);c->increase=now;
}
double share=(double)c->target/MAX(c->ceiling,1);
if(c->tier<4 && share<floors[c->tier] && sending>=(int64_t)c->target*7/10) {
if(!c->below)c->below=now;
if(now-c->below>500000) {
for(c->tier=0;c->tier<4 && share<floors[c->tier];c->tier++) {}
c->below=0;
}
} else c->below=0;
if(c->tier>0 && share>floors[c->tier-1]*1.25) {
if(!c->above)c->above=now;
if(now-c->above>2000000) {c->tier--;c->above=0;}
} else c->above=0;
return c->target;
}
int64_t fc_value(FCController *c,int field) {
switch(field) {
case 0:return c->target;case 1:return c->ceiling;case 2:return c->tier;
case 3:return MIN(c->max_fps,fps[c->tier]);case 4:return scale[c->tier];
case 5:return base(c);case 6:return c->flight.n;case 7:return c->slack;
default:return 0;
}
}
+29
View File
@@ -0,0 +1,29 @@
/* Shared latency controller. Times are monotonic host microseconds.
* Callers serialize access. Capture is gated BEFORE encoding: dropping an
* encoded inter frame would break the decoder's reference chain. */
#ifndef FRAME_CONTROLLER_H
#define FRAME_CONTROLLER_H
#include <stdint.h>
#ifdef _WIN32
#define FC_API __declspec(dllexport)
#else
#define FC_API
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct FCController FCController;
FC_API FCController *fc_new(int fps, int enabled);
FC_API void fc_free(FCController *c);
FC_API void fc_ceiling(FCController *c, int bps);
FC_API int fc_gate(FCController *c, int64_t now, int counts);
FC_API void fc_capture(FCController *c, int64_t now);
FC_API void fc_sent(FCController *c, uint32_t seq, int bytes, int64_t now);
FC_API int fc_ack(FCController *c, uint32_t seq, int64_t now);
FC_API int fc_update(FCController *c, int64_t now);
/* target, ceiling, tier, fps, scale percent, baseline us, in flight, slack us */
FC_API int64_t fc_value(FCController *c, int field);
#ifdef __cplusplus
}
#endif
#endif
+147
View File
@@ -0,0 +1,147 @@
/* xdg-desktop-portal RemoteDesktop + ScreenCast, one consented session per
* panel. PipeWire receives the portal fd, never the unrestricted daemon fd.
* Input uses only the devices granted in Start's response. */
#ifndef _WIN32
#include "controller.h"
#include <gio/gio.h>
#include <gio/gunixfdlist.h>
#include <unistd.h>
#include <string.h>
#define BUS "org.freedesktop.portal.Desktop"
#define PATH "/org/freedesktop/portal/desktop"
#define RD "org.freedesktop.portal.RemoteDesktop"
#define SC "org.freedesktop.portal.ScreenCast"
typedef struct {
GDBusConnection *bus;
GMainContext *context;
char *session;
int fd;
unsigned node, devices;
int width,height;
} Portal;
typedef struct {GVariant *result;int done;unsigned code;} Response;
static void response(GDBusConnection *c,const char *sender,const char *path,const char *iface,
const char *signal,GVariant *params,gpointer data) {
(void)c;(void)sender;(void)path;(void)iface;(void)signal;
Response *r=data;g_variant_get(params,"(u@a{sv})",&r->code,&r->result);r->done=1;
}
static void option(GVariantBuilder *b,const char *key,GVariant *v) {g_variant_builder_add(b,"{sv}",key,v);}
static GVariant *request(Portal *p,const char *iface,const char *method,GVariant *args,
const char *token,char *error,int capacity) {
char *sender=g_strdup(g_dbus_connection_get_unique_name(p->bus)+1);
for(char *c=sender;*c;c++)if(*c=='.')*c='_';
char *path=g_strdup_printf(PATH "/request/%s/%s",sender,token);g_free(sender);
Response r={0};GError *e=NULL;
guint sub=g_dbus_connection_signal_subscribe(p->bus,BUS,"org.freedesktop.portal.Request","Response",
path,NULL,G_DBUS_SIGNAL_FLAGS_NONE,response,&r,NULL);
GVariant *reply=g_dbus_connection_call_sync(p->bus,BUS,PATH,iface,method,args,G_VARIANT_TYPE("(o)"),
G_DBUS_CALL_FLAGS_NONE,10000,NULL,&e);
if(reply)g_variant_unref(reply);
int64_t deadline=g_get_monotonic_time()+120000000;
while(!e && !r.done && g_get_monotonic_time()<deadline) {
while(g_main_context_iteration(p->context,FALSE)) {}
g_usleep(10000);
}
if(!r.done) {
GVariant *closed=g_dbus_connection_call_sync(p->bus,BUS,path,"org.freedesktop.portal.Request","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(closed)g_variant_unref(closed);
}
g_dbus_connection_signal_unsubscribe(p->bus,sub);g_free(path);
if(e || !r.done || r.code) {
g_strlcpy(error,e?e->message:!r.done?"Screen sharing request timed out":"Screen sharing was cancelled or refused",capacity);
if(e)g_error_free(e);if(r.result)g_variant_unref(r.result);return NULL;
}
return r.result;
}
FC_API void fc_portal_close(Portal *p) {
if(!p)return;
if(p->session && p->bus) {
GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,p->session,"org.freedesktop.portal.Session","Close",
NULL,NULL,G_DBUS_CALL_FLAGS_NONE,2000,NULL,NULL);
if(r)g_variant_unref(r);
}
if(p->fd>=0){close(p->fd);p->fd=-1;}
g_free(p->session);if(p->bus)g_object_unref(p->bus);
if(p->context)g_main_context_unref(p->context);g_free(p);
}
/* Each pipeline gets a fresh restricted PipeWire connection. A dup of a
* previously consumed protocol socket is not a new connection.
* Ownership: the Portal owns p->fd for its whole life and is its only closer.
* pipewiresrc never takes the fd it is given: its core connects with
* pw_context_connect_fd(ctx, fcntl(fd, F_DUPFD_CLOEXEC, 3), ...) and that
* duplicate is what PipeWire closes on teardown (src/gst/gstpipewirecore.c,
* unchanged from 0.3.19 through 1.x). So closing p->fd here is not a double
* close; not closing it would leak one socket per pipeline reopen. The
* caller closes the previous pipeline before asking for a new fd. */
FC_API int fc_portal_refresh(Portal *p,char *error,int capacity) {
GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
GUnixFDList *fds=NULL;GError *e=NULL;
GVariant *r=g_dbus_connection_call_with_unix_fd_list_sync(p->bus,BUS,PATH,SC,"OpenPipeWireRemote",
g_variant_new("(oa{sv})",p->session,&b),G_VARIANT_TYPE("(h)"),G_DBUS_CALL_FLAGS_NONE,10000,NULL,&fds,NULL,&e);
if(!r){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
int handle;g_variant_get(r,"(h)",&handle);g_variant_unref(r);
int fd=g_unix_fd_list_get(fds,handle,&e);g_object_unref(fds);
if(fd<0){g_strlcpy(error,e->message,capacity);g_error_free(e);return -1;}
if(p->fd>=0)close(p->fd);
p->fd=fd;return fd;
}
FC_API Portal *fc_portal_select(char *error,int capacity) {
Portal *p=g_new0(Portal,1);p->fd=-1;p->context=g_main_context_new();
g_main_context_push_thread_default(p->context);
GError *e=NULL;GVariant *r=NULL;
p->bus=g_bus_get_sync(G_BUS_TYPE_SESSION,NULL,&e);
if(!p->bus) {g_strlcpy(error,e->message,capacity);g_error_free(e);goto fail;}
GVariantBuilder b;char token[64],session[64];
g_snprintf(session,sizeof(session),"fc_%u",g_random_int());
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());
g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"session_handle_token",g_variant_new_string(session));
r=request(p,RD,"CreateSession",g_variant_new("(a{sv})",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"session_handle","s",&p->session);g_variant_unref(r);r=NULL;
if(!p->session){g_strlcpy(error,"Portal did not return a session",capacity);goto fail;}
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
r=request(p,RD,"SelectDevices",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));option(&b,"types",g_variant_new_uint32(3));
option(&b,"multiple",g_variant_new_boolean(FALSE));option(&b,"cursor_mode",g_variant_new_uint32(2));
r=request(p,SC,"SelectSources",g_variant_new("(oa{sv})",p->session,&b),token,error,capacity);if(!r)goto fail;g_variant_unref(r);
g_snprintf(token,sizeof(token),"fc_%u",g_random_int());g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
option(&b,"handle_token",g_variant_new_string(token));
r=request(p,RD,"Start",g_variant_new("(osa{sv})",p->session,"",&b),token,error,capacity);if(!r)goto fail;
g_variant_lookup(r,"devices","u",&p->devices);
GVariant *streams=g_variant_lookup_value(r,"streams",G_VARIANT_TYPE("a(ua{sv})"));
if(streams && g_variant_n_children(streams)>0) {
GVariant *entry=g_variant_get_child_value(streams,0),*props=NULL;
g_variant_get(entry,"(u@a{sv})",&p->node,&props);
g_variant_lookup(props,"size","(ii)",&p->width,&p->height);
g_variant_unref(props);g_variant_unref(entry);
}
if(streams)g_variant_unref(streams);g_variant_unref(r);r=NULL;
if(!p->node || p->width<=0 || p->height<=0) {g_strlcpy(error,"Portal returned no stream size",capacity);goto fail;}
if(fc_portal_refresh(p,error,capacity)<0)goto fail;
g_main_context_pop_thread_default(p->context);return p;
fail:
g_main_context_pop_thread_default(p->context);fc_portal_close(p);return NULL;
}
FC_API int fc_portal_value(Portal *p,int field) {
switch(field){case 0:return p->fd;case 1:return (int)p->node;case 2:return p->width;
case 3:return p->height;case 4:return (int)p->devices;default:return 0;}
}
FC_API int fc_portal_input(Portal *p,int type,double x,double y,int code,int down) {
const char *method=NULL;GVariant *args=NULL;GVariantBuilder b;g_variant_builder_init(&b,G_VARIANT_TYPE_VARDICT);
if(type==0 && (p->devices&2)) {
method="NotifyPointerMotionAbsolute";args=g_variant_new("(oa{sv}udd)",p->session,&b,p->node,x,y);
} else if(type==1 && (p->devices&2)) {
method="NotifyPointerButton";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else if(type==2 && (p->devices&2)) {
method="NotifyPointerAxis";args=g_variant_new("(oa{sv}dd)",p->session,&b,x,y);
} else if(type==3 && (p->devices&1)) {
method="NotifyKeyboardKeysym";args=g_variant_new("(oa{sv}iu)",p->session,&b,code,(guint)down);
} else return 0;
GError *e=NULL;GVariant *r=g_dbus_connection_call_sync(p->bus,BUS,PATH,RD,method,args,NULL,
G_DBUS_CALL_FLAGS_NONE,2000,NULL,&e);
if(e)g_error_free(e);if(r)g_variant_unref(r);return r!=NULL;
}
#endif
+29
View File
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
+174
View File
@@ -0,0 +1,174 @@
# Backup and recovery
**Tools → Backup & recovery** copies what a factory reset would lose from the
Steam Frame to your computer, puts it back afterwards, and gets the headset out
of Desktop Mode. It runs from Frame Control on a computer, not from the iPhone
app or the Frame itself.
**Partly tested on a headset.** On a Frame running SteamOS 0.5.6 (build
20261015.6141207), a full backup saved 26 parts (40.7 MB): 14 games' Proton
saves, Linux game saves, Steam and desktop settings and 9 Android apps' data,
with no sign-in files or keys in the archives. Restoring one game's saves on top
of identical files wrote nothing and left nothing behind. The session check
reported Game Mode, so **Return to Game Mode** changed nothing. Not tested on a
headset: a restore after a real factory reset, a restore that replaces files,
Android data restore, and the switch out of a real Desktop Mode session. Unit
tests and the fake Frame cover those (see [Verification](#verification)).
On 2026-10-24 a read-only session check on the same build again reported Game
Mode (`gamescope=true`, `plasma=false`, `nested_desktop=false`) and the
`steamos-session-select` command was present. Opening the nested desktop for a
clipboard test did not enter full Desktop Mode. A full session switch was not
attempted because it restarts the headset session and a reliable return path
was not established; the **Return to Game Mode** branch for a real full
Desktop Mode session remains unverified.
## Back up
Choose **Game saves**, **Steam and desktop settings** and **Android app data**
(all three by default), then **Back up now**. **See what's included** lists each
part and its size first. Backing up only reads the headset.
Each backup is a folder named after the time it started:
```
~/Documents/Frame Control Backups/2026-10-23-142530/
manifest.json
saves/saves-1234.tar.gz one per game
saves/saves-linux.tar.gz
settings/settings-steam.tar.gz
settings/settings-system.tar.gz
android/org.example.game.tar.gz one per Android app
```
`manifest.json` records the headset's name, SteamOS version and BUILD_ID, every
file with its size and SHA-256, and each archive's SHA-256. It also lists what
was skipped and why. A backup is written under a hidden `.…partial` name and
renamed when complete, so a cut-off backup never shows up as one.
### What's saved
| Part | From the headset (`~` is `/home/steamos`) | Restored? |
|---|---|---|
| Windows game saves | `~/.local/share/Steam/steamapps/compatdata/<appid>/pfx/drive_c/users/steamuser/` → `AppData/Roaming`, `AppData/Local`, `AppData/LocalLow`, `Documents`, `Saved Games`. Wine's own folders in them (`Temp`, `Microsoft`, `D3DSCache`, `CrashDumps`) are left out. Sideloaded Windows games (non-Steam shortcuts) have prefixes here too | Yes |
| Linux game saves | `~/.config/unity3d`, `~/.local/share/godot/app_userdata`: where Unity and Godot games save by default | Yes |
| Steam controller layouts | `userdata/<account>/241100/remote/controller_config`, `steamapps/common/Steam Controller Configs/<account>/config` | Yes |
| Steam library settings | `userdata/<account>/config/localconfig.vdf`, `shortcuts.vdf`, `grid/` (custom artwork) | Kept for reference |
| Desktop settings | `~/.config/kdeglobals`, `kwinrc`, `kxkbrc` (keyboard layout), `konsolerc`, `dolphinrc` | Yes |
| SteamVR settings | `~/.config/openvr/config/steamvr.vrsettings` | Kept for reference |
| Android app data | Each Frame Control Android app's private data, through the existing `backup-data` helper ([sidequest.md](sidequest.md#private-app-data-backups)) | Yes |
"Kept for reference" files are in the backup but not put back automatically:
Steam and SteamVR rewrite them while they run, and an old `shortcuts.vdf` would
point at apps that aren't installed after a reset. Open the backup folder to
copy one by hand if you need it.
### What's never copied
- **Steam sign-in:** `steam.token`, `config/config.vdf`, `loginusers.vdf`,
`registry.vdf` and `ssfn*` files.
- **Keys and passwords:** `~/.ssh`, `~/.gnupg`, keyrings and KWallet, any
`*.token`, `*.key` or `*.pem` file, Tailscale state and proxy settings
(`secrets.env`). These are refused by name wherever they turn up, including
inside a game's folder, and a restore refuses them too.
- Games, Proton and the Steam runtimes: reinstall them from Steam.
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
screenshots (**Tools → Capture & Live** saves those to Pictures).
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
- Symbolic links (never followed), and single files over 2 GiB.
Backups aren't encrypted. Game folders can still hold a launcher's login, and
Android app data can hold an app's, so keep backups private.
Android apps that are open are skipped (the helper needs the app closed); close
them and back up again.
## Restore after a factory reset
1. Set up the headset again: Developer Mode, **Set Up Connection**, sign in to
Steam with the same account.
2. Reinstall your games, and **start each once** so Proton creates its folders.
3. Reinstall your Android apps from Frame Control.
4. **Tools → Backup & recovery → Restore…** on a backup. Frame Control checks
each part against the headset and says what it would do: how many files it
replaces and how many are new.
5. Tick the parts to restore and **Restore selected**. It asks before replacing
anything.
Parts that can't go back yet are skipped, not failed, with the reason:
| Reason | What to do |
|---|---|
| Install the game first | Install it from Steam, then restore |
| Start the game once | Proton hasn't made its folders yet; start the game, quit, restore |
| Sign in to Steam first | Controller layouts go under your account's folder |
| Install the app first / close the app first | Android app data needs the app installed and closed |
| The backup file doesn't match its checksum | The archive changed since it was made |
A restore writes only the files in the parts you chose, and only inside that
part's folders: it never deletes a file, and a save archive can't write outside
its game. Before replacing a file it copies the current one to
`~/.local/share/frame-control/restore-previous/<time>/` on the headset. Files
already identical are left alone. Android app data goes through the existing
`restore-data` helper, which keeps the previous data folder beside it.
## Stuck in Desktop Mode
On the Frame, Game Mode is gamescope running Steam, and the Linux desktop
normally runs *inside* it as one panel (`steamos-nested-desktop`, see
[how-the-frame-works.md](how-the-frame-works.md#the-layer-cake)). If that panel
is what you see, you're already in Game Mode: close it from the Steam dashboard.
Full Desktop Mode is a different session. SteamOS picks the session through
SDDM's autologin file, `/etc/sddm.conf.d/zz-steamos-autologin.conf` (or the
default `/etc/sddm.conf.d/steamos.conf`), and switches with
`steamos-session-select` (`holo-session-select` on newer images). This is what
Valve's own devkit tools on the Frame read and run
([`steamos-get-status`](../frame/devkit-utils/steamos-get-status),
[`steamos-delete`](../frame/devkit-utils/steamos-delete)).
**Check** reads that file and whether gamescope and Plasma are running; it
changes nothing. On the Frame gamescope's process is `gamescope-wl`, started by
`start-gamescope-session`, and either counts as Game Mode. Only when Plasma is
running without gamescope (Desktop Mode) does **Return to Game Mode** run
exactly `steamos-session-select gamescope` (or `holo-session-select
gamescope`) over SSH, after asking. It closes the desktop and anything open in
it. Frame Control never edits the SDDM files itself, and runs nothing when the
headset is in Game Mode, when it can't tell which mode it's in, or when the
image has neither command.
**Uncertain:** whether the Frame offers a full Desktop Mode session at all
(only the nested desktop has been seen on BUILD_ID 20260922.6101926 and
20260925.6191901), and whether `steamos-session-select` there works without
the Developer Mode password. If the switch is refused, Frame Control says so and
leaves the headset as it was.
### Manual fallback
If the button can't help, or the headset isn't reachable:
1. **Restart it:** hold Power about 10 seconds until the light goes off, then
power on. On the Steam Deck, **Switch to Desktop** starts a one-shot
Desktop Mode session, so a restart brings Game Mode back. That the Frame
behaves the same is **inferred**, not tested.
2. **Over SSH** (Developer Mode on), run Valve's switch yourself:
`ssh frame steamos-session-select gamescope`.
3. **Boot menu:** hold Power 10 seconds, then power on while holding the AUX
button on top of Power. Choose `Current`, or `Previous` to start the other
SteamOS slot; both keep your data. Details and the further options
(`Repair Steam Installation`, `Erase User Data`, a full re-image) are in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing) and
[recovery-and-images.md](recovery-and-images.md). Back up first if you can:
`Erase User Data` is a factory reset.
## Verification
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
for real against a folder laid out like the Frame's home, with only SSH faked:
which paths are picked, that sign-in files and keys never are (also inside game
folders), links never followed, the manifest and its checksums, restore checks
after a reset (game not installed, no Proton folders yet, not signed in), that a
restore replaces only its own files and keeps the old ones, unsafe archives
(`..`, absolute paths, links, other games' folders) writing nothing, backup
names that try to leave the backups folder, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
the server, deletes it and restores it. Neither involves a real headset.
+59
View File
@@ -0,0 +1,59 @@
# Battery and health monitoring
Open **Control Center → Battery & thermal health**, or expand **System
information**, to read the Frame's charge, charging state, signed battery
power, battery temperature and driver-reported health. Optional readings show
charge cycles, full charge capacity, design capacity and full / design capacity.
The existing battery chip also shows charging state and temperature.
These are read-only readings from the Linux host's `/sys/class/power_supply`,
not Android's container battery view. The probe prefers the documented Frame
fuel gauge, `max1720x_bat_7-36`, over other battery nodes, excludes peripheral
(`scope=Device`) and absent batteries, and otherwise uses a deterministic
battery fallback. The UI shows the selected source path.
| Reading | Linux attribute | Interpretation |
|---|---|---|
| Charge | `capacity` | Integer 0–100 percent; zero is a real reading |
| Charging state | `status` | Charging, Full, Discharging or Not charging; missing/unknown states stay unavailable |
| Battery power | `voltage_now × current_now` | Microvolts and microamps converted to signed watts; positive means charging on the Frame |
| Temperature | `temp` | Tenths of a degree Celsius, including valid negative readings |
| Battery health | `health` | Driver status such as Good or Overheat; not a wear percentage |
| Charge cycles | `cycle_count` | Driver-reported count, including zero |
| Full/design capacity | `charge_full`, `charge_full_design` | Microamp-hours converted to mAh |
| Full / design capacity | Ratio of those two capacities | Fuel-gauge estimate; not a diagnosis or a guaranteed runtime |
| Time estimate | `time_to_full_now`, `time_to_empty_now` | Seconds; displayed only for the corresponding charging/discharging state |
Unavailable means the field is absent, unreadable or invalid. One missing
sensor does not hide the others. Zero design capacity cannot produce a ratio. Time estimates outside 1 second
to 7 days and battery temperatures outside −273.15 to 200 °C are rejected
as invalid readings; these bounds are not thermal warning thresholds.
The ratio may exceed 100% because full capacity is a gauge estimate, so the app
does not clamp it or invent a wear warning. The app does not estimate health
from charge percentage or assign temperature thresholds. Existing thermal
alerts use the kernel's own hot/critical trip points.
A connected charger does not necessarily mean charging: the Frame can still
drain under load or sit idle near full charge. A 100% capacity reading alone
does not prove the driver reports Full. Missing status is explicitly unknown;
near-zero measured battery power with a charger is shown as not charging.
Failed, stale or disconnected status samples clear Control Center readings.
**No charge controls are provided.** Frame's SteamOS charge-limit backend is
unsupported; see [charge-limit research](charge-limit.md). Monitoring never
writes to power supplies, pauses charging, changes current or sets a limit.
## Verification
`tests/test_battery.py` uses temporary sysfs-like files for conversion,
malformed/missing values, permission failures, source selection and partial
sensor support. `tests/control_center.cjs` exercises charging states, optional
metrics and failed/stale/disconnected sample handling. These tests do not
connect to a headset. On 2026-10-24, the PR's uninstalled parser was also
piped over SSH to a real Frame running SteamOS 0.5.6 (build
`20261015.6141207`). Its read-only sysfs result selected
`max1720x_bat_7-36` and reported 99% charge, Not charging, 24.9 °C,
Good driver health, 14 cycles, 2,920 mAh full and 2,700 mAh design capacity
(108.1% fuel-gauge estimate). The Control Center UI was not run against this
headset; Windows and macOS were not tested. Optional fields are not claimed to
be available on every firmware build.
+9
View File
@@ -0,0 +1,9 @@
# Changelog
## Unreleased
- Battery monitoring now selects the Frame fuel gauge predictably, handles
missing or invalid readings, and shows optional cycle count, full/design
capacity and estimated capacity health in Control Center. Unknown charging
status stays explicit. Monitoring is read-only; charge limits remain
unsupported on Frame. The read-only parser was checked on a real Frame; the UI was not.
+168
View File
@@ -0,0 +1,168 @@
# Battery charge limit (80%): research
**Question:** can Frame Control set an 80% battery charge limit on the Frame
over SSH or adb, safely?
**Verdict: don't build it.** Valve's supported mechanism (the one Steam's own
charge-limit setting uses on the Deck) is not configured for the Frame. The
only control the Frame has is a vendor charger attribute that Valve's own
charging daemon also manages. Research as of 2026-10-23, confirmed with the
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
- Power supplies: `max1720x_bat_7-36` (Battery, driver `max1720x`),
`pm8550b-charger` (driver `qcom,pm8550b-charger`) and the USB-C `tcpm-source-psy-…`.
No `charge_control_end_threshold` anywhere; `pm8550b-charger` has
`charging_enable` and `charging_pause`, both `root`-only (`-rw-r--r--`).
- `deckard-charger.service` (Deckard Battery Charger service) is enabled and running.
For safe read-only readings in Control Center, see [battery and health
monitoring](battery-health.md). It does not provide charging controls.
## How Steam does it on other devices
- Steam's **Settings → Power → Battery Charge Limit** shipped to the Deck in
SteamOS 3.7.8 (beta client 2025-05-08)
([GamingOnLinux](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)).
- Steam calls SteamOS Manager on the session bus:
`com.steampowered.SteamOSManager1.BatteryChargeLimit1`, with properties
`MaxChargeLevel` (`i`, read/write; `-1` resets to the default) and
`SuggestedMinimumLimit` (`i`, read-only). The CLI is
`steamosctl get-max-charge-level` / `set-max-charge-level` /
`suggested-minimum-charge-limit`. Source: `data/interfaces/com.steampowered.SteamOSManager1.xml`
and `steamos-manager/src/bin/steamosctl.rs` in
[holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager),
read at commit `83981ab` (2026-10-21).
- The root daemon writes the value to sysfs only if the device's TOML in
`data/devices/` has a `[battery_charge_limit]` section. Two methods exist
(`steamos-manager/src/power.rs`, `BatteryChargeLimitMethod`):
- `hwmon_attribute`: the Deck writes `max_battery_charge_level` on
`steamdeck_hwmon` (`data/devices/steam-deck.toml`).
- `acpi_sb`: ROG Ally, MSI Claw and others write the standard
`charge_control_end_threshold` on the ACPI battery
(`/sys/bus/{platform,acpi}/drivers/…battery…/PNP0C0A:00/…/power_supply/*/`).
On ARM there's no ACPI, so this path can't match there either.
- With no `[battery_charge_limit]` section, the interface fails with
`UnknownInterface` (Steam can still show its toggle on unsupported hardware:
[steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044)).
## What the Frame has
- **Upstream `data/devices/steam-frame.toml`** (matched by
`dt.compatible = "qcom,sm8650"`, variant `Deckard`) configures GPU clocks
and performance profiles only. It has **no `[battery_charge_limit]`**, so
SteamOS Manager on the Frame has no charge-limit backend. Its git history
(2026-09-10 to 2026-10-12) never added one.
- **Battery driver:** the Frame's battery is
`/sys/class/power_supply/max1720x_bat_7-36`, and the charger is a
`tcpm-source-psy-…` USB-PD supply ([how-the-frame-works.md](how-the-frame-works.md)).
Both were verified 2026-09-25, and `ui/frame_status.py` `battery()` and
`power_source()` read them. The Maxim MAX1720x is a **fuel gauge** and
can't control charging. Mainline `drivers/power/supply/max1720x_battery.c`
exposes only read-only properties (capacity, voltage, current, temp,
charge_full…), with no `charge_control_end_threshold`. Valve's build is
patched: it reports `time_to_full_now`, which mainline doesn't have. Even
so, a charge stop would have to come from the separate charger IC, not this
node.
- **Not the qcom_battmgr path.** Some other SM8650/SM8750 handhelds get
`charge_control_end_threshold` from Qualcomm's `qcom_battmgr`
(pmic_glink) driver, clamped at 55% or above. A third-party distro enabled
Steam's limit on those by patching steamos-manager
([armada-os/armada#758](https://github.com/armada-os/armada/pull/758)). The
Frame's battery isn't exposed by `qcom_battmgr`, so that doesn't carry over.
(Unverified: whether a `qcom_battmgr` node also exists on the Frame. The
read-only checks below answer this.)
- **What third parties found:**
[y0usaf/frame-charge](https://github.com/y0usaf/frame-charge) reports that
the Frame has no `charge_control_end_threshold` and falls back to a vendor
`charging_pause` attribute. It also reports that `constant_charge_current`
changes on its own, which it attributes to Valve's `deckard_charge_daemon`
(`deckard-charger.service`). By its own README, that tool has only run its
read-only `status` on a Frame and has **not** been tested against real
charging. Two questions are still open: whether pausing stops the current,
and whether Valve's daemon undoes it.
## Why not build it now
- **No supported endpoint.** The only Valve-supported interface,
`BatteryChargeLimit1`, isn't backed on the Frame. Writing sysfs ourselves
would bypass Steam and SteamOS Manager, and it needs root (`sudo -S` with
the user's password).
- **A rival owner.** Valve's charge daemon already adjusts charging
parameters. Toggling `charging_pause` underneath it could be undone without
warning, fight it in a loop, or leave charging paused.
- **Stuck at a low charge.** If the app or the Frame dies while charging is
paused, the Frame could sit at a low charge on the charger. Whether a
reboot clears `charging_pause` is unknown.
- **The UI would mislead.** Steam's setting (if it shows at all) wouldn't
reflect our limit.
- **adb doesn't help.** Wi-Fi adb (`frame:5555`) reaches the Lepton Android
container only. USB-C adb reaches the Linux side as `steamos` (non-root, the
same as SSH, [ssh.md](ssh.md)). Neither adds a charging-control route.
Android's `dumpsys battery` in Lepton is a container view, not the hardware.
**Revisit when** a SteamOS update adds `[battery_charge_limit]` to
`steam-frame.toml` (or `steamosctl get-max-charge-level` stops returning
`UnknownInterface` on the Frame). Then the feature is just a SteamOS Manager
call:
- Read: `steamosctl get-max-charge-level`
- Set: `steamosctl set-max-charge-level 80`
- Undo: `steamosctl set-max-charge-level -- -1`
That runs as `steamos` with no sudo. It's the same value Steam's own
Settings → Power shows, so both stay in sync.
## Headset checks (read-only first)
Run these over SSH as `steamos`. Steps 1–4 only read.
```sh
# 1. Does SteamOS Manager offer the interface on this build?
steamosctl get-max-charge-level; steamosctl suggested-minimum-charge-limit
busctl --user introspect com.steampowered.SteamOSManager1 \
/com/steampowered/SteamOSManager1 | grep -i -A3 ChargeLimit
grep -n battery_charge_limit /usr/share/steamos-manager/devices/*frame* 2>/dev/null
# 2. Which power supplies exist, and which drivers back them?
for d in /sys/class/power_supply/*; do
echo "== $d ($(cat $d/type)) driver=$(basename "$(readlink -f $d/device/driver)" 2>/dev/null)"
ls $d
done
# 3. Any charge-control attributes, with their values and permissions?
ls -l /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
grep -H . /sys/class/power_supply/*/{charge_control_*,charging_pause,charging_enable,constant_charge_current*,charge_behaviour} 2>/dev/null
# 4. What is Valve's charger daemon, and what does it touch?
systemctl status deckard-charger.service --no-pager
systemctl cat deckard-charger.service
journalctl -b -u deckard-charger.service --no-pager | tail -50
```
If step 1 succeeds on some build, test `steamosctl set-max-charge-level 80`
on a charger at a charge above 80%. Watch `current_now` on the
`max1720x_bat_*` node fall to about 0, then `steamosctl set-max-charge-level -- -1` and
confirm charging resumes. Do this on a supervised Frame only. If only step 3
finds a vendor attribute, **stop**: don't write it, and leave the question
with Valve.
## Sources
- steamos-manager source, commit `83981ab` (2026-10-21):
`data/devices/steam-frame.toml`, `data/devices/steam-deck.toml`,
`data/interfaces/com.steampowered.SteamOSManager1.xml`,
`steamos-manager/src/power.rs`, `steamos-manager/src/manager/user.rs`.
[gitlab.steamos.cloud/holo/steamos-manager](https://gitlab.steamos.cloud/holo/steamos-manager)
- Mainline [`max1720x_battery.c`](https://github.com/torvalds/linux/blob/master/drivers/power/supply/max1720x_battery.c)
- [ValveSoftware/steam-for-linux#12044](https://github.com/ValveSoftware/steam-for-linux/issues/12044):
the Steam toggle shows on unsupported hardware
- [armada-os/armada#758](https://github.com/armada-os/armada/pull/758):
`qcom_battmgr` threshold on other SM8650/SM8750 devices
- [y0usaf/frame-charge](https://github.com/y0usaf/frame-charge): third-party,
untested on real charging
- [GamingOnLinux: SteamOS 3.7.7 beta charge limit](https://www.gamingonlinux.com/2025/05/steamos-3-7-7-beta-brings-a-battery-charge-limit-option-and-improved-lenovo-legion-go-s-support/)
- Repo: [how-the-frame-works.md](how-the-frame-works.md) (battery and
charger nodes), [ssh.md](ssh.md) (adb scope), `ui/frame_status.py`
+6
View File
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
+151
View File
@@ -0,0 +1,151 @@
# Windows VM and real Frame test, 2026-10-24
The v0.4.10 installer could enumerate windows/screens and encode a synthetic
Media Foundation stream, but every desktop capture failed at pipeline parsing:
`no property "capture-api" in element "d3d11screencapturesrc"`.
MSYS2's GStreamer 1.28.8 package disables WGC. After the fix, the same installed
bundle captured the actual Windows desktop with Desktop Duplication and sent
H.264 to a real Frame: 219 sent frame records, 215 decoded and drawn before the
snapshot. Individual windows are explicitly unavailable with this bundle.
The base was `e9cbb8fd5555f5cfceeba1babadf2d7d35dcd3be` (`v0.4.10`, origin/main,
including merged #121), in isolated branch `t3/test-windows-vm-headset`.
## Evidence
- [Capture before the fix](windows-vm-2026-10-24/capture-before.json): five
synthetic H.264 frames and five JPEG frames; zero desktop/window frames,
with the missing-property error for both codecs.
- [Actual bundled properties](windows-vm-2026-10-24/plugin-properties.json):
`monitor-handle` exists; `capture-api`, `window-handle`, `show-border` do not.
[MSYS2 package build](https://github.com/msys2/MINGW-packages/blob/master/mingw-w64-gst-plugins-bad/PKGBUILD)
sets `-Dd3d11-wgc=disabled`. Upstream
[installs those properties conditionally](https://github.com/GStreamer/gstreamer/blob/main/subprojects/gst-plugins-bad/sys/d3d11/gstd3d11screencapturesrc.cpp).
- [Capture after the fix](windows-vm-2026-10-24/capture-after.json): the real
display produces five H.264 and five JPEG frames. Window sources are hidden
with an explicit reason, and the display selects `capture_api=dxgi`.
- [Raw locked headset session](windows-vm-2026-10-24/headset-session.json):
Windows helper, Media Foundation encoder, reverse SSH tunnel, production
panel launcher and shared viewer. The image below was captured from that
viewer's own X window on the Frame, rather than the VM's console.
- [Windows tests](windows-vm-2026-10-24/windows-tests.txt) and
[guest system details](windows-vm-2026-10-24/windows-system.txt).
![Windows VM desktop decoded in the Frame's own viewer](windows-vm-2026-10-24/headset-view.png)
## Host and VM prerequisites
lxso2: Intel Core i7-8700, 12 logical CPUs, VT-x/KVM; Docker 29.1.3 and Compose
2.40.3. Before starting: 31 GiB RAM, 15 GiB available; 147 GiB free disk.
Swap was already full. No other VM or service workload was started or stopped.
Docker listed no Windows VM, and ports 2222/8006/13389 were unused.
The execution sandbox hid KVM and denied Docker/network access. Checks outside
it found `/dev/kvm`, `/dev/net/tun` and the Docker daemon. Direct user access to
KVM was denied, but a disposable Docker container with `--device /dev/kvm`
opened it successfully; no host permissions changed.
Created only `frame-winvm-priority3`, labelled
`frame-control.task=priority3-windows-test`: 4 vCPUs, 8 GiB guest RAM, 32 GiB
growable disk, no restart, 150-second stop timeout. SSH 2222 and console 8006
bind only 127.0.0.1; RDP is unpublished. No GPU passthrough. Image:
`dockurr/windows@sha256:16fd7c39a3e48bb946d29c59dc6015942e5b900a1fa5d7e5552053bfc48ee2ea`
(Windows for Docker v6.06, QEMU v11.1.1).
The container downloaded Windows 11 from Microsoft, with no activation changes.
It took about 16 minutes to reach the first desktop. Guest build 26200.8037,
Red Hat VirtIO GPU DOD driver 100.103.104.30200. The old WindowsProductName
registry field still says Windows 10 Pro; the actual desktop/setup is Windows
11. After installing the app, the 32 GiB disk had about 9.3 GB free.
Official `Frame-Control-Setup-x64.exe` v0.4.10 SHA-256:
`30165bc5aa39f941cacc697d195dc5c30b01ad0b9ffb472da8a18a5220549202`.
Its installer exited 0. The VM stream used an intermediate Python/UI-only
fix with the released DLL bundle and bundled Python 3.12.14. A later CI failure
showed that probing GStreamer through a separately loaded DLL could see a
different, uninitialized instance. The final fix probes optional properties
through the capture DLL itself. Windows CI checks that native change; the VM
stream was not repeated with the final DLL.
OEM setup added OpenSSH Server, PowerShell and a dedicated public login key;
password authentication is disabled. Its config needed an explicit
`AuthorizedKeysFile C:/ProgramData/ssh/administrators_authorized_keys` before
key login worked. The default informational share was replaced with a private
writable staging share inside this container. These were test-VM setup steps,
not app defects. Host setup files and the stopped VM disk are retained under
`/tmp/frame-winvm-priority3/`; no private keys are committed or printed.
The Frame test key was generated inside Windows and its private key stayed
there. Only its public key was temporarily authorized on the Frame. After
testing, the guest key and scheduled task were removed; `docker stop -t 150`
shut down the dedicated VM. Inspection confirmed `exited`, restart `no`
(container exit 143; logs show the ACPI shutdown signal, waiting for Windows,
and `Shutdown completed!`). The disk is retained
for reuse; the VM is not running.
## Checks
- Baseline PC-focused suite: 24 tests passed, one native class skipped.
- After fix: full Linux unittest suite **1260 tests, OK, 15 skipped**, 144.366 s;
focused final PC suite **27 tests, OK, one native class skipped**. The C
controller's frozen 600-state trace ran on Linux.
- Actual Windows installer bundle with the intermediate Python/UI fix:
**33 tests, OK, four skipped** (no C compiler, Linux PipeWire test, two
CI-only x264 tests). Its GObject property probe, host/encoder check and
x264-absence check ran. The final native probe is checked in the PR's
Windows CI build, not by this VM run.
- Browser check with the real production HTML/JavaScript and mocked Windows API
responses: DXGI whole-screen Show remains available, the limitation appears
once, WGC window sources retain Show, off hides controls, and the limitation
text is escaped. This used a static asset server, without headset API access.
- `node --test app/test/*.test.js`: passed, three test files.
- `node --test site/test/*.test.mjs`: passed, one test file.
- The first full Linux run inside the sandbox failed (11 failures, 139 errors),
including loopback socket `PermissionError`. The successful full runs were
outside that socket restriction.
- An initial guest test archive omitted `apk-catalog`, causing one import
error; the complete archive produced the 33-test passing Windows run.
Raw local logs: `/tmp/frame-windows-{pc,app,site}-tests.log`,
`/tmp/frame-windows-full-tests-unrestricted.log`,
`/tmp/frame-windows-full-fixed-tests.log`, and
`/tmp/frame-winvm-priority3/`. The committed artifacts above preserve the
capture, Windows test and headset evidence independently of those local files.
## Shared Frame session and cleanup
Read-only checks first: SteamOS 0.5.6, BUILD_ID `20261015.6141207`, battery 99%,
Not charging (another supply reports Full). The lock was free.
Both attempts acquired `/tmp/frame-test.lock` atomically before any key or
viewer changes, and removed only the lock acquired by this task. The first,
35-second attempt failed because the test wrapper sent CRLF script input and
left SSH stdin open. Production already sends script bytes and closes stdin;
correcting the wrapper resolved both. Its key was removed and lock released.
The successful session held the lock **03:09:36–03:10:05 UTC (29 seconds)**.
It used a dedicated `pc-view-priority3` profile and port range 47960–47964,
leaving existing viewers/profiles alone. The app's Windows `PCView` captured
`display:65537`, 1280×800, Light preset (30 fps ceiling), and opened panel
`valve.steam.desktopgame.2001396898`. The viewer reported H.264 software decode,
218 received frames, 215 decoded/drawn before the snapshot (219 sent records).
This mostly idle, roughly ten-second sample reported 19 fps and content delay
p50/p95 51.6/75.2 ms. These are observations, not a performance-target pass.
Cleanup stopped the helper and tunnel, closed the own viewer, removed its two
possible profile paths and log, then removed the exact public-key line. The
`authorized_keys` SHA-256 was identical before/after. A follow-up login from
Windows with that key failed with exit 255. Separate checks found no task-owned
viewer processes, the profile absent, and the shared lock released. Battery
remained 99%; the sampled gamescope focus properties matched before/after.
No reboot, global settings changes, game launch or Steam/SteamVR stop occurred.
## Limits
This verifies a Windows VM's desktop capture, Media Foundation encoding and
real Frame viewer decode/rendering. It does not verify physical-PC hardware
encoding, WGC or individual-window capture, mixed DPI, multiple displays,
physical laser alignment/clicks, keyboard layouts, resize/minimise, busy-scene
performance, bitrate/encoder restarts, or worn-headset comfort. No input was
injected during the real Frame session. The installed app’s Tools navigation was not exercised; its streaming
controller, tunnel and viewer were exercised directly. No independent review was performed.
@@ -0,0 +1,53 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windowCaptureReason": "Individual windows are unavailable in this build. Choose a whole screen instead.",
"windows": [],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800,
"capture_api": "dxgi"
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 65
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 5,
"pipeline": "d3d11screencapturesrc show-cursor=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 113339
}
]
}
@@ -0,0 +1,96 @@
{
"python": "3.12.14 (main, Sep 24 2026, 17:57:31) [MSC v.1944 64 bit (AMD64)]",
"resources": "C:\\Users\\frame\\AppData\\Local\\Programs\\Frame Control\\resources",
"elements": {
"d3d11screencapturesrc": true,
"mfh264enc": true,
"jpegenc": true,
"x264enc": false
},
"windows": [
{
"src": "window:131836",
"id": 131836,
"title": "Administrator: C:\\WINDOWS\\system32\\cmd.exe - powershell -noprofile -executionpolicy bypass -file \\\\host.lan\\data\\bootstrap.ps1",
"app": "Windows",
"w": 1115,
"h": 628,
"x": 51,
"y": 52
},
{
"src": "window:327926",
"id": 327926,
"title": "Administrator: Install",
"app": "Windows",
"w": 979,
"h": 512,
"x": 85,
"y": 78
},
{
"src": "window:65856",
"id": 65856,
"title": "Program Manager",
"app": "Windows",
"w": 1280,
"h": 800,
"x": 0,
"y": 0
}
],
"displays": [
{
"src": "display:65537",
"name": "Display 1",
"x": 0,
"y": 0,
"w": 1280,
"h": 800
}
],
"runs": [
{
"source": "test",
"codec": "h264",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 154
},
{
"source": "test",
"codec": "jpeg",
"frames": 5,
"pipeline": "videotestsrc name=source is-live=true pattern=ball ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"last_bytes": 7336
},
{
"source": "display:65537",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "display:65537",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true monitor-handle=65537 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "h264",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360,format=NV12 ! mfh264enc name=enc low-latency=true bframes=0 gop-size=60 bitrate=1000 ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
},
{
"source": "window:131836",
"codec": "jpeg",
"frames": 0,
"pipeline": "d3d11screencapturesrc capture-api=wgc show-cursor=true show-border=true window-handle=131836 ! video/x-raw,framerate=30/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=640,height=360 ! jpegenc name=enc quality=80 ! appsink name=out sync=false max-buffers=2 drop=false",
"error": "no property \"capture-api\" in element \"d3d11screencapturesrc\""
}
]
}
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

@@ -0,0 +1,65 @@
{
"gstreamer": "1.28.8",
"plugins": [
"coreelements",
"videotestsrc",
"videoconvertscale",
"app",
"jpeg",
"videoparsersbad",
"d3d11",
"mediafoundation"
],
"packages": [
"mingw-w64-ucrt-x86_64-gst-plugins-bad 1.28.8-2",
"mingw-w64-ucrt-x86_64-gst-plugins-bad-libs 1.28.8-2",
"mingw-w64-ucrt-x86_64-gstreamer 1.28.8-1"
],
"elements": {
"d3d11screencapturesrc": {
"name": "The name of the object",
"parent": "The parent of the object",
"blocksize": "Size in bytes to read per buffer (-1 = default)",
"num-buffers": "Number of buffers to output before sending EOS (-1 = unlimited)",
"typefind": "Run typefind before negotiating (deprecated, non-functional)",
"do-timestamp": "Apply current stream time to buffers",
"automatic-eos": "Automatically EOS when the segment is done",
"monitor-index": "Zero-based index for monitor to capture (-1 = primary monitor)",
"monitor-handle": "A HMONITOR handle of monitor to capture",
"show-cursor": "Whether to show mouse cursor",
"crop-x": "Horizontal coordinate of top left corner for the screen capture area",
"crop-y": "Vertical coordinate of top left corner for the screen capture area",
"crop-width": "Width of screen capture area (0 = maximum)",
"crop-height": "Height of screen capture area (0 = maximum)"
},
"d3d12screencapturesrc": null,
"mfh264enc": {
"name": "The name of the object",
"parent": "The parent of the object",
"qos": "Handle Quality-of-Service events from downstream",
"min-force-key-unit-interval": "Minimum interval between force-keyunit requests in nanoseconds",
"bitrate": "Bitrate in kbit/sec",
"rc-mode": "Rate Control Mode",
"adaptive-mode": "Adaptive Mode",
"vbv-buffer-size": "VBV(HRD) Buffer Size in bytes (0 = MFT default)",
"max-bitrate": "The maximum bitrate applied when rc-mode is \"pcvbr\" in kbit/sec",
"quality-vs-speed": "Quality and speed tradeoff, [0, 33]: Low complexity, [34, 66]: Medium complexity, [67, 100]: High complexity",
"cabac": "Enable CABAC entropy coding",
"sps-id": "The SPS id to use",
"pps-id": "The PPS id to use",
"bframes": "The maximum number of consecutive B frames",
"gop-size": "The number of pictures from one GOP header to the next. Depending on GPU vendor implementation, zero gop-size might produce only one keyframe at the beginning (-1 for automatic)",
"threads": "The number of worker threads used by a encoder, (0 = MFT default)",
"content-type": "Indicates the type of video content",
"qp": "QP applied when rc-mode is \"qvbr\"",
"low-latency": "Enable low latency encoding",
"min-qp": "The minimum allowed QP applied to all rc-mode",
"max-qp": "The maximum allowed QP applied to all rc-mode",
"qp-i": "QP applied to I frames",
"qp-p": "QP applied to P frames",
"qp-b": "QP applied to B frames",
"ref": "The number of reference frames",
"d3d11-aware": "Whether device can support Direct3D11 interop"
}
}
}
@@ -0,0 +1,18 @@
WindowsProductName WindowsVersion OsBuildNumber
------------------ -------------- -------------
Windows 10 Pro 2009 26200
Name DriverVersion Status
---- ------------- ------
Red Hat VirtIO GPU DOD controller 100.103.104.30200 OK
Used Free
---- ----
23951253504 9314816000
@@ -0,0 +1,79 @@
python.exe : test_original_mac_congestion_and_recovery
(test_pc_controller.ControllerTrace.test_original_mac_congestion_and_recovery) ... skipped 'C
compiler not installed; native build jobs cover this trace'
At line:12 char:1
+ & $python -B -m unittest discover -s tests -p 'test_pc*.py' -v 2>&1 | ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+ CategoryInfo : NotSpecified: (test_original_m...ver this trace':String) [], Remote
Exception
+ FullyQualifiedErrorId : NativeCommandError
test_controller_is_inert_after_close
(test_pcview.AdapterTests.test_controller_is_inert_after_close) ... ok
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties (test_pcview.AdapterTests.
test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties) ... ok
test_idle_source_is_not_a_stall (test_pcview.AdapterTests.test_idle_source_is_not_a_stall) ... ok
test_library_path_prepend_adds_no_empty_entry
(test_pcview.AdapterTests.test_library_path_prepend_adds_no_empty_entry) ... ok
test_linux_uses_only_portal_fd_and_node
(test_pcview.AdapterTests.test_linux_uses_only_portal_fd_and_node) ... ok
test_malformed_viewer_input_is_dropped_not_fatal
(test_pcview.AdapterTests.test_malformed_viewer_input_is_dropped_not_fatal) ... ok
test_pc_card_receives_window_capture_limitation
(test_pcview.AdapterTests.test_pc_card_receives_window_capture_limitation) ... ok
test_pc_reuses_launcher_and_namespaces_panel_ids
(test_pcview.AdapterTests.test_pc_reuses_launcher_and_namespaces_panel_ids) ... ok
test_portal_input_clamps_and_releases_held_state
(test_pcview.AdapterTests.test_portal_input_clamps_and_releases_held_state) ... ok
test_refused_windows_focus_never_clicks_the_covering_app
(test_pcview.AdapterTests.test_refused_windows_focus_never_clicks_the_covering_app) ... ok
test_session_end_releases_native_input_only_once
(test_pcview.AdapterTests.test_session_end_releases_native_input_only_once) ... ok
test_stats_keep_capture_time_and_bound_records
(test_pcview.AdapterTests.test_stats_keep_capture_time_and_bound_records) ... ok
test_windows_release_and_negative_monitor_origin
(test_pcview.AdapterTests.test_windows_release_and_negative_monitor_origin) ... ok
test_windows_sources_follow_actual_wgc_capabilities
(test_pcview.AdapterTests.test_windows_sources_follow_actual_wgc_capabilities) ... ok
test_windows_uses_wgc_and_media_foundation
(test_pcview.AdapterTests.test_windows_uses_wgc_and_media_foundation) ... ok
test_never_offered_off_windows_even_when_turned_on
(test_pcview.GatingTests.test_never_offered_off_windows_even_when_turned_on) ... ok
test_off_by_default_starts_nothing (test_pcview.GatingTests.test_off_by_default_starts_nothing)
... ok
test_only_windows_gets_the_pc_host (test_pcview.GatingTests.test_only_windows_gets_the_pc_host)
... ok
test_server_enable_action (test_pcview.GatingTests.test_server_enable_action) ... ok
test_turning_off_stops_streams_and_the_helper
(test_pcview.GatingTests.test_turning_off_stops_streams_and_the_helper) ... ok
test_turning_on_checks_for_the_bundled_libraries
(test_pcview.GatingTests.test_turning_on_checks_for_the_bundled_libraries) ... ok
test_expiry_and_stop_before_redeem (test_pcview.GrantsTests.test_expiry_and_stop_before_redeem)
... ok
test_source_bound_retry_ack_and_stop
(test_pcview.GrantsTests.test_source_bound_retry_ack_and_stop) ... ok
test_agent_reports_its_host_and_encoder
(test_pcview.NativeTests.test_agent_reports_its_host_and_encoder) ... ok
test_auth_and_real_h264_stats (test_pcview.NativeTests.test_auth_and_real_h264_stats) ... skipped
'x264 is only in CI test bundles, not the installer'
test_bundled_pipewire_context_needs_no_system_modules
(test_pcview.NativeTests.test_bundled_pipewire_context_needs_no_system_modules) ... skipped
'Linux PipeWire client libraries'
test_installer_bundle_leaves_out_gpl_x264
(test_pcview.NativeTests.test_installer_bundle_leaves_out_gpl_x264) ... ok
test_last_raw_picture_survives_a_closed_gate
(test_pcview.NativeTests.test_last_raw_picture_survives_a_closed_gate) ... skipped 'x264 is only
in CI test bundles, not the installer'
test_native_property_probe_checks_the_instantiated_element
(test_pcview.NativeTests.test_native_property_probe_checks_the_instantiated_element) ... ok
test_card_says_experimental_and_what_is_unverified
(test_pcview.PageTests.test_card_says_experimental_and_what_is_unverified) ... ok
test_opt_in_is_remembered_with_the_client_preferences
(test_pcview.PageTests.test_opt_in_is_remembered_with_the_client_preferences) ... ok
test_tool_link_is_windows_only_and_marked_experimental
(test_pcview.PageTests.test_tool_link_is_windows_only_and_marked_experimental) ... ok
----------------------------------------------------------------------
Ran 33 tests in 1.684s
OK (skipped=4)
+35
View File
@@ -15,6 +15,10 @@ depends on SSH working through the `frame` alias from `scripts/connect.sh`.
| KDE Connect | KDE Connect on both | Guess | There's a macOS build of KDE Connect, but whether it's present or installable on the Frame wasn't confirmed. It would give you clipboard sync, file send, and remote input. Worth checking on-device. |
| microSD | Physical card | Confirmed that the slot exists ([Wikipedia](https://en.wikipedia.org/wiki/Steam_Frame)) | Offline fallback. |
Screenshots and Steam clips have their own buttons on **Tools → Capture &
Live**, and **Settings → Copy to this computer** copies new ones here
automatically: see [recording.md](recording.md#copying-new-ones-automatically).
## Clipboard
`scripts/paste-to-frame.sh` sends the Mac clipboard (or stdin) to the
@@ -36,3 +40,34 @@ desktop has to be running in the headset. It's text only, and pastes over about
A simpler fallback: `ssh frame 'cat > ~/clip.txt'` < file, then open it in the
headset.
### In Frame Control
**Tools → Type on the headset** (also on Home, and press `T` anywhere) has the
clipboard next to the keyboard and trackpad, both ways:
- **Send text** or **Send this computer's clipboard** puts text on the headset's
clipboard, as above.
- **Copy the headset's clipboard** brings it back: copy something in a desktop
app on the Frame, then press it, and paste on your computer. It calls
Klipper's `getClipboardContents` on the same D-Bus bus, and sends the text
back as base64, so accents, emoji and line breaks arrive unchanged. The
desktop app and the iPhone app put it on their own clipboard. With the server
in a browser, the server sets the clipboard of the computer it runs on:
`pbcopy` on macOS, PowerShell's `Set-Clipboard` (or `clip.exe`) on Windows,
`wl-copy`, `xclip` or `xsel` on Linux.
It's text only, and copying back takes up to 1 MB; a bigger clipboard is
refused with a message, so send big things as a file. An empty clipboard, or
one holding an image, says so. Neither the server nor its log ever records what
was on the clipboard.
**Verified on a Frame, 2026-10-24:** SteamOS 0.5.6, build
`20261015.6141207`. With a temporary nested desktop open, Frame Control sent
a 47-character test marker to Klipper, then **Copy the headset's clipboard**
read it through `getClipboardContents` and set a Linux PC's X11 clipboard.
Reading that PC clipboard returned the exact marker. The prior headset
clipboard was restored and the test desktop was closed. The PC side used a
temporary X display on lxso2; macOS and Windows clipboard backends, a user's
copy action in a headset app, and image/size error cases remain untested on a
real headset. The fake Frame (`tests/fakeframe`) covers the protocol cases.
+20 -5
View File
@@ -25,7 +25,7 @@ settings, reports), **Tools** (in the headset: your computer's windows, the medi
player and the panel switcher; then sending files and text, Flatpaks, remote and
power), **Devices** (your headsets and their addresses), and **Control** (the
searchable Control Center, metrics, supported settings and headset guidance).
Keys 1–6 switch between them. The gear in the header (key 7) opens **Settings**:
Keys 1–6 switch between them, and `T` opens Type on the headset. The gear in the header (key 7) opens **Settings**:
privacy and updates, library artwork and the assistant. The battery chip opens
the headset's details: storage, memory, temperature, Wi-Fi, uptime and SteamOS
build. Files can be dropped anywhere in the window. A
@@ -49,6 +49,15 @@ counts them while they run.
gamescope's flat layer instead.
- **Screenshots** you take in the headset with Steam's shortcut: browse them and
save them to `~/Pictures/SteamFrame`.
- **Record** what the headset shows, with its sound, to an `.mp4` in
`~/Videos/SteamFrame`, and **Steam clips**: export clips and background
recordings from Steam's game recording at any length, without re-encoding.
See [recording.md](recording.md). Not tested on a headset yet.
- **Backup & recovery** (Tools): copy game saves, settings and Android app data
to `~/Documents/Frame Control Backups/<time>/` with a checksummed manifest,
restore chosen parts after a factory reset, and **Return to Game Mode** when
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
- **Battery** with charging state: charge rate in watts, time to full or empty,
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
@@ -87,8 +96,14 @@ counts them while they run.
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
with Launch and Remove; see [sideloading.md](sideloading.md).
- **Type on the headset** (on Home, in Tools, in the Control Center, or press
`T`): use your computer's keyboard and mouse, or a phone's touchscreen, in the
Frame's apps through KDE Connect, and move clipboard text both ways. Send
typed text or your computer's clipboard to the headset, or copy the headset's
clipboard back: copy in a desktop app on the Frame, then **Copy the headset's
clipboard**. The clipboard needs the desktop panel open in the headset; it's
text only, up to 1 MB. See [file-transfer.md](file-transfer.md#clipboard).
- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
its own panel in the headset. Place it with the SteamVR dashboard, click and
scroll with the laser, and type on the Mac. Streams hardware H.264 through
@@ -233,8 +248,8 @@ Google publishes no arm64 Linux platform-tools. Set Up Connection runs
`ui/frame_connect.py` in your terminal emulator (GNOME Terminal, Konsole, xterm
and others). The log is at
`~/.config/Frame Control/logs/server.log`. Running `ui/server.py` in a browser
instead of the app, sending the clipboard needs `wl-clipboard` (Wayland) or
`xclip` (X11).
instead of the app, sending the clipboard, or copying the headset's to this
computer, needs `wl-clipboard` (Wayland), `xclip` or `xsel` (X11).
## Building
+121
View File
@@ -0,0 +1,121 @@
# Frame doctor
Run `scripts/frame-doctor.sh` from a checkout on macOS or Linux to diagnose
first-time setup, pairing and common Frame failures without applying repairs.
It needs Bash, Python 3 and OpenSSH on the computer, and Python 3 on the Frame.
Windows users can run `python scripts/frame_doctor.py` with Python and OpenSSH
installed; the desktop app does not bundle this command.
```sh
scripts/frame-doctor.sh
scripts/frame-doctor.sh --alias bedroom --lan frame.local
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
```
Use only addresses belonging to your intended headset. The command tries the
existing `frame` SSH alias first (Tailscale if setup configured it that way),
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
override those defaults. It reports the successful path without printing its
address. It neither discovers other devices nor enables Tailscale.
The existing alias supplies the user, port, keys and known-hosts file. LAN and
USB overrides preserve its `HostKeyAlias`, or its configured hostname if no
alias was set. A first connection therefore needs trusted setup first. The
doctor never accepts a new host key, deletes a saved key, generates keys or
sends a pairing request. It uses your trusted OpenSSH configuration; OpenSSH
can evaluate `Match exec` conditions in that configuration. Proxy commands,
jumps, local commands, forwarding and connection sharing are disabled for
these direct diagnostic connections.
## Reading the result
Each of the eleven runbook sections has one line: `OK`, `WARN`, `BROKEN` or
`UNKNOWN`. `OK` means only that the stated check passed, not that the whole
feature works. Optional features that are off may produce a warning.
| Section | Read-only evidence and limits |
|---|---|
| 1. Boot and crash history | Previous-boot kernel-oops signatures; one day of coredump history. An oops alone does not prove an unclean reset. |
| 2. Wi-Fi | NetworkManager's wlan0 state, without SSIDs or connection profiles. |
| 3. Displays and SteamVR | DSI timeout signatures in the recent kernel journal; no display, compositor or game is launched. |
| 4. Steam and storage | Free space in home and `devkit-game`, including separate mounts; missing Steam install marker. No CRC scan or restart. |
| 5. Idle sleep and keep-awake | Existing keep-awake unit state; never takes ownership or changes idle timers. |
| 6. Remote wake | WoWLAN state, without arming it or testing suspend. Wake with the power button. |
| 7. Lepton | Recent port-clash, binder and data-clearing signatures; no Android/container commands. |
| 8. Chromium XR | Crash history; no browser profile or environment inspection. |
| 9. KDE Connect | Process presence; absence is normal when sharing is off. |
| 10. Streaming and capture | ffmpeg crash history; no capture or encoder is started. |
| 11. Panels | Always `UNKNOWN`: placement requires the user's visual confirmation. |
Journal samples are capped at 2,000 entries; current-boot samples cover the
last ten minutes. This can miss older or high-volume failures. Empty,
inaccessible or permission-limited journals are `UNKNOWN`, never zero errors.
Commands time out individually, and each SSH probe is bounded to 90 seconds.
Exit codes: **0** if all reported checks are OK; **1** for warnings, broken or
unknown checks after connecting; **2** if no authenticated SSH path exists or
local SSH setup is unavailable. Panel verification currently means a connected
run normally exits 1. Automation should read the labelled checks rather than
treating a warning as proof the headset is unusable.
## First-time setup and pairing
- **No SSH response:** wake the headset with its power button. Check the cable
or Steam Settings → Internet, then enable Developer Mode. Sleep and network
failure can look alike; a timeout cannot distinguish them.
- **Key rejected:** open **Set Up Connection** in Frame Control. In the headset,
open Steam Settings → Developer → **Pair new host**, then approve setup's
request. The doctor only checks whether devkit port 32000 responds; that
does not prove pairing mode is open or the service is healthy.
- **Unknown or changed identity:** verify the intended headset through setup
or Devices → **Check its key**. Do not delete `known_hosts` to bypass this.
- **No devkit response:** check Developer Mode and the address. See
[SSH setup](ssh.md) for the manual fallback. The doctor cannot turn SSH on.
No password is requested. Enter any password required by the separate setup
flow only in its local terminal; never paste it into an issue or report.
## Restart safety and recovery
Before deeper checks, and again afterwards, the doctor samples Steam and
SteamVR `NRestarts` and health-check tracker sizes two seconds apart. Rising
counters stop diagnostics immediately. Nonempty health-check trackers also stop
deeper checks even when stable, since a short sample could miss a slower loop.
Missing counters or counters that reset stop deeper checks as `UNKNOWN`. Stable
counters with empty trackers still cannot rule out a slower restart loop.
The [historical runbook from PR #33](https://github.com/saphid/frame-control/blob/cce3d940302054ab5246c92d740afaefc4e58d38/docs/frame-doctor.md)
is the source for these section numbers and observed signatures (September
2026 builds). Its section 4 describes freezing and verifying health-check
trackers before prolonged recovery during a live loop. That changes headset
state, so this read-only implementation stops and directs you there instead.
It does not implement issue #34's automatic fixes, before/after repair runs,
or full recovery automation. Review build-specific recovery instructions with
the person using the headset; a repair can remove data.
Never reload `ath12k`, suspend remotely, kill gamescope to diagnose a loop,
reset or force power off during extraction/repair, or write passwords to disk.
The doctor never restarts services, signals processes, repairs Steam, changes
WoWLAN/standby settings, takes keep-awake ownership, installs anything or writes
a report. Nothing needs restoring after its checks. Manual/device tests must
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
## Privacy and verification
Only fixed diagnostic summaries leave the remote probe. Raw journals, process
arguments, environment variables, SSH errors, account IDs, network addresses,
SSIDs, keys and tokens are not printed. No telemetry or HTTP request is sent;
the only pairing-service probe is a TCP connection. SSH produces normal access
logs. Review summaries before sharing them; feature availability can still be
personal information. Redirecting stdout saves a report only if you choose it.
Unit tests use mocked boundaries. `scripts/e2e.sh test_doctor` covers asleep,
disk-full, SSH/devkit-off and missing-key faults against the fake Frame. Its
stable restart counters are explicitly simulated, not proof about SteamOS.
On 2026-10-24 the script ran read-only against a real Frame, SteamOS build
`20261015.6141207`. It verified the configured SSH alias and key login,
then reported `WARN Restart safety`: nonempty health-check trackers. It
correctly skipped all eleven deeper sections and exited 1. No raw logs,
addresses or keys were printed, and no repair or restart was attempted.
This verifies the safety stop on that device; the deeper checks are not
claimed as headset-tested.
+91
View File
@@ -0,0 +1,91 @@
# Game storage
Open **Tools → Game storage** for a read-only snapshot of the headset's user
filesystem and Steam libraries. Refresh after an installation, download or move.
The page never moves, deletes or modifies content, starts Steam, requests root,
or recursively scans game files. It works through the existing SSH connection.
## What the numbers mean
- **Total, free and used** come from `statvfs` on the headset user's home and
each library path. Free is space available to that user; reserved blocks mean
used + free can be less than total. These are filesystem sizes, not advertised
drive capacity, and do not include separate system partitions.
- Libraries come from `~/.local/share/Steam/steamapps/libraryfolders.vdf` plus
the default Steam library. Modern nested and older numbered-path records are
accepted; canonical duplicate paths are shown once. No drive is mounted.
- Two paths can share one filesystem. Their capacity must **not** be added.
An unmounted drive whose mount directory still exists can report the parent
filesystem's capacity: the page labels this as the filesystem containing the
path, rather than claiming the removable drive is connected.
- Game sizes are Steam's `SizeOnDisk` records in `appmanifest_<appid>.acf`, sorted
largest first, with unknown sizes last. A missing, negative or invalid size
is **Unknown**, while an explicit zero remains zero. Records may be stale,
including during downloads or moves; they do not measure allocated blocks.
- Games/apps and runtimes/tools have separate subtotals of **known recorded**
sizes. Runtime classification uses names (Proton, Steam Linux Runtime,
Steamworks Common, FEX and Lepton Development), so it is only a convenience.
Additional apps or runtimes may fall in the game/app group.
- Saves, Proton prefixes, shaders, workshops, downloads, Android apps,
sideloaded games, media and OS files are **unmeasured other usage**. The page
does not subtract manifest totals from filesystem usage to invent a size for
them. Recorded content is not an exhaustive storage breakdown.
Permission denied, missing/disconnected paths, unavailable filesystem reads,
invalid metadata and partial inventories are reported separately. A failed read
is never proof of an empty library. If library discovery fails, other libraries
may be missing from the view. Refresh clears the old results immediately; a
headset switch clears the snapshot and ignores late replies from the old device.
## Moving a game safely
Use Steam's own **Settings → Storage** on the headset (or in desktop Steam):
choose the source drive, select the games and choose **Move**, then pick a
Steam-supported destination. A second Steam library must already be available.
Steam may withhold Move or a destination when it cannot perform the operation.
Let Steam finish, follow Steam's errors/recovery prompts, then Refresh here.
Do not manually delete files to make room or interrupt an in-progress move.
[Valve's moving guide](https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move)
was read in a browser on 2026-10-24 and documents this UI. This is an instructional
handoff: the page does not send a URI or silently open a UI on the headset.
The guide's separate Windows instructions for moving the entire Steam client
are outside this feature's scope.
### Why Frame Control does not automate moving
The repository's Valve devkit tools register and launch developer titles; no
supported game-move operation is provided there. The existing Steam CEF bridge
uses internal client APIs, which are not a verified supported contract for a
move transaction. [Valve's public ISteamApps reference](https://partner.steamgames.com/doc/api/ISteamApps) provides install-directory
and installed-depot queries but no documented move transaction with completion,
cancellation, rollback and error guarantees. The public support guide describes
the user-operated Storage UI, rather than such an automation interface.
Consequently this feature adds no move endpoint or guessed internal Steam call,
manifest rewrite, copy/delete fallback, or custom rollback. Automating a move
requires a supported interface and verified behaviour for full disks, disconnects,
running games, downloads, cancellation and Steam restart first.
## Verification and limits
`tests/test_storage.py` runs the actual probe against temporary Steam metadata
and filesystem/error fixtures. It covers multiple libraries, duplicate paths,
zero/unknown/invalid sizes, corrupt manifests, unreadable directories and files,
missing discovery/drives, reserved capacity and read-only file preservation.
A loopback HTTP test executes the real guarded endpoint with SSH mocked.
`tests/storage.cjs` runs the production renderer, navigation and refresh handlers
with DOM/transport boundaries faked, including escaping, offline errors,
out-of-order refreshes and headset switches. It runs through unittest discovery.
The production storage component was rendered with synthetic data at 390px and
900px widths; both fit without horizontal overflow. This checks component
layout, not the full connected application or headset browser.
On 2026-10-24 the uninstalled storage parser was piped read-only over SSH
to a real Frame running SteamOS build `20261015.6141207`. It reported
710.4 GiB free on the user filesystem, one available Steam library, 29
readable app manifests, zero unknown sizes and zero parse/permission issues.
No game names or raw metadata were printed in the test report. The
connected UI, removable media, Steam's Move UI and an actual game move were
not tested. UI and error-state fixtures remain synthetic.
+1
View File
@@ -96,5 +96,6 @@ ssh frame 'cat /opt/steamvr/resources/webinterface/dashboard/localization/dashbo
- Remote access from anywhere: [tailscale.md](tailscale.md)
- Floating windows in space: [panels.md](panels.md)
- Recovery images, what's in them, testing without the headset: [recovery-and-images.md](recovery-and-images.md)
- Backing up saves and settings, restoring after a reset, leaving Desktop Mode: [backup-and-recovery.md](backup-and-recovery.md)
- Frame Control on iPhone (the server running on the Frame itself): [iphone.md](iphone.md)
- What's still unverified: [open-questions.md](open-questions.md)
+3 -2
View File
@@ -102,8 +102,9 @@ app installed opens its App Store page.
Things iOS asks the first time: **Local Network** (tap Allow, or the app can't
see the Frame), and **Paste** when you send the iPhone's clipboard (tap Allow
Paste, or set Settings → Apps → Frame Control → Paste from Other Apps → Allow).
Sending text to the Frame's clipboard needs the desktop panel open in the
headset, as on the desktop app.
Sending text to the Frame's clipboard, or copying the Frame's to the phone
(**Type on the headset → Copy the headset's clipboard**), needs the desktop
panel open in the headset, as on the desktop app.
Not yet exercised: Android display changes through podman (no Android app was
running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
+2 -1
View File
@@ -4,7 +4,8 @@ Frame Control can show any Mac window, or a whole Mac screen, as its own panel
in the Steam Frame. You place each panel anywhere in the room with the SteamVR
dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only).
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
The confidence labels are the same as in [ssh.md](ssh.md).
+9
View File
@@ -143,6 +143,15 @@ check in the headset:
- Keys from SteamVR's on-screen keyboard.
- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
## Battery charge limit (2026-10-23)
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
no backend in the Frame's device config, and the battery node is a
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
on a headset.
## Unconfirmed claims made in these docs
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
+155
View File
@@ -0,0 +1,155 @@
# PC in the headset (Experimental)
**Tools → PC in the headset**, on Windows only. It's the Windows version of
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
becomes its own SteamVR panel in the Frame. Individual windows are available only
with streaming libraries built with Windows Graphics Capture (WGC); the shipped
MSYS2 libraries disable WGC, so this build explains that limitation and offers
whole screens. Place the panel with the dashboard's Float
in World, Move and Size, point and click with the laser, scroll with the
thumbstick, and type on the PC's keyboard.
**It's experimental.** A Windows 11 VM has captured its real desktop with
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
hardware encoding and physical laser input remain unverified. See
[the dated evidence](evidence/windows-vm-2026-10-24.md) for results and limits.
Reports are welcome through the
[feedback form](https://frame-control.pages.dev/feedback/).
## Turning it on
It's off by default. Until you tick **Turn on PC in the headset** on the card,
Frame Control starts no helper, loads none of the streaming libraries and lists
no windows. The choice is remembered on this computer. Turning it off ends
every stream, closes the viewers in the headset and stops the helper.
The card and its Tools link only appear in the Windows app. On macOS the card
is Mac in the headset, as before; on Linux and the iPhone app there's no card.
## How it works
The ported pieces are from [PR #38](https://github.com/saphid/frame-control/pull/38).
Frame Control owns the host helper, input, authentication, streaming protocol,
panel launcher and adaptation. It doesn't need Sunshine, OBS or any other
streaming app. GStreamer is bundled as ordinary libraries.
| | Windows |
|---|---|
| Capture | GStreamer's `d3d11screencapturesrc`: Desktop Duplication for whole screens in the shipped build; Windows.Graphics.Capture by window (HWND) or screen (HMONITOR) when that implementation is present |
| Encoding | Media Foundation's H.264 encoder (`mfh264enc`), low latency, no B-frames. Whether it uses the GPU depends on the PC; **not verified** |
| Input | `SendInput`, mapped through the source's bounds, with per-monitor DPI awareness |
API references:
[Windows capture](https://learn.microsoft.com/en-us/windows/uwp/audio-video-camera/screen-capture),
[GStreamer WGC](https://gstreamer.freedesktop.org/documentation/d3d11/d3d11screencapturesrc.html),
[Media Foundation encoder](https://gstreamer.freedesktop.org/documentation/mediafoundation/mfh264enc.html).
It needs Windows 10 or 11 with Desktop Duplication and a Media Foundation
H.264 encoder (Windows "N" editions need the Media Feature Pack). WGC-enabled
builds can also capture individual windows. Clicks and
keys can't reach administrator apps or the sign-in screen, which an ordinary
process can't drive. If Windows won't bring the chosen window to the front,
input is refused rather than sent to the app covering it: bring the window
forward, then Stop and Show again. Minimised or closed windows stop sending
pictures. Protected video isn't supported. Mac in the headset's "own display
per window" is Mac-only.
The pieces it shares with Mac in the headset:
- `ui/frame_macview.py` owns the SSH tunnel, reconnects, quality presets and
panel launch. `ui/frame_pcview.py` picks the Windows helper
(`ui/frame_pc_agent.py`, `ui/frame_pc_capture.py`, `desktop/capture.c`) and
holds the on/off switch. `/api/macview` is the endpoint for both.
- `ui/mac-view.html` is the one viewer in the headset. Its frame header, H.264
and JPEG payloads, reconnect handshake, clock sync, timing reports and input
messages are unchanged.
- `desktop/controller.c` is a C port of the Mac helper's Swift rate controller,
for the Windows helper. The Mac keeps its Swift original;
`tests/test_pc_controller.py` checks the port against a frozen 600-state
trace from it. Capture is gated before encoding, so encoded frames are never
dropped, and an idle window's last picture still goes out once the link
clears.
- Stream records use the Mac helper's JSON schema (`ui/frame_stream_stats.py`),
so the benchmark's analysis is shared.
## Packaging
The Windows installer carries `resources/desktop/bundle`: `pc-host.dll` (our
adapter and the controller), the GStreamer plugins it uses (`coreelements`,
`videotestsrc`, `videoconvertscale`, `app`, `jpeg`, `videoparsersbad`,
`d3d11`, `mediafoundation`) and the DLLs they depend on, all from MSYS2's
UCRT64 packages. `libraries.json` records the GStreamer version, the plugins,
each copied DLL and the exact package versions; licence texts are in
`licenses/`.
MSYS2's `gst-plugins-bad` build disables `d3d11-wgc`. The presence of
`d3d11screencapturesrc` alone does not prove WGC support: its `capture-api`,
`window-handle` and `show-border` properties are conditional. The helper queries
those properties on the instantiated element, advertises only supported
sources, and omits all three properties for the DXGI whole-screen pipeline.
It never substitutes a screen crop for an unavailable window capture.
The x264 encoder is **not** in the installer. It's GPL, and Windows encodes
with Media Foundation, so only CI's test builds include it
(`desktop/build.py --with-x264`). The macOS and Linux installers are unchanged.
From a source checkout on Windows, build the bundle from an MSYS2 UCRT64 shell
with the packages listed in `.github/workflows/pc-host.yml`:
```sh
python desktop/build.py # what the installer ships
python desktop/build.py --with-x264 # for the native tests
```
A missing bundle is reported on the card; nothing is downloaded on first use.
The Linux adapter from #38 (desktop portal, PipeWire, VA-API) is in the tree,
because it shares `capture.c` and the controller, and CI still builds and tests
it. The app never offers it and the Linux installers don't include it.
## Measuring
The Mac benchmark accepts the PC host:
```sh
python scripts/macview-bench.py run --pc --scenario test --label pc-test
python scripts/macview-bench.py run --pc --scenario capture --source window:12345 --label windows-window
```
`capture` needs a window or screen id from the helper's `/windows` or
`/displays`. CPU sampling isn't available on PC hosts. See
[Mac in the headset](mac-in-headset.md) for the targets and shaping options.
## What's verified
- **Verified in CI, this PR:** on `windows-latest`, the native adapter builds
and a real x264 synthetic stream passes the ticket, reconnect, timing and
input-echo tests through the helper. The Windows installer builds with the
bundle, and its bundled Python loads the bundle, starts the helper and
confirms x264 isn't in it. Unit tests check the Windows-only, off-by-default
gating, the card and the Tools link on macOS, Windows and Linux.
- **Verified, #38, 2026-09-28, real Frame on SteamOS BUILD_ID 20260925.6191901:**
the PC helper (ARM64, on the Frame itself, x264 test pattern) streamed
through a Mac SSH relay into the shared viewer: 400 frames at 39.3 fps,
content latency p50/p95 15.1/28.6 ms, synthetic input p50 76.6 ms, at
revision `cd20243`. The frame-rate and late-frame targets failed. This
proves the helper and the viewer work together; it isn't Windows capture or
a real PC link.
[Raw result](../bench/results/2026-09-28-cd20243-pc-agent-frame-arm64-final.json),
[earlier probe](../bench/results/2026-09-28-c5fc552-dirty-pc-agent-frame-arm64-hairpin.json).
- **Verified, #38, on a Mac:** the C controller matched all 600 states of the
Swift controller's congestion and recovery trace.
- **Verified, 2026-10-24:** Windows 11 VM build 26200.8037 on lxso2, using
the v0.4.10 installer with this fix, captured its actual desktop and streamed
H.264 to Frame BUILD_ID `20261015.6141207`: 219 sent frame records, 215 decoded
and drawn before the snapshot. The own-panel screenshot shows the Windows
desktop. This short, mostly idle run does not establish performance targets.
[Evidence, raw traces and cleanup](evidence/windows-vm-2026-10-24.md).
- **Not verified:** Windows.Graphics.Capture, physical-PC capture, hardware
Media Foundation encoding, encoder restarts when the bitrate
changes, mixed-DPI and multi-monitor input alignment, window resize and
minimise, non-US keyboard layouts, physical laser input, and how it performs
in a worn headset. No numbers are claimed for these. Not tested with a
worn headset. See the dated VM evidence for the separate automated checks.
- **Not reviewed independently.** #38 tried an independent review that never
completed.
+14 -3
View File
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
are hidden even when they contain a scrubber token such as `<host>` or overlap
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
and keys. A host name ssh mentions in some other wording can still get through,
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
@@ -287,3 +289,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
Those requests carry no id. To stop it, set
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
## Screenshots and clips
Copying screenshots and Steam clips to your computer, by hand or with
**Settings → Copy to this computer**, goes straight from the headset to your
computer over the same SSH connection as everything else. Nothing is uploaded
anywhere. What automatic copying has copied is listed, by file id, in
`auto-sync.json` in Frame Control's data folder; see
[recording.md](recording.md#copying-new-ones-automatically).
+165
View File
@@ -0,0 +1,165 @@
# Recording and Steam clips
Frame Control can record what the headset shows, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
Both are on **Tools → Capture & Live**. Everything is saved to
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. That Frame had no
Steam recordings, so the clip list came back empty and export has only been
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
Press **Record** next to **Live**. The button turns into **Stop**, and a line
under the viewer counts the time. Press **Stop**: the recording is finished on
the Frame, converted to `.mp4` there (without re-encoding) and copied to
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
The sound is what the headset plays: the monitor of PipeWire's default
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
The microphone isn't recorded.
- **Without sound**: before each recording Frame Control checks for a fraction
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
ahead as video only, and the page and the saved message say so.
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
a full-length recording twice, while it's converted), and checks the space on
your computer before copying. One recording runs at a time.
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
which is a RAM disk on SteamOS. It's deleted once copied.
- **If the connection drops or the app quits** while recording, the Frame
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
SIGINT and carries on recording. A recording that was never copied is
deleted when the next one starts.
Recording and **Live** can run together; both read the same device.
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
```
~/.local/share/Steam/userdata/<account>/gamerecordings/
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
session.mpd
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
init-stream1.m4s chunk-stream1-00001.m4s ...
```
Each stream's `init-stream` segment followed by its chunks, in number order, is
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
copied to the computer and deleted from the Frame. The export needs free space
on the Frame of about twice the recording's size while it runs.
The listing doesn't rely on the folder names: any folder under `clips/` or
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
missing its init segment or its chunks is left out. Missing chunk numbers are
shown as "pieces missing"; the rest still exports.
## Copying new ones automatically
**Settings → Copy to this computer** copies new screenshots and Steam clips
here on their own. It's off until you turn it on, with a switch each for
screenshots and Steam clips. While a headset is connected, Frame Control looks
about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
`auto-sync.json` in Frame Control's data folder, so a file you delete here
isn't copied again (**Copy everything now** would bring it back).
- **Saved clips only.** Steam's background recording is a rolling buffer, not
something you chose to keep, so it isn't copied; export it by hand. A clip
Steam wrote to in the last 30 seconds is still being saved and waits for the
next look.
- **It gives way.** It waits while an install, an export, a recording or
anything else is running on the headset, and stops between files if one
starts. While a copy is running, switching headsets waits for it, as it does
for an export. When the headset can't be reached it waits longer each time,
up to 15 minutes, and starts over once it answers.
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
names only, no `.` or `..`) before it reaches a path or a shell.
The API, with the same guards as every other request:
| Request | Does |
|---|---|
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
## What hasn't been checked
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
Steam recording was created during that check, so a real Steam clip export is
still unverified. The recording and Stop path were verified earlier on that
build, as described above. Remaining checks:
- Audio and video sync. Both inputs are stamped with the wall clock, because
the camera device and the audio server otherwise count from different clocks.
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
whether a clip's segments start mid-recording. The parser is deliberately
loose about the layout.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+3
View File
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
run it for testing without the headset. For recovering a Frame that won't boot,
see the boot menu and boot-loop entries in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
To keep your game saves, settings and Android app data through a factory reset
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
[backup-and-recovery.md](backup-and-recovery.md).
## Downloads
+4
View File
@@ -7,6 +7,10 @@ run on Linux. On Windows, use the app.
## Minimum typing on the headset
If setup or pairing fails, run `scripts/frame-doctor.sh` from this checkout.
It checks existing SSH access without requesting passwords, pairing or applying
repairs. See the [Frame doctor guide](frame-doctor.md) for results and next steps.
Valve's own developer docs say SSH, ADB, and RDP are all turned on through a
**UI toggle**. You don't need a terminal, `passwd`, or `systemctl`. The only
thing you type on the headset is a password you choose.
+1 -1
View File
@@ -144,7 +144,7 @@ capture it.
## Input: type and point in the Frame from the Mac or iPhone
**Built: Home → Keyboard and trackpad**, in every version of Frame Control
**Built: Home → Type on the headset** (or press `T`), in every version of Frame Control
(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
two fingers to scroll, two-finger tap to right-click) plus a text field that
+131 -4
View File
@@ -44,6 +44,20 @@ them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
## The fake Frame
Frame doctor has focused offline tests in `tests/test_frame_doctor.py` and
fake-Frame fault tests in `tests/e2e/test_doctor.py`:
```sh
python3 -m unittest discover -s tests -p test_frame_doctor.py -v
scripts/e2e.sh test_doctor
```
They cover privacy filtering, host-key preservation, restart safety and missing
journal evidence, plus asleep, full-title-storage, SSH/devkit-off and missing-key
faults. The fake's doctor-only `systemctl show` responses assume stable restart
counters and inactive keep-awake; unsupported queries fail. These tests do not
exercise a real SteamOS user manager or touch a headset.
`tests/fakeframe/` builds a container that stands in for the headset, and a
second one for the computer Frame Control runs on. `scripts/e2e.sh` builds
both, starts them with `docker compose`, runs `tests/e2e` in the host
@@ -164,6 +178,43 @@ does). A cleanup that fails counts as a failed step. Results go to
screen; it exits 0 when every step passed, 1 if one failed, 2 if the headset
isn't reachable.
Smoke process cleanup runs before and after title removal and during the
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
an exact title directory in `/proc` cwd/exe or an individual absolute command
argument; similarly named folders and shell command substrings do not count.
Deleted cwd/exe paths still count, so a process left after file removal can be
found. It sends SIGTERM, waits up to three seconds, then uses SIGKILL with
another three-second bound, rescanning for survivors. PID handles prevent PID
reuse from signalling another process. Linux Python 3.9+ with pidfd support
is required; missing support, signal errors and survivors fail the
cleanup step. Stopped PIDs are recorded in the results JSON.
If the initial leftover cleanup fails, the run skips installs and pairing and
records the failure instead of overwriting an active test title.
This deliberately does not stop shared Wine/Proton services by name or prefix,
Steam, SteamVR, process groups or arbitrary descendants. A process that drops
all title-path evidence cannot safely be attributed and is outside this
check. Unreadable `/proc` fields provide no ownership evidence; other readable
fields are still checked, and a wholly unattributable process is left alone.
Real Frame verification, 2026-10-24, SteamOS build `20261015.6141207`: the
fixed Python smoke entry point passed all 13 steps under the shared device lock.
The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
remove step stopped five test-owned wrapper/Proton PIDs before removing the
title; the following process sweep found none. The final cleanup found no
test titles or processes, the test desktop was closed, and the battery
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
the same entry point instead.
The local regression suite runs real Linux processes in temporary fake title
directories (including deleted folders and a TERM-resistant wrapper); it checks
exit, escalation and survival of unrelated processes without SSH or a headset:
```sh
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
```
`--pair` asks the devkit service to pair a new RSA key, which needs someone
in the headset to open **Settings → Developer → Pair new host** and approve
it; the key is checked and then taken out of `authorized_keys` again.
@@ -187,11 +238,36 @@ 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.
## PC panel host tests
PC in the headset (Experimental, Windows only). `tests/test_pcview.py` checks
that only Windows gets the PC host and that it starts nothing until turned on,
the Tools link and card wording, source-bound tickets, reconnect/Stop
revocation, input release, source-coordinate mapping and use of the existing
panel launcher. `tests/test_pc_controller.py` checks the C rate controller
against a frozen trace of the Mac's Swift one.
The `PC host libraries` workflow builds the native adapter on Windows (and
the unshipped Linux adapter on Ubuntu x64 and ARM64), then runs a real x264
synthetic stream through the HTTP/WebSocket agent. x264 is GPL, so only these
CI builds include it (`desktop/build.py --with-x264`); the release workflow
tests the installer's own bundle with its bundled Python and checks x264 isn't
in it. `FRAME_PC_REQUIRE_NATIVE=1` and `FRAME_PC_REQUIRE_X264=1` make a missing
bundle or encoder fail CI instead of skipping.
These automated CI checks do not capture a desktop, exercise Media Foundation
or a GPU, or check laser alignment. A [Windows test VM](#windows-test-vm) run
on 2026-10-24 captured its actual desktop with Desktop Duplication, encoded
with Media Foundation and streamed to a real Frame. It found MSYS2 disables
WGC: an element name alone does not prove its optional capture properties
exist. Native property-probe tests and source-selection regressions now cover
that boundary. [Dated evidence and limits](evidence/windows-vm-2026-10-24.md);
[what's verified](pc-in-headset.md#whats-verified).
## 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
Windows CI runs the unit tests and checks installer contents. Desktop capture,
installer launch and interactive behavior need a 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`.
@@ -211,7 +287,14 @@ Setting it up, once, on the Linux machine:
`~/.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.
and the tests. Inspect free memory/disk, KVM access and existing VMs before
starting; leave other VMs alone. The first install can take tens of minutes
(the 2026-10-24 run took about 16 minutes to reach the desktop), rather than
the 1–2 minute startup of an already installed guest.
- For PC streaming, check the actual bundled capture properties. MSYS2's
GStreamer build disables WGC: this app falls back to Desktop Duplication for
whole screens and explains why individual windows are unavailable. A VM
capture test does not prove physical GPU encoding or laser alignment.
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
@@ -325,3 +408,47 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
## Backup and recovery kit
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
for real against a temporary folder laid out like the Frame's home, with only
SSH faked: path selection and the secrets it must never copy, the manifest and
checksums, restore checks after a reset, restore writing only its own files,
unsafe archives and backup names, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
A full backup and an identical-file Proton save restore were checked on a
real Frame running build `20261015.6141207`; an after-reset restore and
Android data restore remain unverified. See the linked device evidence.
## Game storage
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
regressions. All headset reads are temporary fixtures or mocked SSH. See
[game storage](game-storage.md#verification-and-limits) for coverage and device
verification limits. This suite does not move games or contact a headset.
## Battery monitoring
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
files, source preference, missing/invalid sensors and permission errors.
`tests/control_center.cjs` covers charging states and optional health fields,
alongside existing stale/offline handling. No headset is used. See
[battery monitoring](battery-health.md) for sources, units and limitations.
## Host-key recovery presentation
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
connection/banner/selection functions in Node and checks the recovery CSS guard.
`test_link.py` covers connection failures and short probe reasons with fake SSH
and local sockets. Neither suite uses a real headset.
Browser verification on 2026-10-24 used the current page's markup, styles and
recovery renderers with synthetic changed-key state and two headsets at
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
+5
View File
@@ -55,6 +55,7 @@ struct WebShell: UIViewRepresentable {
},
notify: (message, request) => call("notify", { message, request }),
readClipboard: () => call("readClipboard"),
writeClipboard: (text) => call("writeClipboard", text),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
saveImages: (images) => call("saveImages", images),
@@ -143,6 +144,10 @@ struct WebShell: UIViewRepresentable {
replyHandler(true, nil)
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "writeClipboard":
guard let text = arg as? String else { return replyHandler(nil, "Expected text") }
UIPasteboard.general.string = text
replyHandler(true, nil)
case "setUpConnection":
model.showSetup()
replyHandler(nil, nil)
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Read-only setup and health diagnostics; see docs/frame-doctor.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_doctor.py" "$@"
+279
View File
@@ -0,0 +1,279 @@
"""Read-only doctor. Also sent over SSH on stdin; never installed on the Frame.
Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
process arguments, environment, key material or HTTP response bodies.
"""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import socket
import subprocess
import sys
import time
SECTIONS = ('Boot and crash history', 'Wi-Fi', 'Displays and SteamVR',
'Steam and storage', 'Idle sleep and keep-awake', 'Remote wake',
'Lepton (Android)', 'Chromium XR', 'KDE Connect',
'Streaming and capture', 'Panels')
def run(args, timeout=4, **kwargs):
try:
return subprocess.run(args, capture_output=True, text=True, timeout=timeout, **kwargs)
except (OSError, UnicodeError, subprocess.TimeoutExpired):
return None
def command(*args):
r = run(list(args))
return r.stdout if r is not None and r.returncode == 0 else None
def journal(*args):
# Even exit 0 can mean the account cannot see the system journal.
r = run(['journalctl', '--no-pager', '-q', '-n', '2000', *args])
if r is None or r.returncode or r.stderr.strip() or not r.stdout.strip():
return None
return r.stdout
def snapshot():
values = []
for unit in ('steam', 'steamvr'):
s = command('systemctl', '--user', 'show', unit + '.service',
'-p', 'NRestarts', '--value')
if s is None or not s.strip().isdigit():
return None
values.append(int(s.strip()))
p = Path('/run/user') / str(os.getuid()) / (unit + '-short-session-tracker')
try:
values.append(p.stat().st_size)
except FileNotFoundError:
values.append(0)
except OSError:
return None
return values
def guard():
first = snapshot()
time.sleep(2)
second = snapshot()
if first is None or second is None:
return 'unknown'
if any(b > a for a, b in zip(first, second)):
return 'loop'
if first != second:
return 'unknown' # counters reset or tracker replaced during the sample
if second[1] or second[3]:
return 'risk' # avoid a long run even if a short sample missed the loop
return 'stable'
def signal_check(text, pattern, problem, healthy):
if text is None:
return ['UNKNOWN', 'Evidence unavailable; consult the runbook locally.']
return ['WARN', problem] if re.search(pattern, text, re.I) else ['OK', healthy]
def storage():
# devkit-game may be a separate mount (including in the fake Frame).
try:
paths = [Path.home()]
games = Path.home() / 'devkit-game'
if games.exists():
paths.append(games)
free = min(shutil.disk_usage(p).free for p in paths)
if free < 16 * 1024**2:
return ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
if free < 256 * 1024**2:
return ['WARN', 'Less than 256 MiB free; review storage before large installs.']
return ['OK', 'Home and title storage have at least 256 MiB free.']
except OSError:
return ['UNKNOWN', 'Storage could not be read.']
def probe():
state = guard()
if state != 'stable':
return {'guard': state, 'checks': []}
kernel = journal('-b', '-k', '--since=-10min')
recent = journal('-b', '--since=-10min')
previous = journal('-b', '-1', '-k')
crashes = command('coredumpctl', 'list', '--no-pager', '--since=-1d')
checks = []
boot = signal_check(previous, r'Unable to handle kernel|Internal error|Kernel panic',
'Previous boot has a kernel oops; this alone does not prove an unclean reset.',
'No kernel-oops signature in the sampled previous boot.')
if crashes is None and boot[0] != 'WARN':
boot = ['UNKNOWN', 'Crash history unavailable; inspect boot history locally.']
elif crashes is not None and boot[0] != 'WARN' and re.search(r'SIG(SEGV|ABRT)|\b(SEGV|ABRT)\b', crashes):
boot = ['WARN', 'Crash records exist in the last day; inspect locally using the runbook.']
checks.append(boot)
wifi = command('nmcli', '-t', '-f', 'DEVICE,TYPE,STATE', 'device')
checks.append(signal_check(wifi if wifi and 'wlan0:wifi:' in wifi else None, r'wlan0:wifi:(unavailable|disconnected|failed)',
'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.',
'No unavailable/disconnected wlan0 reported; other interfaces may differ.'))
checks.append(signal_check(kernel, r'wait for video done',
'Display timeouts observed; follow display recovery before touching Steam.',
'No display-timeout signature in recent kernel sample.'))
if checks[-1][0] != 'WARN' and recent is not None and re.search(r'left-over process|steamvr.service: Failed|!modifiers.empty', recent):
checks[-1] = ['WARN', 'SteamVR/session failure signature observed; inspect locally before restarting anything.']
disk = storage()
if disk[0] == 'OK' and not (Path.home() / '.local/share/Steam/.install-complete').exists():
disk = ['WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.']
checks.append(disk)
awake = command('systemctl', '--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value')
checks.append(['UNKNOWN', 'Keep-awake state unavailable.'] if awake is None or not awake.strip() else
['WARN', 'Keep-awake is active; leave it with its owner.'] if awake.strip() == 'active' else
['OK', 'Keep-awake is inactive; diagnostics do not inhibit sleep.'])
wow = command('iw', 'phy', 'phy0', 'wowlan', 'show')
checks.append(['UNKNOWN', 'WoWLAN state unavailable; remote wake is not verified.'] if wow is None else
['OK', 'WoWLAN is disabled; wake the headset with its power button.'] if 'WoWLAN is disabled' in wow else
['WARN', 'WoWLAN may be armed; review the runbook. Remote wake is not reliable.'])
checks.append(signal_check(recent, r'Listen failed for HOST TCP|binder_user_error|Clearing baked app data',
'Android failure signature observed; inspect Lepton locally before retrying.',
'No known Android failure signature in recent journal sample.'))
checks.append(signal_check(crashes, r'chromium|/chrome\b',
'Chromium crash history found; update/relaunch the panel through its normal UI.',
'No Chromium crash signature in available crash history.'))
kde = run(['pgrep', '-f', 'frame-control/kdeconnect/root/usr/lib/kdeconnectd'])
checks.append(['UNKNOWN', 'KDE Connect process check unavailable.'] if kde is None or kde.returncode not in (0, 1) else
['OK', 'KDE Connect process found.'] if kde.returncode == 0 else
['WARN', 'KDE Connect is not running; normal if keyboard/trackpad sharing is off.'])
checks.append(signal_check(crashes, r'ffmpeg',
'Capture crash history found; consult software-encoder guidance in the runbook.',
'No ffmpeg crash signature in available crash history.'))
checks.append(['UNKNOWN', 'Panel placement needs visual confirmation; open one panel at a time.'])
# A short sample is not proof that Steam is healthy. Recheck after collecting.
after = guard()
return {'guard': after, 'checks': checks if after == 'stable' else []}
SSH_OPTIONS = ['BatchMode=yes', 'NumberOfPasswordPrompts=0', 'StrictHostKeyChecking=yes',
'UpdateHostKeys=no', 'CheckHostIP=no', 'ConnectTimeout=3', 'ConnectionAttempts=1',
'ControlMaster=no', 'ControlPath=none', 'ClearAllForwardings=yes',
'PermitLocalCommand=no', 'RemoteCommand=none', 'RequestTTY=no',
'ProxyCommand=none', 'ProxyJump=none', 'ForwardAgent=no', 'ForwardX11=no']
def ssh_args(alias, host=None, identity=None):
args = ['ssh']
for opt in SSH_OPTIONS:
args += ['-o', opt]
if host:
args += ['-o', 'HostName=' + host, '-o', 'HostKeyAlias=' + identity]
return args + [alias]
def failure(text):
# Never echo SSH stderr: it can contain addresses, paths or remote text.
if 'REMOTE HOST IDENTIFICATION HAS CHANGED' in text:
return 'SSH identity changed; use Devices > Check its key after verifying the headset. Do not delete known_hosts.'
if 'Host key verification failed' in text:
return 'SSH identity is untrusted; verify it through Set Up Connection or Devices > Check its key.'
if 'Permission denied' in text:
return "SSH key rejected; use Set Up Connection, then Developer > Pair new host in the headset."
return 'No SSH response; wake the headset, check the network/cable and enable Developer Mode.'
def port_open(host, port):
try:
with socket.create_connection((host, port), timeout=2):
return True
except OSError:
return False
def report(data):
state = data.get('guard')
if state != 'stable':
status = 'BROKEN' if state == 'loop' else 'WARN' if state == 'risk' else 'UNKNOWN'
message = {'loop': 'Counters are rising. Stop diagnostics; use the runbook health-check recovery procedure.',
'risk': 'Health-check trackers contain failures. Deeper checks skipped; review the runbook before recovery.'}
print(status + ' Restart safety: ' + message.get(state, 'Cannot verify restart counters; deeper checks skipped for safety.'))
for i, label in enumerate(SECTIONS, 1):
print(f'UNKNOWN {i}. {label}: Skipped until restart safety is verified.')
return 1
checks = data.get('checks')
# Treat the remote endpoint as untrusted. Only accept pairs from our own
# fixed vocabulary by matching against the production probe's messages.
if not isinstance(checks, list) or len(checks) != len(SECTIONS):
print('UNKNOWN Probe: Incomplete diagnostic response; no raw output displayed.')
return 1
for check in checks:
if (not isinstance(check, list) or len(check) != 2 or check[0] not in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
or not isinstance(check[1], str) or check[1] not in MESSAGES):
print('UNKNOWN Probe: Unexpected diagnostic response; no raw output displayed.')
return 1
print('OK Restart safety: No rising counters during short samples; this is not a health guarantee.')
for i, (label, (status, message)) in enumerate(zip(SECTIONS, checks), 1):
print(f'{status} {i}. {label}: {message}')
return int(any(c[0] != 'OK' for c in checks))
def main(argv=None):
parser = argparse.ArgumentParser(description='Read-only Frame setup diagnostics. Never pairs, repairs or asks for a password.')
parser.add_argument('--alias', default='frame', help='Existing trusted SSH alias (default: frame)')
parser.add_argument('--lan', default='frame.local', help='LAN fallback host/IP')
parser.add_argument('--usb', default='10.86.200.233', help='USB fallback IP; use only with the intended headset connected')
args = parser.parse_args(argv)
for value in (args.alias, args.lan, args.usb):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._:-]{0,252}', value):
parser.error('Use a plain alias, hostname or IP address.')
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. No report is saved.')
if not shutil.which('ssh'):
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
return 2
config = run(ssh_args(args.alias)[:-1] + ['-G', args.alias])
if config is None or config.returncode:
print('UNKNOWN Setup: SSH configuration could not be read; check it locally.')
return 2
settings = dict(line.split(' ', 1) for line in config.stdout.splitlines() if ' ' in line)
identity = settings.get('hostkeyalias') or settings.get('hostname', args.alias)
source = Path(__file__).read_text()
for label, host in (('configured alias (Tailscale if configured)', None), ('LAN', args.lan), ('USB-C', args.usb)):
result = run(ssh_args(args.alias, host, identity) + ['python3 -B - --probe'], timeout=90, input=source)
if result is not None and result.returncode == 0:
try:
data = json.loads(result.stdout)
if not isinstance(data, dict):
raise ValueError()
except ValueError:
print('UNKNOWN Probe: Invalid response; check Python 3 availability on the headset. No raw output displayed.')
return 1
print('OK Connection: ' + label + '; verified SSH identity and key login.')
return report(data)
if result is not None and result.returncode != 255:
print('UNKNOWN Probe: SSH connected but diagnostics failed; check Python 3 on the headset.')
return 1
reason = failure(result.stderr if result is not None else '')
print('WARN Connection (' + label + '): ' + reason)
# No /register call: an open devkit port cannot prove pairing mode is enabled.
hosts = (settings.get('hostname', args.alias), args.lan, args.usb)
if any(port_open(host, 32000) for host in hosts):
print('WARN Pairing: Devkit port responds. Use Set Up Connection and Developer > Pair new host; no pairing request was sent.')
else:
print('UNKNOWN Pairing: Devkit port did not respond. Needs your hands: wake headset, enable Developer Mode and check its address.')
for i, label in enumerate(SECTIONS, 1):
print(f'UNKNOWN {i}. {label}: No authenticated SSH path.')
return 2
# Derive the output allowlist from literal strings, not remote data. This module
# has no dynamic diagnostic messages. AST stays stdlib and runs only locally.
def messages():
import ast
tree = ast.parse(Path(__file__).read_text())
return {node.value for node in ast.walk(tree) if isinstance(node, ast.Constant) and isinstance(node.value, str)}
if __name__ == '__main__':
if sys.argv[1:] == ['--probe']:
print(json.dumps(probe()))
else:
MESSAGES = messages()
sys.exit(main())
+55 -13
View File
@@ -55,6 +55,7 @@ from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponen
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
RESULTS = ROOT / "bench" / "results"
PAGES = ROOT / "bench" / "pages"
@@ -254,7 +255,10 @@ class LabView(frame_macview.MacView):
def ssh_runner(base):
def run(remote, stdin=None, timeout=30):
r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
r = subprocess.run([*base, remote], input=stdin.encode("utf-8") if stdin is not None else None,
capture_output=True, timeout=timeout)
r.stdout = r.stdout.decode("utf-8", errors="replace")
r.stderr = r.stderr.decode("utf-8", errors="replace")
if r.returncode:
e = RuntimeError((r.stderr or r.stdout).strip())
e.stdout = r.stdout
@@ -381,7 +385,11 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if args.browser_flag is not None:
mv.browser_flags = [f for f in args.browser_flag if f]
chrome = None
src = "test"
src = args.source or "test"
cpu = None
if args.pc:
mv.host = "windows" if sys.platform == "win32" else "linux"
mv.viewer_profile = "pc-view"
try:
if scenario in ("scroll", "type"):
chrome = chrome_window(f"{scenario}.html")
@@ -405,9 +413,10 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
since = max([f["s"] for f in first["frames"]] or [0])
captured_before = first["captured"]
start_mac_us = mv.call("/stats", id=stream["id"])["now"]
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
if not args.pc:
agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
capture_output=True, text=True).stdout.split()[0])
cpu = CpuSampler(frame_ssh, agent_pid)
if relay:
relay.begin()
expected = 0 # input events the harness asked the viewer for
@@ -443,7 +452,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
if not streams:
raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
data = streams[0]
cpu.stop()
if cpu:
cpu.stop()
if args.raw:
Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
@@ -452,7 +462,7 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
for e in data.get("events", []) if e["t"] >= start_mac_us]
result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
result["cpu"] = cpu.summary()
result["cpu"] = cpu.summary() if cpu else {"host": "not sampled"}
result["viewer"] = data["summary"].get("decoder", "")
result["show_s"] = show_s
result["panel"] = shown.get("panel")
@@ -460,6 +470,8 @@ def run_scenario(args, scenario, agent_port, token, frame_ssh):
result["route"] = mv.route
return result
finally:
if cpu:
cpu.stop()
try:
mv.stop(src)
except Exception: # noqa: BLE001 - best effort
@@ -670,6 +682,8 @@ def fmt(v):
def add_run_args(r):
r.add_argument("--scenario", default="test,scroll,type")
r.add_argument("--pc", action="store_true", help="run the bundled PC host; use --scenario test or capture")
r.add_argument("--source", default="", help="PC source: window:ID, display:ID, or choose (Linux portal)")
r.add_argument("--duration", type=float, default=20)
r.add_argument("--warmup", type=float, default=3)
r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
@@ -717,7 +731,7 @@ def frame_ssh_for(args):
def run_suite(args, frame_ssh, quiet=False):
agent_port, token = lab_agent()
agent_port, token = (args.pc_view.port, args.pc_view.token) if args.pc else lab_agent()
results = []
for sc in args.scenario.split(","):
if not quiet:
@@ -744,11 +758,12 @@ def document(args, frame_ssh, results, **extra):
commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
return {
"label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
"config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
"config": {"quality": args.quality, "mode": "native" if args.pc else args.mode, "net": args.net or "none", "delay_ms": args.delay,
"buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
"encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
"browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
"frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0],
"host_platform": platform.platform(), "source": args.source, "pc_host": args.pc, "headset": standby[-40:],
"scenarios": results, **extra,
}
@@ -819,19 +834,46 @@ def main():
print("\nworse:\n " + "\n ".join(regs))
sys.exit(1 if regs else 0)
lab_agent()
if args.pc and (args.cmd == "ab" or args.scenario not in ("test", "capture")):
p.error("PC runs use --scenario test or --scenario capture; Mac automation is not portable")
if args.pc and args.scenario == "capture" and not args.source:
p.error("capture needs --source window:ID, display:ID, or choose")
if not args.pc:
lab_agent()
frame_ssh = frame_ssh_for(args)
# Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))]) if sys.platform == "darwin" else None
args.pc_view = None
try:
if args.pc:
args.pc_view = frame_pcview.PCView(frame_ssh[:-1], ssh_runner(frame_ssh), frame_ssh[-1])
args.pc_view.ensure_agent()
if args.source == "choose":
args.pc_view.call("/permissions", method="POST")
print("Choose a window or screen in your desktop's sharing dialog.", flush=True)
deadline = time.monotonic()+125
while time.monotonic() < deadline:
state = args.pc_view.call("/status")
if not state.get("selecting"):
sources = args.pc_view.call("/windows")["windows"]
if not sources:
raise SystemExit(state.get("selectionError") or "Nothing was shared")
args.source = sources[-1]["src"]
break
time.sleep(.5)
else:
raise SystemExit("Sharing dialog timed out")
if args.cmd == "ab":
arm_runs, table = ab(args, frame_ssh)
write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
return
results = run_suite(args, frame_ssh)
finally:
awake.terminate()
if args.pc_view:
args.pc_view.shutdown()
if awake:
awake.terminate()
doc = document(args, frame_ssh, results)
write(doc, args)
bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
+45 -1
View File
@@ -64,7 +64,7 @@ function sharedPage({productionApi=false} = {}) {
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
};
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
padReset(){},ctrlReset(){},resetShots(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
padReset(){},ctrlReset(){},resetShots(){},resetClips(){},closeDevPop(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
vm.runInContext(`const UI_KEY = 'test-only';
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
const disp = {}, gm = {seq:0}, cat = {}, wi = {}, sourceState = {jobs:new Map(),detail:null,detailRequest:0};
@@ -165,6 +165,50 @@ test('metrics use real fields, preserve zero, and distinguish unavailable values
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
});
test('battery health distinguishes charging states and optional sensors', () => {
for (const status of [null, 'Unknown', 'Unexpected']) {
const m = cc.metrics({battery:{percent:100,status,timeToEmpty:3600}});
assert.equal(m.batteryDetail,'Charging state unavailable');
assert.equal(m.estimate,'Unavailable');
}
assert.equal(cc.metrics({battery:{status:'Not charging'}}).batteryDetail,'Not charging');
assert.equal(cc.metrics({battery:{status:'Full'}}).batteryDetail,'Fully charged');
assert.equal(cc.metrics({battery:{status:'Charging',timeToFull:3600}}).estimate,'1 h 0 min to full');
assert.equal(cc.metrics({battery:{status:'Discharging',timeToEmpty:600}}).estimate,'10 min remaining');
const m = cc.metrics({battery:{cycleCount:0,fullMah:4500,designMah:5000,capacityHealthPercent:90,
source:'/sys/class/power_supply/max1720x_bat_7-36',health:'Unknown',tempC:0}});
assert.equal(m.cycles,'0'); assert.equal(m.capacityHealth,'90%');
assert.equal(m.fullCapacity,'4,500 mAh'); assert.equal(m.batteryTemp,'0 °C');
assert.equal(m.batteryHealth,'Unavailable'); assert.match(m.batterySource,/max1720x/);
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource','batteryHealth','batteryTemp'])
assert.equal(cc.metrics(null)[key],'Unavailable');
});
test('battery chip preserves unknown status and does not infer Full at 100%', () => {
const f=fixture();
const start=pageSource.indexOf('function battery(b, power)');
const end=pageSource.indexOf('// hidePopover', start);
const context={$:f.element,dur:String}; vm.createContext(context);
vm.runInContext(pageSource.slice(start,end),context);
context.battery({percent:100,status:'Unknown'},{watts:20});
assert.equal(f.element('battState').textContent,'Charging state unavailable');
context.battery({percent:50,status:'Not charging'},null);
assert.equal(f.element('battState').textContent,'Not charging');
context.battery({percent:50,status:'Charging',watts:2,tempC:0},null);
assert.match(f.element('battState').textContent,/Charging/);
assert.match(f.element('battDetail').textContent,/battery 0°C/);
context.battery(null,null);
assert.equal(f.element('battState').textContent,'Battery unknown');
});
test('new health readings clear after a failed status read', () => {
const f=mounted();
f.ui.status({...sample(),battery:{...sample().battery,cycleCount:42,capacityHealthPercent:92,
fullMah:4600,designMah:5000,source:'/sys/class/power_supply/max1720x_bat_7-36'}});
assert.equal(f.element('metric-capacityHealth').textContent,'92%');
f.ui.unavailable(Error('Read failed'));
for (const key of ['cycles','fullCapacity','designCapacity','capacityHealth','batterySource'])
assert.equal(f.element('metric-'+key).textContent,'Unavailable');
f.ui.destroy();
});
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
+7
View File
@@ -45,6 +45,13 @@ class Device(harness.FrameTestCase):
self.assertRegex(out['message'], r'^copied via Klipper \(\d+ chars\)$')
self.assertEqual(state()['clipboard'], [text])
def test_clipboard_comes_back_from_klipper(self):
text = 'from the headset: 世界 🎧\n\nwith trailing newlines\n\n'
ok('POST', '/api/clipboard', {'text': text})
out = ok('POST', '/api/clipboard', {'read': True})
self.assertEqual(out['text'], text)
self.assertRegex(out['message'], r"^Copied \d+ characters from the headset's clipboard$")
def test_flatpak_install_and_remove(self):
# Installs run as background jobs (server.start_job); uninstall answers at once.
job = finished(ok('POST', '/api/flatpak', {'id': 'org.videolan.VLC', 'action': 'install'}))
+43
View File
@@ -0,0 +1,43 @@
"""Real doctor/SSH against fake-Frame faults; never a real headset."""
import subprocess
import sys
import harness
from harness import ROOT, ctl
harness.require()
class Doctor(harness.FrameTestCase):
def doctor(self):
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST],
capture_output=True, text=True, timeout=120)
def test_disk_full(self):
before = self.doctor()
self.assertNotIn('BROKEN 4. Steam and storage', before.stdout)
ctl('disk-full', 'on')
r = self.doctor()
self.assertEqual(r.returncode, 1, r.stdout + r.stderr)
self.assertIn('BROKEN 4. Steam and storage: Less than 16 MiB free', r.stdout)
def test_unreachable_asleep(self):
ctl('sleep', 'on')
r = self.doctor()
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
self.assertIn('No authenticated SSH path', r.stdout)
def test_no_ssh_and_no_devkit(self):
ctl('sshd', 'off')
ctl('devkit-service', 'off')
r = self.doctor()
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
self.assertIn('Devkit port did not respond', r.stdout)
def test_no_keys_but_devkit_available(self):
ctl('keys', 'none')
r = self.doctor()
self.assertEqual(r.returncode, 2, r.stdout + r.stderr)
self.assertIn('SSH key rejected', r.stdout)
self.assertIn('no pairing request was sent', r.stdout)
+55
View File
@@ -0,0 +1,55 @@
"""Backup and recovery kit through the server against the fake Frame: a Proton game's save
goes to this computer and back, Steam's sign-in token never leaves the headset."""
import json
import shutil
import tarfile
import unittest
from pathlib import Path
import harness
from harness import api, finished, ok, ssh
harness.require()
SAVE = '.local/share/Steam/steamapps/compatdata/620980/pfx/drive_c/users/steamuser/AppData/LocalLow/Beat Games/Beat Saber/PlayerData.dat'
class Backup(harness.FrameTestCase):
def test_saves_round_trip_and_secrets_stay(self):
ssh(f"mkdir -p ~/'{Path(SAVE).parent}' && printf 'level 7' > ~/'{SAVE}' && "
"mkdir -p ~/.steam && printf SECRET > ~/.steam/steam.token")
job = finished(ok('POST', '/api/backup', {'action': 'backup', 'kinds': ['saves', 'settings']}), 120)
self.assertIsNone(job['error'], job)
folder = Path(job['result']['path'])
self.addCleanup(shutil.rmtree, str(folder), True)
manifest = json.loads((folder / 'manifest.json').read_text())
self.assertEqual(manifest['headset']['build'], '20260922.6101926')
saves = next(p for p in manifest['parts'] if p['id'] == 'saves:620980')
self.assertEqual(saves['label'], 'Beat Saber saves')
self.assertEqual([f['path'] for f in saves['files']], [SAVE])
for part in manifest['parts']:
with tarfile.open(str(folder / part['archive'])) as tar:
self.assertFalse([m.name for m in tar if 'steam.token' in m.name])
self.assertEqual(ssh('cat ~/.steam/steam.token'), 'SECRET') # read-only on the headset
ssh(f"rm ~/'{SAVE}'")
name = folder.name
plan = {p['id']: p for p in ok('POST', '/api/backup', {'action': 'plan', 'name': name})['parts']}
self.assertEqual((plan['saves:620980']['status'], plan['saves:620980']['new']), ('ok', 1))
status, out, _ = api('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980']})
self.assertEqual(status, 400, out) # not confirmed
job = finished(ok('POST', '/api/backup', {'action': 'restore', 'name': name, 'parts': ['saves:620980'],
'confirm': True}), 120)
self.assertIsNone(job['error'], job)
self.assertEqual(ssh(f"cat ~/'{SAVE}'"), 'level 7')
def test_bad_names_and_session(self):
status, out, _ = api('POST', '/api/backup', {'action': 'plan', 'name': '../../etc'})
self.assertEqual(status, 400, out)
state = ok('POST', '/api/backup', {'action': 'session'})
self.assertIn(state['mode'], ('game', 'desktop', 'unknown'))
self.assertIsNone(state['selector']) # the fake has no session switch command
if __name__ == '__main__':
unittest.main()
+8 -1
View File
@@ -1,7 +1,9 @@
#!/usr/bin/env python3
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
verified 2026-09-25)."""
verified 2026-09-25), and answers getClipboardContents with the last one
(server.COPY). Printing the string and a newline is how qdbus prints a
returned QString; getClipboardContents itself is a guess, not seen on a headset."""
import os
import sys
@@ -15,5 +17,10 @@ if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipb
sys.exit('Could not connect to D-Bus server')
with fs.update() as s:
s['clipboard'].append(args[3])
elif args == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.getClipboardContents']:
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
sys.exit('Could not connect to D-Bus server')
clips = fs.read()['clipboard']
sys.stdout.buffer.write((clips[-1] if clips else '').encode() + b'\n')
else:
sys.exit(f'qdbus6 stub: unsupported {args}')
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Doctor's read-only systemd queries in a container without a user manager.
Stable NRestarts=0 and inactive keep-awake are harness assumptions, not observed
device behavior. Real Frame query forms come from the historical doctor runbook
(2026-09-28, BUILD_ID 20260925.6191901). No service control is emulated here.
"""
import sys
args = sys.argv[1:]
if args in (['--user', 'show', 'steam.service', '-p', 'NRestarts', '--value'],
['--user', 'show', 'steamvr.service', '-p', 'NRestarts', '--value']):
print('0')
elif args == ['--user', 'show', 'fc-keep-awake.service', '-p', 'ActiveState', '--value']:
print('inactive')
else:
sys.exit(1)
+30 -3
View File
@@ -13,13 +13,13 @@ function grab(name) {
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
@@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) {
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
async (title, key, work) => work(), () => {}, async (title, work) => work(),
{ open() { fail("an in-app installation opened an external page"); } }, 0);
@@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0],
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
}
// Report warnings appear on real cards, shelves and details; they are advisory.
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
const p = page();
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
const app = {name: o.name, offers: [o]};
p.sourceState.apps = [app];
p.sourceState.detail = {app, choice: 0};
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
if (!p.renderAppDetail()) fail('report detail did not render');
const html = p.els.detailBody.innerHTML;
if (!html.includes('Saved compatibility reports') || !html.includes('Reported &lt;unsafe&gt; notes')) fail('report evidence missing or unescaped');
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
for (const reportKind of [kind, 'works']) {
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
app.offers = [blocked];
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
}
}
console.log("ok");
+1
View File
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
+36 -17
View File
@@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
(someone has to open Settings > Developer > Pair new host and approve it in
the headset), checked, and taken out of authorized_keys again.
Everything it installs is removed again, also when a step fails: the titles
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
Everything it installs is removed again, also when a step fails: the reserved
fc_smoke_{arm64,x86_64,exe} titles (and legacy leftovers first), their Steam
shortcuts, the paired key, and ~/devkit-utils if it wasn't there before (if it
was, it stays, synced to this checkout as Frame Control always does). A
cleanup that fails is a failed step.
cleanup that fails is a failed step. Test-owned processes are stopped and
rechecked around deletion; shared Proton services are never stopped by name.
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
@@ -45,11 +46,17 @@ import frame_android # noqa: E402
import frame_connect # noqa: E402
import frame_titles # noqa: E402
import tiny_programs # noqa: E402
import stop_processes # noqa: E402
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
PREFIX = 'fc_smoke_'
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
OLD_PREFIX = 'fc-smoke'
def stop_titles(ids):
"""Run the same bounded, PID-safe helper used by the local process tests."""
return json.loads(frame_android.ssh(
'python3 - ' + ' '.join(shlex.quote(gid) for gid in sorted(ids)),
input=(HERE / 'stop_processes.py').read_text(), timeout=20))
class Smoke:
@@ -172,14 +179,17 @@ def title_cycle(smoke, kind, folder):
return detail
def remove():
stopped = stop_titles({gid})
frame_titles.remove(gid)
# A wrapper could have launched a child just as deletion completed.
after = stop_titles({gid})
left = ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip()
if left:
raise AssertionError(f'left behind: {left}')
if gid in {t['id'] for t in frame_titles.list_titles()}:
raise AssertionError(f'{gid} is still listed')
smoke.installed.discard(gid)
return {'removed': gid}
return {'removed': gid, 'process_cleanup': [stopped, after]}
if smoke.step(f'{kind}: install', install)['ok']:
smoke.step(f'{kind}: launch', launch)
@@ -187,15 +197,21 @@ def title_cycle(smoke, kind, folder):
def cleanup(smoke):
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
"""Stop reserved smoke processes and remove their titles and shortcuts.
Legacy fc-smoke-* IDs are included; arbitrary similarly named titles are
never removed. Raises if any owned process, file or shortcut stays.
"""
problems = []
try:
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
except frame_android.FrameError as e:
ids = set()
problems.append(f'could not list titles: {e}')
ids |= smoke.installed
# Scan every reserved ID, even when a prior run removed its folder and
# shortcut but left Proton running. Stop before removing any more files.
stopped = stop_titles(stop_processes.IDS)
gone = set()
for gid in sorted(ids):
try:
@@ -226,9 +242,10 @@ def cleanup(smoke):
smoke.installed.discard(gid)
except (frame_android.FrameError, AssertionError) as e:
problems.append(f"syncing Steam's shortcuts: {e}")
after = stop_titles(stop_processes.IDS)
if problems:
raise AssertionError('; '.join(problems))
return {'removed': sorted(ids)}
return {'removed': sorted(ids), 'process_cleanup': [stopped, after]}
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
@@ -307,15 +324,17 @@ def main():
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
# (as Valve's client does). If they weren't there before, they go again at the end.
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
ready = smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)['ok']
folder = tempfile.mkdtemp(prefix='frame-smoke-')
try:
smoke.step('devkit service: properties.json', properties)
smoke.step('status', status, build)
for kind in ('arm64', 'x86_64', 'exe'):
title_cycle(smoke, kind, folder)
if args.pair:
smoke.step('devkit pairing (throwaway key)', pair, folder)
# Do not overwrite a leftover title when safe process cleanup failed.
if ready:
smoke.step('devkit service: properties.json', properties)
smoke.step('status', status, build)
for kind in ('arm64', 'x86_64', 'exe'):
title_cycle(smoke, kind, folder)
if args.pair:
smoke.step('devkit pairing (throwaway key)', pair, folder)
finally:
smoke.step('cleanup: everything this run installed', cleanup, smoke)
if not had_utils:
+91
View File
@@ -0,0 +1,91 @@
"""Linux-only smoke cleanup helper, sent over SSH before deleting test files.
Only exact, reserved smoke title directories establish ownership. Never signal
by process name, process group, Steam app id, or a shared Wine/Proton prefix.
"""
import json
import os
from pathlib import Path
import signal
import sys
import time
IDS = frozenset(prefix + kind for prefix in ('fc_smoke_', 'fc-smoke-')
for kind in ('arm64', 'x86_64', 'exe'))
def owned(pid, roots):
"""Require our uid and an exact cwd/exe or individual absolute argv path.
/proc retains a deleted cwd/exe with ' (deleted)' appended. This also lets
the final sweep catch an older run whose title was already removed.
"""
proc = Path('/proc') / str(pid)
try:
if pid == os.getpid() or proc.stat().st_uid != os.getuid():
return False
state = (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
if state == 'Z':
return False
paths = []
for key in ('cwd', 'exe'):
try:
paths.append(os.readlink(proc / key).removesuffix(' (deleted)'))
except (FileNotFoundError, PermissionError):
pass
try:
paths += os.fsdecode((proc / 'cmdline').read_bytes()).split('\0')
except PermissionError:
pass
return any(path == root or path.startswith(root + '/')
for path in paths for root in roots)
except (FileNotFoundError, ProcessLookupError, PermissionError):
return False
def candidates(roots):
return sorted(int(p.name) for p in Path('/proc').iterdir()
if p.name.isdigit() and owned(int(p.name), roots))
def stop(ids, grace=3):
if not ids or not set(ids) <= IDS:
raise ValueError('only reserved smoke title IDs may be stopped')
if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
raise RuntimeError('safe smoke cleanup requires Linux Python with pidfd support')
roots = [str(Path.home() / 'devkit-game' / gid) for gid in ids]
stopped = set()
for sig in (signal.SIGTERM, signal.SIGKILL):
# Re-scan while waiting: wrappers can spawn a child during shutdown.
deadline = time.monotonic() + grace
while True:
pids = candidates(roots)
if not pids:
return {'stopped_pids': sorted(stopped)}
for pid in pids:
try:
fd = os.pidfd_open(pid)
except ProcessLookupError:
continue
try:
# Open first, then revalidate ownership. The handle cannot
# signal an unrelated process if this numeric PID is reused.
if owned(pid, roots):
try:
signal.pidfd_send_signal(fd, sig)
stopped.add(pid)
except ProcessLookupError:
pass
finally:
os.close(fd)
if time.monotonic() >= deadline:
break
time.sleep(0.1)
left = candidates(roots)
if left:
raise RuntimeError(f'smoke processes survived cleanup: {left}')
return {'stopped_pids': sorted(stopped)}
if __name__ == '__main__':
print(json.dumps(stop(sys.argv[1:])))
+50
View File
@@ -0,0 +1,50 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
function fixture() {
const f=workspaceFixture();
const start=html.indexOf('// ---- Read-only game storage ----');
f.run(html.slice(start,html.indexOf('// ---- Backup and recovery kit',start)));
const size=html.indexOf('function sizeText(');
f.run(html.slice(size,html.indexOf('const backupKinds',size)));
return f;
}
const sample={home:{state:'ok',total:1000,free:200,used:700},discovery:'permission-denied',libraries:[{
path:'<img onerror=alert(1)>',capacity:{state:'missing'},state:'partial',gameBytes:10,toolBytes:0,
recordedBytes:10,unknownSizes:1,issues:[{file:'bad.acf',state:'invalid-metadata'}],
apps:[{appid:'1',name:'<script>bad</script>',bytes:null,kind:'game'},{appid:'2',name:'Zero',bytes:0,kind:'game'}]}]};
test('navigation reaches storage and refresh runs once per visit',()=>{
const f=fixture();let calls=0;f.context.loadStorage=()=>calls++;
f.navigate('#storage');assert.equal(f.activePage,'tools');assert.equal(f.el('storage').hidden,false);
assert.equal(f.el('storage').parentNode,f.el('toolWorkspace'));assert.equal(calls,1);
});
test('unknown, shared capacity, partial discovery and untrusted names remain explicit',()=>{
const f=fixture();f.context.sample=sample;const out=f.run('renderStorage(sample)');
assert.match(out,/Permission denied/);assert.match(out,/Missing or disconnected/);
assert.match(out,/Unknown/);assert.match(out,/0 bytes/);assert.match(out,/Partial inventory/);
assert.match(out,/must not be added/);assert.match(out,/Other usage is unmeasured/);
assert.match(out,/&lt;script&gt;/);assert.doesNotMatch(out,/<script>|<img/);
});
test('refresh clears old data, ignores earlier requests and shows offline failure',async()=>{
const f=fixture(),pending=[];
f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
f.el('storageResult').innerHTML='old data';
const first=f.run('loadStorage()'),second=f.run('loadStorage()');
assert.match(f.el('storageResult').textContent,/Reading/);
pending[1].resolve(sample);await second;
const current=f.el('storageResult').innerHTML;
pending[0].resolve({...sample,libraries:[]});await first;
assert.equal(f.el('storageResult').innerHTML,current);
const last=f.run('loadStorage()');pending[2].reject(new Error('Offline'));await last;
assert.match(f.el('storageResult').textContent,/Storage unavailable: Offline/);
});
test('a response or error from another headset cannot update the view',async()=>{
for(const fail of [false,true]) {
const f=fixture();let resolve,reject;
f.context.api=()=>new Promise((yes,no)=>{resolve=yes;reject=no;});
const request=f.run('loadStorage()');f.context.devGen++;
f.el('storageResult').textContent='New headset';
fail?reject(new Error('Old headset')):resolve(sample);await request;
assert.equal(f.el('storageResult').textContent,'New headset');assert.equal(f.el('storageResult').innerHTML,'');
}
});
+3
View File
@@ -17,6 +17,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea
class ArtworkTests(unittest.TestCase):
def setUp(self):
p = patch.object(search, 'compat_reports', None)
p.start()
self.addCleanup(p.stop)
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
+169
View File
@@ -0,0 +1,169 @@
"""Search report snapshots: synthetic evidence only, no credentials or services."""
import sandbox # noqa: F401
import sys
import json
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_catalog
import frame_compat_db as db
from apk_sources import search
from test_apk_search import SettingsTest, fake, ENTRIES
class ReportTests(SettingsTest):
def hook(self, rows):
by = frame_catalog.reports.by_package(rows)
snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))}
p = patch.object(search, 'compat_reports', lambda: snapshot)
p.start()
self.addCleanup(p.stop)
def test_search_and_details_share_catalogue_rules(self):
pkg = ENTRIES[0]['package']
self.hook([dict(package=pkg, date='2026-10-01', result='runs'),
dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes <here>'),
dict(package=pkg, date='2026-10-03', result='runs')])
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])):
offer = search.search(timeout=.01)['apps'][0]['offers'][0]
detail = search.details('one', ENTRIES[0]['id'])
self.assertEqual(detail['compat_report']['verdict'], 'no')
self.assertEqual(offer['compat_report'], detail['compat_report'])
self.assertEqual(detail['verdict']['tone'], 'warning')
self.assertIn('Reported not working', detail['verdict']['label'])
def test_warnings_never_change_installability_or_hard_blockers(self):
entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a'])
for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]:
self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)])
decorated = search.decorate(entry)
self.assertEqual(decorated['verdict']['tone'], tone)
self.assertTrue(decorated['fit']['installable'])
for blocker in [dict(min_sdk=31), dict(abis=['x86_64']),
dict(requires_meta_services=True), dict(engine='VrApi')]:
blocked = search.decorate(dict(entry, **blocker))
self.assertEqual(blocked['verdict']['tone'], 'blocked')
self.assertIsNotNone(blocked['compat_report'])
def test_wrong_file_failures_do_not_warn_but_real_failures_do(self):
entry = dict(ENTRIES[0], min_sdk=23, abis=[])
for notes in ['no arm64-v8a build', 'needs Android API 32']:
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)])
self.assertIsNone(search.decorate(entry)['compat_report'])
self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')])
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')])
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
def test_report_warning_does_not_prevent_install(self):
self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')])
mod = fake()
with patch.object(search, 'modules', return_value=([mod], [])), \
patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install:
result = search.install('one', ENTRIES[0]['id'])
self.assertEqual(result['package'], ENTRIES[0]['package'])
install.assert_called_once()
def test_stalled_loader_does_not_block_search_or_details(self):
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
main = threading.get_ident()
def loader():
self.assertNotEqual(threading.get_ident(), main)
entered.set()
try:
if not release.wait(2):
raise TimeoutError('test stalled')
return []
finally:
finished.set()
cache = search.ReportCache(loader, frame_catalog.reports.verdict)
from apk_sources import _web
with patch.object(search, 'compat_reports', cache.read), \
patch.object(search, 'modules', return_value=([fake()], [])), \
patch.object(_web, 'prune'), \
patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \
patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \
patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')):
try:
search.warm() # production server startup, before the first request
self.assertTrue(entered.wait(1))
start = time.monotonic()
result = search.search(timeout=.01)
detail = search.details('one', ENTRIES[0]['id'])
self.assertLess(time.monotonic() - start, .2)
self.assertTrue(result['apps'])
self.assertIsNone(detail['compat_report'])
finally:
release.set()
self.assertTrue(finished.wait(1))
class CacheTests(unittest.TestCase):
def wait_idle(self, cache):
deadline = time.monotonic() + 1
while time.monotonic() < deadline:
with cache._lock:
if not cache._running:
return
time.sleep(.001)
self.fail('report worker did not finish')
def test_public_copy_reads_its_local_mirror_and_outbox(self):
with tempfile.TemporaryDirectory() as tmp:
mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl'
mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old',
date='2026-10-01', result='runs')]}))
outbox.write_text(json.dumps(dict(package='org.example.app', id='new',
date='2026-10-02', rating='broken')) + '\n')
with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \
patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \
patch.object(db, 'shared', return_value=False), \
patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')):
cache = search.ReportCache(db.load, frame_catalog.reports.verdict)
cache.read()
self.wait_idle(cache)
self.assertEqual(cache.read()['org.example.app'][0], 'no')
def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self):
loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')])
cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60)
cache.read()
self.wait_idle(cache)
first = cache.read()
self.assertEqual(first['org.example.app'][0], 'maybe')
self.assertEqual(loader.call_count, 1)
entered, release = threading.Event(), threading.Event()
def stalled():
entered.set()
release.wait(2)
raise OSError('offline')
loader.side_effect = stalled
cache._at -= 61
try:
self.assertIs(cache.read(), first)
self.assertTrue(entered.wait(1))
for _ in range(10):
self.assertIs(cache.read(), first)
self.assertEqual(loader.call_count, 2)
finally:
release.set()
self.wait_idle(cache)
self.assertIs(cache.read(), first)
self.assertEqual(loader.call_count, 2) # failure also has a retry TTL
loader.side_effect = None
loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')]
cache._at -= 61
cache.read()
self.wait_idle(cache)
self.assertEqual(cache.read()['org.example.app'][0], 'works')
self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated
+3
View File
@@ -27,6 +27,9 @@ def fake(source_id='one', fn=None):
class SettingsTest(unittest.TestCase):
def setUp(self):
p = patch.object(search, 'compat_reports', None)
p.start()
self.addCleanup(p.stop)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
+291
View File
@@ -0,0 +1,291 @@
"""Copying new screenshots and clips to the computer on their own (ui/frame_autosync.py).
The headset is a fake: lists of shots and clips, and save/export functions that
record what they were asked to copy. No SSH, no headset.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import errno
import json
import shutil
import sys
import tempfile
import threading
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_autosync as fa # noqa: E402
NOW = 1_800_000_000.0
GB = 1024**3
def shot(n, t, saved=False):
return {"id": f"1/620/20261020{n:06d}_1.jpg", "time": t, "saved": saved}
def clip(n, t, idle=600, kind="clip", saved=False):
return {"id": f"1/clips/clip_620_{n}/video/bg", "kind": kind, "time": t, "idle": idle, "saved": saved}
class FakeFrame:
def __init__(self):
self.shots, self.clips = [], []
self.saved, self.exported, self.calls = [], [], []
self.device, self.busy, self.down = "frame-a", False, False
self.save_error = self.export_error = None
def list_shots(self):
self.calls.append("list_shots")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"shots": list(self.shots)}
def list_clips(self):
self.calls.append("list_clips")
if self.down:
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
return {"clips": list(self.clips)}
def save_shots(self, device, ids):
self.calls.append("save_shots")
if self.save_error:
raise self.save_error
self.saved.append((device, list(ids)))
for s in self.shots:
if s["id"] in ids:
s["saved"] = True
def export_clip(self, device, clip_id):
self.calls.append("export_clip")
if self.export_error:
raise self.export_error
self.exported.append((device, clip_id))
for c in self.clips:
if c["id"] == clip_id:
c["saved"] = True
class AutoSyncTest(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.frame = FakeFrame()
self.now = [NOW]
self.free = [100 * GB]
self.sync = self.make()
def make(self):
f = self.frame
return fa.AutoSync(f.list_shots, f.save_shots, f.list_clips, f.export_clip,
lambda: f.device, lambda: f.busy,
offline=lambda e: "The Frame isn't answering." if "timed out" in str(e) else None,
shots_folder=self.dir / "Pictures", clips_folder=self.dir / "Videos",
path=self.dir / "auto-sync.json", clock=lambda: self.now[0],
space=lambda folder: self.free[0])
def test_off_by_default_and_does_nothing(self):
self.frame.shots = [shot(1, NOW + 10)]
self.frame.clips = [clip(1, NOW + 10)]
s = self.sync.state()
self.assertFalse(s["screenshots"] or s["clips"])
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, []) # not even a listing
self.assertFalse((self.dir / "auto-sync.json").exists())
def test_settings_persist(self):
self.sync.update({"screenshots": True})
again = self.make().state()
self.assertTrue(again["screenshots"])
self.assertFalse(again["clips"])
self.assertEqual(again["since"], NOW)
saved = json.loads((self.dir / "auto-sync.json").read_text())
self.assertEqual((saved["screenshots"], saved["clips"]), (True, False))
def test_only_what_is_newer_than_turning_it_on(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW + 5), shot(3, NOW + 50, saved=True)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW + 5)]
self.sync.update({"screenshots": True, "clips": True})
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.saved, [("frame-a", [shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(2, 0)["id"])])
s = self.sync.state()
self.assertEqual((s["copied"], s["copied_time"], s["checked"], s["error"]), (2, NOW, NOW, None))
def test_watermark_moves_when_turned_off_and_on_again(self):
self.sync.update({"screenshots": True})
self.now[0] += 1000
self.sync.update({"clips": True}) # already on: the watermark stays
self.assertEqual(self.sync.state()["since"], NOW)
self.sync.update({"screenshots": False, "clips": False})
self.sync.update({"screenshots": True})
self.assertEqual(self.sync.state()["since"], NOW + 1000)
def test_copy_everything_now_once(self):
self.frame.shots = [shot(1, NOW - 3600), shot(2, NOW - 60)]
self.frame.clips = [clip(1, NOW - 3600), clip(2, NOW - 60, kind="background")]
self.sync.copy_all() # works with the toggles off too
self.assertTrue(self.sync.state()["pending"])
self.sync.tick()
self.assertEqual(self.frame.saved, [("frame-a", [shot(1, 0)["id"], shot(2, 0)["id"]])])
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])]) # never the background recording
self.assertFalse(self.sync.state()["pending"])
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # off again afterwards
def test_not_copied_again_after_deleting_it_here(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.clips = [clip(1, NOW + 5)]
self.sync.tick()
for s in self.frame.shots + self.frame.clips:
s["saved"] = False # the person deleted the files
self.frame.saved.clear()
self.frame.exported.clear()
self.make().tick() # remembered across restarts too
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
def test_saved_by_hand_counts_as_copied(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5, saved=True)]
self.sync.tick()
self.frame.shots[0]["saved"] = False
self.sync.tick()
self.assertEqual(self.frame.saved, [])
def test_remembered_per_headset(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.sync.tick()
self.frame.device = "frame-b"
self.frame.shots = [shot(1, NOW + 5)] # the same name on another headset
self.sync.tick()
self.assertEqual([d for d, _ in self.frame.saved], ["frame-a", "frame-b"])
def test_clip_still_being_recorded_waits(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5, idle=4), clip(2, NOW + 6, idle=None)]
self.sync.tick()
self.assertEqual(self.frame.exported, [])
self.assertEqual(self.sync.state()["waiting"], 2)
self.frame.clips[0]["idle"] = fa.SETTLE + 1
self.sync.tick()
self.assertEqual(self.frame.exported, [("frame-a", clip(1, 0)["id"])])
self.assertEqual(self.sync.state()["waiting"], 1)
def test_no_headset_no_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.device = None
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertEqual(self.frame.calls, [])
def test_waits_for_other_work(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.shots = [shot(1, NOW + 5)]
self.frame.busy = True
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
self.assertEqual(self.frame.calls, [])
self.frame.busy = False
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
def test_stops_between_items_when_other_work_starts(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5), clip(2, NOW + 6)]
export = self.frame.export_clip
def then_busy(device, clip_id):
export(device, clip_id)
self.frame.busy = True # an install started meanwhile
self.frame.export_clip = then_busy
sync = self.make()
self.assertEqual(sync.tick(), fa.BUSY_WAIT)
self.assertEqual(len(self.frame.exported), 1)
def test_backs_off_while_unreachable(self):
self.sync.update({"screenshots": True})
self.frame.down = True
waits = [self.sync.tick() for _ in range(6)]
self.assertEqual(waits[:4], [120, 240, 480, 900])
self.assertEqual(waits[-1], fa.MAX_BACKOFF)
self.assertEqual(self.sync.state()["error"], "The Frame isn't answering.")
self.frame.down = False
self.assertEqual(self.sync.tick(), fa.INTERVAL)
self.assertIsNone(self.sync.state()["error"])
self.frame.down = True
self.assertEqual(self.sync.tick(), 120) # starts over after a good look
def test_pauses_when_this_computer_is_nearly_full(self):
self.sync.update({"screenshots": True})
self.frame.shots = [shot(1, NOW + 5)]
self.free[0] = 10 * 1024**2
self.sync.tick()
s = self.sync.state()
self.assertIn("out of space", s["paused"])
self.assertEqual(self.frame.saved, [])
self.free[0] = 100 * GB
self.frame.calls.clear()
self.sync.tick()
self.assertEqual(self.frame.calls, []) # stays paused, also after a restart
self.assertTrue(self.make().state()["paused"])
self.sync.resume()
self.sync.tick()
self.assertEqual(len(self.frame.saved), 1)
self.assertIsNone(self.sync.state()["paused"])
def test_pauses_when_a_copy_runs_out_of_space(self):
self.sync.update({"screenshots": True, "clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on this computer: it needs 900 MB")
self.sync.tick()
self.assertIn("ran out of space", self.sync.state()["paused"])
self.sync.resume()
self.frame.export_error = None
self.frame.shots = [shot(1, NOW + 5)]
self.frame.save_error = OSError(errno.ENOSPC, "No space left on device")
self.sync.tick()
self.assertTrue(self.sync.state()["paused"])
def test_space_on_the_frame_is_not_a_pause(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("Not enough free space on the Frame to export it: it needs 900 MB")
self.sync.tick()
s = self.sync.state()
self.assertIsNone(s["paused"])
self.assertIn("Not enough free space on the Frame", s["error"])
def test_an_item_that_keeps_failing_is_left_alone(self):
self.sync.update({"clips": True})
self.frame.clips = [clip(1, NOW + 5)]
self.frame.export_error = RuntimeError("ffmpeg couldn't join the recording")
for _ in range(fa.TRIES + 2):
self.sync.tick()
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
def test_worker_stops(self):
stop = threading.Event()
t = threading.Thread(target=self.sync.run, args=(stop,), daemon=True)
t.start()
stop.set()
self.sync.update({}) # wakes it
t.join(5)
self.assertFalse(t.is_alive())
class Server(unittest.TestCase):
def test_route_and_settings(self):
import server
self.assertIn("/api/autosync", server.POST)
with self.assertRaises(server.Failure):
server.auto_sync({"action": "explode"})
state = server.auto_sync({"action": "settings", "screenshots": "yes"}) # not a bool: ignored
self.assertFalse(state["screenshots"])
if server.LINK is None:
with self.assertRaises(server.Failure):
server.auto_sync({"action": "now"}) # nothing connected
if __name__ == "__main__":
unittest.main()
+450
View File
@@ -0,0 +1,450 @@
"""Backup and recovery kit (ui/frame_backup.py, ui/frame_backup_remote.py) against a folder laid
out like the Frame's home. The headset-side script runs for real, locally; only SSH is faked."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android # noqa: E402
import frame_backup as backup # noqa: E402
import frame_backup_remote as remote # noqa: E402
import server # noqa: E402
STEAM = '.local/share/Steam'
USER = STEAM + '/steamapps/compatdata/%s/pfx/drive_c/users/steamuser'
SECRETS = ['.steam/steam.token', STEAM + '/config/config.vdf', STEAM + '/config/loginusers.vdf',
STEAM + '/ssfn123456', '.ssh/id_ed25519', '.ssh/authorized_keys',
USER % 1234 + '/AppData/Roaming/Launcher/session.token',
USER % 1234 + '/AppData/Roaming/Launcher/.ssh/id_rsa',
'.config/cliproxyapi/secrets.env', '.local/share/keyrings/login.keyring']
def write(home, rel, data=b'x'):
path = Path(home) / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data if isinstance(data, bytes) else data.encode())
return path
def manifest(home, appid, name):
write(home, '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid),
'"AppState"\n{\n\t"appid"\t\t"%s"\n\t"name"\t\t"%s"\n}\n' % (appid, name))
def frame_home(home):
"""A Frame before a reset: one Proton game, a Linux game, settings and secrets."""
manifest(home, 1234, 'Test Game')
write(home, USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat', b'save one')
write(home, USER % 1234 + '/Documents/My Games/Game/profile.sav', b'profile')
write(home, USER % 1234 + '/AppData/Local/Temp/junk.tmp', b'junk')
write(home, USER % 1234 + '/AppData/Local/Microsoft/Windows/x.db', b'wine')
write(home, USER % 999 + '/AppData/Local/Temp/only-cache', b'c') # no saves: no part
write(home, STEAM + '/steamapps/compatdata/2800000001/internal/org.example/x', b'android')
write(home, STEAM + '/userdata/555/config/localconfig.vdf', b'local')
write(home, STEAM + '/userdata/555/config/shortcuts.vdf', b'shortcuts')
write(home, STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', b'layout')
write(home, STEAM + '/userdata/555/1234/remote/cloud.sav', b'cloud') # Steam Cloud's own
write(home, STEAM + '/userdata/555/760/remote/1234/screenshots/a.jpg', b'shot')
write(home, '.config/kdeglobals', b'[General]')
write(home, '.config/openvr/config/steamvr.vrsettings', b'{}')
write(home, '.config/unity3d/Company/LinuxGame/prefs', b'prefs')
write(home, '.bashrc', b'# mine')
for rel in SECRETS:
write(home, rel, b'SECRET')
class Remote(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.home = Path(self.tmp.name)
patcher = patch.object(remote, 'HOME', self.home)
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.tmp.cleanup)
def files(self, part):
return [rel for rel, _ in remote.part_files(part)[0]]
def test_plan_picks_saves_and_settings_and_never_secrets(self):
frame_home(self.home)
plan = remote.plan()
ids = [p['id'] for p in plan['parts']]
self.assertEqual(ids, ['saves:1234', 'saves:linux', 'settings:steam', 'settings:system'])
self.assertEqual(plan['parts'][0]['label'], 'Test Game saves')
saves = self.files('saves:1234')
self.assertEqual(sorted(saves), [USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat',
USER % 1234 + '/Documents/My Games/Game/profile.sav'])
steam = self.files('settings:steam')
self.assertIn(STEAM + '/userdata/555/241100/remote/controller_config/1234/layout.vdf', steam)
self.assertIn(STEAM + '/userdata/555/config/localconfig.vdf', steam)
self.assertFalse([f for f in steam if '/1234/remote/' in f or '/760/' in f])
everything = sum((self.files(i) for i in ids), [])
for rel in SECRETS + ['.bashrc']:
self.assertNotIn(rel, everything)
self.assertFalse([f for f in everything if remote.is_secret(f)])
self.assertTrue(any('steam.token' in x for x in plan['excluded']))
# The controller layout goes back; localconfig.vdf and shortcuts.vdf are for reference.
steam_part = plan['parts'][2]
self.assertEqual((steam_part['files'], steam_part['restorable']), (3, 1))
def test_secret_names(self):
for rel in SECRETS + ['a/b/ID_RSA.pub', 'x/kwalletd/k', 'x/cert.PEM']:
self.assertTrue(remote.is_secret(rel), rel)
for rel in ['saves/game.sav', 'Documents/tokens-collected.txt', 'config/localconfig.vdf']:
self.assertFalse(remote.is_secret(rel), rel)
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
def test_links_are_never_followed(self):
frame_home(self.home)
outside = write(self.home, 'elsewhere/private.txt', b'p')
base = self.home / (USER % 1234) / 'Documents'
os.symlink(str(outside.parent), str(base / 'linked-dir'))
os.symlink(str(outside), str(base / 'linked-file'))
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
def test_big_files_are_listed_as_skipped(self):
frame_home(self.home)
with patch.object(remote, 'MAX_FILE', 3):
files, skipped = remote.part_files('saves:1234')
self.assertEqual(files, [])
self.assertEqual({s['reason'] for s in skipped}, {'over 2 GiB'})
def test_pack_holds_exactly_the_planned_files(self):
frame_home(self.home)
out = io.BytesIO()
remote.pack('saves:1234', out)
out.seek(0)
with tarfile.open(fileobj=out, mode='r:gz') as archive:
names = sorted(m.name for m in archive)
self.assertEqual(names, sorted(self.files('saves:1234')))
with self.assertRaises(ValueError):
remote.pack('saves:../../etc', io.BytesIO())
def archive(self, members):
out = io.BytesIO()
with tarfile.open(fileobj=out, mode='w:gz') as tar:
for name, data in members:
info = tarfile.TarInfo(name)
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
out.seek(0)
return out
def test_check_after_a_reset(self):
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
ask = {'parts': [{'id': 'saves:1234', 'files': [save]},
{'id': 'settings:steam', 'files': [STEAM + '/userdata/555/241100/remote/controller_config/1/a.vdf']},
{'id': 'settings:system', 'files': ['.config/openvr/config/steamvr.vrsettings']},
{'id': 'saves:../x', 'files': []}]}
states = lambda: [p['status'] for p in remote.check(ask)['parts']] # noqa: E731
self.assertEqual(states(), ['not_installed', 'no_account', 'reference', 'unknown'])
manifest(self.home, 1234, 'Test Game')
(self.home / STEAM / 'userdata/555').mkdir(parents=True)
self.assertEqual(states()[:2], ['no_prefix', 'ok'])
(self.home / (USER % 1234)).mkdir(parents=True)
result = remote.check(ask)['parts'][0]
self.assertEqual((result['status'], result['new'], result['replace']), ('ok', 1, 0))
write(self.home, save, b'newer')
self.assertEqual(remote.check(ask)['parts'][0]['replace'], 1)
def test_restore_replaces_only_its_files_and_keeps_the_old_ones(self):
manifest(self.home, 1234, 'Test Game')
save = USER % 1234 + '/AppData/Roaming/Studio/Game/save1.dat'
write(self.home, save, b'played since')
other = write(self.home, USER % 1234 + '/AppData/Roaming/Studio/Game/other.dat', b'untouched')
bashrc = write(self.home, '.bashrc', b'# mine')
source = self.archive([(save, b'from backup'), (USER % 1234 + '/Documents/new.sav', b'new'),
('.bashrc', b'# replaced?')])
result = remote.restore('saves:1234', '20261023-120000', source)
self.assertEqual((result['written'], result['replaced']), (2, 1))
self.assertEqual(result['held'], {'reference': 1})
self.assertEqual((self.home / save).read_bytes(), b'from backup')
self.assertEqual(other.read_bytes(), b'untouched')
self.assertEqual(bashrc.read_bytes(), b'# mine')
kept = self.home / remote.PREVIOUS / '20261023-120000' / save
self.assertEqual(kept.read_bytes(), b'played since')
self.assertFalse([p for p in (self.home / save).parent.iterdir() if p.name.endswith('.part')])
def test_unsafe_archives_write_nothing(self):
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True)
good = (USER % 1234 + '/Documents/a.sav', b'a')
for bad in ('../escape', '/etc/passwd', USER % 1234 + '/Documents/../../../../../../x',
USER % 1234 + '/Documents/a.sav'):
with self.assertRaises(ValueError, msg=bad):
remote.restore('saves:1234', '20261023-120000', self.archive([good, (bad, b'evil')]))
self.assertFalse((self.home / good[0]).exists(), bad)
link = io.BytesIO()
with tarfile.open(fileobj=link, mode='w:gz') as tar:
info = tarfile.TarInfo(USER % 1234 + '/Documents/l')
info.type, info.linkname = tarfile.SYMTYPE, '/etc/passwd'
tar.addfile(info)
link.seek(0)
with self.assertRaises(ValueError):
remote.restore('saves:1234', '20261023-120000', link)
with self.assertRaises(ValueError):
remote.restore('saves:1234', '../stamp', self.archive([good]))
def test_restore_refuses_secrets_and_other_games(self):
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True)
result = remote.restore('saves:1234', '20261023-120000', self.archive([
(USER % 1234 + '/AppData/Roaming/L/auth.token', b't'), (USER % 5678 + '/Documents/x.sav', b'x')]))
self.assertEqual(result['written'], 0)
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
def test_session_reads_the_mode(self):
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
running = set()
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
state = remote.session()
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('start-gamescope-session')
self.assertEqual(remote.session()['mode'], 'game')
def fake_ssh(home, os_release):
"""Runs the headset-side command locally, as the Frame's python3 would."""
def run(command, src=None, dst=None, timeout=None):
argv = shlex.split(command)
assert argv[:2] == ['python3', '-c'], argv
data = src if isinstance(src, bytes) else src.read() if src is not None else b''
env = dict(os.environ, FRAME_BACKUP_HOME=str(home), FRAME_BACKUP_OS_RELEASE=str(os_release))
r = subprocess.run([sys.executable, *argv[1:]], input=data, capture_output=True, env=env)
if r.returncode:
raise backup.BackupError(r.stderr.decode(errors='replace'))
if dst is not None:
dst.write(r.stdout)
return None
return r.stdout
return run
class Local(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
base = Path(self.tmp.name)
self.home, self.store = base / 'frame', base / 'backups'
self.release = write(base, 'os-release', 'ID=steamos\nVERSION_ID=0.4.1\nBUILD_ID=20260925.6191901\n')
frame_home(self.home)
for p in (patch.dict(os.environ, FRAME_BACKUP_DIR=str(self.store)),
patch.object(backup, '_ssh', side_effect=fake_ssh(self.home, self.release))):
p.start()
self.addCleanup(p.stop)
def android_backup(self, package, destination):
write(Path(destination).parent, Path(destination).name, b'android archive ' + package.encode())
return {'package': package, 'instance': android.instance_id(package), 'files': 3, 'bytes': 42,
'path': str(destination), 'sha256': backup._sha256(destination)}
def make(self):
apps = [{'package': 'org.example.open', 'label': 'Open app', 'running': True},
{'package': 'org.example.game', 'label': 'Game', 'running': False}]
with patch.object(android, 'list_apps', return_value=apps), \
patch.object(android, 'backup_data', side_effect=self.android_backup) as data, \
patch.object(backup.time, 'strftime', side_effect=lambda f: '2026-10-23-120000' if f == '%Y-%m-%d-%H%M%S'
else '2026-10-23T12:00:00+0100'):
result = backup.backup(headset='My Frame')
data.assert_called_once()
return result
def test_backup_writes_a_manifest_with_checksums(self):
result = self.make()
folder = backup.backup_path('2026-10-23-120000') # resolved: macOS's temp folder is a link
self.assertEqual(result['path'], str(folder))
self.assertEqual(sorted(p.name for p in self.store.iterdir()), ['2026-10-23-120000']) # no .partial left
m = json.loads((folder / 'manifest.json').read_text())
self.assertEqual(m['headset'], {'name': 'My Frame', 'build': '20260925.6191901', 'version': '0.4.1'})
self.assertEqual([p['id'] for p in m['parts']],
['saves:1234', 'saves:linux', 'settings:steam', 'settings:system', 'android:org.example.game'])
for part in m['parts']:
self.assertEqual(backup._sha256(folder / part['archive']), part['sha256'])
saves = m['parts'][0]
self.assertEqual(saves['archive'], 'saves/saves-1234.tar.gz')
self.assertEqual(sum(f['bytes'] for f in saves['files']), saves['bytes'])
self.assertEqual(saves['files'][0]['sha256'], __import__('hashlib').sha256(b'save one').hexdigest())
self.assertEqual([s['id'] for s in m['skipped']], ['android:org.example.open'])
for part in m['parts'][:-1]:
with tarfile.open(str(folder / part['archive'])) as tar:
for member in tar:
self.assertFalse(remote.is_secret(member.name), member.name)
self.assertNotEqual(tar.extractfile(member).read(), b'SECRET', member.name)
def test_a_failed_backup_leaves_nothing_behind(self):
with patch.object(backup, 'inspect_archive', side_effect=backup.BackupError('cut off')), \
patch.object(android, 'list_apps', return_value=[]):
with self.assertRaises(backup.BackupError):
backup.backup(kinds=['saves'])
self.assertEqual(list(self.store.iterdir()), [])
def test_backup_names_cannot_leave_the_folder(self):
for name in ('../2026-10-23-120000', '2026-10-23-120000/..', '..', '/etc', 'C:\\x', '',
'2026-10-23-120000/../../x', '2026-10-23-12000', None, 5):
with self.assertRaises(backup.BackupError, msg=repr(name)):
backup.backup_path(name)
self.assertEqual(backup.backup_path('2026-10-23-120000'), (self.store / '2026-10-23-120000').resolve())
def test_manifest_entries_cannot_point_outside_the_backup(self):
self.make()
path = self.store / '2026-10-23-120000' / 'manifest.json'
m = json.loads(path.read_text())
for archive in ('../../secret.tar.gz', '/etc/passwd', 'saves/saves-5678.tar.gz'):
m['parts'][0]['archive'] = archive
path.write_text(json.dumps(m))
with self.assertRaises(backup.BackupError, msg=archive):
backup.read_manifest('2026-10-23-120000')
def reset(self):
"""A factory reset: the home folder is empty again."""
import shutil
shutil.rmtree(str(self.home))
self.home.mkdir()
def test_restore_after_a_reset_skips_what_is_not_back_yet(self):
self.make()
self.reset()
manifest(self.home, 1234, 'Test Game')
(self.home / (USER % 1234)).mkdir(parents=True) # started once
with patch.object(android, 'list_apps', return_value=[]):
plan = {p['id']: p for p in backup.restore_plan('2026-10-23-120000')['parts']}
self.assertEqual(plan['saves:1234']['status'], 'ok')
self.assertEqual(plan['settings:steam']['status'], 'no_account')
self.assertEqual(plan['android:org.example.game']['status'], 'not_installed')
with self.assertRaises(backup.BackupError):
backup.restore('2026-10-23-120000', ['saves:1234']) # not confirmed
self.assertFalse((self.home / (USER % 1234) / 'AppData').exists())
result = backup.restore('2026-10-23-120000', ['saves:1234', 'settings:steam', 'android:org.example.game'],
confirmed=True)
self.assertEqual([r['id'] for r in result['restored']], ['saves:1234'])
self.assertEqual({s['id'] for s in result['skipped']}, {'settings:steam', 'android:org.example.game'})
self.assertEqual((self.home / (USER % 1234) / 'AppData/Roaming/Studio/Game/save1.dat').read_bytes(), b'save one')
def test_android_data_goes_back_through_the_existing_helper(self):
self.make()
apps = [{'package': 'org.example.game', 'label': 'Game', 'running': False}]
with patch.object(android, 'list_apps', return_value=apps), \
patch.object(android, 'restore_data', return_value={'restored': True}) as restore:
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
restore.assert_called_once_with('org.example.game', str(backup.backup_path('2026-10-23-120000') /
'android' / 'org.example.game.tar.gz'))
self.assertEqual(len(result['restored']), 1)
apps[0]['running'] = True
with patch.object(android, 'list_apps', return_value=apps), patch.object(android, 'restore_data') as restore:
result = backup.restore('2026-10-23-120000', ['android:org.example.game'], confirmed=True)
restore.assert_not_called()
self.assertEqual(result['skipped'][0]['reason'], 'Close the app first')
def test_a_damaged_archive_is_skipped(self):
self.make()
with open(str(self.store / '2026-10-23-120000/saves/saves-1234.tar.gz'), 'ab') as f:
f.write(b'tampered')
with patch.object(android, 'list_apps', return_value=[]):
result = backup.restore('2026-10-23-120000', ['saves:1234'], confirmed=True)
self.assertEqual(result['restored'], [])
self.assertIn('checksum', result['skipped'][0]['reason'])
def test_list_backups(self):
self.make()
(self.store / '.2026-10-23-130000.partial').mkdir()
(self.store / 'notes').mkdir()
listed = backup.list_backups()['backups']
self.assertEqual([b['name'] for b in listed], ['2026-10-23-120000'])
self.assertEqual(listed[0]['build'], '20260925.6191901')
class Ssh(unittest.TestCase):
def test_bytes_go_as_input_and_files_as_stdin(self):
done = subprocess.CompletedProcess([], 0, b'{}', b'')
with patch.object(backup.frame_host, 'run_ssh', return_value=done) as run:
backup._ssh('true', src=b'{"parts": []}')
self.assertEqual(run.call_args.kwargs['input'], b'{"parts": []}')
self.assertNotIn('stdin', run.call_args.kwargs)
with tempfile.TemporaryFile() as f:
backup._ssh('true', src=f)
self.assertIs(run.call_args.kwargs['stdin'], f)
backup._ssh('true')
self.assertIs(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
self.assertEqual(run.call_args.args[0][-1], 'true')
with patch.object(backup.frame_host, 'run_ssh',
return_value=subprocess.CompletedProcess([], 1, b'', b'Permission denied')):
with self.assertRaisesRegex(backup.BackupError, 'Permission denied'):
backup._ssh('true')
class GameMode(unittest.TestCase):
def test_command_is_only_ever_the_session_switch(self):
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
with self.assertRaises(backup.BackupError):
backup.session_command(bad)
def test_switch_needs_confirmation_and_desktop_mode(self):
with patch.object(backup, '_ssh') as ssh:
with self.assertRaises(backup.BackupError):
backup.return_to_game_mode()
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'game', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'unknown', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': 'steamos-session-select'}), \
patch.object(backup, '_ssh') as ssh:
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_called_once_with('steamos-session-select gamescope', timeout=60)
ssh.side_effect = backup.BackupError('the headset took too long to answer') # the session restarted
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.side_effect = backup.BackupError('Interactive authentication required')
with self.assertRaisesRegex(backup.BackupError, 'manual steps'):
backup.return_to_game_mode(confirmed=True)
with patch.object(backup, 'session_status', return_value={'mode': 'desktop', 'selector': None}), \
patch.object(backup, '_ssh') as ssh:
with self.assertRaises(backup.BackupError):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
class Endpoint(unittest.TestCase):
def test_guards_run_before_the_headset(self):
with patch.object(backup, '_ssh') as ssh, patch.object(server, 'start_job') as job:
for body in ({'action': 'plan', 'name': '../x'}, {'action': 'open', 'name': '..'},
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:1234']},
{'action': 'restore', 'name': '2026-10-23-120000', 'parts': ['saves:x'], 'confirm': True},
{'action': 'gamemode'}, {'action': 'backup', 'kinds': ['ssh-keys']}, {'action': 'nope'}):
with self.assertRaises(server.Failure, msg=body) as caught:
server.backup_action(body)
self.assertEqual(caught.exception.status, 400)
ssh.assert_not_called()
job.assert_not_called()
with patch.object(server, 'LOCAL', True):
with self.assertRaises(server.Failure):
server.backup_action({'action': 'list'})
if __name__ == '__main__':
unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Read-only sysfs battery parsing; fixture values, no real headset access."""
import sandbox # noqa: F401
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_status as status
class Battery(unittest.TestCase):
def probe(self, fields):
# Real files exercise missing files, whitespace and integer conversion.
with tempfile.TemporaryDirectory() as tmp:
node = Path(tmp) / 'max1720x_bat_7-36'
node.mkdir()
for name, value in dict(type='Battery', **fields).items():
(node / name).write_text(str(value) + '\n')
with mock.patch.object(status.glob, 'glob', return_value=[str(node)]):
result = status.battery()
if result:
result.pop('source')
return result
def test_frame_units_and_optional_health(self):
b = self.probe(dict(capacity=73, status='Charging', voltage_now=3800000,
current_now=1000000, temp=325, health='Good',
charge_full=4600000, charge_full_design=5000000,
cycle_count=42, time_to_full_now=3600))
self.assertEqual(b, dict(percent=73, status='Charging', watts=3.8,
tempC=32.5, health='Good', timeToFull=3600,
timeToEmpty=None, cycleCount=42, fullMah=4600,
designMah=5000, capacityHealthPercent=92))
def test_zero_negative_current_and_temperature(self):
b = self.probe(dict(capacity=0, cycle_count=0, temp=-50,
voltage_now=3800000, current_now=-1000000))
self.assertEqual((b['percent'], b['cycleCount'], b['tempC'], b['watts']),
(0, 0, -5, -3.8))
def test_missing_optional_sensors_stay_null(self):
b = self.probe(dict(capacity=50, status='Discharging'))
self.assertEqual(b['percent'], 50)
self.assertTrue(all(value is None for key, value in b.items()
if key not in ('percent', 'status')))
def test_invalid_values_do_not_break_other_readings(self):
for invalid in ('abc', '', '-1', '101', '12.5'):
b = self.probe(dict(capacity=invalid, status='Charging', temp='bad',
charge_full=4500000, charge_full_design=0,
time_to_full_now=-1, time_to_empty_now=4294967295,
cycle_count=-1, voltage_now='bad', current_now=100))
self.assertIsNone(b['percent'])
for key in ('watts', 'tempC', 'capacityHealthPercent', 'timeToFull', 'timeToEmpty', 'cycleCount'):
self.assertIsNone(b[key], key)
self.assertEqual(b['status'], 'Charging')
def test_no_battery_and_unreadable_type(self):
with mock.patch.object(status.glob, 'glob', return_value=[]):
self.assertIsNone(status.battery())
with mock.patch.object(status.glob, 'glob', return_value=['/missing']), \
mock.patch('builtins.open', side_effect=PermissionError):
self.assertIsNone(status.battery())
def test_prefers_frame_and_excludes_peripheral_or_absent_batteries(self):
paths = ['/sys/class/power_supply/aaa', '/sys/class/power_supply/max1720x_bat_7-36']
def read(path):
return {'type': 'Battery', 'capacity': '80'}.get(path.rsplit('/', 1)[-1])
with mock.patch.object(status.glob, 'glob', return_value=paths), \
mock.patch.object(status, 'read', side_effect=read):
self.assertEqual(status.battery()['source'], paths[1])
self.assertIsNone(self.probe(dict(scope='Device', capacity=99)))
self.assertIsNone(self.probe(dict(present=0, capacity=99)))
def test_permission_failure_affects_only_one_sensor(self):
actual_open = open
def guarded(path, *args, **kwargs):
if str(path).endswith('/temp'):
raise PermissionError('unreadable')
return actual_open(path, *args, **kwargs)
with mock.patch('builtins.open', side_effect=guarded):
b = self.probe(dict(capacity=45, temp=300))
self.assertEqual(b['percent'], 45)
self.assertIsNone(b['tempC'])
+241
View File
@@ -0,0 +1,241 @@
"""The clipboard both ways: the headset's read script, the server's answers, and the
writer for this computer's clipboard on each platform. No headset: SSH and every
clipboard program are mocked, and the read script runs against stub pgrep and qdbus6."""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import base64
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_host # noqa: E402
import server # noqa: E402
TEXT = "héllo, 世界 🎧\nsecond line\n\n" # accents, CJK, an emoji outside the BMP, trailing newlines
def b64(text):
return base64.b64encode(text.encode()).decode()
class ReadCommand(unittest.TestCase):
def test_reads_klipper_on_the_desktop_bus(self):
self.assertIn("org.kde.klipper.klipper.getClipboardContents", server.COPY)
self.assertIn("DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper", server.COPY)
self.assertIn(server.DESKTOP_CLOSED, server.COPY)
self.assertIn(server.CLIPBOARD_TOO_BIG, server.COPY)
self.assertIn(f"-gt {server.CLIPBOARD_LIMIT + 1} ]", server.COPY) # the limit, plus qdbus6's newline
self.assertIn("base64 -w 0", server.COPY)
def test_command_carries_the_script_intact(self):
encoded = server.COPY_CMD.split("echo ", 1)[1].split(" |", 1)[0]
self.assertEqual(base64.b64decode(encoded).decode(), server.COPY)
self.assertNotIn("'", server.COPY_CMD)
def test_send_still_uses_the_same_bus(self):
self.assertIn(server.DESKTOP_BUS, server.PASTE)
self.assertIn(server.DESKTOP_BUS, server.COPY)
self.assertIn("setClipboardContents", server.PASTE)
@unittest.skipUnless(sys.platform.startswith("linux") and shutil.which("bash") and shutil.which("base64"),
"the script reads /proc, as on the Frame")
class ReadScript(unittest.TestCase):
"""server.COPY for real, with plasmashell's pid and Klipper stubbed."""
def setUp(self):
self.dir = Path(tempfile.mkdtemp(prefix="frame-clip-"))
self.addCleanup(shutil.rmtree, self.dir, True)
self.clip = self.dir / "clip"
# A process whose environment holds the desktop's bus address, standing in for plasmashell.
self.plasma = subprocess.Popen(["sleep", "60"], env={**os.environ, "DBUS_SESSION_BUS_ADDRESS": "unix:path=/fake"})
self.addCleanup(self.plasma.wait)
self.addCleanup(self.plasma.kill)
(self.dir / "pgrep").write_text(f'#!/bin/sh\n[ -e "{self.dir}/closed" ] || echo {self.plasma.pid}\n')
(self.dir / "qdbus6").write_text(
'#!/bin/sh\n[ "$DBUS_SESSION_BUS_ADDRESS" = unix:path=/fake ] || exit 1\n'
f'[ "$3" = org.kde.klipper.klipper.getClipboardContents ] || exit 1\ncat "{self.clip}"; echo\n')
for name in ("pgrep", "qdbus6"):
(self.dir / name).chmod(0o755)
def run_script(self):
def local_ssh(remote, **kw):
r = subprocess.run(["bash", "-c", server.COPY], capture_output=True, text=True,
env={**os.environ, "PATH": f"{self.dir}:{os.environ['PATH']}"})
if r.returncode:
raise server.Failure(r.stderr.strip())
return r.stdout
with mock.patch.object(server, "ssh", local_ssh):
return server.headset_clipboard()
def test_unicode_and_newlines_come_back_exactly(self):
self.clip.write_bytes(TEXT.encode())
self.assertEqual(self.run_script(), TEXT)
def test_empty(self):
self.clip.write_bytes(b"")
with self.assertRaises(server.Failure) as cm:
self.run_script()
self.assertEqual(cm.exception.status, 400)
self.assertIn("empty", str(cm.exception))
def test_too_big_is_refused_on_the_headset(self):
self.clip.write_bytes(b"x" * (server.CLIPBOARD_LIMIT + 1))
with self.assertRaises(server.Failure) as cm:
self.run_script()
self.assertEqual(cm.exception.status, 413)
self.assertIn("1 MB", str(cm.exception))
self.clip.write_bytes(b"x" * server.CLIPBOARD_LIMIT)
self.assertEqual(len(self.run_script()), server.CLIPBOARD_LIMIT)
def test_desktop_closed(self):
(self.dir / "closed").touch()
with self.assertRaises(server.Failure) as cm:
self.run_script()
self.assertEqual(cm.exception.status, 409)
class HeadsetClipboard(unittest.TestCase):
def read(self, out=None, error=None):
def fake_ssh(remote, **kw):
self.assertEqual(remote, server.COPY_CMD)
self.assertNotIn("stdin", kw)
if error:
raise server.Failure(error)
return out
with mock.patch.object(server, "ssh", fake_ssh):
return server.headset_clipboard()
def test_drops_only_qdbus6s_newline(self):
self.assertEqual(self.read(b64(TEXT + "\n") + "\n"), TEXT)
self.assertEqual(self.read(b64("one line\n")), "one line")
def test_empty_and_images_say_so(self):
for out, words in ((b64("\n"), "empty"), ("", "empty"), (b64("▨ 640x480 32bpp\n"), "image")):
with self.assertRaises(server.Failure) as cm:
self.read(out)
self.assertEqual(cm.exception.status, 400)
self.assertIn(words, str(cm.exception))
def test_unreadable_output_is_a_fault(self):
for out in ("not base64!", base64.b64encode(b"\xff\xfe\n").decode()):
with self.assertRaises(server.Failure) as cm:
self.read(out)
self.assertEqual(cm.exception.status, 502)
def test_failures(self):
for error, status in (("The headset's desktop isn't open (plasmashell is not running).", 409),
("The headset's clipboard holds too much to copy (over 1 MB).", 413),
("Klipper call failed (bus: none)", 502)):
with self.assertRaises(server.Failure) as cm:
self.read(error=error)
self.assertEqual(cm.exception.status, status, error)
class ClipboardRequests(unittest.TestCase):
def test_read_returns_the_text_for_the_page(self):
with mock.patch.object(server, "headset_clipboard", return_value=TEXT):
out = server.clipboard({"read": True})
self.assertEqual(out["text"], TEXT)
self.assertNotIn(TEXT, out["message"])
def test_to_computer_sets_this_computers_clipboard(self):
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
mock.patch.object(frame_host, "set_clipboard_text") as put:
out = server.clipboard({"toComputer": True})
put.assert_called_once_with(TEXT)
self.assertNotIn("text", out)
self.assertEqual(out["message"], f"Copied {len(TEXT)} characters from the headset to this computer's clipboard")
def test_to_computer_without_a_clipboard_program(self):
with mock.patch.object(server, "headset_clipboard", return_value=TEXT), \
mock.patch.object(frame_host, "set_clipboard_text", side_effect=frame_host.HostError("Can't set the clipboard")):
with self.assertRaises(server.Failure) as cm:
server.clipboard({"toComputer": True})
self.assertEqual(cm.exception.status, 500)
def test_to_computer_refused_when_the_server_runs_on_the_headset(self):
with mock.patch.object(server, "LOCAL", True), mock.patch.object(server, "headset_clipboard") as read:
with self.assertRaises(server.Failure) as cm:
server.clipboard({"toComputer": True})
self.assertEqual(cm.exception.status, 400)
read.assert_not_called()
def test_flags_must_be_true(self):
# Anything else is a send, and an empty one is refused before SSH.
with mock.patch.object(server, "ssh") as ssh:
for body in ({"read": "yes"}, {"toComputer": 1}):
with self.assertRaises(server.Failure):
server.clipboard(body)
ssh.assert_not_called()
class SetClipboardText(unittest.TestCase):
def put(self, platform, have, text=TEXT, fail=()):
runs = []
def run(cmd, **kw):
runs.append((cmd, kw))
if cmd[0] in fail:
if fail[cmd[0]] == "timeout":
raise subprocess.TimeoutExpired(cmd, 10)
return subprocess.CompletedProcess(cmd, 1)
return subprocess.CompletedProcess(cmd, 0)
flags = {"MAC": platform == "mac", "WINDOWS": platform == "windows", "LINUX": platform == "linux"}
with mock.patch.multiple(frame_host, **flags), \
mock.patch.object(frame_host.shutil, "which", lambda name: f"/bin/{name}" if name in have else None), \
mock.patch.object(frame_host.subprocess, "run", run):
frame_host.set_clipboard_text(text)
for _, kw in runs:
# Nothing captured or logged, and no pipes left for a clipboard daemon to hold open.
self.assertEqual((kw["stdout"], kw["stderr"]), (subprocess.DEVNULL, subprocess.DEVNULL))
return runs
def test_mac_pbcopy_in_utf8(self):
[(cmd, kw)] = self.put("mac", {"pbcopy"})
self.assertEqual(cmd, ["pbcopy"])
self.assertEqual(kw["input"], TEXT.encode("utf-8"))
self.assertEqual(kw["env"]["LC_CTYPE"], "UTF-8")
def test_windows_powershell_gets_base64_utf8(self):
[(cmd, kw)] = self.put("windows", {"powershell.exe", "clip.exe"})
self.assertEqual(cmd[0], "powershell.exe")
self.assertIn("Set-Clipboard", cmd[-1])
self.assertIn("FromBase64String", cmd[-1])
self.assertEqual(base64.b64decode(kw["input"]).decode("utf-8"), TEXT)
def test_windows_clip_fallback_gets_utf16_with_a_bom(self):
runs = self.put("windows", {"powershell.exe", "clip.exe"}, fail={"powershell.exe": 1})
self.assertEqual([c[0] for c, _ in runs], ["powershell.exe", "clip.exe"])
data = runs[1][1]["input"]
self.assertEqual(data[:2], b"\xff\xfe")
self.assertEqual(data[2:].decode("utf-16-le"), TEXT)
def test_linux_tries_wayland_then_x11(self):
[(cmd, kw)] = self.put("linux", {"wl-copy", "xclip", "xsel"})
self.assertEqual(cmd, ["wl-copy"])
self.assertEqual(kw["input"], TEXT.encode())
runs = self.put("linux", {"wl-copy", "xclip", "xsel"}, fail={"wl-copy": 1, "xclip": "timeout"})
self.assertEqual([c for c, _ in runs], [["wl-copy"], ["xclip", "-selection", "clipboard", "-in"],
["xsel", "--clipboard", "--input"]])
[(cmd, _)] = self.put("linux", {"xsel"})
self.assertEqual(cmd[0], "xsel")
def test_nothing_to_set_it_with(self):
with self.assertRaises(frame_host.HostError) as cm:
self.put("linux", set())
self.assertIn("wl-clipboard", str(cm.exception))
with self.assertRaises(frame_host.HostError):
self.put("linux", {"wl-copy"}, fail={"wl-copy": 1})
with self.assertRaises(frame_host.HostError):
self.put("mac", set())
if __name__ == "__main__":
unittest.main()
+591
View File
@@ -0,0 +1,591 @@
"""Recordings of the headset view and Steam clip exports (ui/frame_clips.py), without a headset.
Steam's recordings are fixture trees in the layout frame_clips.py describes. The
recording's shell runs for real under bash with a stand-in ffmpeg, so Stop, the time
limit and the clean-up are the real script's behaviour. With ffmpeg installed here, a
65-second DASH recording is made in Steam's file naming and exported for real.
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
import frame_clips as clips # noqa: E402
POSIX = os.name != "nt" and shutil.which("bash")
def write(path, data=b"x"):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
return path
def session(folder, streams=(0, 1), chunks=3, mpd=None, first=1, pad=5):
"""A session folder as Steam writes it: init + numbered chunks per stream."""
for n in streams:
write(folder / f"init-stream{n}.m4s", f"init{n};".encode())
for c in range(first, first + chunks):
write(folder / f"chunk-stream{n}-{c:0{pad}d}.m4s", f"s{n}c{c};".encode())
if mpd is not None:
write(folder / "session.mpd", mpd.encode())
return folder
class Ids(unittest.TestCase):
def test_good_ids(self):
for cid in ("123/video/bg_620_20261020_201950",
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
"9/clips/a/b/c/d"):
self.assertEqual(clips.parse_id(cid)[0], cid.split("/")[0])
self.assertEqual(clips.export_name("123/clips/clip_620_20261020_202134/video/bg_620_1"),
"clip_620_20261020_202134.mp4")
def test_no_traversal_or_shell_from_ids(self):
for cid in ("", None, 5, "123", "123/video", "123/other/bg", "abc/video/bg", "/123/video/bg",
"123/video/..", "123/video/.", "123/video/bg/../../../../.ssh", "123/video/.hidden",
"123/video/bg;rm -rf ~", "123/video/bg$(id)", "123/video/bg name", "123/video/bg\n",
"123/video/%2e%2e", "123\\video\\bg", "123/video//bg", "123/clips/a/b/c/d/e",
"123/video/" + "a" * 101, "1234567890123/video/bg"):
with self.subTest(cid=cid), self.assertRaises(ValueError):
clips.parse_id(cid)
with self.subTest(cid=cid), self.assertRaises(ValueError):
clips.export_name(cid)
def test_export_paths_checked_before_delete(self):
good = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
self.assertEqual(clips.remove_command(good), f'rm -rf "$HOME"/{clips.WORK}/export-0123456789abcdef')
for bad in (None, "", "/etc/passwd", f"{clips.WORK}/export-0123456789abcdef/../../x.mp4",
f"{clips.WORK}/export-xyz/clip.mp4", f"{clips.WORK}/../export-0123456789abcdef/a.mp4",
f"{clips.WORK}/export-0123456789abcdef/a.mp4; rm -rf ~", ".ssh/export-0123456789abcdef/a.mp4"):
with self.subTest(bad=bad), self.assertRaises(ValueError):
clips.remove_command(bad)
def test_recording_names_checked(self):
for fn in (clips.finish_command, clips.cleanup_command, lambda n: clips.record_command(n, True)):
for bad in ("rec-1", "rec-20261020-201950; rm -rf ~", "../rec-20261020-201950", "*"):
with self.subTest(bad=bad), self.assertRaises(ValueError):
fn(bad)
class Sessions(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
def test_chunks_in_number_order_whatever_the_padding(self):
s = self.dir / "s"
write(s / "init-stream0.m4s")
for c in (10, 2, 1, 100, 9):
write(s / f"chunk-stream0-{c}.m4s")
write(s / "chunk-stream0-00003.m4s")
got = clips.read_session(s)
self.assertEqual([os.path.basename(p) for p in got[0]],
["init-stream0.m4s", "chunk-stream0-1.m4s", "chunk-stream0-2.m4s", "chunk-stream0-00003.m4s",
"chunk-stream0-9.m4s", "chunk-stream0-10.m4s", "chunk-stream0-100.m4s"])
self.assertEqual(clips.chunk_gaps(got[0]), 100 - 6)
def test_unplayable_streams_and_strangers_left_out(self):
s = self.dir / "s"
session(s, streams=(0,), chunks=2)
write(s / "init-stream1.m4s") # no chunks
write(s / "chunk-stream2-00001.m4s") # no init
write(s / "chunk-stream0-00001.m4s.part") # not a chunk
write(s / "thumbnail.jpg")
(s / "chunk-stream0-00009.m4s").mkdir() # a folder, not a file
if POSIX:
os.symlink("/etc/passwd", s / "chunk-stream0-00005.m4s") # links aren't followed
got = clips.read_session(s)
self.assertEqual(list(got), [0])
self.assertEqual(len(got[0]), 3)
def test_manifest_duration(self):
for text, want in (('mediaPresentationDuration="PT1M23.4S"', 83.4), ('mediaPresentationDuration="PT2H"', 7200),
('mediaPresentationDuration="PT0H3M0.000S"', 180), ('mediaPresentationDuration="P1DT1S"', 86401),
('mediaPresentationDuration="PT"', None), ('nothing here', None), (None, None)):
self.assertEqual(clips.mpd_duration(text), want, text)
def test_listing_a_fixture_tree(self):
ud = self.dir / "userdata"
gr = ud / "123" / "gamerecordings"
session(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950", chunks=40,
mpd='<MPD mediaPresentationDuration="PT1M20.0S">')
write(gr / "clips" / "clip_620_20261020_202134" / "thumbnail.jpg")
session(gr / "video" / "bg_1172470_20261021_090000", streams=(0,), chunks=2)
session(gr / "video" / "odd-name", chunks=1) # no stamp: time from the files
(gr / "video" / "empty").mkdir()
session(gr / "video" / ".hidden", chunks=1) # not a name an id can carry
session(gr / "timelines" / "x", chunks=1) # not clips/ or video/
session(ud / "notanaccount" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
session(gr / "video" / "a" / "b" / "c" / "d" / "e", chunks=1) # deeper than ids go
if POSIX:
os.symlink(gr, gr / "video" / "loop") # links aren't followed
for f in (gr / "video" / "odd-name").iterdir():
os.utime(f, (1e9, 1e9)) # 2001: older than the others
got = clips.list_recordings(ud)
self.assertEqual([c["id"] for c in got], ["123/video/bg_1172470_20261021_090000",
"123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950",
"123/video/odd-name"]) # newest first
self.assertEqual(got[-1]["time"], 1e9)
by = {c["id"]: c for c in got}
clip = by["123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"]
self.assertEqual((clip["kind"], clip["appid"], clip["streams"], clip["chunks"], clip["gaps"], clip["duration"]),
("clip", "620", 2, 80, 0, 80.0))
self.assertEqual(clip["file"], "clip_620_20261020_202134.mp4")
self.assertEqual(clip["time"], time.mktime((2026, 10, 20, 20, 21, 34, 0, 0, -1)))
self.assertEqual(clip["size"], sum(f.stat().st_size for f in
(gr / "clips" / "clip_620_20261020_202134" / "video" / "bg_620_20261020_201950").iterdir()
if f.suffix == ".m4s"))
bg = by["123/video/bg_1172470_20261021_090000"]
self.assertEqual((bg["kind"], bg["appid"], bg["streams"], bg["duration"]), ("background", "1172470", 1, None))
self.assertIsNone(by["123/video/odd-name"]["appid"])
# How long since Steam last wrote to it: auto-sync waits for a clip still being recorded.
self.assertLess(clip["idle"], 60)
self.assertGreater(by["123/video/odd-name"]["idle"], 10**8)
self.assertEqual(clips.list_recordings(self.dir / "missing"), [])
def test_listing_marks_what_is_already_here(self):
here = self.dir / "here"
write(here / "clip_620_20261020_202134.mp4")
frame = lambda args, timeout: {"clips": [{"id": "1/clips/clip_620_20261020_202134/video/bg"},
{"id": "1/video/bg_620_20261020_201950"}]}
got = clips.list_clips(frame, folder=here)
self.assertEqual([c["saved"] for c in got["clips"]], [True, False])
self.assertEqual(got["folder"], str(here))
class Export(unittest.TestCase):
"""The Frame's half of an export, against a fixture tree, with ffmpeg stood in."""
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.ud = self.dir / "home" / clips.STEAM_USERDATA
self.home = self.dir / "home"
self.cid = "123/clips/clip_620_20261020_202134/video/bg_620_20261020_201950"
session(self.ud / "123" / "gamerecordings" / "clips" / "clip_620_20261020_202134" / "video" /
"bg_620_20261020_201950", chunks=12)
def fake_ffmpeg(self, seen, rc=0):
def run(cmd, **kw):
ins = [cmd[i + 1] for i, a in enumerate(cmd) if a == "-i"]
seen.append({"cmd": cmd, "inputs": {Path(i).name: Path(i).read_bytes() for i in ins}})
if rc == 0:
Path(cmd[-1]).write_bytes(b"mp4")
return subprocess.CompletedProcess(cmd, rc, "", "Invalid data found" if rc else "")
return run
def test_streams_stitched_in_order_and_copied_not_reencoded(self):
seen = []
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg(seen)):
got = clips.export(self.ud, self.cid, home=self.home)
self.assertRegex(got["path"], clips.EXPORT_PATH)
self.assertTrue(got["path"].endswith("/clip_620_20261020_202134.mp4"))
self.assertEqual(got["size"], 3)
cmd = seen[0]["cmd"]
self.assertEqual(cmd[cmd.index("-c") + 1], "copy")
self.assertEqual([cmd[i + 1] for i, a in enumerate(cmd) if a == "-map"], ["0", "1"])
for n in (0, 1):
self.assertEqual(seen[0]["inputs"][f"stream{n}.mp4"],
f"init{n};".encode() + b"".join(f"s{n}c{c};".encode() for c in range(1, 13)))
out = self.home / got["path"]
self.assertEqual(sorted(p.name for p in out.parent.iterdir()), [out.name]) # stream files gone
def test_failed_join_leaves_nothing_behind(self):
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([], rc=1)):
with self.assertRaisesRegex(clips.ClipError, "Invalid data"):
clips.export(self.ud, self.cid, home=self.home)
self.assertEqual(list((self.home / clips.WORK).glob("export-*")), [])
def test_bad_or_missing_ids(self):
with self.assertRaises(ValueError):
clips.export(self.ud, "123/video/../../../../etc", home=self.home)
with self.assertRaises(ClipErrorOrOSError):
clips.export(self.ud, "123/video/nothing_here", home=self.home)
@unittest.skipUnless(POSIX, "symlinks")
def test_a_linked_account_cannot_reach_outside_userdata(self):
outside = session(self.dir / "elsewhere" / "gamerecordings" / "video" / "bg_1_20260101_000000", chunks=1)
os.symlink(self.dir / "elsewhere", self.ud / "456")
self.assertIsNotNone(outside)
with self.assertRaises(ValueError):
clips.export(self.ud, "456/video/bg_1_20260101_000000", home=self.home)
def test_no_space_on_the_frame(self):
full = mock.Mock(free=10)
with mock.patch.object(clips.shutil, "disk_usage", return_value=full):
with self.assertRaisesRegex(clips.ClipError, "Not enough free space on the Frame"):
clips.export(self.ud, self.cid, home=self.home)
def test_old_exports_swept(self):
work = self.home / clips.WORK
old, new = work / "export-aaaaaaaaaaaaaaaa", work / "export-bbbbbbbbbbbbbbbb"
write(old / "x.mp4"), write(new / "y.mp4")
os.utime(old, (time.time() - 7 * 3600,) * 2)
with mock.patch.object(clips.subprocess, "run", self.fake_ffmpeg([])):
clips.export(self.ud, self.cid, home=self.home)
self.assertFalse(old.exists())
self.assertTrue(new.exists())
def test_main_prints_json(self):
with mock.patch.object(clips.Path, "home", return_value=self.home), \
mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
self.assertEqual(clips.main(["list"]), 0)
self.assertEqual(json.loads(out.getvalue())["clips"][0]["id"], self.cid)
with mock.patch("sys.stdout", new_callable=lambda: __import__("io").StringIO()) as out:
self.assertEqual(clips.main(["export", "../../x"]), 1)
self.assertEqual(json.loads(out.getvalue()), {"error": "bad clip id"})
ClipErrorOrOSError = (clips.ClipError, OSError)
class ComputerSide(unittest.TestCase):
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
def test_export_copies_then_deletes_on_the_frame(self):
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
calls = []
frame = lambda args, timeout: calls.append(("frame", args)) or {"path": path, "size": 3}
run = lambda cmd, timeout: calls.append(("run", cmd))
fetch = lambda remote, local: calls.append(("fetch", remote)) or Path(local).write_bytes(b"mp4")
got = clips.export_clip("1/clips/clip_620/video/bg", frame, run, fetch, folder=self.dir)
self.assertEqual(got["message"], "Saved clip_620.mp4 to ~/Videos/SteamFrame")
self.assertEqual((self.dir / "clip_620.mp4").read_bytes(), b"mp4")
self.assertEqual([c[0] for c in calls], ["frame", "fetch", "run"])
self.assertEqual(calls[2][1], clips.remove_command(path))
self.assertEqual([p.name for p in self.dir.iterdir()], ["clip_620.mp4"])
def test_failed_copy_leaves_no_partial_file_and_still_cleans_up(self):
path = f"{clips.WORK}/export-0123456789abcdef/clip_620.mp4"
ran = []
def fetch(remote, local):
Path(local).write_bytes(b"half")
raise RuntimeError("connection lost")
with self.assertRaisesRegex(RuntimeError, "connection lost"):
clips.export_clip("1/clips/clip_620/video/bg", lambda a, t: {"path": path, "size": 4},
lambda cmd, timeout: ran.append(cmd), fetch, folder=self.dir)
self.assertEqual(list(self.dir.iterdir()), [])
self.assertEqual(ran, [clips.remove_command(path)])
def test_bad_id_never_reaches_the_frame(self):
never = mock.Mock(side_effect=AssertionError("reached the Frame"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/../../.ssh", never, never, never, folder=self.dir)
def test_unexpected_path_from_the_frame_refused(self):
never = mock.Mock(side_effect=AssertionError("deleted or copied"))
with self.assertRaises(ValueError):
clips.export_clip("1/video/bg", lambda a, t: {"path": ".ssh/id_ed25519", "size": 1}, never, never,
folder=self.dir)
class Commands(unittest.TestCase):
def test_record_command_with_and_without_sound(self):
with_sound = shlex.split(clips.record_command("rec-20261020-201950", True))
self.assertEqual(with_sound[:2], ["bash", "-c"])
script = with_sound[2]
self.assertIn(f"-f v4l2 -video_size 1920x1080 -i {clips.STREAM_DEVICE}", script)
self.assertIn("-c:v libx264", script)
self.assertNotIn("v4l2m2m", script) # the hardware encoder crashes ffmpeg
self.assertIn(f"-f pulse -i {clips.AUDIO_SOURCE}", script)
self.assertIn("-map 1:a:0", script)
self.assertIn("-c:a aac", script)
self.assertIn(f"-t {clips.MAX_SECONDS} ", script)
self.assertIn('"$d/rec-20261020-201950.mkv"', script)
self.assertEqual(script.count("-use_wallclock_as_timestamps 1"), 2)
quiet = shlex.split(clips.record_command("rec-20261020-201950", False))[2]
self.assertNotIn("pulse", quiet)
self.assertNotIn("-c:a", quiet)
self.assertEqual(quiet.count("-use_wallclock_as_timestamps 1"), 1)
with self.assertRaises(ValueError):
clips.record_command("rec-20261020-201950", True, max_seconds=clips.MAX_SECONDS + 1)
def test_probe_checks_device_space_and_sound(self):
probe = clips.probe_command()
self.assertIn(f"[ -e {clips.STREAM_DEVICE} ]", probe)
self.assertIn(f"-ge {clips.NEED_KB} ]", probe)
self.assertIn(f"timeout 5 ffmpeg -hide_banner -loglevel error -nostdin -f pulse -i {clips.AUDIO_SOURCE}", probe)
self.assertIn("echo audio=1; else echo audio=0", probe)
self.assertIn(f'd="$HOME/{clips.WORK}"', probe)
def test_finish_remuxes_without_reencoding(self):
cmd = clips.finish_command("rec-20261020-201950")
self.assertIn("-i rec-20261020-201950.mkv -map 0 -c copy -movflags +faststart rec-20261020-201950.mp4", cmd)
self.assertNotIn("libx264", cmd)
self.assertEqual(clips.cleanup_command("rec-20261020-201950"), f'rm -f "$HOME/{clips.WORK}/rec-20261020-201950".*')
def test_stitch_command(self):
self.assertEqual(clips.stitch_command(["a.mp4", "b.mp4"], "out.mp4"),
["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", "a.mp4", "-i", "b.mp4",
"-map", "0", "-map", "1", "-c", "copy", "-movflags", "+faststart", "out.mp4"])
# A stand-in for ffmpeg on the Frame: records how it was run, writes its output file,
# and finishes the file when q arrives on stdin. Like SteamOS's ffmpeg n7.0 (seen on a
# Frame), it catches SIGTERM and SIGINT and carries on, exiting only after more than
# three. FAKE_FFMPEG_RUN makes the recording end by itself after that many seconds (the
# time limit); FAKE_FFMPEG_DEAF makes it ignore q too, so only SIGKILL ends it.
FAKE_FFMPEG = r'''#!/usr/bin/env python3
import json, os, signal, sys, time
home = os.environ["HOME"]
with open(os.path.join(home, "ffmpeg-calls.jsonl"), "a") as f:
f.write(json.dumps(sys.argv[1:]) + "\n")
out = sys.argv[-1]
if "-c" in sys.argv and sys.argv[sys.argv.index("-c") + 1] == "copy": # the remux after a recording
src = sys.argv[sys.argv.index("-i") + 1]
open(out, "wb").write(open(src, "rb").read() + b"+mp4")
sys.exit(0)
import threading
stop, signals = [], []
def caught(*a):
signals.append(1)
if len(signals) > 3:
os._exit(255) # "Received > 3 system signals, hard exiting"
signal.signal(signal.SIGTERM, caught)
signal.signal(signal.SIGINT, caught)
def keys():
while True:
ch = os.read(0, 1)
if not ch:
return
if ch == b"q" and not os.environ.get("FAKE_FFMPEG_DEAF"):
stop.append("q")
threading.Thread(target=keys, daemon=True).start()
f = open(out, "wb")
f.write(b"v"); f.flush()
with open(os.path.join(home, "ffmpeg.pid.tmp"), "w") as p:
p.write(str(os.getpid()))
os.replace(os.path.join(home, "ffmpeg.pid.tmp"), os.path.join(home, "ffmpeg.pid"))
end = time.time() + float(os.environ.get("FAKE_FFMPEG_RUN", "60"))
while not stop and time.time() < end:
f.write(b"v"); f.flush(); time.sleep(0.05)
f.close()
open(os.path.join(home, "ffmpeg-ended"), "w").write(stop[0] if stop else "limit")
sys.exit(255 if stop else 0)
'''
@unittest.skipUnless(POSIX, "the recording's shell is bash on the Frame")
class Recording(unittest.TestCase):
"""The Recorder with the real record/finish/cleanup shell, run locally under bash."""
def setUp(self):
self.dir = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
self.frame = self.dir / "frame" # the Frame's home
(self.frame / clips.WORK).mkdir(parents=True)
bin_dir = self.dir / "bin"
write(bin_dir / "ffmpeg", FAKE_FFMPEG.encode()).chmod(0o755)
self.env = {**os.environ, "HOME": str(self.frame), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}
self.here = self.dir / "Videos"
self.channels, self.released, self.ran = [], [], []
self.audio = "audio=1"
def open_channel(self, command, stderr):
proc = subprocess.Popen(shlex.split(command), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr,
env=self.env)
self.channels.append(proc)
self.addCleanup(lambda: proc.poll() is None and proc.kill())
return proc
def run_cmd(self, command, timeout):
self.ran.append(command)
if command == clips.probe_command():
return self.audio + "\n" # needs /dev/video99, so answered here
r = subprocess.run(["bash", "-c", command], capture_output=True, text=True, env=self.env, timeout=timeout)
if r.returncode:
raise RuntimeError(r.stderr.strip() or f"exit {r.returncode}")
return r.stdout
def fetch(self, remote, local):
shutil.copyfile(self.frame / remote, local)
def recorder(self):
rec = clips.Recorder(self.open_channel, self.run_cmd, self.fetch,
lambda fn: threading.Thread(target=fn, daemon=True).start(),
release=self.released.append, folder=self.here)
start = rec.start
def started():
# "started" means ffmpeg was launched; wait until it's running, as it would be by a person's Stop.
pid = self.frame / "ffmpeg.pid"
for f in (pid, self.frame / "ffmpeg-ended"):
f.unlink(missing_ok=True)
got = start()
for _ in range(200):
if pid.exists() or (self.frame / "ffmpeg-ended").exists():
break
time.sleep(0.02)
return got
rec.start = started
return rec
def wait_for(self, rec, states, timeout=20):
end = time.time() + timeout
while time.time() < end:
s = rec.status()
if s["state"] in states:
return s
time.sleep(0.05)
self.fail(f"still {rec.status()}")
def ffmpeg_calls(self):
return [json.loads(line) for line in (self.frame / "ffmpeg-calls.jsonl").read_text().splitlines()]
def test_stop_finishes_the_file_and_copies_it_here(self):
rec = self.recorder()
started = rec.start()
self.assertEqual((started["state"], started["audio"], started["message"]), ("recording", True, "Recording"))
self.assertIn("pulse", self.ffmpeg_calls()[0])
time.sleep(0.3)
self.assertEqual(rec.stop()["state"], "stopping")
done = self.wait_for(rec, ("saved", "failed"))
self.assertEqual(done["state"], "saved", done)
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q") # asked to finish, not killed
saved = Path(done["file"])
self.assertEqual(saved.parent, self.here)
self.assertRegex(saved.name, r"^rec-\d{8}-\d{6}\.mp4$")
self.assertTrue(saved.read_bytes().endswith(b"+mp4"))
self.assertEqual([p.name for p in self.here.iterdir()], [saved.name]) # no .incoming- left
self.assertEqual(list((self.frame / clips.WORK).iterdir()), []) # cleaned on the Frame
self.assertIsNotNone(self.channels[0].poll()) # the channel has gone
self.assertEqual(self.released, self.channels)
self.assertEqual(self.ran[-1], clips.cleanup_command(saved.stem))
def test_channel_closing_stops_ffmpeg_on_the_frame(self):
# The computer's ssh going away closes the channel just as Stop does.
rec = self.recorder()
rec.start()
pid = int((self.frame / "ffmpeg.pid").read_text())
self.channels[0].stdin.close()
self.wait_for(rec, ("saved", "failed"))
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "q")
with self.assertRaises(ProcessLookupError):
for _ in range(100):
os.kill(pid, 0)
time.sleep(0.05)
def test_an_ffmpeg_that_ignores_q_is_killed_not_left_recording(self):
self.env["FAKE_FFMPEG_DEAF"] = "1"
rec = self.recorder()
rec.start()
pid = int((self.frame / "ffmpeg.pid").read_text())
rec.stop()
self.wait_for(rec, ("saved", "failed"), timeout=30)
self.assertFalse((self.frame / "ffmpeg-ended").exists()) # it never finished by itself
with self.assertRaises(ProcessLookupError):
for _ in range(100):
os.kill(pid, 0)
time.sleep(0.05)
def test_time_limit_saves_on_its_own(self):
self.env["FAKE_FFMPEG_RUN"] = "0.3"
rec = self.recorder()
rec.start()
done = self.wait_for(rec, ("saved", "failed"))
self.assertEqual(done["state"], "saved", done)
self.assertEqual((self.frame / "ffmpeg-ended").read_text(), "limit")
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
rec.stop()
def test_no_sound_records_video_and_says_so(self):
self.audio = "audio=0"
rec = self.recorder()
started = rec.start()
self.assertFalse(started["audio"])
self.assertIn("without sound", started["message"])
self.assertNotIn("pulse", self.ffmpeg_calls()[0])
rec.stop()
done = self.wait_for(rec, ("saved", "failed"))
self.assertIn("(no sound)", done["message"])
def test_one_at_a_time(self):
rec = self.recorder()
with self.assertRaisesRegex(clips.ClipError, "Not recording"):
rec.stop()
rec.start()
with self.assertRaises(clips.ClipError) as e:
rec.start()
self.assertEqual(e.exception.status, 409)
rec.stop()
self.wait_for(rec, ("saved",))
rec.start() # a new one once the last is saved
rec.stop()
self.wait_for(rec, ("saved",))
def test_failed_start_reports_why(self):
rec = clips.Recorder(lambda cmd, err: subprocess.Popen(["bash", "-c", "echo 'no v4l2 here' >&2; exit 1"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=err),
lambda cmd, t: "audio=1", self.fetch, lambda fn: fn(), release=self.released.append,
folder=self.here)
with self.assertRaisesRegex(clips.ClipError, "no v4l2 here"):
rec.start()
self.assertEqual(rec.status()["state"], "failed")
self.assertEqual(len(self.released), 1)
def test_failed_probe_reports_why(self):
def run(cmd, timeout):
raise clips.ClipError("No headset view device (/dev/video99). Is SteamVR running?")
rec = clips.Recorder(mock.Mock(side_effect=AssertionError("recorded anyway")), run, self.fetch,
lambda fn: fn(), folder=self.here)
with self.assertRaisesRegex(clips.ClipError, "Is SteamVR running"):
rec.start()
self.assertEqual(rec.status()["state"], "failed")
def test_empty_recording_is_an_error_not_a_file(self):
self.env["FAKE_FFMPEG_RUN"] = "0"
rec = self.recorder()
with mock.patch.object(clips, "finish_command", lambda name: f"echo 'The recording is empty' >&2; exit 1"):
rec.start()
done = self.wait_for(rec, ("saved", "failed"))
self.assertEqual(done["state"], "failed")
self.assertIn("The recording is empty", done["error"])
self.assertFalse(self.here.exists() and any(self.here.iterdir()))
self.assertEqual(list((self.frame / clips.WORK).iterdir()), [])
@unittest.skipUnless(shutil.which("ffmpeg") and shutil.which("ffprobe"), "needs ffmpeg and ffprobe")
class RealFfmpeg(unittest.TestCase):
"""A 65 s recording in Steam's DASH naming (ffmpeg's own dash muxer names files the same
way), stitched by the real ffmpeg: longer than the 60 s Steam's own export manages."""
def test_export_of_a_long_recording(self):
d = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, d, ignore_errors=True)
ud = d / "home" / clips.STEAM_USERDATA
folder = ud / "123" / "gamerecordings" / "video" / "bg_620_20261020_201950"
folder.mkdir(parents=True)
r = subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=8000", "-t", "65",
"-c:v", "libx264", "-preset", "ultrafast", "-g", "20", "-c:a", "aac",
"-seg_duration", "2", "-use_template", "1", "-use_timeline", "0",
"-f", "dash", str(folder / "session.mpd")], capture_output=True, text=True)
if r.returncode != 0 or not (folder / "chunk-stream0-00001.m4s").exists():
self.skipTest("this ffmpeg can't write DASH with libx264: " + r.stderr[-300:])
listed = clips.list_recordings(ud)
self.assertEqual(len(listed), 1)
self.assertGreater(listed[0]["chunks"], 30)
got = clips.export(ud, listed[0]["id"], home=d / "home")
out = d / "home" / got["path"]
probe = json.loads(subprocess.run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(out)],
capture_output=True, text=True, check=True).stdout)
self.assertEqual(sorted(s["codec_type"] for s in probe["streams"]), ["audio", "video"])
self.assertGreater(float(probe["format"]["duration"]), 64)
if __name__ == "__main__":
unittest.main()
+122
View File
@@ -0,0 +1,122 @@
"""Doctor tests never contact a headset or the user's SSH configuration."""
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import unittest
from unittest.mock import patch, Mock
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('frame_doctor', ROOT / 'scripts/frame_doctor.py')
doctor = importlib.util.module_from_spec(spec)
spec.loader.exec_module(doctor)
doctor.MESSAGES = doctor.messages()
def result(out='', code=0, err=''):
return subprocess.CompletedProcess([], code, out, err)
class FrameDoctor(unittest.TestCase):
def main(self, responses, **kwargs):
config = result('hostname private-tailnet.ts.net\nhostkeyalias pinned-frame\n')
with patch.object(doctor.shutil, 'which', return_value='/usr/bin/ssh'), \
patch.object(doctor, 'run', side_effect=[config, *responses]) as run, \
patch.object(doctor, 'port_open', return_value=kwargs.get('devkit', False)), \
patch('sys.stdout', new_callable=io.StringIO) as output:
code = doctor.main([])
return code, output.getvalue(), run
def test_unreachable_is_actionable_without_exposing_stderr(self):
code, text, _ = self.main([result(code=255, err='secret-token 192.0.2.5')] * 3)
self.assertEqual(code, 2)
self.assertIn('wake headset', text)
self.assertEqual(text.count('No authenticated SSH path.'), 11)
self.assertNotIn('secret-token', text)
self.assertNotIn('192.0.2.5', text)
self.assertNotIn('private-tailnet', text)
def test_no_keys_and_devkit_available_never_registers(self):
code, text, run = self.main([result(code=255, err='Permission denied')] * 3, devkit=True)
self.assertEqual(code, 2)
self.assertIn('no pairing request was sent', text)
self.assertIn('Pair new host', text)
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
args = run.call_args_list[2].args[0]
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
'ClearAllForwardings=yes', 'ControlPath=none'):
self.assertIn(flag, args)
self.assertIn('OK Connection: LAN', text)
self.assertIn('Counters are rising', text)
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
self.assertIn('Do not delete known_hosts', text)
def test_invalid_probe_response_never_prints_remote_data(self):
for payload in ('a secret token', json.dumps({'guard': 'stable', 'checks': [['OK', 'secret']] * 11})):
code, text, _ = self.main([result(payload)])
self.assertEqual(code, 1)
self.assertNotIn('secret', text)
def test_guard_stops_before_any_deeper_checks(self):
for state in ('loop', 'unknown', 'risk'):
with patch.object(doctor, 'guard', return_value=state), patch.object(doctor, 'journal') as journal:
self.assertEqual(doctor.probe(), {'guard': state, 'checks': []})
journal.assert_not_called()
def test_rising_and_reset_counters_and_missing_data(self):
for snapshots, expected in (([[0, 0, 0, 0], [1, 0, 0, 0]], 'loop'),
([[1, 0, 0, 0], [0, 0, 0, 0]], 'unknown'),
([None, None], 'unknown'),
([[0, 4, 0, 0]] * 2, 'risk'),
([[0, 0, 0, 0]] * 2, 'stable')):
with patch.object(doctor, 'snapshot', side_effect=snapshots), patch.object(doctor.time, 'sleep'):
self.assertEqual(doctor.guard(), expected)
def test_journal_permission_denied_and_empty_are_unknown(self):
for response in (result('partial', err='not seeing messages from other users'), result(), result(code=1)):
with patch.object(doctor, 'run', return_value=response):
self.assertIsNone(doctor.journal('-b'))
self.assertEqual(doctor.signal_check(None, 'bad', 'problem', 'healthy')[0], 'UNKNOWN')
def test_disk_full_reports_broken_and_never_fixes(self):
with patch.object(doctor.shutil, 'disk_usage', return_value=Mock(free=0)):
check = doctor.storage()
with patch('sys.stdout', new_callable=io.StringIO) as output:
code = doctor.report({'guard': 'stable', 'checks': [check] * 11})
self.assertEqual(code, 1)
self.assertIn('BROKEN 4. Steam and storage', output.getvalue())
def test_probe_only_emits_approved_messages_and_rechecks_guard(self):
with patch.object(doctor, 'guard', side_effect=['stable', 'stable']) as guard, \
patch.object(doctor, 'journal', return_value=None), \
patch.object(doctor, 'command', return_value=None), \
patch.object(doctor, 'run', return_value=None), \
patch.object(doctor, 'storage', return_value=['UNKNOWN', 'Storage could not be read.']):
data = doctor.probe()
self.assertEqual(guard.call_count, 2)
self.assertEqual(len(data['checks']), 11)
with patch('sys.stdout', new_callable=io.StringIO):
self.assertEqual(doctor.report(data), 1)
def test_help_shell_entry_point_is_offline(self):
# This Bash wrapper is for macOS/Linux; Windows uses the Python CLI.
import os, shutil
if os.name == 'nt':
self.skipTest('POSIX shell wrapper')
if not shutil.which('bash'):
self.skipTest('bash unavailable')
r = subprocess.run(['bash', str(ROOT / 'scripts/frame-doctor.sh'), '--help'],
capture_output=True, text=True, timeout=5)
self.assertEqual(r.returncode, 0, r.stderr)
self.assertIn('Never pairs', r.stdout)
if __name__ == '__main__':
unittest.main()
+4 -1
View File
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
renderPill();
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
assert.ok($('conn').title.includes(changed.error.message));
assert.ok(!$('conn').title.includes('..'), $('conn').title);
// Without a short reason: the stage that failed.
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
renderPill();
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
class HeaderLayout(unittest.TestCase):
def test_the_devices_tab_explanation_wraps(self):
# It was cut to one line with no way to read the rest.
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
# More specific than the later .item .s rule: the old equal-specificity
# rule passed this test while the browser still clipped the explanation.
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
def test_the_header_buttons_dont_give_way(self):
+151
View File
@@ -0,0 +1,151 @@
"""Issue #81: synthetic, deterministic log scrubbing; no sockets or private data."""
import sandbox # noqa: F401
import random
import re
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_link as fl
import frame_telemetry as tm
def baseline(text, hosts):
"""Frozen v0.4.11 connection pipeline, before issue #81's extra spans.
Keep these operations independent of frame_link's implementations so a change
to its ordering or matching cannot silently weaken the differential oracle.
The shared scrubber's default behavior is deliberately unchanged by this fix.
"""
keep = {'host', 'hostname', 'to', 'port', 'answer', 'the', 'a', 'an', 'this',
'it', 'its', 'be', 'connect', 'find', 'work', 'try'}
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
parts = re.split(r'(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)', text)
for i in range(0, len(parts), 2):
for name in names:
parts[i] = re.sub(r'(?<![\w.:-])%s(?![\w-]|[:.%%]\w)' % re.escape(name),
'<host>', parts[i], flags=re.I)
plain = keep - {'host', 'hostname', 'to', 'port'}
def operand(match):
word = match.group(2).rstrip('.')
return (match.group(0) if word.lower() in plain else
f'{match.group(1)} <host>{match.group(2)[len(word):]}')
text = re.sub(r'''(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'"()|]+)''',
operand, ''.join(parts))
return tm.scrub(text, 600)
class LogScrubbing(unittest.TestCase):
def setUp(self):
patch = mock.patch.object(tm, '_user_names', return_value={'SyntheticLogin'})
patch.start()
self.addCleanup(patch.stop)
def test_display_name_crosses_a_scrubber_token(self):
name = '<host> Jane Doe headset'
self.assertEqual(fl.scrub_failure(name + ' stopped answering', [name]), '<host> stopped answering')
self.assertEqual(fl.scrub_failure('<user>@<host>: <ip>', ['user', 'host', 'ip']),
'<user>@<host>: <ip>')
self.assertEqual(fl.scrub_failure('<host>', ['<host>']), '<host>')
def test_all_standalone_tokens_survive_and_whole_names_cross_them(self):
for token in ('host', 'user', 'email', 'ip', 'mac', 'steamid', 'hex',
'token', 'ssh-key', 'pem', 'url', 'redacted'):
token = '<' + token + '>'
name = token + ' Synthetic Fixture headset'
with self.subTest(token=token):
self.assertEqual(fl.scrub_failure(token, [token, token[1:-1]]), token)
self.assertEqual(fl.scrub_failure(name.upper() + ' failed', [name]), '<host> failed')
def test_a_name_crossing_the_output_limit_leaves_no_fragment(self):
name = '<host> ' + 'Synthetic ' * 100 + 'headset'
self.assertEqual(fl.scrub_failure(name + ' trailing detail', [name]), '<host>')
def test_display_name_cannot_swallow_ssh_operand_marker(self):
text = 'ssh: connect to host bastion-jane port 22: Connection refused'
self.assertEqual(fl.scrub_failure(text, ['to host']),
'ssh: connect <host> <host> port 22: Connection refused')
def test_reverted_fix_counterexamples(self):
cases = [
('Could not connect to hostname bastion-jane', ['connect to'], ['bastion-jane']),
('Jane Smith@<host>: Permission denied', ['Smith@<host>'], ['Jane', 'Smith']),
('frame.local Jane Doe headset stopped answering', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
('x ' * 295 + 'frame.local Jane Doe headset', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
('https://jane@example.com/private(confidential)', ['jane@example.com'], ['private', 'confidential']),
(r'C:\Users\Jane@workpc (Smith)\.ssh\config', ['Jane@workpc'], ['Jane', 'Smith']),
('-----BEGIN PRIVATE KEY-----\nSYNTHETICBODY\n-----END PRIVATE KEY-----@frame',
['KEY-----@frame'], ['SYNTHETICBODY']),
('Could not connect to hostname /home/Jane Doe/.ssh/config', ['connect to'], ['Jane', 'Doe']),
]
for text, hosts, private in cases:
with self.subTest(text=text):
out = fl.scrub_failure(text, hosts)
for value in private:
self.assertNotIn(value, out)
self.assertLessEqual(len(out), 600)
def test_overlapping_names_and_operands_and_literal_tokens(self):
cases = [
('Alpha Beta Gamma failed', ['Alpha Beta', 'Beta Gamma'], '<host> failed'),
('<host> Jane Doe headset failed', ['<host> Jane Doe headset', 'Jane Doe'], '<host> failed'),
('to hostname bastion-jane', [], 'to <host> <host>'),
('<host failed', ['<host'], '<host> <host>'),
]
for text, hosts, expected in cases:
with self.subTest(text=text):
self.assertEqual(fl.scrub_failure(text, hosts), expected)
def test_randomized_differential_against_v0411(self):
rng = random.Random(81)
templates = [
'https://{u}@example.invalid/{p}(confidential)?key={p}',
r'C:\Users\{u}@workpc ({p})\.ssh\config',
'/home/{u} {p}/.ssh/config',
'-----BEGIN PRIVATE KEY-----\n{p}\n-----END PRIVATE KEY-----@frame',
'{u}.{p}@example.invalid',
'fe80::abcd%{u}',
'{u} {p}@frame.local: Permission denied',
'ssh-ed25519 {p}',
'password={p}',
'<host> {u} {p} headset stopped answering',
'frame.local {u} {p} headset stopped answering',
]
for iteration in range(1200):
user = 'Synthetic' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=14))
private = 'Fixture' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=18))
field = rng.choice(templates).format(u=user, p=private)
prefix = rng.choice(['', 'Could not connect to hostname ', 'ssh: connect to host ',
'Timed out talking to ', 'debug | ', 'x ' * 295])
text = prefix + field + rng.choice(['', ' port 22: refused', '\nnext line', "'s password: "])
# Names intentionally cut through the syntax later scrubber rules need.
options = [user, user + '@workpc', private, 'connect to', 'to host',
'<host> ' + user + ' ' + private + ' headset',
'frame.local ' + user + ' ' + private + ' headset',
'KEY-----@frame', 'host', '<host>', 'SyntheticLogin']
hosts = rng.sample(options, rng.randrange(5))
before, after = baseline(text, hosts), fl.scrub_failure(text, hosts)
with self.subTest(iteration=iteration, text=text, hosts=hosts):
trace = fl._ScrubTrace(text)
traced = tm.scrub(fl.hide_operands(fl.hide_hosts(text, hosts, _trace=trace),
_sub=trace.sub), 600, _sub=trace.sub)
self.assertEqual(traced, before)
# Compare every original three-character window, not just whole
# canaries: even a partially exposed user/path/secret is a regression.
# Mask placeholders with a separator so removing one cannot join
# unrelated text into a misleadingly new three-character window.
old_visible = fl.PLACEHOLDER.sub('\0', before)
new_visible = fl.PLACEHOLDER.sub('\0', after)
for start in range(len(text) - 2):
fragment = text[start:start + 3]
if fragment not in old_visible:
self.assertNotIn(fragment, new_visible)
self.assertLessEqual(len(after), 600)
if __name__ == '__main__':
unittest.main()
+4 -1
View File
@@ -84,7 +84,10 @@ class Helpers(unittest.TestCase):
@unittest.skipUnless(shutil.which("bash"), "needs bash")
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
def test_launch_script_parses(self):
r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
bash = str(Path(os.environ.get("ProgramFiles", "C:/Program Files")) / "Git/bin/bash.exe") if sys.platform == "win32" else "bash"
if sys.platform == "win32" and not Path(bash).exists():
self.skipTest("Git Bash is not installed (WSL bash is not a local shell)")
r = subprocess.run([bash, "-n"], input=frame_macview.LAUNCH.encode("utf-8"), capture_output=True)
self.assertEqual(r.returncode, 0, r.stderr)
def test_show_checks_the_source_before_anything_else(self):
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+64
View File
@@ -0,0 +1,64 @@
"""Frozen regression trace from the original mac-in-headset Swift controller.
The 600-state reference was produced at 1b90c64, before the C extraction:
60 seconds with a slow/congested middle, then recovery. No generated behavior
from the new implementation is used to calculate the expected digest.
"""
import ctypes as C
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
from frame_pc_capture import Controller
class ControllerTrace(unittest.TestCase):
def test_original_mac_congestion_and_recovery(self):
cc = shutil.which('cc') or shutil.which('gcc')
if not cc:
self.skipTest('C compiler not installed; native build jobs cover this trace')
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('trace.dll' if sys.platform == 'win32' else 'trace.so')
subprocess.run([cc, '-O2', '-shared', '-fPIC', str(ROOT/'desktop/controller.c'), '-o', str(path)], check=True, capture_output=True)
lib = C.CDLL(str(path))
sig = {'new': (C.c_void_p, [C.c_int, C.c_int]), 'free': (None, [C.c_void_p]),
'ceiling': (None, [C.c_void_p, C.c_int]), 'value': (C.c_int64, [C.c_void_p, C.c_int]),
'capture': (None, [C.c_void_p, C.c_int64]), 'gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
'sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
'ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]), 'update': (C.c_int, [C.c_void_p, C.c_int64])}
for name, (ret, args) in sig.items():
fn = getattr(lib, 'fc_'+name)
fn.restype, fn.argtypes = ret, args
c = Controller(lib, 60, 8000000)
due, states, seq = [], [], 0
try:
for tick in range(6000):
now = 10000000+tick*10000
for t, s in due:
if t <= now:
c.call('ack', s, now)
due = [(t, s) for t, s in due if t > now]
if tick % 2 == 0:
c.call('capture', now)
if c.call('gate', now, 1):
seq += 1
c.call('sent', seq, 8000 if tick < 1000 or tick > 4000 else 22000, now)
due.append((now+(240000 if 1500 < tick < 3500 else 20000), seq))
if tick % 10 == 0:
c.update(now)
x = c.state()
states.append([x[k] for k in ('target', 'ceiling', 'tier', 'fps', 'inFlight')] +
[round(x['scale']*100), round(x['baseRtt']*1000), round(x['slack']*1000)])
digest = hashlib.sha256(json.dumps(states, separators=(',', ':')).encode()).hexdigest()
self.assertEqual(digest, '1932f16aafce5c9157bcd2aba5d90719a366f9999422920aaeae78a6cdec08f6')
finally:
c.close()
if sys.platform == 'win32':
import _ctypes
_ctypes.FreeLibrary(lib._handle)
+582
View File
@@ -0,0 +1,582 @@
"""PC adapters: grants, input cleanup, launcher reuse and native codec contract.
Native tests require the bundled libraries; CI sets REQUIRE_NATIVE so a missing
bundle is a failure, never a silent skip. No desktop consent or GPU is faked as
successful real-host capture.
The encoding tests need x264, which only CI's own builds bundle (it's GPL, so
the installer leaves it out). FRAME_PC_REQUIRE_X264=1 makes its absence a
failure; FRAME_PC_INSTALLER_BUNDLE=1 checks the installer's bundle instead.
"""
import ctypes
import http.client
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
import threading
import time
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / 'ui'))
import frame_pc_agent as agent
import frame_pc_capture as capture
import frame_pcview
import frame_macview
from frame_stream_stats import Stats
from test_macview import WS
class GrantsTests(unittest.TestCase):
def test_source_bound_retry_ack_and_stop(self):
g = agent.Grants('master')
ticket = g.ticket('window:1')
self.assertIsNone(g.redeem('window:2', {'t': ticket}))
key = g.redeem('window:1', {'t': ticket})
self.assertEqual(key, g.redeem('window:1', {'t': ticket}))
g.ack(key)
self.assertIsNone(g.redeem('window:1', {'t': ticket}))
self.assertEqual(key, g.redeem('window:1', {'r': key}))
self.assertIsNone(g.redeem('window:2', {'r': key}))
g.revoke('window:1')
self.assertIsNone(g.redeem('window:1', {'r': key}))
def test_expiry_and_stop_before_redeem(self):
g = agent.Grants('master')
with mock.patch.object(agent.time, 'monotonic', return_value=1):
ticket = g.ticket('test')
with mock.patch.object(agent.time, 'monotonic', return_value=62):
self.assertIsNone(g.redeem('test', {'t': ticket}))
ticket = g.ticket('test')
g.revoke()
self.assertIsNone(g.redeem('test', {'t': ticket}))
class GatingTests(unittest.TestCase):
"""Experimental, Windows only, and off until the person turns it on."""
def view(self):
return frame_pcview.PCView(['ssh'], mock.Mock(), 'frame')
def test_only_windows_gets_the_pc_host(self):
for platform, cls in (('win32', frame_pcview.PCView), ('linux', frame_macview.MacView),
('darwin', frame_macview.MacView)):
with mock.patch.object(frame_pcview.sys, 'platform', platform):
view = frame_pcview.host_view(['ssh'], mock.Mock(), 'frame')
self.assertIs(type(view), cls, platform)
def test_off_by_default_starts_nothing(self):
view = self.view()
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
mock.patch.object(frame_macview.subprocess, 'Popen', side_effect=AssertionError('started while off')):
state = view.state()
self.assertEqual(state, {'available': False, 'reason': frame_pcview.OFF, 'host': view.host,
'experimental': True, 'enabled': False})
with self.assertRaises(frame_macview.MacViewError):
view.show('window:12')
view.run.assert_not_called()
def test_never_offered_off_windows_even_when_turned_on(self):
view = self.view()
view.enabled = True
with mock.patch.object(frame_pcview.sys, 'platform', 'linux'):
self.assertIn('only available on Windows', view.unavailable())
def test_turning_on_checks_for_the_bundled_libraries(self):
view = self.view()
missing = mock.Mock(is_file=mock.Mock(return_value=False))
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
mock.patch.object(frame_pcview, 'LIBRARY', missing):
state = view.set_enabled(True)
self.assertTrue(state['enabled'])
self.assertFalse(state['available'])
self.assertIn('libraries are missing', state['reason'])
def test_turning_off_stops_streams_and_the_helper(self):
view = self.view()
view.enabled = True
agent = mock.Mock()
agent.poll.return_value = None
view.agent, view.tunnel = agent, mock.Mock()
tunnel = view.tunnel
tunnel.poll.return_value = None
view.stop = mock.Mock()
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'):
state = view.set_enabled(False)
view.stop.assert_called_once_with()
agent.stdin.close.assert_called_once()
agent.wait.assert_called()
tunnel.terminate.assert_called_once()
self.assertIsNone(view.agent)
self.assertEqual((state['enabled'], state['reason']), (False, frame_pcview.OFF))
def test_server_enable_action(self):
import sandbox # noqa: F401 (keeps the server off real data and services)
import server
with mock.patch.object(server, 'macview', frame_macview.MacView(['ssh'], mock.Mock(), 'frame')):
with self.assertRaises(server.Failure) as e:
server.macview_action({'action': 'enable', 'on': True})
self.assertIn('only available on Windows', str(e.exception))
view = self.view()
view.set_enabled = mock.Mock(return_value={'enabled': True})
with mock.patch.object(server, 'macview', view):
self.assertEqual(server.macview_action({'action': 'enable', 'on': True}), {'enabled': True})
server.macview_action({'action': 'enable', 'on': 'yes'}) # anything but true is off
self.assertEqual([c.args for c in view.set_enabled.call_args_list], [(True,), (False,)])
class PageTests(unittest.TestCase):
page = (ROOT / 'ui' / 'index.html').read_text(encoding='utf-8')
def test_tool_link_is_windows_only_and_marked_experimental(self):
self.assertIn('<a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span>'
'<small>Experimental:', self.page)
self.assertIn('$("pcToolLink").hidden = h.mobile || h.os !== "Windows";', self.page)
self.assertIn('$("macToolLink").hidden = h.mobile || h.os !== "macOS";', self.page)
def test_card_says_experimental_and_what_is_unverified(self):
self.assertIn('<span class="tag" id="mvTag" hidden>Experimental</span>', self.page)
self.assertIn('<input type="checkbox" id="mvPcOn">', self.page)
self.assertIn('Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified.', self.page)
self.assertIn('docs/pc-in-headset.md', self.page)
def test_opt_in_is_remembered_with_the_client_preferences(self):
self.assertIn("const keys = ['mvSeparate', 'padUsed', 'pcView']", self.page)
prefs = (ROOT / 'app' / 'preferences.js').read_text(encoding='utf-8')
self.assertIn('const KEYS = ["mvSeparate", "padUsed", "pcView"];', prefs)
class AdapterTests(unittest.TestCase):
def test_dxgi_only_bundle_captures_displays_without_optional_wgc_properties(self):
p = capture.pipeline(dict(src='display:65537', capture_api='dxgi'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
self.assertIn('monitor-handle=65537', p)
self.assertNotIn('capture-api=', p)
self.assertNotIn('show-border=', p)
self.assertIn('mfh264enc', p)
for source in (dict(src='window:42', capture_api='dxgi'),
dict(src='display:42', capture_api='dxgi ! fakesink')):
with self.assertRaises(ValueError):
capture.pipeline(source, 'win32', 'mfh264enc', 640, 360, 30, 1000000)
def test_windows_sources_follow_actual_wgc_capabilities(self):
for wgc in (False, True):
with self.subTest(wgc=wgc):
native, windows = mock.Mock(), mock.Mock()
native.has_property.return_value = wgc
windows.sources.side_effect = lambda: ([dict(src='window:12', w=800, h=600)],
[dict(src='display:4', w=1920, h=1080)])
with mock.patch.object(agent.sys, 'platform', 'win32'), \
mock.patch.object(agent, 'Windows', return_value=windows):
host = agent.Agent(native, 'test', ROOT/'ui/mac-view.html')
wins, displays = host.lists()
self.assertEqual(len(wins), int(wgc))
self.assertEqual(displays[0]['capture_api'], 'wgc' if wgc else 'dxgi')
self.assertEqual(host.source('display:4')['capture_api'], displays[0]['capture_api'])
if wgc:
self.assertEqual(host.source('window:12')['capture_api'], 'wgc')
self.assertEqual(host.window_capture_reason, '')
else:
with self.assertRaisesRegex(ValueError, 'Individual windows are unavailable'):
host.source('window:12')
self.assertIn('whole screen', host.window_capture_reason)
# Synthetic encoder/protocol checks remain available without WGC.
self.assertEqual(host.source('test')['src'], 'test')
def test_pc_card_receives_window_capture_limitation(self):
view = frame_pcview.PCView([], lambda *a: None, 'frame')
view.enabled = True
view.call = mock.Mock(side_effect=lambda path: {
'/status': dict(screen=True, accessibility=True, windowCaptureReason=agent.WINDOW_CAPTURE_UNAVAILABLE),
'/windows': dict(windows=[]), '/displays': dict(displays=[dict(src='display:4')])}[path])
with mock.patch.object(frame_pcview.sys, 'platform', 'win32'), \
mock.patch.object(frame_pcview, 'LIBRARY', mock.Mock(is_file=lambda: True)):
state = view.state()
self.assertTrue(state['available'])
self.assertEqual(state['windowCaptureReason'], agent.WINDOW_CAPTURE_UNAVAILABLE)
self.assertEqual(state['windows'], [])
self.assertEqual(state['displays'], [dict(src='display:4')])
def test_windows_uses_wgc_and_media_foundation(self):
for kind in ('window', 'display'):
pipeline = capture.pipeline(dict(src=kind+':42'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
self.assertIn('capture-api=wgc', pipeline)
self.assertIn('window-handle=42' if kind == 'window' else 'monitor-handle=42', pipeline)
self.assertIn('mfh264enc name=enc low-latency=true bframes=0', pipeline)
self.assertNotIn('drop=true', pipeline)
with self.assertRaises(ValueError):
capture.pipeline(dict(src='window:42 ! fakesink'), 'win32', 'mfh264enc', 640, 360, 30, 1000000)
def test_linux_uses_only_portal_fd_and_node(self):
p = capture.pipeline(dict(src='window:consented', fd=19, node=47), 'linux', 'x264enc', 640, 360, 30, 1000000)
self.assertIn('pipewiresrc fd=19 path=47', p)
self.assertIn('tune=zerolatency', p)
self.assertEqual(capture.dimensions(1920, 1080, 1280), (1280, 720))
def test_portal_input_clamps_and_releases_held_state(self):
native = mock.Mock()
native.lib.fc_portal_input.return_value = 1
inp = capture.PortalInput(native, dict(portal=123, w=100, h=50))
inp.handle(dict(t='m', e='down', b=0, x=2, y=-1))
inp.handle(dict(t='k', e='down', key='Control'))
inp.release()
calls = native.lib.fc_portal_input.call_args_list
self.assertEqual(calls[0].args, (123, 0, 99, 0, 0, 0))
self.assertIn(mock.call(123, 1, 0, 0, 0x110, 0), calls)
self.assertIn(mock.call(123, 3, 0, 0, 0xffe3, 0), calls)
self.assertFalse(inp.buttons or inp.keys)
def test_windows_release_and_negative_monitor_origin(self):
host = mock.Mock()
host.user.GetSystemMetrics.side_effect = [-1920, 0, 3840, 1080]
inp = capture.WindowsInput(host, dict(src='display:4', x=-1920, y=0, w=1920, h=1080))
inp.handle(dict(t='m', e='down', b=2, x=0, y=0))
self.assertEqual(host.send.call_args_list[0], mock.call(mouse=(0, 0, 0, 0xc001)))
inp.handle(dict(t='k', e='down', code='ControlLeft', key='Control'))
inp.release()
self.assertIn(mock.call(mouse=(0, 0, 0, 0x10)), host.send.call_args_list)
self.assertIn(mock.call(key=(162, 0, 2)), host.send.call_args_list)
def test_refused_windows_focus_never_clicks_the_covering_app(self):
host = mock.Mock()
host.rect.return_value = (0, 0, 800, 600)
host.user.GetForegroundWindow.return_value = 99
host.user.SetForegroundWindow.return_value = False
inp = capture.WindowsInput(host, dict(src='window:12', w=800, h=600))
with self.assertRaisesRegex(RuntimeError, 'could not focus'):
inp.handle(dict(t='m', e='down', b=0, x=.5, y=.5))
host.send.assert_not_called()
def test_pc_reuses_launcher_and_namespaces_panel_ids(self):
view = frame_pcview.PCView(['ssh'], mock.Mock(return_value='panel created'), 'frame')
view.call = mock.Mock(side_effect=lambda path, **kw: {'screen': True, 'ticket': 'one-use', 'streams': []})
view.ensure_tunnel = mock.Mock()
view.remote_port = 47900
view.show('window:12')
self.assertEqual(view.run.call_args.kwargs['stdin'], frame_macview.LAUNCH)
self.assertNotEqual(frame_macview.panel_id('window:12'), frame_macview.panel_id('window:12', 'windows'))
self.assertFalse(view.prefer_usb)
with self.assertRaises(frame_macview.MacViewError):
view.show('separate:12')
def test_session_end_releases_native_input_only_once(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event = threading.Event()
session.ws, session.input = mock.Mock(), mock.Mock()
session.released = False
session.end()
session.end()
session.input.release.assert_called_once()
self.assertTrue(session.stop_event.is_set())
def test_idle_source_is_not_a_stall(self):
# PipeWire/WGC deliver frames only on damage: a static desktop sends
# its first frame and then nothing. That must not end the session.
session = object.__new__(agent.Session)
session.lock, session.pending = threading.RLock(), {}
session.stats = Stats(lambda: 0)
session.stats.captured = 1
self.assertIsNone(session.stalled(60000000, 0, 0))
# A frame that entered the encoder and never came out is a fault.
session.pending[5] = dict(cap=0, arr=0, e0=1000000, tier=0, br=0)
self.assertIn('encoder', session.stalled(12000000, 0, 0))
self.assertIsNone(session.stalled(10000000, 0, 0))
# No initial frame at all after the pipeline opened is a fault.
session.pending.clear()
self.assertIn('capture', session.stalled(12000000, 0, 1))
self.assertIsNone(session.stalled(9000000, 0, 1))
def test_controller_is_inert_after_close(self):
lib = mock.Mock()
lib.fc_new.return_value = 1234
lib.fc_value.return_value = 100
controller = capture.Controller(lib, 60, 5000000)
controller.close()
lib.fc_free.assert_called_once_with(1234)
lib.reset_mock()
state = controller.state()
self.assertEqual((state['fps'], state['target'], state['scale']), (60, 0, 1))
self.assertEqual(controller.call('gate', 1, 1), 0)
self.assertEqual(controller.update(1), 0)
controller.close()
self.assertEqual(lib.method_calls, []) # nothing touched the freed pointer
def test_library_path_prepend_adds_no_empty_entry(self):
env = {}
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/opt/fc/lib')
self.assertEqual(env['LD_LIBRARY_PATH'], '/opt/fc/lib')
frame_pcview.prepend(env, 'LD_LIBRARY_PATH', '/x')
self.assertEqual(env['LD_LIBRARY_PATH'], '/x' + os.pathsep + '/opt/fc/lib')
with mock.patch.dict(os.environ, clear=True):
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
for name in ('PATH', 'LD_LIBRARY_PATH'):
parts = env.get(name, 'x').split(os.pathsep)
self.assertNotIn('', parts, name)
def test_malformed_viewer_input_is_dropped_not_fatal(self):
session = object.__new__(agent.Session)
session.lock, session.input_lock = threading.RLock(), threading.RLock()
session.stop_event, session.key_event = threading.Event(), threading.Event()
session.src, session.codec, session.key, session.w, session.h = 'display:1', 'h264', 'r', 100, 100
session.source = dict(src='display:1', w=100, h=100, portal=1)
session.input_enabled, session.released = True, False
session.native = mock.Mock()
session.native.now.return_value = 0
session.controller, session.agent = mock.Mock(), mock.Mock()
session.stats = Stats(lambda: 0)
session.produce = lambda: None
lib = mock.Mock()
lib.fc_portal_input.return_value = 1
session.input = capture.PortalInput(mock.Mock(lib=lib), session.source)
session.ws = mock.Mock()
session.ws.receive.side_effect = [
dict(t='m', x='left', y=0), dict(t='wheel', dx=[1]), dict(t='k', e='down', key=None, i='x'),
dict(t='fd', drop='many'), dict(t='rx', s=[1]), dict(t='m', x=.5, y=.5), ConnectionError('closed')]
with self.assertRaises(ConnectionError):
session.start()
# Every malformed message was skipped; the good move after them still ran.
moves = [c.args for c in lib.fc_portal_input.call_args_list if c.args[1] == 0]
self.assertAlmostEqual(moves[-1][2], .5*99)
self.assertEqual(session.ws.receive.call_count, 7)
def test_stats_keep_capture_time_and_bound_records(self):
stats = Stats(lambda: 10000000)
stats.input(dict(t='m', i=1, tv=9999990))
f = stats.add(cap=10000001, arr=10000002, e0=10000003, e1=10000004)
self.assertEqual(f['echo'], 1)
stats.report(dict(t='rx', s=f['s'], r=10000010))
stats.report(dict(t='fd', f=[[f['s'], 10000011, 10000012, 10000013]]))
self.assertEqual(stats.snapshot(settle=0)['inputs'][0]['frame'], f['s'])
for _ in range(4100):
stats.add(cap=10000001)
self.assertEqual(len(stats.frames), 4096)
def x264_bundled():
return any((capture.NATIVE / 'lib' / 'gstreamer-1.0').glob('*gstx264*'))
def needs_x264(test):
def run(self):
if not x264_bundled():
if os.environ.get('FRAME_PC_REQUIRE_X264') == '1':
raise AssertionError('CI required x264 in the native PC bundle (desktop/build.py --with-x264)')
raise unittest.SkipTest('x264 is only in CI test bundles, not the installer')
return test(self)
run.__name__, run.__doc__ = test.__name__, test.__doc__
return run
class NativeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
if not capture.LIBRARY.exists():
if os.environ.get('FRAME_PC_REQUIRE_NATIVE') == '1':
raise AssertionError('CI required native PC libraries but they were not built')
raise unittest.SkipTest('native PC libraries not built on this host')
cls.token = 'test-' + os.urandom(8).hex()
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_MAC_VIEW_TOKEN'] = cls.token
cls.proc = subprocess.Popen([sys.executable, str(ROOT/'ui/frame_pc_agent.py'), 'serve', '--port', '0',
'--page', str(ROOT/'ui/mac-view.html'), '--exit-on-eof'], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, env=env)
line = cls.proc.stdout.readline()
if 'listening' not in line:
raise AssertionError('Native agent failed: ' + cls.proc.stderr.read())
cls.port = int(line.rsplit(':', 1)[1])
@classmethod
def tearDownClass(cls):
cls.proc.stdin.close()
try:
rc = cls.proc.wait(10)
except subprocess.TimeoutExpired:
cls.proc.kill()
raise AssertionError('Native agent did not stop after EOF')
error = cls.proc.stderr.read()
cls.proc.stdout.close()
cls.proc.stderr.close()
if rc:
raise AssertionError('Native agent exited %s: %s' % (rc, error))
def request(self, path, method='GET'):
conn = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10)
try:
conn.request(method, path)
response = conn.getresponse()
return response.status, response.read()
finally:
conn.close()
def tearDown(self):
self.request('/close?k='+self.token, 'POST')
def test_native_property_probe_checks_the_instantiated_element(self):
code = """
from frame_pc_capture import Native
n = Native()
assert n.has_property('fakesink', 'sync')
assert not n.has_property('fakesink', 'not-a-gstreamer-property')
assert not n.has_property('not-a-gstreamer-element', 'sync')
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['PYTHONPATH'] = str(ROOT/'ui')
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
@unittest.skipUnless(sys.platform.startswith('linux'), 'Linux PipeWire client libraries')
def test_bundled_pipewire_context_needs_no_system_modules(self):
# No portal request, remote connection or desktop capture: create and
# destroy a private client context to test the dynamically loaded SPA
# and protocol modules that ldd cannot discover.
code = """
import ctypes as C, os
lib = C.CDLL(os.path.join(os.environ['FRAME_PC_NATIVE'], 'lib', 'libpipewire-0.3.so.0'))
for name, ret, args in [
('pw_init', None, [C.c_void_p, C.c_void_p]),
('pw_main_loop_new', C.c_void_p, [C.c_void_p]),
('pw_main_loop_get_loop', C.c_void_p, [C.c_void_p]),
('pw_context_new', C.c_void_p, [C.c_void_p, C.c_void_p, C.c_size_t]),
('pw_context_find_factory', C.c_void_p, [C.c_void_p, C.c_char_p]),
('pw_context_find_spa_lib', C.c_char_p, [C.c_void_p, C.c_char_p]),
('pw_context_destroy', None, [C.c_void_p]),
('pw_main_loop_destroy', None, [C.c_void_p])]:
fn = getattr(lib, name)
fn.restype, fn.argtypes = ret, args
lib.pw_init(None, None)
loop = lib.pw_main_loop_new(None)
assert loop, 'bundled SPA loop support did not load'
context = lib.pw_context_new(lib.pw_main_loop_get_loop(loop), None, 0)
assert context, 'bundled PipeWire client modules did not load'
assert lib.pw_context_find_factory(context, b'adapter'), 'video adapter factory is missing'
# The exported factory is video.adapt, which needs a live follower node to
# instantiate. Check its configured library and exported factory without
# pretending a headless context is a consented video stream.
plugin_name = lib.pw_context_find_spa_lib(context, b'video.adapt')
assert plugin_name, 'video.adapt has no bundled library mapping'
plugin = C.CDLL(os.path.join(os.environ['SPA_PLUGIN_DIR'], plugin_name.decode() + '.so'))
class Factory(C.Structure):
_fields_ = [('version', C.c_uint32), ('name', C.c_char_p)]
plugin.spa_handle_factory_enum.argtypes = [C.POINTER(C.POINTER(Factory)), C.POINTER(C.c_uint32)]
plugin.spa_handle_factory_enum.restype = C.c_int
factory, index, names = C.POINTER(Factory)(), C.c_uint32(), []
while plugin.spa_handle_factory_enum(C.byref(factory), C.byref(index)) > 0:
names.append(factory.contents.name)
assert b'video.adapt' in names, 'bundled SPA video adapter factory is missing'
lib.pw_context_destroy(context)
lib.pw_main_loop_destroy(loop)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['FRAME_PC_NATIVE'] = str(capture.NATIVE)
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stderr)
def test_agent_reports_its_host_and_encoder(self):
self.assertEqual(self.request('/status')[0], 403)
status, body = self.request('/status?k='+self.token)
self.assertEqual(status, 200)
data = json.loads(body)
if sys.platform == 'win32':
self.assertEqual((data['host'], data['encoder']), ('windows', 'mfh264enc'))
self.assertEqual(data['streams'], [])
@unittest.skipUnless(os.environ.get('FRAME_PC_INSTALLER_BUNDLE') == '1', 'checks the installer\'s bundle')
def test_installer_bundle_leaves_out_gpl_x264(self):
self.assertFalse(x264_bundled())
self.assertFalse(list((capture.NATIVE / 'bin').glob('libx264*')))
plugins = [p.name for p in (capture.NATIVE / 'lib' / 'gstreamer-1.0').iterdir()]
for name in ('mediafoundation', 'd3d11', 'jpeg'):
self.assertTrue(any(p.endswith('gst%s.dll' % name) for p in plugins), (name, plugins))
@needs_x264
def test_last_raw_picture_survives_a_closed_gate(self):
# A source sends ONE changed frame, then becomes idle. It must reach
# the encoder after congestion clears, without needing another update.
code = """
import ctypes as C, sys, time
from frame_pc_capture import Native, GATE, Encoded, pipeline
native = Native()
start = None
closing = False
phases = []
def gate(stage, pts, capture, arrived):
global start
phases.append(stage)
if closing: return -1
if start is None: start = time.monotonic()
return int(stage == 1 or time.monotonic()-start >= .15)
callback = GATE(gate)
p = pipeline(dict(src='test'), sys.platform, 'x264enc', 320, 180, 30, 300000)
p = p.replace('is-live=true', 'is-live=true num-buffers=1')
error = C.create_string_buffer(1024)
handle = native.lib.fc_capture_open(p.encode(), callback, error, len(error))
assert handle, error.value
try:
frame = Encoded()
result = 0
deadline = time.monotonic()+5
while not result and time.monotonic() < deadline:
result = native.lib.fc_capture_pull(handle, C.byref(frame))
assert result == 1, native.lib.fc_capture_error(handle)
assert frame.key and frame.size > 17
assert phases.count(0) == 1 and 2 in phases, phases
finally:
closing = True
native.lib.fc_capture_close(handle)
"""
env = frame_pcview.PCView([], lambda *a: None, 'frame').agent_environment()
env['PYTHONPATH'] = str(ROOT / 'ui')
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
@needs_x264
def test_auth_and_real_h264_stats(self):
self.assertEqual(self.request('/status')[0], 403)
status, body = self.request('/ticket?src=test&k='+self.token, 'POST')
self.assertEqual(status, 200)
ticket = json.loads(body)['ticket']
ws = WS(self.port, '/stream?src=test&fps=30&max=640&t='+ticket)
try:
self.assertEqual(ws.status, 101)
key, got, echo = None, 0, False
ws.send_text(json.dumps(dict(t='m', e='down', i=7, tv=1, x=.2, y=.3)))
deadline = time.monotonic()+10
while time.monotonic() < deadline and (got < 5 or not echo):
op, data = ws.recv()
if op == 1:
m = json.loads(data)
self.assertNotEqual(m.get('t'), 'error', m)
if m.get('t') == 'hello':
key = m['r']
ws.send_text(json.dumps(dict(t='ack')))
elif op == 2:
got += 1
seq, echoed = struct.unpack('!II', data[9:17])
now = struct.unpack('!Q', data[1:9])[0]
echo |= echoed == 7
self.assertIn(b'\x00\x00\x00\x01', data[17:])
ws.send_text(json.dumps(dict(t='rx', s=seq, r=now+100)))
ws.send_text(json.dumps(dict(t='fd', f=[[seq, now+200, now+300, now+400]])))
self.assertGreaterEqual(got, 5)
self.assertTrue(echo)
time.sleep(.1)
stats = json.loads(self.request('/stats?settle=0&k='+self.token)[1])['streams'][0]
self.assertGreater(stats['frames'][0]['e1'], stats['frames'][0]['e0'])
self.assertIn('controller', stats)
used = WS(self.port, '/stream?src=test&t='+ticket)
self.assertEqual(used.status, 403)
used.close()
self.request('/close?src=test&k='+self.token, 'POST')
revoked = WS(self.port, '/stream?src=test&r='+key)
self.assertEqual(revoked.status, 403)
revoked.close()
finally:
ws.close()
if __name__ == '__main__':
unittest.main()
+25
View File
@@ -109,6 +109,10 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.request("GET", "/api/stream")[0], 403)
self.assertEqual(self.request("GET", "/api/shots/image?id=1/250820/20260925225208_1.jpg")[0], 403)
self.assertEqual(self.request("POST", "/api/launch", {"appid": "620"})[0], 403)
self.assertEqual(self.request("GET", "/api/record")[0], 403)
self.assertEqual(self.request("GET", "/api/clips")[0], 403)
self.assertEqual(self.request("POST", "/api/record", {"action": "start"})[0], 403)
self.assertEqual(self.request("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})[0], 403)
def test_captures_are_not_cacheable(self):
# Headset captures show everything on screen; nothing may cache them.
@@ -134,11 +138,24 @@ class ServerGuards(unittest.TestCase):
("/api/shots/save", {"ids": [1]}),
("/api/shots/save", {"ids": ["1/250820/../../.ssh/id_ed25519"]}),
("/api/shots/save", {"ids": ["1/250820/20260925225208_1.jpg; rm -rf ~"]}),
("/api/record", {}),
("/api/record", {"action": "explode"}),
("/api/clips/export", {}),
("/api/clips/export", {"id": 5}),
("/api/clips/export", {"id": "1/video/../../../../.ssh"}),
("/api/clips/export", {"id": "1/video/bg;rm -rf ~"}),
("/api/clips/export", {"id": "../1/video/bg"}),
]
for path, body in cases:
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_recorder_idle_until_started(self):
status, _, payload = self.request("GET", "/api/record", headers={"X-Frame-UI": "1"})
self.assertEqual((status, json.loads(payload)), (200, {"state": "idle"}))
status, payload = self.post("/api/record", {"action": "stop"})
self.assertEqual(status, 409, payload)
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
@@ -530,6 +547,14 @@ class LocalMode(unittest.TestCase):
self.assertEqual(status, 200, body)
self.assertIsInstance(body["titles"], list)
def test_recordings_are_for_a_computer(self):
for method, path, body in (("GET", "/api/record", None), ("GET", "/api/clips", None),
("POST", "/api/record", {"action": "start"}),
("POST", "/api/clips/export", {"id": "1/video/bg_620_20261020_201950"})):
status, payload = self.request(method, path, body)
self.assertEqual(status, 400, (path, payload))
self.assertIn("computer", payload["error"])
def test_open_is_for_the_app_and_power_needs_a_password(self):
self.assertEqual(self.request("POST", "/api/open", {"what": "terminal"})[0], 400)
status, body = self.request("POST", "/api/open", {"what": "reboot"})
+153
View File
@@ -0,0 +1,153 @@
"""Smoke cleanup against real local Linux processes, never a headset or SSH."""
import sandbox # noqa: F401
import os
from pathlib import Path
import shutil
import shlex
import signal
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
SMOKE = Path(__file__).resolve().parent / 'smoke'
sys.path.insert(0, str(SMOKE))
import stop_processes # noqa: E402
import frame_smoke # noqa: E402
@unittest.skipUnless(sys.platform == 'linux' and hasattr(os, 'pidfd_open'), 'Linux pidfds required')
class RealProcesses(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
self.title = self.home / 'devkit-game' / 'fc_smoke_exe'
self.title.mkdir(parents=True)
self.children = []
self.patcher = mock.patch.object(stop_processes.Path, 'home', return_value=self.home)
self.patcher.start()
self.addCleanup(self.patcher.stop)
self.addCleanup(self.reap)
def reap(self):
for child in self.children:
if child.poll() is None:
child.kill()
child.wait(timeout=5)
def start(self, cwd, *args, ignore=False):
code = ('import signal,time; '
+ ('signal.signal(signal.SIGTERM, signal.SIG_IGN); ' if ignore else '')
+ 'print("ready", flush=True); time.sleep(60)')
child = subprocess.Popen([sys.executable, '-c', code, *args], cwd=cwd,
stdout=subprocess.PIPE, text=True)
self.children.append(child)
self.addCleanup(child.stdout.close)
self.assertEqual(child.stdout.readline().strip(), 'ready')
return child
def test_deleted_title_process_is_stopped_but_similar_names_survive(self):
owned = self.start(self.title)
neighbour = self.title.with_name('fc_smoke_exe_other')
neighbour.mkdir()
other = self.start(neighbour, 'fc_smoke_exe', 'wine', 'proton')
shutil.rmtree(self.title)
# Reproduces the old success condition: no title remains, yet it runs.
self.assertFalse(self.title.exists())
self.assertIsNone(owned.poll())
def local_target(command, *, input, timeout):
return subprocess.run(shlex.split(command), input=input, text=True,
capture_output=True, check=True, timeout=timeout,
env={**os.environ, 'HOME': str(self.home)}).stdout
# Exercise the production SSH command and stdin payload against a local
# fake target, including the helper's CLI and JSON result contract.
with mock.patch.object(frame_smoke.frame_android, 'ssh', side_effect=local_target):
result = frame_smoke.stop_titles({'fc_smoke_exe'})
self.assertIn(owned.pid, result['stopped_pids'])
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
self.assertIsNone(other.poll())
def test_proton_style_absolute_argument_escalates_without_stopping_shared_wine(self):
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'), ignore=True)
shared = self.start(self.home, 'wineserver', '-p')
quoted = self.start(self.home, 'echo ' + str(self.title / 'fc-smoke-exe.exe'))
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
self.assertIn(owned.pid, result['stopped_pids'])
self.assertEqual(owned.wait(timeout=5), -signal.SIGKILL)
self.assertIsNone(shared.poll())
self.assertIsNone(quoted.poll())
self.assertEqual(stop_processes.stop(['fc_smoke_exe']), {'stopped_pids': []})
def test_unreadable_cwd_does_not_abort_scan_or_hide_readable_argv(self):
owned = self.start(self.home, str(self.title / 'fc-smoke-exe.exe'))
other = self.start(self.home, 'wineserver')
with mock.patch.object(stop_processes.os, 'readlink', side_effect=PermissionError):
self.assertTrue(stop_processes.owned(owned.pid, [str(self.title)]))
self.assertFalse(stop_processes.owned(other.pid, [str(self.title)]))
result = stop_processes.stop(['fc_smoke_exe'], grace=0.2)
self.assertIn(owned.pid, result['stopped_pids'])
self.assertEqual(owned.wait(timeout=5), -signal.SIGTERM)
self.assertIsNone(other.poll())
class Safety(unittest.TestCase):
def test_non_test_ids_rejected_before_scanning(self):
for ids in ([], ['portal2'], ['fc_smoke_exe/../portal2'], ['fc_smoke_custom']):
with self.subTest(ids=ids), self.assertRaises(ValueError):
stop_processes.stop(ids)
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
def test_lost_ownership_after_open_never_signalled(self):
with mock.patch.object(stop_processes, 'candidates', side_effect=[[123], [], []]), \
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
mock.patch.object(stop_processes.os, 'close'), \
mock.patch.object(stop_processes, 'owned', return_value=False), \
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True) as send:
self.assertEqual(stop_processes.stop(['fc_smoke_exe'], grace=0), {'stopped_pids': []})
send.assert_not_called()
@unittest.skipUnless(sys.platform == 'linux', 'Linux signalling helper')
def test_survivor_is_a_failure(self):
with mock.patch.object(stop_processes, 'candidates', return_value=[123]), \
mock.patch.object(stop_processes.os, 'pidfd_open', return_value=9, create=True), \
mock.patch.object(stop_processes.os, 'close'), \
mock.patch.object(stop_processes, 'owned', return_value=True), \
mock.patch.object(stop_processes.signal, 'pidfd_send_signal', create=True):
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
stop_processes.stop(['fc_smoke_exe'], grace=0)
def test_cleanup_scans_even_when_title_already_removed(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
result = frame_smoke.cleanup(frame_smoke.Smoke())
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
ssh.assert_not_called()
def test_stop_failure_prevents_file_removal(self):
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
mock.patch.object(frame_smoke, 'ssh') as ssh, \
mock.patch.object(frame_smoke, 'stop_titles', side_effect=RuntimeError('still running')):
with self.assertRaisesRegex(RuntimeError, 'still running'):
frame_smoke.cleanup(frame_smoke.Smoke())
ssh.assert_not_called()
def test_failed_initial_cleanup_prevents_install_and_records_failure(self):
with tempfile.TemporaryDirectory() as folder, \
mock.patch.object(frame_smoke, 'RESULTS', Path(folder)), \
mock.patch.object(frame_smoke.sys, 'argv', ['frame_smoke.py']), \
mock.patch.object(frame_smoke.subprocess, 'run', return_value=mock.Mock(returncode=0)), \
mock.patch.object(frame_smoke.tempfile, 'mkdtemp', return_value=folder), \
mock.patch.object(frame_smoke, 'os_release', return_value={
'BUILD_ID': 'fake', 'VERSION_ID': 'fake', 'VARIANT_ID': 'fake'}), \
mock.patch.object(frame_smoke, 'ssh', return_value='yes'), \
mock.patch.object(frame_smoke, 'cleanup', side_effect=RuntimeError('cannot stop')), \
mock.patch.object(frame_smoke, 'title_cycle') as cycle, \
mock.patch('builtins.print'):
self.assertEqual(frame_smoke.main(), 1)
cycle.assert_not_called()
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
+151
View File
@@ -0,0 +1,151 @@
"""Storage inventory uses only local temporary metadata; no headset connection."""
import sandbox # noqa: F401
import http.client
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_storage as storage
import server
class Storage(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name)
self.steam = self.home / '.local/share/Steam'
(self.steam / 'steamapps').mkdir(parents=True)
def write(self, relative, text):
path = self.steam / 'steamapps' / relative
path.write_text(text)
return path
def app(self, appid, name, size=None):
return self.write(f'appmanifest_{appid}.acf', '"AppState" { "appid" "%s" "name" "%s" %s }' %
(appid, name, '' if size is None else '"SizeOnDisk" "%s"' % size))
def inventory(self):
return storage.inventory(self.home, self.steam)
def test_multilibrary_dedup_sizes_and_read_only(self):
extra = self.home / 'other library'
(extra / 'steamapps').mkdir(parents=True)
self.write('libraryfolders.vdf', '"libraryfolders" { "0" { "path" "%s" } "1" { "path" "%s" } "2" "%s" }' %
(self.steam, extra, extra))
self.app(1, 'Game', '400')
self.app(2, 'Proton 10', '100')
self.app(3, 'Unknown')
self.app(4, 'Zero', '0')
self.app(5, 'Bad', '-10')
(extra / 'steamapps/appmanifest_9.acf').write_text('"AppState" { "appid" "9" "name" "Other" "SizeOnDisk" "200" }')
before = {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()}
result = self.inventory()
self.assertEqual(result['discovery'], 'ok')
self.assertEqual(len(result['libraries']), 2)
lib = result['libraries'][0]
self.assertEqual((lib['gameBytes'], lib['toolBytes'], lib['recordedBytes'], lib['unknownSizes']), (400, 100, 500, 2))
self.assertEqual([a['bytes'] for a in lib['apps']], [400, 100, 0, None, None])
self.assertEqual(result['libraries'][1]['recordedBytes'], 200)
self.assertEqual(before, {p: p.read_bytes() for p in self.home.rglob('*') if p.is_file()})
def test_partial_and_corrupt_metadata_never_become_empty_success(self):
self.write('libraryfolders.vdf', '"libraryfolders" {')
self.app(1, 'Good', '12')
self.write('appmanifest_2.acf', '"AppState" { "appid" "2"')
self.write('appmanifest_3.acf', '"AppState" { "appid" "9" }')
r = self.inventory()
self.assertEqual(r['discovery'], 'invalid-metadata')
lib = r['libraries'][0]
self.assertEqual((lib['state'], lib['recordedBytes'], len(lib['issues'])), ('partial', 12, 2))
def test_permission_denied_discovery_listing_manifest_and_capacity(self):
original = storage.read_vdf
self.app(1, 'Game', '10')
def read(path):
if str(path).endswith('appmanifest_1.acf') or str(path).endswith('libraryfolders.vdf'):
raise PermissionError('denied')
return original(path)
with patch.object(storage, 'read_vdf', side_effect=read), patch.object(storage.os, 'statvfs', side_effect=PermissionError(), create=True):
r = self.inventory()
self.assertEqual(r['discovery'], 'permission-denied')
self.assertEqual(r['home']['state'], 'permission-denied')
self.assertIsNone(r['home']['free'])
self.assertEqual(r['libraries'][0]['issues'][0]['state'], 'permission-denied')
with patch.object(storage.os, 'scandir', side_effect=PermissionError()):
self.assertEqual(self.inventory()['libraries'][0]['state'], 'permission-denied')
def test_disconnected_library_and_missing_discovery(self):
r = self.inventory()
self.assertEqual(r['discovery'], 'missing')
missing = self.home / 'disconnected'
self.write('libraryfolders.vdf', '"libraryfolders" { "1" { "path" "%s" } }' % missing)
lib = self.inventory()['libraries'][1]
self.assertEqual(lib['state'], 'missing')
self.assertIsNone(lib['capacity']['total'])
def test_reserved_capacity_and_shared_filesystem(self):
stat = SimpleNamespace(f_blocks=100, f_frsize=1024, f_bavail=10, f_bfree=20)
with patch.object(storage.os, 'statvfs', return_value=stat, create=True):
r = self.inventory()
self.assertEqual((r['home']['total'], r['home']['free'], r['home']['used']), (102400, 10240, 81920))
self.assertEqual(r['home']['filesystem'], r['libraries'][0]['capacity']['filesystem'])
def test_keyvalues_comments_escaping_and_limits(self):
path = self.write('parse.vdf', '// comment\n"root" { "name" "A \\"quote\\"" "nested" { "size" "42" } }')
self.assertEqual(storage.read_vdf(path)['root']['name'], 'A "quote"')
path.write_text('"root" { "name" "}" }')
self.assertEqual(storage.read_vdf(path)['root']['name'], '}')
for bad in ['"a" {', '"a" "1" "a" "2"', '"a" }', 'garbage', '"a" {"b" "c"}}']:
path.write_text(bad)
with self.assertRaises(ValueError):
storage.read_vdf(path)
path.write_text('x' * (storage.MAX_TEXT + 1))
with self.assertRaises(ValueError):
storage.read_vdf(path)
def test_http_probe_is_guarded_and_read_only(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
with patch.object(server, 'ssh', return_value=json.dumps(self.inventory())) as ssh:
conn = http.client.HTTPConnection(*httpd.server_address)
conn.request('GET', '/api/storage')
response = conn.getresponse()
self.assertEqual(response.status, 403)
response.read()
ssh.assert_not_called()
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY})
response = conn.getresponse()
self.assertEqual(response.status, 200)
self.assertIn('libraries', json.loads(response.read()))
self.assertEqual(ssh.call_args.args, ('python3 -',))
self.assertEqual(ssh.call_args.kwargs['stdin'], (ROOT / 'ui/frame_storage.py').read_text())
with patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new-frame'})):
ssh.reset_mock()
conn.request('GET', '/api/storage', headers={'X-Frame-UI': server.UI_KEY, 'X-Frame-Device': 'old-frame'})
response = conn.getresponse()
self.assertEqual(response.status, 409)
response.read()
ssh.assert_not_called()
conn.close()
finally:
httpd.shutdown()
httpd.server_close()
thread.join()
@unittest.skipUnless(shutil.which('node'), 'Node required')
def test_ui_behavior(self):
r = subprocess.run(['node', '--test', str(ROOT / 'tests/storage.cjs')], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+14
View File
@@ -0,0 +1,14 @@
"""Type on the headset: entry points, the T key and the clipboard copy, without a browser or headset."""
import shutil
import subprocess
import unittest
from pathlib import Path
@unittest.skipUnless(shutil.which('node'), 'Node exercises the page code')
class TypingUi(unittest.TestCase):
def test_behavior(self):
root = Path(__file__).resolve().parents[1]
result = subprocess.run(['node', '--test', str(root / 'tests/typing_ui.cjs')],
capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+114
View File
@@ -0,0 +1,114 @@
// Type on the headset: where people find it, the T key, and copying the headset's clipboard.
// Production page code with only browser and transport boundaries replaced; no headset.
const test=require('node:test');
const assert=require('node:assert/strict');
const cc=require('../ui/control-center.js');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
const between=(from,to)=>{const start=html.indexOf(from),end=html.indexOf(to,start);assert.ok(start>=0&&end>start,from);return html.slice(start,end);};
const section=id=>between(`id="${id}"`,'</section>');
test('Home, the Right now card and the Tools directory all name it Type on the headset',()=>{
const home=between('data-page="home"','data-page="capture"');
assert.match(home,/<a href="#pad" title="Type on the headset \(T\)"><span>Type on the headset<\/span><small>Keyboard, trackpad and clipboard<\/small>/);
assert.match(home,/<button data-go="pad" title="Type on the headset \(T\)">Type on the headset<\/button>/);
assert.match(between('id="toolDirectory"','<h2>Your Frame</h2>'),/href="#pad"[^>]*><span>Type on the headset<\/span>/);
assert.doesNotMatch(html,/Keyboard &(amp;)? trackpad<\/span>|Type on the Frame</);
});
test('the clipboard, both ways, lives beside the keyboard, not in Send to Frame',()=>{
const pad=section('pad');
for(const id of ['clipText','clipSend','clipMac','clipFrom'])assert.match(pad,new RegExp(`id="${id}"`),id);
assert.match(pad,/Text only, up to 1 MB/);
const transfer=section('transfer');
assert.doesNotMatch(transfer,/id="clip/);
assert.match(transfer,/<a href="#pad">Type on the headset<\/a>/);
});
test('the tool page is titled Type on the headset',()=>{
const f=workspaceFixture();
f.navigate('#pad');
assert.equal(f.activePage,'tools');assert.equal(f.el('pad').hidden,false);
assert.equal(f.el('toolTitle').textContent,'Type on the headset');
assert.equal(f.el('transfer').hidden,true);
});
function keyFixture() {
const f=workspaceFixture(),keys=[];let focused=0,started=0;
Object.assign(f.context,{pad:{state:'off'},padStart(){started++;},ctrl:{on:false},PAGES:['apps','games','tools','settings'],
history:{pushState(_,__,url){f.context.location.hash=url;}}});
f.context.document.addEventListener=(type,fn)=>{if(type==='keydown')keys.push(fn);};
f.context.document.querySelector=()=>null;
f.context.document.activeElement={tagName:'BODY'};
f.el('padType').focus=()=>{focused++;};
f.run(between('function showNow(','$("nowSend").onclick'));
f.run(between('document.addEventListener("keydown", e => {\n if (e.metaKey || e.ctrlKey || e.altKey','// ---- after an APK install'));
const press=(key,extra={})=>{let prevented=false;keys.forEach(fn=>fn({key,preventDefault(){prevented=true;},...extra}));return prevented;};
return {f,press,get focused(){return focused;},get started(){return started;}};
}
test('T opens Type on the headset, turns the keyboard on and puts the cursor in its field',()=>{
const k=keyFixture();
k.f.navigate('#games');
assert.equal(k.press('t'),true,'the T must not also be typed into the field it focuses');
assert.equal(k.f.context.location.hash,'#pad');assert.equal(k.f.activePage,'tools');
assert.equal(k.started,1);assert.equal(k.focused,1);
k.f.context.pad.state='ready';k.press('T');assert.equal(k.started,1,'already on: not started again');
});
test('T stays out of the way of typing, shortcuts and keys going to the Frame',()=>{
const k=keyFixture();
k.f.navigate('#games');
k.press('t',{metaKey:true});k.press('t',{ctrlKey:true});k.press('t',{altKey:true});
k.f.context.document.activeElement={tagName:'INPUT'};k.press('t');
k.f.context.document.activeElement={tagName:'BODY'};k.f.context.document.querySelector=s=>s==='dialog[open]'?{}:null;k.press('t');
k.f.context.document.querySelector=()=>null;
k.f.context.ctrl.on=true;k.f.context.document.activeElement=k.f.el('viewer');k.press('t');
assert.equal(k.f.context.location.hash,'#games');assert.equal(k.started,0);
k.f.context.document.activeElement={tagName:'BODY'};k.press('3');assert.equal(k.f.context.location.hash,'#tools','number keys still switch pages');
});
test('Home’s button goes through the same path, showing the tool once before focusing it',()=>{
const f=workspaceFixture(),buttons=[{dataset:{go:'pad'}},{dataset:{go:'media'}}];let focusedVisible=null;
f.context.document.querySelectorAll=(q=>s=>s==='[data-go]'?buttons:q(s))(f.context.document.querySelectorAll);
Object.assign(f.context,{pad:{state:'error'},padStart(){f.calls.push(['padStart']);},history:{pushState(_,__,url){f.context.location.hash=url;}}});
f.el('padType').focus=()=>{focusedVisible=f.activePage==='tools'&&!f.el('pad').hidden;};
f.navigate('#home');f.run(between('function showNow(','$("nowSend").onclick'));
buttons[0].onclick();
assert.equal(focusedVisible,true);assert.equal(f.calls.filter(c=>c[0]==='padStart').length,1);
buttons[1].onclick();assert.equal(f.context.location.hash,'#media');
});
function copyFixture(frameApp) {
const calls=[],f=workspaceFixture();
Object.assign(f.context,{window:{frameApp},api:async(path,body)=>{calls.push(JSON.parse(JSON.stringify([path,body])));return body.read?{text:'héllo 🎧\n',message:'Copied 8 characters from the headset\'s clipboard'}:{message:'Copied to this computer'};}});
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
return {f,calls};
}
test('in the app, the headset’s clipboard is read and the app writes it, text untouched',async()=>{
const written=[],{f,calls}=copyFixture({writeClipboard:async text=>{written.push(text);return true;}});
const result=await f.run('copyHeadsetClipboard()');
assert.deepEqual(calls,[['/api/clipboard',{read:true}]]);
assert.deepEqual(written,['héllo 🎧\n']);
assert.equal(result.message,'Copied 8 characters from the headset\'s clipboard');
assert.equal(result.text,undefined,'the text is not handed on to be logged or toasted');
});
test('in a browser, the server sets the clipboard of the computer it runs on',async()=>{
for(const frameApp of [undefined,{readClipboard(){}}]){
const {f,calls}=copyFixture(frameApp);
assert.equal((await f.run('copyHeadsetClipboard()')).message,'Copied to this computer');
assert.deepEqual(calls,[['/api/clipboard',{toComputer:true}]]);
}
});
test('a failed read writes nothing',async()=>{
const written=[],f=workspaceFixture();
Object.assign(f.context,{window:{frameApp:{writeClipboard:async t=>written.push(t)}},api:async()=>{throw new Error("The headset's clipboard is empty");}});
f.run(between('async function copyHeadsetClipboard()','$("clipFrom").onclick'));
await assert.rejects(f.run('copyHeadsetClipboard()'),/empty/);
assert.deepEqual(written,[]);
});
test('the Control Center finds Type on the headset by typing, keyboard and clipboard words',()=>{
const input=cc.catalog.find(item=>item.id==='input');
assert.equal(input.title,'Type on the headset');
assert.deepEqual(input.links[0],['#pad','Type on the headset']);
for(const query of ['type','typing','keyboard','clipboard','copy paste','kde connect'])
assert.ok(cc.matches(input,query,'all'),query);
const files=cc.catalog.find(item=>item.id==='files');
assert.ok(files.links.some(([href])=>href==='#pad'),'Send files points clipboard seekers to the right place');
});
+3 -1
View File
@@ -8,7 +8,7 @@ function connectionFixture() {
const f=workspaceFixture(), c=f.context;
Object.assign(c,{URLSearchParams,Date,clearTimeout,link:{s:{phase:'connected'},device:'a',phase:'connected',rev:0},disp:{list:[],seq:0},cat:{apps:null},wi:{},devGen:1,androidSeq:0,androidPending:new Set(),titlesSeq:0,runningGames:[],runningSeq:0,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
c.window.connDevice='a';
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
@@ -40,7 +40,9 @@ test('a device switch discards completed install results and the open detail',()
const f=connectionFixture(),c=f.context;
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
f.el('appDetail').open=true;
f.el('storageResult').textContent='Old headset storage';
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
assert.match(f.el('storageResult').textContent,/Headset changed/);
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
});
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
+56 -5
View File
@@ -2,7 +2,7 @@
Optional store metadata: images {icon, banner, screenshots}, developer,
description, popularity, open_source, requires_meta_services, frame_tested.
Only an explicit frame_tested=True produces a working-on-Frame verdict.
An explicit frame_tested=True or a saved working report produces a working verdict.
"""
import importlib
import inspect
@@ -24,6 +24,46 @@ _game_data = {} # package -> downloaded OBB paths waiting for "Add game data"
_status = {}
TIMEOUT = 12
BROWSE_LIMIT = 300 # apps per source when browsing without a query, before grouping
compat_reports = None # optional memory-only snapshot hook; enabled at server start, not import
class ReportCache:
"""Stale-while-refresh reports. Never hold the snapshot lock while loading.
The loader may block on disk, credentials or the database. Only its daemon
worker calls it; readers keep the last complete snapshot, even after errors.
"""
def __init__(self, loader, report_verdict, ttl=60):
self.loader, self.report_verdict, self.ttl = loader, report_verdict, ttl
self._lock = threading.Lock()
self._snapshot, self._at, self._running = {}, None, False
def read(self):
with self._lock:
snapshot = self._snapshot
if not self._running and (self._at is None or time.monotonic() - self._at >= self.ttl):
self._running = True
threading.Thread(target=self._refresh, daemon=True).start()
return snapshot
def _refresh(self):
try:
by = {}
for report in self.loader():
if report.get('package'):
by.setdefault(report['package'], []).append(report)
snapshot = {pkg: v for pkg, rows in by.items() if (v := self.report_verdict(rows))}
with self._lock:
self._snapshot = snapshot
except Exception:
pass # optional evidence must never break search or discard a good snapshot
finally:
with self._lock:
self._at, self._running = time.monotonic(), False
def _reports():
return compat_reports() if compat_reports else {}
def modules():
@@ -149,7 +189,7 @@ def fit(entry):
'reasons': reasons}
def verdict(entry):
def verdict(entry, report=None):
compatibility = fit(entry)
hints = ' '.join(str(entry.get(k) or '') for k in ('engine', 'vr_engine', 'vr_hints', 'vr_issues')).lower()
if entry.get('requires_meta_services') is True:
@@ -160,6 +200,12 @@ def verdict(entry):
return {'label': "This version isn't made for the Frame", 'tone': 'blocked'}
if 'vrapi' in hints:
return {'label': "Made for older Quest headsets; won't run on the Frame", 'tone': 'blocked'}
if report:
kind, _ = report
return {'label': {'works': 'Reported working on the Frame',
'maybe': 'Reported issues on the Frame',
'no': 'Reported not working on the Frame'}[kind],
'tone': {'works': 'works', 'maybe': 'issues', 'no': 'warning'}[kind]}
if entry.get('frame_tested') is True:
return {'label': 'Works on the Frame', 'tone': 'works'}
if compatibility['installable'] is True:
@@ -167,10 +213,13 @@ def verdict(entry):
return {'label': 'Not yet checked on the Frame', 'tone': 'unknown'}
def decorate(entry, shots=12):
def decorate(entry, shots=12, reports=None):
"""shots: screenshots to offer; a listing card shows three, the detail page all of them."""
from apk_sources import _images
return dict(entry, fit=fit(entry), verdict=verdict(entry), artwork=_images.artwork(entry, shots),
report = (reports if reports is not None else _reports()).get(entry.get('package'))
evidence = {'verdict': report[0], 'lines': list(report[1])} if report else None
return dict(entry, fit=fit(entry), verdict=verdict(entry, report), compat_report=evidence,
artwork=_images.artwork(entry, shots),
**categories.classify(entry))
@@ -191,8 +240,9 @@ def offer_rank(entry):
def group(entries, query='', vr=None, installable=False, category=None, phone=False):
groups = {}
reports = _reports() # one complete snapshot for every offer in this response
for entry in entries:
entry = decorate(entry, shots=3)
entry = decorate(entry, shots=3, reports=reports)
if not categories.matches(entry, category, phone):
continue
if vr is not None and (entry.get('vr') is True) != vr: # unknown counts as flat
@@ -310,6 +360,7 @@ def search(query='', vr=None, source=None, installable=False, timeout=TIMEOUT, l
def warm():
"""Start every enabled source's index download in the background (server start, new repo)."""
_reports() # warm independently of potentially slow source indexes
from apk_sources import _web
_web.prune()
items, _ = registry()
+19 -13
View File
@@ -16,8 +16,8 @@
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
keywords:'sound speakers headphones loud quiet', special:'audio'},
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
description:'Inspect charge, signed battery power, charger readings, battery temperature, health and optional capacity / cycle readings. Heat warnings use the kernel’s own hot and critical trip points.',
keywords:'battery charging charger charge time estimate remaining power watts usb health cycles capacity wear heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#linuxApps','Linux apps'],['#power','SFTP & remote tools']]},
@@ -71,9 +71,9 @@
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
{id:'files', category:'apps', icon:'files', title:'Send files', kind:'flow', label:'Existing controls',
description:'Send files to ~/Downloads, or a game to the Steam library. Clipboard text, both ways, is in Type on the headset.',
keywords:'upload transfer downloads sftp storage', links:[['#transfer','Send files'],['#pad','Clipboard']]},
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
@@ -88,9 +88,11 @@
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
{id:'input', category:'network', icon:'keyboard', title:'Type on the headset', kind:'flow', label:'Existing controls',
description:'Type and point in the Frame’s apps with this computer’s keyboard and mouse, and copy text both ways between the clipboards. Press T anywhere to open it.',
keywords:'mouse pointer keyboard trackpad touch click kde connect input type typing text clipboard copy paste send',
links:[['#pad','Type on the headset'],['#view','Panel control']],
guidance:'The keyboard sets up KDE Connect on the Frame the first time. The clipboard needs the desktop panel open in the headset.'},
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
@@ -142,9 +144,9 @@
const capacity = b.percent;
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
const charging = b.status === 'Charging';
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
const full = b.status === 'Full';
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
full ? 'Fully charged' : charging ? 'Charging' : b.status === 'Not charging' || idle ? (s.power ? 'Plugged in · not charging' : 'Not charging') : b.status === 'Discharging' ? (s.power ? 'Plugged in · on battery' : 'On battery') : 'Charging state unavailable';
return {
battery:number(capacity, '%'), batteryDetail:batteryState,
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
@@ -153,8 +155,11 @@
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health && b.health !== 'Unknown' ? b.health : 'Unavailable',
batterySource:b.source || 'Unavailable', cycles:number(b.cycleCount),
fullCapacity:number(b.fullMah, ' mAh', 1), designCapacity:number(b.designMah, ' mAh', 1),
capacityHealth:number(b.capacityHealthPercent, '%', 1),
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : b.status === 'Discharging' && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
@@ -212,7 +217,8 @@
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
const batteryNote = 'Read-only. Unavailable means not reported, unreadable or invalid. Health is the driver’s status; full / design capacity is a fuel-gauge estimate, not a diagnosis. Charge limits are unsupported on Frame.';
const health = `<dl class="cc-readings">${[['battery','Charge'],['batteryDetail','Charging state'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health'],['cycles','Charge cycles'],['fullCapacity','Full charge capacity'],['designCapacity','Design capacity'],['capacityHealth','Full / design capacity'],['batterySource','Battery source']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">${batteryNote}</p>`;
const forcequit = `<div class="cc-quit"><div class="cc-audio-actions"><button class="small" type="button" id="ccRunningCheck">Check what’s running</button></div><ul class="cc-running" id="ccRunningList"></ul><p class="cc-note" id="ccRunningResult" role="status"></p></div>`;
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
+285
View File
@@ -0,0 +1,285 @@
"""Copy new screenshots and Steam clips from the headset to this computer on their own.
Off until it's turned on in Settings. Then a worker in the server looks about once a
minute, while a headset is connected and nothing else is running on it, and copies
what's new with the same code as Save and Export: screenshots to ~/Pictures/SteamFrame,
saved Steam clips to ~/Videos/SteamFrame.
- Only what's newer than the moment it was turned on, so turning it on doesn't copy
the whole back-catalogue; Copy everything now does that, once.
- What it has copied is remembered per headset (auto-sync.json in the app's data
folder), so a file deleted from this computer isn't copied again.
- A clip Steam is still writing (a chunk changed in the last SETTLE seconds) waits
for the next look. Steam's background recording is never copied: it's a rolling
buffer, not something anyone chose to keep.
- When this computer runs out of space it pauses and says so, until Resume.
- When the headset can't be reached it waits longer each time, up to MAX_BACKOFF.
The server supplies how to reach the headset, so this can be tested without one:
list_shots() -> {"shots": [{"id", "time", "saved"}...]}
save_shots(device, ids) copies those screenshots here
list_clips() -> {"clips": [{"id", "kind", "time", "idle", "saved"}...]}
export_clip(device, id) exports one clip here
device() -> the connected headset's id, or None
busy() -> True while other work runs on the headset (installs, jobs, a recording)
offline(error) -> a plain message if the error means the headset couldn't be reached
"""
import errno
import json
import os
import shutil
import threading
import time
from pathlib import Path
import frame_host
INTERVAL = 60 # seconds between looks
BUSY_WAIT = 15 # something else is running: look again soon
MAX_BACKOFF = 15 * 60 # the longest wait while the headset can't be reached
SETTLE = 30 # a clip written to within this many seconds is still being recorded
MIN_FREE = 512 * 1024**2 # pause below this much free space on this computer
KEEP = 5000 # copied ids remembered per headset and kind
BATCH = 100 # screenshots per copy
TRIES = 3 # an item that fails this often is left until the app restarts
KINDS = ("shots", "clips")
class DiskFull(Exception):
pass
def free_space(folder):
"""Free bytes on the disk `folder` is (or will be) on."""
p = Path(folder)
while not p.exists() and p != p.parent:
p = p.parent
return shutil.disk_usage(p).free
def disk_full(error):
"""Whether an error means this computer ran out of space (not the headset)."""
if isinstance(error, OSError) and error.errno == errno.ENOSPC:
return True
text = str(error)
return "No space left on device" in text or "free space on this computer" in text
def _defaults():
return {"screenshots": False, "clips": False, "since": None, "synced": {}, "paused": None,
"checked": None, "copied": 0, "copied_time": None, "error": None, "waiting": 0}
class AutoSync:
def __init__(self, list_shots, save_shots, list_clips, export_clip, device, busy, offline=lambda e: None,
shots_folder=None, clips_folder=None, path=None, clock=time.time, space=free_space):
self.list_shots, self.save_shots = list_shots, save_shots
self.list_clips, self.export_clip = list_clips, export_clip
self.device, self.busy, self.offline = device, busy, offline
self.shots_folder = Path(shots_folder or Path.home() / "Pictures" / "SteamFrame")
self.clips_folder = Path(clips_folder or Path.home() / "Videos" / "SteamFrame")
self.path = Path(path or frame_host.data_dir("auto-sync.json"))
self.clock, self.space = clock, space
self._lock = threading.RLock()
self._wake = threading.Event()
self._all = False # Copy everything now, waiting for the next pass
self._running = False
self._backoff = 0
self._tries = {} # (device, id) -> failed attempts this run
self._errors = [] # this pass's, for the page
self._s = self._load()
# ---- settings and what the page shows ----
def _load(self):
s = _defaults()
try:
saved = json.loads(self.path.read_text())
if isinstance(saved, dict):
s.update({k: v for k, v in saved.items() if k in s})
except (OSError, ValueError):
pass
s["screenshots"], s["clips"] = bool(s["screenshots"]), bool(s["clips"])
if not isinstance(s["synced"], dict):
s["synced"] = {}
return s
def _save(self):
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self._s, indent=1))
os.replace(tmp, self.path)
except OSError:
pass # the settings still apply until the app restarts
def enabled(self):
with self._lock:
return self._s["screenshots"] or self._s["clips"]
def state(self):
with self._lock:
s = self._s
return {k: s[k] for k in ("screenshots", "clips", "since", "paused", "checked", "copied",
"copied_time", "error", "waiting")} | {
"pending": self._all, "running": self._running,
"folders": {"shots": str(self.shots_folder), "clips": str(self.clips_folder)}}
def update(self, changes):
"""{"screenshots": bool, "clips": bool}: turning it on starts the watermark now."""
with self._lock:
was = self.enabled()
for key in ("screenshots", "clips"):
if key in changes:
self._s[key] = bool(changes[key])
if self.enabled() and (not was or self._s["since"] is None):
self._s["since"] = self.clock()
if not self.enabled():
self._s["error"], self._s["waiting"] = None, 0
self._save()
self._wake.set()
return self.state()
def copy_all(self):
"""Copy everything on the headset that isn't on this computer, once (also resumes)."""
with self._lock:
self._all, self._s["paused"] = True, None
self._save()
self._wake.set()
return self.state()
def resume(self):
with self._lock:
self._s["paused"], self._s["error"] = None, None
self._save()
self._backoff = 0
self._wake.set()
return self.state()
# ---- the worker ----
def run(self, stop):
"""Until `stop` (a threading.Event) is set."""
while not stop.is_set():
delay = self.tick()
self._wake.wait(delay)
self._wake.clear()
def tick(self):
"""One look. -> seconds until the next."""
with self._lock:
want_all = self._all
shots = self._s["screenshots"] or want_all
clips = self._s["clips"] or want_all
if not (shots or clips) or self._s["paused"]:
return INTERVAL
since = None if want_all else self._s["since"] or 0
device = self.device()
if not device:
return INTERVAL # nothing connected: nothing to ask
if self.busy():
return BUSY_WAIT
with self._lock:
self._running = True
self._errors = []
try:
copied, waiting, done = self._pass(device, shots, clips, since)
except DiskFull as e:
with self._lock:
self._s.update(paused=str(e), error=str(e))
self._save()
return INTERVAL
except Exception as e:
friendly = self.offline(e)
self._backoff = min(max(self._backoff * 2, INTERVAL * 2), MAX_BACKOFF)
with self._lock:
self._s["error"] = friendly or str(e)
self._save()
return self._backoff
finally:
with self._lock:
self._running = False
self._backoff = 0
with self._lock:
if copied:
self._s.update(copied=copied, copied_time=self.clock())
self._s.update(checked=self.clock(), waiting=waiting)
if done:
self._s["error"] = self._errors[0] if self._errors else None
if want_all:
self._all = False
self._save()
return INTERVAL if done else BUSY_WAIT
def _synced(self, device, kind):
with self._lock:
return set(self._s["synced"].get(device, {}).get(kind, []))
def _mark(self, device, kind, ids):
with self._lock:
per = self._s["synced"].setdefault(device, {})
have = per.get(kind, [])
new = [i for i in ids if i not in set(have)]
if new:
per[kind] = (have + new)[-KEEP:]
self._save()
def _room(self, folder):
if self.space(folder) < MIN_FREE:
raise DiskFull(f"Paused: this computer is nearly out of space ({folder} has under "
f"{MIN_FREE // 1024**2} MB free). Free some space, then press Resume.")
def _failed(self, device, item, error):
if disk_full(error):
raise DiskFull("Paused: this computer ran out of space while copying. Free some space, "
"then press Resume.")
if self.offline(error):
raise error
key = (device, item)
self._tries[key] = self._tries.get(key, 0) + 1
self._errors.append(f"Couldn't copy {item.rsplit('/', 1)[-1]}: {error}")
def _pass(self, device, shots, clips, since):
"""-> (copied, clips still being written, finished without stopping for other work)."""
copied = waiting = 0
if shots:
listing = self.list_shots()["shots"]
done = self._synced(device, "shots")
self._mark(device, "shots", [s["id"] for s in listing if s.get("saved")])
todo = [s["id"] for s in sorted(listing, key=lambda s: s["time"])
if not s.get("saved") and (since is None or (s["time"] >= since and s["id"] not in done))
and self._tries.get((device, s["id"]), 0) < TRIES]
for i in range(0, len(todo), BATCH):
if self.busy():
return copied, waiting, False
self._room(self.shots_folder)
batch = todo[i:i + BATCH]
try:
self.save_shots(device, batch)
except Exception as e:
for item in batch:
self._failed(device, item, e)
continue
self._mark(device, "shots", batch)
copied += len(batch)
if clips:
listing = [c for c in self.list_clips()["clips"] if c.get("kind") == "clip"]
done = self._synced(device, "clips")
self._mark(device, "clips", [c["id"] for c in listing if c.get("saved")])
for c in sorted(listing, key=lambda c: c["time"]):
if c.get("saved") or self._tries.get((device, c["id"]), 0) >= TRIES:
continue
if since is not None and (c["time"] < since or c["id"] in done):
continue
if c.get("idle") is None or c["idle"] < SETTLE:
waiting += 1 # still being written: copied on a later look
continue
if self.busy():
return copied, waiting, False
self._room(self.clips_folder)
try:
self.export_clip(device, c["id"])
except Exception as e:
self._failed(device, c["id"], e)
continue
self._mark(device, "clips", [c["id"]])
copied += 1
return copied, waiting, True
+402
View File
@@ -0,0 +1,402 @@
"""Backup and recovery kit: copy game saves, settings and Android app data off the Frame,
put them back after a factory reset, and get out of Desktop Mode. Python stdlib only.
A backup is a folder in ~/Documents/Frame Control Backups/<YYYY-MM-DD-HHMMSS>/ on this
computer: one tar.gz per part (saves/, settings/, android/) and manifest.json listing every
file with its size and SHA-256, the archives' checksums and the headset's BUILD_ID. What the
parts hold, and what is never copied, is decided on the headset by frame_backup_remote.py.
Backing up only reads the headset. See docs/backup-and-recovery.md.
CLI: python3 ui/frame_backup.py backup | list | plan NAME | restore NAME PART... | session | game-mode
"""
import hashlib
import json
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import sys
import tarfile
import time
import frame_android as android
import frame_host
REMOTE = Path(__file__).resolve().parent / 'frame_backup_remote.py'
NAME = re.compile(r'^\d{4}-\d{2}-\d{2}-\d{6}$')
ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$')
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
KINDS = ('saves', 'settings', 'android')
FORMAT = 1
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
# is ever run as a session command.
SELECTORS = ('holo-session-select', 'steamos-session-select')
REASONS = {
'not_installed': 'Install the game first, then restore its saves',
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
'no_account': 'Sign in to Steam on the headset first',
'reference': 'Kept for reference only; copy by hand if you need it',
'refused': 'Not restored: the path is not safe to write',
'running': 'Close the app first',
'damaged': "The backup file doesn't match its checksum",
'missing': 'The backup file is missing',
'empty': 'Nothing to restore',
'unknown': 'Not something this version of Frame Control can restore',
}
class BackupError(android.FrameError):
pass
def backup_root():
return Path(os.environ.get('FRAME_BACKUP_DIR') or Path.home() / 'Documents' / 'Frame Control Backups')
def backup_path(name):
"""The folder for a backup name; refuses anything that isn't one of ours."""
if not isinstance(name, str) or not NAME.fullmatch(name):
raise BackupError('not a backup name')
root = backup_root().resolve()
path = (root / name).resolve()
if path.parent != root:
raise BackupError('not a backup name')
return path
def archive_name(part):
kind, _, rest = part.partition(':')
return '%s/%s.tar.gz' % (kind, rest if kind == 'android' else part.replace(':', '-'))
def remote_command(cmd, *args):
return shlex.join(['python3', '-c', REMOTE.read_text(), cmd, *args])
def _ssh(command, src=None, dst=None, timeout=1800):
try:
# src: bytes, or a file to stream (an archive can be larger than memory).
feed = {'input': src} if isinstance(src, bytes) else {
'stdin': src if src is not None else subprocess.DEVNULL}
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command], **feed,
stdout=dst if dst is not None else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=timeout)
except subprocess.TimeoutExpired:
raise BackupError('the headset took too long to answer')
except OSError as error:
raise BackupError('could not reach the headset: ' + str(error))
if result.returncode:
raise BackupError(result.stderr.decode(errors='replace').strip()[-600:] or
'the headset command failed')
return result.stdout
def remote_json(cmd, *args, src=None, timeout=120):
out = _ssh(remote_command(cmd, *args), src=src, timeout=timeout)
try:
return json.loads(out)
except (ValueError, TypeError):
raise BackupError('unexpected answer from the headset')
def _sha256(path):
digest = hashlib.sha256()
with open(str(path), 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def inspect_archive(path):
"""Every file in one of our part archives, with its size and SHA-256; refuses anything else."""
files, names = [], set()
try:
with tarfile.open(str(path), 'r:gz') as archive:
for member in archive:
name = member.name
parts = name.split('/')
if (not member.isfile() or name.startswith('/') or '\\' in name or name in names
or any(p in ('', '.', '..') for p in parts)):
raise BackupError('backup archive has an unsafe entry: %r' % name[:200])
names.add(name)
digest = hashlib.sha256()
src = archive.extractfile(member)
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
files.append({'path': name, 'bytes': member.size, 'sha256': digest.hexdigest()})
except (OSError, EOFError, tarfile.TarError) as error:
raise BackupError('could not read the backup archive: ' + str(error))
return files
def preview():
"""What a backup would hold, before making one."""
plan = remote_json('plan')
apps, error = _android_apps()
return {'build': plan.get('build'), 'version': plan.get('version'), 'parts': plan.get('parts', []),
'android': apps, 'android_error': error, 'excluded': plan.get('excluded', []), 'folder': str(backup_root())}
def _android_apps():
"""(installed Android apps, None) or ([], why they couldn't be listed)."""
try:
return [{'package': a['package'], 'label': a.get('label') or a['package'],
'running': bool(a.get('running'))} for a in android.list_apps()], None
except android.FrameError as error:
return [], str(error)
def backup(report=lambda stage, percent=None: None, kinds=KINDS, headset=None):
kinds = [k for k in KINDS if k in kinds]
if not kinds:
raise BackupError('choose something to back up')
report('Reading what is on the headset', 1)
plan = remote_json('plan')
parts = [p for p in plan.get('parts', []) if p.get('kind') in kinds and PART.fullmatch(str(p.get('id')))]
apps, error = _android_apps() if 'android' in kinds else ([], None)
root = backup_root()
root.mkdir(parents=True, exist_ok=True)
name = time.strftime('%Y-%m-%d-%H%M%S')
final = backup_path(name)
if final.exists():
raise BackupError('a backup was started this second already; try again')
# Built under a hidden name and renamed when complete, so a cut-off backup is never listed.
staging = root / ('.' + name + '.partial')
staging.mkdir()
entries = []
skipped = [{'id': 'android', 'label': 'Android apps', 'reason': error}] if error else []
total = len(parts) + len(apps) or 1
try:
for i, part in enumerate(parts):
report('Copying ' + part['label'], 2 + 90 * i / total)
rel = archive_name(part['id'])
dest = staging / rel
dest.parent.mkdir(parents=True, exist_ok=True)
with open(str(dest), 'wb') as dst:
_ssh(remote_command('pack', part['id']), dst=dst)
files = inspect_archive(dest)
entries.append({'id': part['id'], 'kind': part['kind'], 'label': part['label'],
'appid': part.get('appid'), 'archive': rel, 'sha256': _sha256(dest),
'archive_bytes': dest.stat().st_size, 'files': files,
'bytes': sum(f['bytes'] for f in files)})
skipped += [{'id': part['id'], 'label': part['label'], 'path': s['path'], 'reason': s['reason']}
for s in part.get('skipped', [])]
for j, app in enumerate(apps):
report('Copying ' + app['label'] + ' (Android app data)', 2 + 90 * (len(parts) + j) / total)
part_id = 'android:' + app['package']
if app['running']:
skipped.append({'id': part_id, 'label': app['label'], 'reason': 'It was open; close it and back up again'})
continue
rel = archive_name(part_id)
dest = staging / rel
dest.parent.mkdir(parents=True, exist_ok=True)
try:
result = android.backup_data(app['package'], dest)
except android.FrameError as error:
skipped.append({'id': part_id, 'label': app['label'], 'reason': str(error)})
continue
entries.append({'id': part_id, 'kind': 'android', 'label': app['label'] + ' (Android app data)',
'package': app['package'], 'instance': result.get('instance'), 'archive': rel,
'sha256': result['sha256'], 'archive_bytes': dest.stat().st_size,
'files': result.get('files'), 'bytes': result.get('bytes')})
report('Writing the manifest', 95)
manifest = {'format': FORMAT, 'app': 'Frame Control', 'name': name,
'created': time.strftime('%Y-%m-%dT%H:%M:%S%z'),
'headset': {'name': headset or android.FRAME, 'build': plan.get('build'),
'version': plan.get('version')},
'parts': entries, 'skipped': skipped, 'excluded': plan.get('excluded', [])}
(staging / 'manifest.json').write_text(json.dumps(manifest, indent=1))
os.rename(str(staging), str(final))
except BaseException:
shutil.rmtree(str(staging), ignore_errors=True)
raise
size = sum(e['archive_bytes'] for e in entries)
message = 'Backed up %d part%s (%s) to %s' % (len(entries), '' if len(entries) == 1 else 's',
human(size), final)
if skipped:
message += '; %d skipped' % len(skipped)
return {'message': message, 'name': name, 'path': str(final), 'parts': len(entries),
'bytes': size, 'skipped': skipped}
def human(n):
for unit in ('bytes', 'KB', 'MB', 'GB'):
if n < 1000 or unit == 'GB':
return ('%d %s' % (n, unit)) if unit == 'bytes' else '%.1f %s' % (n, unit)
n /= 1000.0
def read_manifest(name):
path = backup_path(name)
try:
manifest = json.loads((path / 'manifest.json').read_text())
except (OSError, ValueError):
raise BackupError('that backup has no readable manifest')
if not isinstance(manifest, dict) or manifest.get('format') != FORMAT or \
not isinstance(manifest.get('parts'), list):
raise BackupError('that backup was made by a newer or unknown version')
for part in manifest['parts']:
if (not isinstance(part, dict) or not PART.fullmatch(str(part.get('id'))) or
not ARCHIVE.fullmatch(str(part.get('archive'))) or part['archive'] != archive_name(part['id'])):
raise BackupError('that backup manifest lists something unexpected')
return path, manifest
def list_backups():
root = backup_root()
out = []
try:
names = sorted((p.name for p in root.iterdir() if NAME.fullmatch(p.name) and p.is_dir()), reverse=True)
except OSError:
names = []
for name in names:
try:
_, m = read_manifest(name)
except BackupError as error:
out.append({'name': name, 'error': str(error)})
continue
out.append({'name': name, 'created': m.get('created'), 'build': (m.get('headset') or {}).get('build'),
'headset': (m.get('headset') or {}).get('name'), 'parts': len(m['parts']),
'bytes': sum(p.get('archive_bytes') or 0 for p in m['parts'])})
return {'backups': out, 'folder': str(root)}
def restore_plan(name):
"""Each part of a backup and whether it can go back now; reads only."""
path, manifest = read_manifest(name)
plans, check = {}, []
for part in manifest['parts']:
archive = path / part['archive']
state = None
if not archive.is_file():
state = 'missing'
elif _sha256(archive) != part.get('sha256'):
state = 'damaged'
plans[part['id']] = {'id': part['id'], 'kind': part.get('kind'), 'label': part.get('label'),
'bytes': part.get('bytes'), 'status': state}
if state is None and part['kind'] != 'android':
check.append({'id': part['id'], 'files': [f['path'] for f in part.get('files') or []]})
if check:
answer = remote_json('check', src=json.dumps({'parts': check}).encode())
for result in answer.get('parts', []):
plan = plans.get(result.get('id'))
if plan:
plan.update(status=result.get('status'), replace=result.get('replace', 0),
new=result.get('new', 0), held=result.get('held', {}))
android_parts = [p for p in plans.values() if p['kind'] == 'android' and p['status'] is None]
if android_parts:
installed = {a['package']: a for a in android.list_apps()}
for plan in android_parts:
app = installed.get(plan['id'].split(':', 1)[1])
plan['status'] = 'not_installed' if not app else 'running' if app.get('running') else 'ok'
if plan['status'] == 'not_installed':
plan['reason'] = 'Install the app first, then restore its data'
elif plan['status'] == 'ok':
plan['replace'] = 1 # the app's whole private data folder, kept beside it
for plan in plans.values():
plan.setdefault('reason', REASONS.get(plan['status']) if plan['status'] != 'ok' else None)
return {'name': name, 'headset': manifest.get('headset'), 'parts': list(plans.values())}
def restore(name, ids, confirmed=False, report=lambda stage, percent=None: None):
"""Restore the chosen parts that can go back now; the rest are skipped, not failed."""
if not isinstance(ids, list) or not ids or not all(isinstance(i, str) and PART.fullmatch(i) for i in ids):
raise BackupError('choose what to restore')
if confirmed is not True:
raise BackupError('confirm the restore first: it replaces files on the headset')
path, manifest = read_manifest(name)
by_id = {p['id']: p for p in manifest['parts']}
plan = {p['id']: p for p in restore_plan(name)['parts']}
stamp = time.strftime('%Y%m%d-%H%M%S')
done, skipped = [], []
for i, part_id in enumerate(ids):
part, state = by_id.get(part_id), plan.get(part_id)
if not part or not state:
skipped.append({'id': part_id, 'reason': 'Not in this backup'})
continue
if state['status'] != 'ok':
skipped.append({'id': part_id, 'label': state['label'], 'reason': state.get('reason') or state['status']})
continue
report('Restoring ' + state['label'], 5 + 90 * i / len(ids))
archive = path / part['archive']
try:
if part['kind'] == 'android':
result = android.restore_data(part_id.split(':', 1)[1], str(archive))
else:
with open(str(archive), 'rb') as src:
result = remote_json('restore', part_id, stamp, src=src, timeout=1800)
except android.FrameError as error:
skipped.append({'id': part_id, 'label': state['label'], 'reason': str(error)})
continue
done.append(dict(result, id=part_id, label=state['label']))
message = 'Restored %d part%s' % (len(done), '' if len(done) == 1 else 's')
if skipped:
message += ', skipped %d' % len(skipped)
return {'message': message, 'restored': done, 'skipped': skipped}
# ---- Game Mode ---------------------------------------------------------------
def session_status():
return remote_json('session', timeout=30)
def session_command(selector):
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
if selector not in SELECTORS:
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
return shlex.join([selector, 'gamescope'])
def return_to_game_mode(confirmed=False):
if confirmed is not True:
raise BackupError('confirm first: switching closes the desktop and any apps open in it')
state = session_status()
if state.get('mode') == 'game':
return {'message': 'The headset is already in Game Mode. The desktop you see is a panel: '
'close it from the Steam dashboard.', 'state': state, 'switched': False}
if state.get('mode') != 'desktop':
# Only a desktop session seen running is switched: the switch restarts the session,
# which would close whatever is running in Game Mode.
raise BackupError("couldn't tell that the headset is in Desktop Mode, so nothing was changed. "
'Use the manual steps in the guide if it is stuck.')
command = session_command(state.get('selector'))
try:
# The session restarts under it, so the command may not get to answer.
_ssh(command, timeout=60)
except BackupError as error:
if 'took too long' not in str(error):
raise BackupError('the switch was refused: %s. Use the manual steps in the guide.' % error)
return {'message': 'Asked the headset to return to Game Mode; it restarts its session in a few seconds',
'state': state, 'switched': True, 'command': command}
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd == 'backup':
r = backup(lambda stage, percent=None: print(stage, file=sys.stderr))
elif cmd == 'list':
r = list_backups()
elif cmd == 'plan' and len(args) == 1:
r = restore_plan(args[0])
elif cmd == 'restore' and len(args) >= 2:
r = restore(args[0], args[1:], confirmed=True)
elif cmd == 'session':
r = session_status()
elif cmd == 'game-mode':
r = return_to_game_mode(confirmed=True)
else:
print(__doc__.strip().splitlines()[-1], file=sys.stderr)
return 2
except android.FrameError as error:
print('error:', error, file=sys.stderr)
return 1
print(json.dumps(r, indent=1))
return 0
if __name__ == '__main__':
sys.exit(main())
+442
View File
@@ -0,0 +1,442 @@
"""Backup and restore on the Frame: what to save, packing it and putting it back. Stdlib only.
Runs on the headset as `python3 -c <this file> <command> ...` (ui/frame_backup.py sends it),
so stdin is free for the archive or file list. Commands:
plan JSON: the headset's build and every part worth saving, with sizes
pack PART a tar.gz of that part's files on stdout (reads only)
check stdin JSON {"parts": [{"id", "files": [paths]}]}: what a restore would do
restore PART STAMP stdin tar.gz: write that part's restorable files, keeping replaced ones
session JSON: Game Mode or Desktop Mode, and the switch command on this image
Paths are relative to the home folder, with '/' separators, everywhere: in archives, in the
manifest and in `check`. Nothing here deletes a file. `restore` replaces only files named in
its archive, and first copies each one it replaces under PREVIOUS/<stamp>/.
"""
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import subprocess
import sys
import tarfile
import tempfile
# FRAME_BACKUP_HOME: the tests point this at a folder laid out like the Frame's home.
HOME = Path(os.environ.get('FRAME_BACKUP_HOME') or Path.home())
STEAM = '.local/share/Steam'
COMPAT = STEAM + '/steamapps/compatdata'
PREFIX_USER = 'pfx/drive_c/users/steamuser'
# Where Windows games keep saves inside a Proton prefix. Steam Cloud covers many games, not all.
SAVE_DIRS = ('AppData/Roaming', 'AppData/Local', 'AppData/LocalLow', 'Documents', 'Saved Games')
# Wine's and Windows' own folders inside those: caches, not saves.
SAVE_SKIP = ('AppData/Local/Temp', 'AppData/Local/Microsoft', 'AppData/Roaming/Microsoft',
'AppData/Local/D3DSCache', 'AppData/Local/NVIDIA', 'AppData/Local/CrashDumps',
'AppData/LocalLow/Microsoft')
# Native Linux games: the engines' standard save folders.
LINUX_SAVES = ('.config/unity3d', '.local/share/godot/app_userdata')
# Steam settings. Controller layouts go back; the rest is kept so you can look things up.
STEAM_RESTORE = ('241100/remote/controller_config',) # under userdata/<account>/
STEAM_REFERENCE = ('config/localconfig.vdf', 'config/shortcuts.vdf', 'config/grid')
STEAM_CONTROLLER = STEAM + '/steamapps/common/Steam Controller Configs' # <account>/config/
# Desktop settings worth keeping. Steam rewrites steamvr.vrsettings while SteamVR runs, so
# it's only for reference.
SYSTEM_RESTORE = ('.config/kdeglobals', '.config/kwinrc', '.config/kxkbrc', '.config/konsolerc',
'.config/dolphinrc')
SYSTEM_REFERENCE = ('.config/openvr/config/steamvr.vrsettings',)
PREVIOUS = '.local/share/frame-control/restore-previous'
MAX_FILE = 2 * 1024 ** 3 # a bigger "save" is a cache or a video; listed as skipped
MAX_PART = 20 * 1024 ** 3
MAX_FILES = 100000
SHORTCUT_IDS = 2 ** 31 # non-Steam shortcuts and Lepton instances use ids above this
# Never copied, whatever folder they turn up in. Checked on every path, packing and restoring.
SECRET_NAMES = {'steam.token', 'loginusers.vdf', 'registry.vdf', 'config.vdf', 'secrets.env',
'authorized_keys', 'known_hosts', 'credentials', 'credentials.json'}
SECRET_DIRS = {'.ssh', '.gnupg', '.pki', 'keyrings', 'kwalletd', 'cliproxyapi', 'tailscale'}
SECRET_SUFFIXES = ('.token', '.pem', '.key', '.kwl')
EXCLUDED = [
'Steam sign-in: steam.token, config/config.vdf, loginusers.vdf, registry.vdf, ssfn files',
'Keys and passwords: ~/.ssh, ~/.gnupg, keyrings and KWallet, *.token/*.key/*.pem files, '
'Tailscale and proxy settings (secrets.env)',
'Games themselves, Proton and runtimes (reinstall them from Steam), shader caches, Steam '
'Cloud folders under userdata/<account>/<appid>, screenshots (Tools → Capture & Live saves them)',
'Android apps themselves and their shared storage (/sdcard); only private app data is saved',
'Symbolic links, and single files over 2 GiB',
]
def is_secret(rel):
parts = PurePosixPath(rel).parts
name = parts[-1].lower() if parts else ''
return (name in SECRET_NAMES or name.startswith('ssfn') or name.startswith('id_rsa') or
name.startswith('id_ed25519') or name.startswith('id_ecdsa') or
name.endswith(SECRET_SUFFIXES) or any(p.lower() in SECRET_DIRS for p in parts[:-1]))
def safe_rel(rel):
"""A relative '/' path with no '..', '.', empty or absolute parts."""
if not isinstance(rel, str) or not rel or '\\' in rel or '\0' in rel or rel.startswith('/'):
return False
parts = rel.split('/')
return all(p not in ('', '.', '..') for p in parts) and not re.match(r'^[A-Za-z]:', rel)
def _under(rel, root):
return rel == root or rel.startswith(root + '/')
def _walk(rel_root, skip=()):
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
files, skipped = [], []
base = HOME / rel_root
if base.is_symlink() or not base.is_dir():
if base.is_file() and not base.is_symlink():
return [(rel_root, base.stat().st_size)], skipped
return files, skipped
for top, dirs, names in os.walk(str(base)):
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
not any(_under(rel_top + '/' + d, s) for s in skip) and
not is_secret(rel_top + '/' + d + '/x'))
for name in sorted(names):
path, rel = os.path.join(top, name), rel_top + '/' + name
if os.path.islink(path) or is_secret(rel):
continue
try:
st = os.lstat(path)
except OSError:
continue
if not os.path.isfile(path):
continue
if st.st_size > MAX_FILE:
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
continue
files.append((rel, st.st_size))
return files, skipped
def _manifest_field(text, key):
m = re.search(r'"%s"\s+"([^"]*)"' % key, text)
return m.group(1) if m else None
def game_name(appid):
try:
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
except OSError:
return None
return _manifest_field(text, 'name')
def accounts():
root = HOME / STEAM / 'userdata'
try:
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
and not p.is_symlink() and p.name != '0')
except OSError:
return []
def _prefixes():
try:
return sorted((p.name for p in (HOME / COMPAT).iterdir()
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
except OSError:
return []
def part_roots(part):
"""(roots to save, roots that a restore may write) for a part id, or None if unknown."""
if part.startswith('saves:') and part[6:].isdigit():
user = '%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER)
roots = tuple(user + '/' + d for d in SAVE_DIRS)
return roots, roots
if part == 'saves:linux':
return LINUX_SAVES, LINUX_SAVES
if part == 'settings:steam':
save, restore = [], []
for acct in accounts():
base = '%s/userdata/%s/' % (STEAM, acct)
restore += [base + r for r in STEAM_RESTORE] + ['%s/%s/config' % (STEAM_CONTROLLER, acct)]
save += [base + r for r in STEAM_REFERENCE]
return tuple(save + restore), tuple(restore)
if part == 'settings:system':
return SYSTEM_RESTORE + SYSTEM_REFERENCE, SYSTEM_RESTORE
return None
def restore_roots(part, rel):
"""The restore roots for rel within part; the Steam ones don't depend on today's accounts."""
if part == 'settings:steam':
m = re.match(r'^(?:%s/userdata/(\d+)/|%s/(\d+)/)' % (re.escape(STEAM), re.escape(STEAM_CONTROLLER)), rel)
acct = m and (m.group(1) or m.group(2))
if not acct:
return ()
base = '%s/userdata/%s/' % (STEAM, acct)
return tuple(base + r for r in STEAM_RESTORE) + ('%s/%s/config' % (STEAM_CONTROLLER, acct),)
roots = part_roots(part)
return roots[1] if roots else ()
def part_files(part):
roots = part_roots(part)
if roots is None:
raise ValueError('unknown backup part %r' % part)
skip = ()
if part.startswith('saves:') and part != 'saves:linux':
skip = tuple('%s/%s/%s/%s' % (COMPAT, part[6:], PREFIX_USER, s) for s in SAVE_SKIP)
files, skipped = [], []
for root in roots[0]:
f, s = _walk(root, skip)
files += f
skipped += s
return files, skipped
def part_label(part):
if part == 'saves:linux':
return 'Linux game saves (Unity and Godot games)'
if part == 'settings:steam':
return 'Steam settings and controller layouts'
if part == 'settings:system':
return 'Desktop and SteamVR settings'
appid = part[6:]
name = game_name(appid)
if name:
return name + ' saves'
return ('Non-Steam game %s saves' if int(appid) >= SHORTCUT_IDS else 'Game %s saves') % appid
def plan():
parts = []
ids = ['saves:' + a for a in _prefixes()] + ['saves:linux', 'settings:steam', 'settings:system']
for part in ids:
files, skipped = part_files(part)
if not files:
continue
parts.append({'id': part, 'kind': part.split(':')[0], 'label': part_label(part),
'files': len(files), 'bytes': sum(s for _, s in files), 'skipped': skipped,
'appid': part[6:] if part[6:].isdigit() else None,
'restorable': sum(1 for rel, _ in files
if any(_under(rel, r) for r in restore_roots(part, rel)))})
return {'build': os_release().get('BUILD_ID'), 'version': os_release().get('VERSION_ID'),
'parts': parts, 'excluded': EXCLUDED}
def os_release():
out = {}
try:
for line in Path(os.environ.get('FRAME_BACKUP_OS_RELEASE') or '/etc/os-release').read_text().splitlines():
k, _, v = line.partition('=')
out[k.strip()] = v.strip().strip('"')
except OSError:
pass
return out
def pack(part, output):
files, _ = part_files(part)
if len(files) > MAX_FILES or sum(s for _, s in files) > MAX_PART:
raise ValueError('this part is too big to back up (over 100,000 files or 20 GiB)')
with tarfile.open(fileobj=output, mode='w|gz') as archive:
for rel, _ in files:
path = HOME / rel
try:
with open(str(path), 'rb') as src:
st = os.fstat(src.fileno())
member = tarfile.TarInfo(rel)
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
archive.addfile(member, src)
except OSError:
continue # gone since the walk (a game wrote it); the manifest lists what's in the archive
def target_status(part, rel):
"""None if rel may be restored now, else why not (a short code)."""
if is_secret(rel) or not safe_rel(rel):
return 'refused'
if not any(_under(rel, r) for r in restore_roots(part, rel)):
return 'reference'
if part.startswith('saves:') and part != 'saves:linux':
appid = part[6:]
if int(appid) < SHORTCUT_IDS and \
not (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).is_file():
return 'not_installed'
if not (HOME / COMPAT / appid / PREFIX_USER).is_dir():
return 'no_prefix'
if part == 'settings:steam':
acct = re.search(r'/(\d+)/', rel[len(STEAM):])
if not acct or not (HOME / STEAM / 'userdata' / acct.group(1)).is_dir():
return 'no_account'
# A link anywhere on the way could send the write somewhere else.
path = HOME
for p in rel.split('/')[:-1]:
path = path / p
if path.is_symlink() or (path.exists() and not path.is_dir()):
return 'refused'
if (HOME / rel).is_symlink() or ((HOME / rel).exists() and not (HOME / rel).is_file()):
return 'refused'
return None
def check(request):
out = []
for item in request.get('parts', []):
part, files = item.get('id'), item.get('files') or []
if not isinstance(part, str) or part_roots(part) is None:
out.append({'id': part, 'status': 'unknown'})
continue
counts = {}
replace = new = 0
for rel in files:
why = target_status(part, rel)
if why:
counts[why] = counts.get(why, 0) + 1
elif (HOME / rel).exists():
replace += 1
else:
new += 1
status = 'ok' if replace + new else next(
(c for c in ('not_installed', 'no_prefix', 'no_account', 'reference', 'refused') if c in counts), 'empty')
out.append({'id': part, 'status': status, 'replace': replace, 'new': new, 'held': counts})
return {'parts': out}
def _sha256(path):
digest = hashlib.sha256()
with open(str(path), 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def restore(part, stamp, source):
if part_roots(part) is None:
raise ValueError('unknown backup part %r' % part)
if not re.fullmatch(r'\d{8}-\d{6}', stamp):
raise ValueError('bad restore stamp')
# Spool first: everything is checked before anything is written.
spool_dir = HOME / '.cache/frame-control'
spool_dir.mkdir(parents=True, exist_ok=True)
written, replaced, held = [], [], {}
with tempfile.TemporaryDirectory(prefix='restore-', dir=str(spool_dir)) as tmp:
spool = Path(tmp) / 'part.tar.gz'
with open(str(spool), 'wb') as dst:
shutil.copyfileobj(source, dst, 1024 * 1024)
todo, names, total = [], set(), 0
with tarfile.open(str(spool), 'r:gz') as archive:
for member in archive:
if not member.isfile() or not safe_rel(member.name) or member.name in names:
raise ValueError('backup has an unsafe, duplicate or non-file entry: %r' % member.name[:200])
names.add(member.name)
total += member.size
if len(names) > MAX_FILES or total > MAX_PART:
raise ValueError('backup part is too big')
why = target_status(part, member.name)
if why:
held[why] = held.get(why, 0) + 1
else:
todo.append(member.name)
previous = HOME / PREVIOUS / stamp
keep = set(todo)
with tarfile.open(str(spool), 'r:gz') as archive:
for member in archive:
if member.name not in keep:
continue
target = HOME / member.name
target.parent.mkdir(parents=True, exist_ok=True)
part_file = target.parent / ('.frame-restore-%d.part' % os.getpid())
with archive.extractfile(member) as src, open(str(part_file), 'wb') as dst:
shutil.copyfileobj(src, dst, 1024 * 1024)
os.chmod(str(part_file), (member.mode & 0o755) | 0o600)
os.utime(str(part_file), (member.mtime, member.mtime))
if target.exists():
if _sha256(target) == _sha256(part_file):
os.unlink(str(part_file))
held['same'] = held.get('same', 0) + 1
continue
keep_copy = previous / member.name
keep_copy.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(target), str(keep_copy))
replaced.append(member.name)
os.replace(str(part_file), str(target))
written.append(member.name)
return {'id': part, 'written': len(written), 'replaced': len(replaced), 'held': held,
'previous': (PREVIOUS + '/' + stamp) if replaced else None}
# ---- Game Mode and Desktop Mode --------------------------------------------
#
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
# read the session from these files and switch with holo-session-select or
# steamos-session-select, so that's what this reads and suggests.
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
def _running(name, full=False):
try:
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0
except OSError:
return False
def session():
configured, conf = None, None
for path in SDDM_CONFS:
try:
text = Path(path).read_text(errors='replace')
except OSError:
continue
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
conf, configured = path, (m.group(1) if m else None)
break
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
# under the start-gamescope-session script.
gamescope = (_running('gamescope') or _running('gamescope-wl') or
_running('start-gamescope-session', full=True))
plasma = _running('plasmashell')
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
if gamescope:
mode = 'game'
elif plasma or (configured and 'plasma' in configured):
mode = 'desktop'
else:
mode = 'unknown'
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
'configured': configured, 'config_file': conf, 'selector': selector}
def main(argv):
cmd, args = (argv[0] if argv else ''), argv[1:]
try:
if cmd == 'plan':
print(json.dumps(plan()))
elif cmd == 'pack' and len(args) == 1:
out = sys.stdout.buffer
pack(args[0], out)
out.flush()
elif cmd == 'check':
print(json.dumps(check(json.load(sys.stdin))))
elif cmd == 'restore' and len(args) == 2:
print(json.dumps(restore(args[0], args[1], sys.stdin.buffer)))
elif cmd == 'session':
print(json.dumps(session()))
else:
sys.stderr.write('usage: plan | pack PART | check | restore PART STAMP | session\n')
return 2
except (OSError, ValueError, tarfile.TarError) as error:
sys.stderr.write(str(error) + '\n')
return 1
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))
+590
View File
@@ -0,0 +1,590 @@
#!/usr/bin/env python3
"""Video from the headset: recordings of what it shows, with its sound, and Steam's own clips.
Two halves share this file.
On the computer, server.py builds the shell commands below, runs them over SSH and
copies the finished .mp4 to ~/Videos/SteamFrame (Recorder does a recording from Start
to the copy; export_clip does a Steam clip). Nothing here needs ffmpeg on the computer.
On the Frame, this file runs as `python3 - list` or `python3 - export ID` (sent on
stdin, like frame_steam.py, so stdlib only): it lists Steam's game recordings and
stitches one into a single .mp4 with SteamOS's ffmpeg, copying the streams as they are.
It prints one JSON object, or {"error": ...}.
Steam's game recording keeps DASH segments, one folder per recording session:
~/.local/share/Steam/userdata/<account>/gamerecordings/
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<appid>_<...>/ a saved clip
session.mpd
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
init-stream1.m4s chunk-stream1-00001.m4s ...
Each stream's init segment followed by its chunks, in order, is a fragmented MP4 of
that stream. The folder names are as users and Steam's own files describe them; the
listing doesn't depend on them: any folder under clips/ or video/ (up to four levels)
holding init-stream*.m4s counts as a session.
"""
import json
import os
import re
import secrets
import shlex
import shutil
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
# ---- where things go ---------------------------------------------------------
VIDEOS_DIR = Path.home() / "Videos" / "SteamFrame" # on the computer, beside ~/Pictures/SteamFrame
# On the Frame, relative to its home: recordings in progress and stitched exports.
# Not /tmp: it's a RAM disk there, and a recording can be over a gigabyte.
WORK = ".cache/frame-control/video"
# ---- recording what the headset shows ------------------------------------------
#
# The same picture the live view streams: SteamVR's copy of the headset view in the
# v4l2loopback device, encoded with x264 (the hardware encoder crashes ffmpeg). The
# sound is the monitor of PipeWire's default output, read through its PulseAudio
# server: SteamOS's ffmpeg has the pulse device (the media player writes to it, see
# docs/vr-video.md). If it can't be read, the recording goes ahead without sound and
# says so. Both inputs are stamped with the wall clock: the camera device and the
# audio server would otherwise count from different clocks.
STREAM_DEVICE = "/dev/video99"
AUDIO_SOURCE = "@DEFAULT_MONITOR@" # PulseAudio's name for the default output's monitor
MAX_SECONDS = 20 * 60
FPS = 30
VIDEO_RATE = 8 # Mbit/s at 1080p
AUDIO_RATE = 160 # kbit/s AAC
# Room for a full-length recording twice over (the .mkv, then the .mp4 made from it).
NEED_KB = (VIDEO_RATE * 1000 + AUDIO_RATE) * MAX_SECONDS // 8 * 2 + 512 * 1024
REC_NAME = re.compile(r"rec-\d{8}-\d{6}")
class ClipError(Exception):
def __init__(self, message, status=502):
super().__init__(message)
self.status = status
def recording_name(now=None):
return time.strftime("rec-%Y%m%d-%H%M%S", time.localtime(now))
def probe_command():
"""Checks before a recording, run on the Frame: prints audio=1 or audio=0.
One recording runs at a time, so anything left in the folder is from one that was
cut off (the app quit, or the copy failed); it's removed to free the space."""
return (f"set -u; export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; "
f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"command -v ffmpeg >/dev/null || {{ echo 'ffmpeg is missing on the Frame' >&2; exit 3; }}; "
f"d=\"$HOME/{WORK}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
f"free=$(df -Pk \"$d\" | awk 'NR==2 {{print $4}}'); "
f"[ \"${{free:-0}}\" -ge {NEED_KB} ] || {{ echo 'Not enough free space on the Frame to record: "
f"it needs {NEED_KB // 1024 // 1024 + 1} GB' >&2; exit 4; }}; "
f"if timeout 5 ffmpeg -hide_banner -loglevel error -nostdin -f pulse -i {AUDIO_SOURCE} -t 0.2 -f null - "
f">/dev/null 2>&1; then echo audio=1; else echo audio=0; fi")
def record_command(name, audio, max_seconds=MAX_SECONDS):
"""The recording, run on the Frame through `bash -c`.
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
channel: when that closes (Stop closed it, or the computer's ssh went away) the
shell types q into ffmpeg's stdin, a FIFO, which makes it finish the file properly.
Not a signal: SteamOS's ffmpeg (n7.0) catches SIGTERM and SIGINT and records on
(seen on a Frame, build 20261015.6141207). If it's still there 10 s later it's
killed. A recording that ends by itself (the time limit, an error) ends the wait
too. The shell prints "started" once ffmpeg is running and "exit N" when it has gone."""
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
if not 1 <= max_seconds <= MAX_SECONDS:
raise ValueError("max_seconds out of range")
clock = "-thread_queue_size 1024 -use_wallclock_as_timestamps 1"
inputs = f"{clock} -f v4l2 -video_size 1920x1080 -i {STREAM_DEVICE}"
maps = "-map 0:v:0"
codecs = (f"-vf fps={FPS},scale=-2:1080,format=yuv420p -c:v libx264 -preset ultrafast "
f"-b:v {VIDEO_RATE}M -maxrate {VIDEO_RATE}M -bufsize {VIDEO_RATE * 2}M -g {FPS * 2}")
if audio:
inputs += f" {clock} -f pulse -i {AUDIO_SOURCE}"
maps += " -map 1:a:0"
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
# ffmpeg's stdin is a FIFO the shell holds open on fd 3 (opening it waits for both
# ends, so ffmpeg is started first); it's unlinked once both ends are open.
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
f"trap '' PIPE; c=\"$d/{name}.ctl\"; rm -f \"$c\"; mkfifo \"$c\" || exit 1; "
f"ffmpeg -hide_banner -loglevel error -y {inputs} {maps} {codecs} -t {max_seconds} "
f"\"$d/{name}.mkv\" <\"$c\" >/dev/null 2>\"$d/{name}.log\" & p=$!; "
f"exec 3>\"$c\"; rm -f \"$c\"; "
f"echo started; "
f"while kill -0 $p 2>/dev/null; do read -r -t 1 _; r=$?; [ $r -eq 0 ] || [ $r -gt 128 ] || break; done; "
f"kill -0 $p 2>/dev/null && printf q >&3 2>/dev/null; exec 3>&-; "
f"i=0; while kill -0 $p 2>/dev/null && [ $i -lt 40 ]; do sleep 0.25; i=$((i+1)); done; "
f"kill -0 $p 2>/dev/null && kill -KILL $p 2>/dev/null; wait $p; echo \"exit $?\"")
return "bash -c " + shlex.quote(script)
def finish_command(name):
"""After the recording: make the .mp4 the computer gets (no re-encode), on the Frame."""
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return (f"cd \"$HOME/{WORK}\" || exit 1; "
f"[ -s {name}.mkv ] || {{ tail -c 2000 {name}.log >&2 2>/dev/null; echo 'The recording is empty' >&2; exit 1; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -y -i {name}.mkv -map 0 -c copy -movflags +faststart {name}.mp4 "
f"&& rm -f {name}.mkv && wc -c < {name}.mp4")
def cleanup_command(name):
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return f"rm -f \"$HOME/{WORK}/{name}\".*"
class Recorder:
"""One recording at a time, from Start to the .mp4 on this computer.
The server supplies how to reach the Frame, so this can be tested without one:
open_channel(command, stderr) -> Popen of `command` on the Frame, stdin and stdout pipes
run(command, timeout) -> its stdout; raises on failure
fetch(remote, local) copies a file from the Frame (a path relative to its home)
start_thread(fn) starts fn in a thread that counts as headset work
release(proc) forgets a channel once it has closed
"""
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None):
self.open_channel, self.run, self.fetch = open_channel, run, fetch
self.start_thread, self.release = start_thread, release
self.folder = Path(folder) if folder else VIDEOS_DIR
self._lock = threading.Lock()
self._state = {"state": "idle"}
self._proc = None
def status(self):
with self._lock:
s = dict(self._state)
if s["state"] == "recording":
s["elapsed"] = round(time.time() - s["started"], 1)
return s
def start(self):
with self._lock:
if self._state["state"] in ("starting", "recording", "stopping", "saving"):
raise ClipError("Already recording, or still saving the last recording", 409)
self._state = {"state": "starting"}
try:
out = self.run(probe_command(), 30)
audio = "audio=1" in out
name = recording_name()
errors = tempfile.TemporaryFile()
proc = self.open_channel(record_command(name, audio), errors)
lines = _line_reader(proc.stdout)
first = _next_line(lines, 20)
if first != "started":
_end(proc)
errors.seek(0)
err = errors.read().decode(errors="replace").strip()
errors.close()
self.release(proc)
raise ClipError(err or "The Frame didn't start recording")
except BaseException as e:
with self._lock:
self._state = {"state": "failed", "error": str(e)}
raise
with self._lock:
self._proc = proc
self._state = {"state": "recording", "name": name, "audio": audio, "started": time.time(),
"limit": MAX_SECONDS}
try:
self.start_thread(lambda: self._watch(proc, name, audio, lines, errors))
except BaseException:
_end(proc)
self.release(proc)
with self._lock:
self._state, self._proc = {"state": "failed", "error": "Couldn't follow the recording"}, None
raise
return dict(self.status(), message="Recording" + ("" if audio else
" without sound: the Frame's audio couldn't be read"))
def stop(self):
"""Ends the recording; the copy to this computer carries on in the background."""
with self._lock:
if self._state["state"] != "recording":
raise ClipError("Not recording", 409)
self._state = dict(self._state, state="stopping")
proc = self._proc
_close(proc.stdin) # the shell on the Frame sees EOF and stops ffmpeg
return {"state": "stopping", "message": "Stopping the recording…"}
def _watch(self, proc, name, audio, lines, errors):
# Waits for ffmpeg to finish: after Stop, or at the time limit (a margin
# past it, so a stuck channel can't hold the recording open forever).
deadline = time.time() + MAX_SECONDS + 60
while True:
stopping = self.status()["state"] == "stopping"
line = _next_line(lines, 30 if stopping else 5)
if line is None or line.startswith("exit "):
break # ffmpeg has gone and the file is finished
if line == "" and (stopping or time.time() > deadline):
break # no answer within 30 s of Stop, or well past the limit: close the channel
_end(proc)
errors.close()
self.release(proc)
with self._lock:
self._proc = None
seconds = round(time.time() - self._state["started"])
self._state = dict(self._state, state="saving")
try:
self.save(name)
msg = f"Saved {name}.mp4 to ~/Videos/SteamFrame" + ("" if audio else " (no sound)")
state = {"state": "saved", "file": str(self.folder / f"{name}.mp4"), "audio": audio,
"seconds": seconds, "message": msg}
except Exception as e:
state = {"state": "failed", "error": f"Couldn't save the recording: {e}"}
finally:
try:
self.run(cleanup_command(name), 20)
except Exception:
pass # the next recording's checks remove it
with self._lock:
self._state = state
def save(self, name):
"""Make the .mp4 on the Frame and copy it here, never leaving a partial file in the folder."""
out = self.run(finish_command(name), 600)
size = int(out.strip().splitlines()[-1]) if out.strip() else 0
_make_room(self.folder, size)
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=self.folder))
try:
self.fetch(f"{WORK}/{name}.mp4", incoming / f"{name}.mp4")
os.replace(incoming / f"{name}.mp4", self.folder / f"{name}.mp4")
finally:
shutil.rmtree(incoming, ignore_errors=True)
def _make_room(folder, size):
folder.mkdir(parents=True, exist_ok=True)
free = shutil.disk_usage(folder).free
if size and free < size + 256 * 1024**2:
raise ClipError(f"Not enough free space on this computer: it needs {size // 1024**2 + 256} MB", 507)
def _close(f):
try:
f.close()
except OSError:
pass
def _end(proc):
"""Close our end of the channel and make sure the local ssh has gone."""
_close(proc.stdin)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
_close(proc.stdout)
def _line_reader(pipe):
"""Lines from a pipe via a thread (select() can't wait on pipes on Windows); None at EOF."""
import queue
lines = queue.Queue()
def pump():
try:
for raw in iter(pipe.readline, b""):
lines.put(raw.decode(errors="replace").strip())
except (OSError, ValueError):
pass
lines.put(None)
threading.Thread(target=pump, daemon=True).start()
return lines
def _next_line(lines, timeout):
"""The next line, None at EOF, or "" after `timeout` seconds of silence."""
import queue
try:
return lines.get(timeout=timeout)
except queue.Empty:
return ""
# ---- Steam's game recordings ---------------------------------------------------
STEAM_USERDATA = ".local/share/Steam/userdata"
KINDS = {"clips": "clip", "video": "background"}
MAX_DEPTH = 4 # folders below clips/ or video/
PART = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}") # no leading dot, so never "." or ".."
INIT = re.compile(r"init-stream(\d{1,3})\.m4s")
CHUNK = re.compile(r"chunk-stream(\d{1,3})-(\d{1,9})\.m4s")
STAMP = re.compile(r"(?:^|_)(\d{1,10})_(\d{8})_(\d{6})$")
EXPORT_PATH = re.compile(re.escape(WORK) + r"/export-[0-9a-f]{16}/[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}\.mp4")
def parse_id(clip_id):
"""A recording's id, "<account>/<clips|video>/<folder>[/<folder>...]", as path parts.
Checked before it goes near a path or a shell: plain names only, no "." or "..",
at most MAX_DEPTH folders. Raises ValueError."""
parts = clip_id.split("/") if isinstance(clip_id, str) and len(clip_id) <= 600 else []
if (len(parts) < 3 or len(parts) > 2 + MAX_DEPTH or not re.fullmatch(r"\d{1,12}", parts[0])
or parts[1] not in KINDS or not all(PART.fullmatch(p) for p in parts[2:])):
raise ValueError("bad clip id")
return parts
def export_name(clip_id):
"""The .mp4's name on the computer: the clip's (or background session's) own folder name."""
return parse_id(clip_id)[2] + ".mp4"
def read_session(folder):
"""{stream number: [init, chunk, chunk...]} for one session folder, chunks in number
order. A stream without its init segment or without chunks can't be played, so it's left out."""
inits, chunks = {}, {}
for entry in os.scandir(folder):
if not entry.is_file(follow_symlinks=False):
continue
m = INIT.fullmatch(entry.name)
if m:
inits[int(m[1])] = entry.path
continue
m = CHUNK.fullmatch(entry.name)
if m:
chunks.setdefault(int(m[1]), []).append((int(m[2]), entry.path))
return {n: [inits[n], *(p for _, p in sorted(chunks[n]))] for n in sorted(inits) if chunks.get(n)}
def chunk_gaps(files):
"""How many chunk numbers are missing between the first and last (Steam deleted or lost them)."""
nums = sorted(int(CHUNK.fullmatch(os.path.basename(f))[2]) for f in files[1:])
return nums[-1] - nums[0] + 1 - len(nums) if nums else 0
def mpd_duration(text):
"""Seconds from the manifest's mediaPresentationDuration ("PT1M23.4S"), or None."""
m = re.search(r'mediaPresentationDuration="P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?"', text or "")
if not m or not any(m.groups()):
return None
d, h, mi, s = m.groups()
try:
return int(d or 0) * 86400 + int(h or 0) * 3600 + int(mi or 0) * 60 + float(s or 0)
except ValueError:
return None
def _sessions(base, depth=0):
"""Folders under base (clips/ or video/) that hold init segments, without following links."""
try:
entries = list(os.scandir(base))
except OSError:
return
if any(e.is_file(follow_symlinks=False) and INIT.fullmatch(e.name) for e in entries):
yield base
return
if depth < MAX_DEPTH:
for e in sorted(entries, key=lambda e: e.name):
if e.is_dir(follow_symlinks=False) and PART.fullmatch(e.name):
yield from _sessions(e.path, depth + 1)
def describe(userdata, clip_id):
"""What the list shows about one recording, or None if it holds nothing playable."""
parts = parse_id(clip_id)
folder = os.path.join(userdata, parts[0], "gamerecordings", *parts[1:])
streams = read_session(folder)
if not streams:
return None
files = [f for s in streams.values() for f in s]
stats = [os.stat(f) for f in files]
duration = None
for mpd in sorted(p for p in os.listdir(folder) if p.endswith(".mpd")):
try:
duration = mpd_duration(Path(folder, mpd).read_text(errors="replace")[:20000])
except OSError:
pass
if duration is not None:
break
newest = max(s.st_mtime for s in stats)
appid, when = None, newest
for p in parts[2:]:
m = STAMP.search(p)
if m:
appid = m[1]
try:
when = time.mktime(time.strptime(m[2] + m[3], "%Y%m%d%H%M%S"))
except ValueError:
pass
break
return {"id": clip_id, "kind": KINDS[parts[1]], "appid": appid, "time": when,
"size": sum(s.st_size for s in stats), "streams": len(streams),
"chunks": sum(len(s) - 1 for s in streams.values()),
"gaps": sum(chunk_gaps(s) for s in streams.values()), "duration": duration,
"file": export_name(clip_id),
# Seconds since Steam last wrote to it, by the Frame's clock: still recording if small.
"idle": round(max(0.0, time.time() - newest), 1)}
def list_recordings(userdata):
"""Every Steam recording session on this Frame, newest first (runs on the Frame)."""
found = []
try:
accounts = sorted(e.name for e in os.scandir(userdata) if e.is_dir(follow_symlinks=False)
and re.fullmatch(r"\d{1,12}", e.name))
except OSError:
return []
for account in accounts:
root = os.path.join(userdata, account, "gamerecordings")
for kind in KINDS:
for folder in _sessions(os.path.join(root, kind)):
clip_id = "/".join([account, *os.path.relpath(folder, root).split(os.sep)])
try:
info = describe(userdata, clip_id)
except (OSError, ValueError):
continue # changed while we looked, or a name we don't take
if info:
found.append(info)
found.sort(key=lambda c: c["time"], reverse=True)
return found
def stitch_command(inputs, out):
"""ffmpeg that puts each stream file in one .mp4, copying the streams as they are."""
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-nostdin", "-y"]
for f in inputs:
cmd += ["-i", str(f)]
for i in range(len(inputs)):
cmd += ["-map", str(i)]
return cmd + ["-c", "copy", "-movflags", "+faststart", str(out)]
def export(userdata, clip_id, home=None):
"""Stitch one recording into an .mp4 under WORK (runs on the Frame). -> its path
relative to home and its size. The computer copies it, then deletes it with
remove_command."""
home = Path(home or Path.home())
parts = parse_id(clip_id)
folder = Path(userdata, parts[0], "gamerecordings", *parts[1:])
real = os.path.realpath(folder)
if os.path.commonpath([real, os.path.realpath(userdata)]) != os.path.realpath(userdata):
raise ValueError("bad clip id")
streams = read_session(real)
if not streams:
raise ClipError("That recording has no complete streams on the Frame", 404)
size = sum(os.path.getsize(f) for s in streams.values() for f in s)
work = home / WORK
work.mkdir(parents=True, exist_ok=True)
_sweep(work)
# The stream files and the .mp4 made from them are each about the recording's size.
if shutil.disk_usage(work).free < size * 2 + 64 * 1024**2:
raise ClipError(f"Not enough free space on the Frame to export it: it needs {(size * 2) // 1024**2 + 64} MB", 507)
out_dir = work / f"export-{secrets.token_hex(8)}"
out_dir.mkdir()
try:
inputs = []
for n, files in streams.items():
path = out_dir / f"stream{n}.mp4"
with open(path, "wb") as dst:
for f in files:
with open(f, "rb") as src:
shutil.copyfileobj(src, dst, 1 << 20)
inputs.append(path)
out = out_dir / export_name(clip_id)
r = subprocess.run(stitch_command(inputs, out), capture_output=True, text=True, errors="replace", timeout=1800)
if r.returncode != 0 or not out.exists():
raise ClipError("ffmpeg couldn't join the recording: " + (r.stderr.strip()[-1500:] or f"exit {r.returncode}"))
for f in inputs:
f.unlink()
except BaseException:
shutil.rmtree(out_dir, ignore_errors=True)
raise
return {"path": out.relative_to(home).as_posix(), "size": out.stat().st_size}
def _sweep(work, older=6 * 3600):
"""Exports left behind by a copy that never finished."""
for d in work.glob("export-*"):
try:
if time.time() - d.stat().st_mtime > older:
shutil.rmtree(d, ignore_errors=True)
except OSError:
pass
def remove_command(path):
"""Delete an export on the Frame once it's been copied (path as export returned it)."""
if not EXPORT_PATH.fullmatch(path or ""):
raise ValueError(f"unexpected export path {path!r}")
return f"rm -rf \"$HOME\"/{shlex.quote(path.rsplit('/', 1)[0])}"
# ---- the computer's side of Steam clips -------------------------------------------
#
# run_frame(args, timeout) runs this file on the Frame with `args` and returns its JSON;
# run(command, timeout) and fetch(remote, local) as for Recorder.
def list_clips(run_frame, folder=None):
"""Steam's recordings on the Frame, each marked saved if its .mp4 is already here."""
folder = Path(folder) if folder else VIDEOS_DIR
clips = run_frame(["list"], 60)["clips"]
for c in clips:
c["saved"] = (folder / export_name(c["id"])).exists()
return {"clips": clips, "folder": str(folder)}
def export_clip(clip_id, run_frame, run, fetch, folder=None):
"""Stitch a Steam recording on the Frame and copy the .mp4 here. -> {"message", "file"}."""
folder = Path(folder) if folder else VIDEOS_DIR
name = export_name(clip_id) # checks the id before anything reaches the Frame
made = run_frame(["export", clip_id], 1900)
path = made.get("path")
remove = remove_command(path) # refuses a path that isn't one of our exports
try:
_make_room(folder, int(made.get("size") or 0))
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=folder))
try:
fetch(path, incoming / name)
os.replace(incoming / name, folder / name)
finally:
shutil.rmtree(incoming, ignore_errors=True)
finally:
try:
run(remove, 30)
except Exception:
pass # the next export sweeps it
return {"message": f"Saved {name} to ~/Videos/SteamFrame", "file": str(folder / name)}
# ---- on the Frame -----------------------------------------------------------------
def main(argv):
userdata = Path.home() / STEAM_USERDATA
try:
if argv[:1] == ["list"]:
result = {"clips": list_recordings(userdata)}
elif len(argv) == 2 and argv[0] == "export":
result = export(userdata, argv[1])
else:
result = {"error": "usage: frame_clips.py list | export ID"}
except (ClipError, ValueError, OSError, subprocess.TimeoutExpired) as e:
result = {"error": str(e)}
print(json.dumps(result))
return 1 if "error" in result else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+33
View File
@@ -5,6 +5,7 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
import base64
import hashlib
import io
import os
@@ -308,6 +309,38 @@ def clipboard_text():
raise HostError("Can't read the clipboard")
def set_clipboard_text(text):
"""Put TEXT on this computer's clipboard."""
data = text.encode("utf-8")
env = None
if MAC:
# pbcopy reads stdin in the locale's encoding, and the app may start with none (MacRoman).
cmds, env = [["pbcopy"]], {**os.environ, "LC_CTYPE": "UTF-8"}
elif WINDOWS:
# Base64 on stdin, so no console code page touches the text. clip.exe takes UTF-16 with a BOM.
script = ("Set-Clipboard -Value ([Text.Encoding]::UTF8.GetString("
"[Convert]::FromBase64String([Console]::In.ReadToEnd())))")
cmds = [["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], ["clip.exe"]]
else:
cmds = [["wl-copy"], ["xclip", "-selection", "clipboard", "-in"], ["xsel", "--clipboard", "--input"]]
for cmd in cmds:
if not shutil.which(cmd[0]):
continue
feed = (base64.b64encode(data) if cmd[0] == "powershell.exe"
else b"\xff\xfe" + text.encode("utf-16-le") if cmd[0] == "clip.exe" else data)
# wl-copy and xclip stay behind to serve the clipboard: give them no pipes to hold open.
try:
r = subprocess.run(cmd, input=feed, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env, timeout=10)
except subprocess.TimeoutExpired:
continue
if r.returncode == 0:
return
if LINUX:
raise HostError("Can't set the clipboard: install wl-clipboard (Wayland) or xclip (X11)")
raise HostError("Can't set the clipboard")
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
BAD_PERMISSIONS = "Bad owner or permissions on "
+143 -16
View File
@@ -25,6 +25,7 @@ when the page asks.
Python stdlib only. Runs on this computer, never on the Frame.
"""
from bisect import bisect_right
import copy
import hashlib
import ipaddress
@@ -203,16 +204,33 @@ def probes_summary(probes):
PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)")
def hide_hosts(text, hosts, keep=()):
"""text with each of `hosts` (the headset's own addresses and names) replaced by <host>,
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 <token> already there
for i in range(0, len(parts), 2):
for h in names:
parts[i] = re.sub(r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(h), "<host>", parts[i], flags=re.I)
def _host_names(hosts, keep=()):
keep = {k.lower() for k in keep if k} | KEYWORDS
return sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
def _host_pattern(name):
return r"(?<![\w.:-])%s(?![\w-]|[:.%%]\w)" % re.escape(name)
def hide_hosts(text, hosts, keep=(), *, _trace=None):
"""Hide whole known names, longest first, preserving existing scrubber tokens."""
names = _host_names(hosts, keep)
parts = PLACEHOLDER.split(text)
ranges, offset = [], 0
for i, part in enumerate(parts):
trace = _ScrubTrace(part) if _trace is not None else None
if trace is not None:
trace.ranges = _trace.ranges[offset:offset + len(part)]
offset += len(part)
if i % 2 == 0:
sub = trace.sub if trace is not None else re.sub
for name in names:
parts[i] = sub(_host_pattern(name), "<host>", parts[i], flags=re.I)
if trace is not None:
ranges.extend(trace.ranges)
if _trace is not None:
_trace.ranges = ranges
return "".join(parts)
@@ -223,19 +241,128 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect",
KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS
def hide_operands(text):
def hide_operands(text, *, _sub=re.sub):
def one(m):
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)} <host>{dots}"
return OPERAND.sub(one, text)
return _sub(OPERAND, one, text)
class _ScrubTrace:
"""Map every output character to its source interval through sequential redactions.
Unchanged prefixes/suffixes retain their origins. The changed middle inherits
its source interval and is marked as replaced; extra spans can widen to cover
that whole interval without restoring anything the old pipeline removed.
"""
def __init__(self, text):
self.ranges = [(i, i + 1, False) for i in range(len(text))]
self.redactions = []
def sub(self, pattern, replacement, text, flags=0):
pattern = re.compile(pattern, flags)
pieces, ranges, end = [], [], 0
for match in pattern.finditer(text):
start, stop = match.span()
value = replacement(match) if callable(replacement) else match.expand(replacement)
pieces.extend((text[end:start], value))
ranges.extend(self.ranges[end:start])
if value == match.group(0):
ranges.extend(self.ranges[start:stop])
else:
original = match.group(0)
prefix = 0
while prefix < min(len(original), len(value)) and original[prefix] == value[prefix]:
prefix += 1
suffix = 0
while (suffix < min(len(original), len(value)) - prefix
and original[-suffix - 1] == value[-suffix - 1]):
suffix += 1
ranges.extend(self.ranges[start:start + prefix])
source = self.ranges[start + prefix:stop - suffix]
# A known name can itself be a prefix of "<host>". Attribute
# inserted characters to its last character in that case.
if not source:
source = self.ranges[max(start, start + prefix - 1):start + prefix]
interval = (source[0][0], source[-1][1])
self.redactions.append(interval)
ranges.extend([(*interval, True)] * (len(value) - prefix - suffix))
if suffix:
ranges.extend(self.ranges[stop - suffix:stop])
end = stop
pieces.append(text[end:])
ranges.extend(self.ranges[end:])
self.ranges = ranges
return "".join(pieces)
def _failure_spans(text, hosts):
"""Extra redactions discovered before any replacement can destroy context."""
spans = []
tokens = [m.span() for m in PLACEHOLDER.finditer(text)]
token_starts = [start for start, end in tokens]
for name in _host_names(hosts):
for match in re.finditer(_host_pattern(name), text, re.I):
# Standalone scrubber tokens survive; a whole display name may cross one.
token = bisect_right(token_starts, match.start()) - 1
if token < 0 or match.end() > tokens[token][1]:
spans.append(match.span())
start = 0
while True:
match = OPERAND.search(text, start)
if not match:
break
word = match.group(2).rstrip(".")
if word.lower() not in PLAIN_WORDS:
spans.append((match.start(2), match.start(2) + len(word)))
# An operand can itself be another marker ("to hostname bastion").
start = match.start(2)
merged = []
for start, end in sorted(spans):
if merged and start <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(end, merged[-1][1]))
else:
merged.append((start, end))
return merged
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)
"""Keep the existing scrubbed log, then hide additional original name/ssh spans.
Source ranges carry those spans through the existing substitutions. Applying
them only to that pipeline's output cannot reveal text it previously removed.
Keep the old output boundary, then truncate again after extra redaction so
shortening a name cannot pull previously omitted text into the log.
"""
text = str(text or "")
hosts = tuple(hosts)
spans = _failure_spans(text, hosts)
# Also preserve rules whose context the old name/operand pass could destroy,
# such as a URL, home folder, or PEM footer containing a display name.
original = _ScrubTrace(text)
frame_telemetry.scrub(text, limit=None, _sub=original.sub)
privacy_spans = sorted(original.redactions)
trace = _ScrubTrace(text)
clean = frame_telemetry.scrub(
hide_operands(hide_hosts(text, hosts, _trace=trace), _sub=trace.sub),
limit=600, _sub=trace.sub)
out, hidden, index, privacy_index = [], False, 0, 0
for char, (start, end, replaced) in zip(clean, trace.ranges):
while index < len(spans) and spans[index][1] <= start:
index += 1
redact = index < len(spans) and spans[index][0] < end
while privacy_index < len(privacy_spans) and privacy_spans[privacy_index][1] <= start:
privacy_index += 1
if not replaced and privacy_index < len(privacy_spans):
redact |= privacy_spans[privacy_index][0] < end
if redact:
if not hidden:
out.append("<host>")
else:
out.append(char)
hidden = redact
return "".join(out)[:600]
def failure_detail(error):
+28 -16
View File
@@ -56,12 +56,12 @@ BROWSER_FLAGS = []
PANEL_BOX = (1920, 1080)
# Opens one viewer on gamescope's X display and gives its window its own panel
# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
# id. Args: appid url width height tag profile [browser flags...]. The page puts "[tag]" in its title at
# once, which is how its X window is found (Chromium may hand the URL to an
# instance that's already running, so there's no process to follow).
LAUNCH = r"""set -u
appid=$1 url=$2 w=$3 h=$4 tag=$5
shift 5
appid=$1 url=$2 w=$3 h=$4 tag=$5 profile=$6
shift 6
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
@@ -73,15 +73,15 @@ common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-d
--disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
"--window-size=$w,$h" "$@" "--app=$url")
if [ -x "$HOME/chromium-xr/chrome" ]; then
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/$profile" "${common[@]}")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
cmd=(flatpak run org.chromium.Chromium
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
"--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-$profile" "${common[@]}")
else
echo "NO_BROWSER"
exit 3
fi
log=/tmp/frame-mac-view.log
log=/tmp/frame-$profile.log
setsid nohup "${cmd[@]}" >>"$log" 2>&1 </dev/null &
for _ in $(seq 1 60); do
sleep 0.5
@@ -105,9 +105,9 @@ class MacViewError(Exception):
pass
def panel_id(src):
def panel_id(src, host="mac"):
"""A stable panel id per source, in the range panel-on-frame.sh uses."""
return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
return 2_001_000_000 + zlib.crc32(f"{host}:{src}".encode()) % 1_000_000
def fit(w, h, box=PANEL_BOX):
@@ -116,6 +116,9 @@ def fit(w, h, box=PANEL_BOX):
class MacView:
host = "mac"
viewer_profile = "mac-view"
def __init__(self, tunnel_ssh, run, frame, track=None):
self.tunnel_ssh = list(tunnel_ssh)
self.run = run
@@ -174,6 +177,12 @@ class MacView:
except OSError:
return False
def agent_command(self):
return [str(AGENT)]
def agent_environment(self):
return {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
def ensure_agent(self):
with self.lock:
if self.agent and self.agent.poll() is None:
@@ -182,11 +191,11 @@ class MacView:
if reason:
raise MacViewError(reason)
self.build()
env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
env = self.agent_environment()
# The same port as before when restarting, so a running tunnel still
# fits; otherwise (or if it's gone) whatever the system gives.
for port in dict.fromkeys([self.port or 0, 0]):
self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
self.agent = subprocess.Popen([*self.agent_command(), "serve", "--port", str(port), "--page", str(PAGE),
"--exit-on-eof"], env=env, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
line = self.agent.stdout.readline()
@@ -195,7 +204,7 @@ class MacView:
break
self.agent.kill()
else:
raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
raise MacViewError(f"The streaming helper didn't start: {line.strip() or 'no output'}")
if self.port != int(m.group(1)):
self._drop_tunnel()
self.port = int(m.group(1))
@@ -211,7 +220,7 @@ class MacView:
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r)
except (urllib.error.URLError, OSError, ValueError) as e:
raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
raise MacViewError(f"The streaming helper didn't answer: {e}")
# ---- the tunnel from the Frame ----
@@ -396,7 +405,7 @@ class MacView:
# the agent no longer counts them, so only move when none are out there.
others = self.shown - {src}
self.ensure_tunnel(allow_new_port=not others)
appid = panel_id(src)
appid = panel_id(src, self.host)
# Unique per launch, so a new window is never confused with an old one.
tag = "fc" + secrets.token_hex(4)
# A single-use ticket for this source, not Frame Control's key: the URL
@@ -406,7 +415,7 @@ class MacView:
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, self.viewer_profile, *self.browser_flags))
try:
out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
@@ -438,7 +447,8 @@ class MacView:
if self.shown or self.shows != shows or self.launching:
return
try:
self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
pattern = "[f]rame-control/" + self.viewer_profile + "|[d]ata/frame-" + self.viewer_profile
self.run("pkill -f " + _quote(pattern) + " || true", timeout=10)
except Exception:
pass
@@ -449,7 +459,9 @@ class MacView:
status = self.call("/status")
windows = self.call("/windows").get("windows", []) if status.get("screen") else []
displays = self.call("/displays").get("displays", [])
return {"available": True, "screen": status.get("screen", False),
return {"available": True, "host": self.host, "selecting": status.get("selecting", False),
**({"windowCaptureReason": status["windowCaptureReason"]} if status.get("windowCaptureReason") else {}),
"selectionError": status.get("selectionError", ""), "screen": status.get("screen", False),
"accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
"windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
"route": self.route}
+635
View File
@@ -0,0 +1,635 @@
#!/usr/bin/env python3
"""Frame Control's Windows/Linux host. Same ticket, H.264/JPEG, timing and
input protocol as frame-mac-view; serves the same ui/mac-view.html.
Only loopback is bound. Frame Control owns the master token, the Frame gets
one-source tickets and reconnect keys. Native libraries are bundled.
"""
import argparse
import base64
import ctypes as C
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import math
import os
import queue
from pathlib import Path
import secrets
import socket
import struct
import sys
import threading
import time
from urllib.parse import parse_qs, urlsplit
from frame_pc_capture import Native, Controller, Windows, WindowsInput, PortalInput, Encoded, GATE, dimensions, pipeline
from frame_stream_stats import Stats
# Viewer fields are untrusted JSON: float('x'), int(None), m['t'] missing.
MALFORMED = (ValueError, TypeError, KeyError, OverflowError)
WINDOW_CAPTURE_UNAVAILABLE = 'Individual windows are unavailable in this build. Choose a whole screen instead.'
class Grants:
"""Caller holds the agent lock, including replacement and Stop."""
def __init__(self, token):
self.token, self.tickets, self.keys = token, {}, {}
def master(self, key):
return isinstance(key, str) and secrets.compare_digest(key, self.token)
def ticket(self, src):
self.tickets = {k: v for k, v in self.tickets.items() if v[1] > time.monotonic()}
if len(self.tickets) >= 128:
raise ValueError('Too many pending viewers')
ticket = secrets.token_urlsafe(24)
self.tickets[ticket] = (src, time.monotonic()+60, None)
return ticket
def redeem(self, src, q):
if self.master(q.get('k')):
key = secrets.token_urlsafe(24)
self.keys[key] = src
return key
entry = self.tickets.get(q.get('t'))
if entry and entry[0] == src and entry[1] > time.monotonic():
key = entry[2] or secrets.token_urlsafe(24)
self.tickets[q['t']] = (src, entry[1], key)
self.keys[key] = src
return key
key = q.get('r')
return key if key and self.keys.get(key) == src else None
def ack(self, key):
self.tickets = {k: v for k, v in self.tickets.items() if v[2] != key}
def revoke(self, src=None):
self.tickets = {k: v for k, v in self.tickets.items() if src is not None and v[0] != src}
self.keys = {k: v for k, v in self.keys.items() if src is not None and v != src}
class WebSocket:
def __init__(self, handler):
self.sock, self.reader = handler.connection, handler.rfile
self.lock = threading.Lock()
self.closed = False
self.sock.settimeout(5)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
def send(self, data, opcode=1):
if not isinstance(data, bytes):
data = json.dumps(data, separators=(',', ':')).encode()
n = len(data)
head = bytes([0x80 | opcode, n]) if n < 126 else bytes([0x80 | opcode, 126]) + struct.pack('!H', n) if n <= 65535 else bytes([0x80 | opcode, 127]) + struct.pack('!Q', n)
with self.lock:
if self.closed:
raise ConnectionError('Viewer disconnected')
self.sock.sendall(head + data)
def exact(self, n):
data = self.reader.read(n)
if len(data) != n:
raise ConnectionError('Viewer disconnected')
return data
def receive(self):
a, b = self.exact(2)
opcode, size = a & 15, b & 127
if a & 0x70 or not a & 0x80 or not b & 0x80 or opcode not in (1, 8, 9, 10):
raise ValueError('Unsupported WebSocket frame')
if size == 126:
size = struct.unpack('!H', self.exact(2))[0]
elif size == 127:
size = struct.unpack('!Q', self.exact(8))[0]
if size > 65536 or (opcode >= 8 and size > 125):
raise ValueError('WebSocket message too large')
mask = self.exact(4)
data = bytes(v ^ mask[i % 4] for i, v in enumerate(self.exact(size)))
if opcode == 8:
raise ConnectionError('Viewer closed')
if opcode == 9:
self.send(data, 10)
if opcode != 1:
return {}
message = json.loads(data)
if not isinstance(message, dict):
raise ValueError('Expected an input object')
return message
def close(self):
self.closed = True
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
class Session:
def __init__(self, agent, ws, key, source, query):
self.agent, self.native, self.ws, self.key, self.source = agent, agent.native, ws, key, source
self.src = source['src']
self.codec = query.get('codec', 'h264')
if self.codec not in ('h264', 'jpeg'):
raise ValueError('Unsupported codec')
self.fps = min(120, max(5, int(query.get('fps', 60))))
bpp = float(query.get('bpp', .1))
if not math.isfinite(bpp):
raise ValueError('Invalid bitrate')
self.w, self.h = dimensions(source['w'], source['h'], min(3840, max(320, int(query.get('max', 1920)))))
self.bitrate = max(300000, int(self.w*self.h*self.fps*min(.5, max(.02, bpp))))
self.controller = Controller(self.native.lib, self.fps, self.bitrate)
self.stats = Stats(self.native.now)
self.stop_event, self.key_event = threading.Event(), threading.Event()
self.lock, self.input_lock = threading.RLock(), threading.RLock()
self.pending, self.last_submit = {}, 0
self.reconfiguring = False
self.test_inputs = queue.Queue(maxsize=128)
self.input = None if self.src == 'test' else WindowsInput(agent.windows, source) if agent.windows else PortalInput(self.native, source)
self.writer = None
self.released = False
self.input_enabled = self.src == 'test' or self.source.get('devices', 3) == 3
def gate(self, stage, pts, capture, arrived):
# Exceptions cannot cross a ctypes callback boundary.
try:
with self.lock:
if self.stop_event.is_set() or self.reconfiguring:
return -1
if stage == 1:
if pts in self.pending:
self.pending[pts]['e0'] = self.native.now()
return 1
if stage == 0:
self.stats.captured += 1
self.controller.call('capture', arrived)
now = self.native.now()
state = self.controller.state()
if len(self.pending) >= 3 or now-self.last_submit < 1000000/state['fps'] or not self.controller.call('gate', now, int(stage == 0)):
if stage == 0:
self.stats.skipped += 1
return 0
self.pending[pts] = dict(cap=capture, arr=arrived, e0=now, tier=state['tier'], br=state['target'])
self.last_submit = now
return 1
except Exception:
self.stop_event.set()
return -1
STALL = 10000000
def stalled(self, now, opened, captured_at_open):
"""PipeWire and WGC deliver frames only on screen damage, so an idle
desktop legitimately produces no output. Fail only on evidence of a
fault: a frame entered the encoder and never came out, or the capture
never produced its initial frame after the pipeline opened."""
with self.lock:
oldest = min((r['e0'] for r in self.pending.values()), default=None)
captured = self.stats.captured
if oldest is not None and now-oldest > self.STALL:
return 'The encoder stopped producing frames; check the video encoder'
if captured == captured_at_open and now-opened > self.STALL:
return 'No frames captured for 10 seconds; check capture permissions'
return None
def fresh_pipewire(self):
if 'portal' not in self.source:
return
error = C.create_string_buffer(1024)
fd = self.native.lib.fc_portal_refresh(self.source['portal'], error, len(error))
if fd < 0:
raise RuntimeError(error.value.decode(errors='replace'))
self.source['fd'] = fd
def produce(self):
native, capture = self.native.lib, None
try:
encoder = "x264enc" if self.src == "test" and self.native.has("x264enc") else self.agent.encoder
self.fresh_pipewire()
description = pipeline(self.source, sys.platform, encoder, self.w, self.h, self.fps, self.bitrate, self.codec)
self.callback = GATE(self.gate)
error = C.create_string_buffer(1024)
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
last_update = last_stats = self.native.now()
last_config = opened = last_update
captured_at_open = self.stats.captured
applied = (self.w, self.h, self.bitrate)
wanted = applied
while not self.stop_event.is_set():
while not self.test_inputs.empty():
event = self.test_inputs.get_nowait()
try:
native.fc_capture_test(capture, int(event.get('i', 0)) & 0xffffffff)
self.stats.input(event)
except MALFORMED:
pass # a bad probe field drops the event, not the stream
output = Encoded()
result = native.fc_capture_pull(capture, C.byref(output))
now = self.native.now()
if result < 0:
raise RuntimeError(native.fc_capture_error(capture).decode(errors='replace'))
if result:
with self.lock:
# At most three raw frames are in flight; no B-frames.
# x264 offsets PTS, so match the FIFO encode order while
# retaining the actual pre-encode capture timestamp.
raw_pts = next(iter(self.pending), None)
record = self.pending.get(raw_pts)
if record is None:
raise RuntimeError('Encoder changed frame timestamps; timing cannot be matched')
data = C.string_at(output.data, output.size)
f = self.stats.add(**record, e1=now, snd=now, b=len(data), k=output.key,
w=output.width or self.w, h=output.height or self.h)
self.controller.call('sent', f['s'], len(data)+17, now)
self.ws.send(struct.pack('!BQII', output.key, max(0, f['cap']), f['s'], f['echo'])+data, 2)
with self.lock:
f['wire'] = self.native.now()
self.pending.pop(raw_pts, None)
stall = self.stalled(now, opened, captured_at_open)
if stall:
raise RuntimeError(stall)
if self.key_event.is_set():
self.key_event.clear()
if self.codec == 'h264':
native.fc_capture_key(capture)
if now-last_update >= 100000:
target = self.controller.update(now)
state = self.controller.state()
w, h = dimensions(self.w, self.h, max(320, int(max(self.w, self.h)*state['scale'])))
if target and self.codec == 'h264' and encoder == 'x264enc':
native.fc_capture_bitrate(capture, target)
self.bitrate = target
# Hardware properties are not uniformly mutable in PLAYING.
# Drain then reopen the pipeline at a keyframe when its
# budget changes materially. The portal fd/session stays
# alive, so this does not bypass or repeat user consent.
desired_bitrate = target or applied[2]
bitrate_change = self.codec == 'h264' and encoder != 'x264enc' and abs(desired_bitrate-applied[2]) > applied[2]*.2
if not self.reconfiguring and now-last_config >= 1000000 and ((w, h) != applied[:2] or bitrate_change):
wanted = (w, h, desired_bitrate)
with self.lock:
self.reconfiguring = True
last_update = now
if self.reconfiguring:
with self.lock:
drained = not self.pending
if drained:
native.fc_capture_close(capture)
capture = None
self.fresh_pipewire()
description = pipeline(self.source, sys.platform, encoder, wanted[0], wanted[1], self.fps, wanted[2], self.codec)
capture = native.fc_capture_open(description.encode(), self.callback, error, len(error))
if not capture:
raise RuntimeError(error.value.decode(errors='replace'))
applied, self.bitrate, last_config = wanted, wanted[2], now
opened, captured_at_open = now, self.stats.captured
with self.lock:
self.reconfiguring = False
if now-last_stats >= 1000000:
self.ws.send(dict(self.stats.summary(), t='stats', bitrate=self.bitrate,
size='%dx%d' % (self.w, self.h), tier=self.controller.state()['tier']))
last_stats = now
except Exception as e:
try:
self.ws.send({'t': 'error', 'message': str(e)})
except OSError:
pass
finally:
self.stop_event.set()
if capture:
native.fc_capture_close(capture)
self.ws.close()
def start(self):
if self.stop_event.is_set():
self.controller.close()
return
self.ws.send(dict(t='hello', r=self.key))
self.ws.send(dict(t='info', src=self.src, title=self.source.get('title', self.source.get('name', 'Test pattern')),
app='PC', codec=self.codec, input=self.input_enabled,
inputMessage='Allow pointer and keyboard control in the host sharing dialog.',
aspect=self.w/self.h, warm=0))
with self.lock:
if self.stop_event.is_set():
self.controller.close()
return
self.writer = threading.Thread(target=self.produce, daemon=True)
self.writer.start()
try:
while not self.stop_event.is_set():
m = self.ws.receive()
t = m.get('t')
if t == 'ping':
self.ws.send(dict(t='pong', c=m.get('c', 0), a=self.native.now()))
elif t == 'ack':
with self.agent.lock:
self.agent.grants.ack(self.key)
elif t == 'key-frame':
self.key_event.set()
elif t in ('rx', 'fd', 'clock'):
try:
self.stats.report(m)
except MALFORMED:
pass
if t == 'rx' and isinstance(m.get('s'), int):
self.controller.call('ack', m['s'] & 0xffffffff, self.native.now())
elif t in ('m', 'wheel', 'k', 'text', 'release'):
with self.input_lock:
if self.stop_event.is_set():
break
if self.input:
if not self.input_enabled and t != 'release':
continue
try:
self.input.handle(m)
self.stats.input(m)
except MALFORMED:
pass # drop a malformed viewer event; keep the session
except RuntimeError as e:
self.input_enabled = False
try:
self.input.release()
except RuntimeError:
pass
self.ws.send(dict(t='error', message=str(e)))
elif m.get('i'):
try:
self.test_inputs.put_nowait(m)
except queue.Full:
pass
finally:
self.end()
self.writer.join(6)
if not self.writer.is_alive():
self.controller.close()
def end(self):
with self.lock:
self.stop_event.set()
self.ws.close()
with self.input_lock:
if self.input and not self.released:
self.released = True
try:
self.input.release()
except (RuntimeError, OSError):
pass
class Agent:
def __init__(self, native, token, page):
self.native, self.page = native, page
self.lock = threading.RLock()
self.grants = Grants(token)
self.sessions, self.sources = {}, {}
self.next_id, self.selecting, self.selection_error = 1, False, ''
self.windows = Windows() if sys.platform == 'win32' else None
self.wgc = bool(self.windows) and all(native.has_property('d3d11screencapturesrc', p)
for p in ('capture-api', 'window-handle', 'show-border'))
self.window_capture_reason = WINDOW_CAPTURE_UNAVAILABLE if self.windows and not self.wgc else ''
self.encoder = 'mfh264enc' if self.windows else 'vah264enc' if native.has('vah264enc') else 'x264enc'
self.shutting_down = False
self.selection_generation = 0
def lists(self):
if self.windows:
windows, displays = self.windows.sources()
if not self.wgc:
windows = []
for source in windows + displays:
source['capture_api'] = 'wgc' if self.wgc else 'dxgi'
self.sources = {s['src']: s for s in windows + displays}
return windows, displays
return [{k: v for k, v in s.items() if k not in ('portal', 'fd', 'node')} for s in self.sources.values()], []
def source(self, src):
if src == 'test':
return dict(src='test', title='Test pattern', w=1280, h=720)
if self.window_capture_reason and isinstance(src, str) and src.startswith('window:'):
raise ValueError(self.window_capture_reason)
self.lists()
if src not in self.sources:
raise ValueError('Choose a window or screen on this computer first')
return dict(self.sources[src])
def select(self):
if self.windows or self.selecting or self.shutting_down:
return
if len(self.sources) >= 8:
raise ValueError('Stop a panel before sharing another source')
self.selecting, self.selection_error = True, ''
generation = self.selection_generation
def choose():
error = C.create_string_buffer(1024)
portal = self.native.lib.fc_portal_select(error, len(error))
with self.lock:
self.selecting = False
if not portal:
self.selection_error = error.value.decode(errors='replace')
elif self.shutting_down or generation != self.selection_generation:
self.native.lib.fc_portal_close(portal)
else:
fd, node, w, h, devices = [self.native.lib.fc_portal_value(portal, i) for i in range(5)]
src = 'window:' + secrets.token_hex(8)
self.sources[src] = dict(src=src, title='Shared window or screen', app='Linux portal',
w=w, h=h, fd=fd, node=node, portal=portal, devices=devices)
threading.Thread(target=choose, daemon=True).start()
def stop(self, src=None):
with self.lock:
if src is None:
self.selection_generation += 1
self.grants.revoke(src)
sessions = [s for s in self.sessions.values() if src is None or s.src == src]
for session in sessions:
try:
session.ws.send(dict(t='close'))
except OSError:
pass
session.end()
for session in sessions:
if session.writer and session.writer is not threading.current_thread():
session.writer.join(6)
# The capture is stopped before releasing its PipeWire fd/session.
with self.lock:
if not self.windows:
for key, source in list(self.sources.items()):
if src is None or key == src:
if any(s.src == key and s.writer and s.writer.is_alive() for s in sessions):
continue
self.native.lib.fc_portal_close(source['portal'])
self.sources.pop(key, None)
return {'closed': len(sessions)}
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, *args):
pass # URLs contain credentials
def reply(self, data, status=200, content='application/json'):
if not isinstance(data, bytes):
data = json.dumps(data).encode()
self.send_response(status)
self.send_header('Content-Type', content)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store')
self.send_header('Connection', 'close')
self.end_headers()
self.wfile.write(data)
self.close_connection = True
def do_GET(self):
self.dispatch('GET')
def do_POST(self):
self.dispatch('POST')
def dispatch(self, method):
agent = self.server.agent
url = urlsplit(self.path)
q = {k: v[-1] for k, v in parse_qs(url.query).items()}
try:
if method == 'GET' and url.path == '/ping':
return self.reply(b'frame-mac-view', content='text/plain')
if method == 'GET' and url.path == '/view':
return self.reply(agent.page.read_bytes(), content='text/html; charset=utf-8')
if method == 'GET' and url.path == '/stream':
return self.stream(q)
if not agent.grants.master(q.get('k', self.headers.get('X-Token'))):
return self.reply({'error': 'forbidden'}, 403)
if method == 'POST' and url.path == '/close':
return self.reply(agent.stop(q.get('src')))
with agent.lock:
windows, displays = agent.lists()
if method == 'GET' and url.path == '/status':
data = dict(version=1, host='windows' if agent.windows else 'linux', screen=True,
windowCaptureReason=agent.window_capture_reason,
accessibility=True, selecting=agent.selecting, selectionError=agent.selection_error,
encoder=agent.encoder, finished=[], streams=[dict(id=i, src=s.src,
title=s.source.get('title', ''), stats=s.stats.summary(), controller=s.controller.state())
for i, s in agent.sessions.items() if not s.stop_event.is_set()])
elif method == 'GET' and url.path in ('/windows', '/displays'):
data = dict(windows=windows, displays=displays, screen=True)
elif method == 'POST' and url.path == '/ticket':
agent.source(q.get('src'))
data = {'ticket': agent.grants.ticket(q['src'])}
elif method == 'POST' and url.path == '/permissions':
agent.select()
data = {'selecting': agent.selecting}
elif method == 'GET' and url.path == '/stats':
data = dict(now=agent.native.now(), streams=[dict(id=i, src=s.src, controller=s.controller.state(),
events=list(s.controller.events), **s.stats.snapshot(max(0, int(q.get('since', 0))), max(0, int(q.get('settle', 1500000)))))
for i, s in agent.sessions.items() if q.get('id', str(i)) == str(i) and not s.stop_event.is_set()])
elif method == 'POST' and url.path == '/bench':
data = {'sent': 0}
for s in agent.sessions.values():
if s.src == q.get('src'):
m = {k: v for k, v in q.items() if k not in ('k', 'src')}
for k in ('x', 'y', 'interval'):
if k in m:
m[k] = float(m[k])
s.ws.send(dict(m, t='bench'))
data['sent'] += 1
else:
return self.reply({'error': 'not found'}, 404)
self.reply(data)
except (ValueError, RuntimeError) as e:
self.reply({'error': str(e)}, 400)
except (OSError, ConnectionError):
self.close_connection = True
def stream(self, query):
agent = self.server.agent
with agent.lock:
key = agent.grants.redeem(query.get('src'), query)
if not key:
return self.reply({'error': 'forbidden'}, 403)
source = agent.source(query.get('src'))
if self.headers.get('Upgrade', '').lower() != 'websocket' or self.headers.get('Sec-WebSocket-Version') != '13':
return self.reply({'error': 'expected WebSocket'}, 400)
wskey = self.headers.get('Sec-WebSocket-Key', '')
if len(base64.b64decode(wskey, validate=True)) != 16:
raise ValueError('Bad WebSocket key')
if len(agent.sessions) >= 8:
raise ValueError('At most eight panels may be open')
# Stop can revoke and close only a registered session. Register
# under the same lock as redemption, before any capture starts.
session = Session(agent, WebSocket(self), key, source, query)
for old in list(agent.sessions.values()):
if old.key == key or old.src == source['src']:
if old.key != key:
agent.grants.keys.pop(old.key, None)
try:
old.ws.send(dict(t='close'))
except OSError:
pass
old.end()
if old.writer:
old.writer.join(6)
if old.writer.is_alive():
session.controller.close()
raise ValueError('The previous capture is still stopping; retry shortly')
ident = agent.next_id
agent.next_id += 1
agent.sessions[ident] = session
accept = base64.b64encode(hashlib.sha1((wskey+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
self.send_response(101)
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', accept)
self.end_headers()
try:
session.start()
except (OSError, ValueError, RuntimeError):
session.end()
finally:
session.end()
if session.writer:
session.writer.join(6)
if not session.writer or not session.writer.is_alive():
session.controller.close()
with agent.lock:
agent.sessions.pop(ident, None)
self.close_connection = True
def main():
parser = argparse.ArgumentParser()
parser.add_argument('command', choices=['serve'])
parser.add_argument('--port', type=int, default=0)
parser.add_argument('--page', type=Path, required=True)
parser.add_argument('--exit-on-eof', action='store_true')
args = parser.parse_args()
token = os.environ.get('FRAME_MAC_VIEW_TOKEN')
if not token:
raise SystemExit('Frame Control must provide a private token')
native = Native()
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
agent = server.agent = Agent(native, token, args.page)
if args.exit_on_eof:
def eof():
sys.stdin.buffer.read()
with agent.lock:
agent.shutting_down = True
agent.stop()
server.shutdown()
threading.Thread(target=eof, daemon=True).start()
print('listening on 127.0.0.1:%d' % server.server_port, flush=True)
try:
server.serve_forever()
finally:
agent.shutting_down = True
agent.stop()
server.server_close()
if __name__ == '__main__':
try:
main()
except (OSError, RuntimeError, AttributeError) as e:
raise SystemExit('PC streaming helper: ' + str(e))
+393
View File
@@ -0,0 +1,393 @@
"""Native PC capture and input bindings. No desktop-streaming app required.
The packaged native library contains our adapter and the shared rate controller;
GStreamer supplies WGC/Media Foundation and PipeWire/VA-API/x264 libraries.
"""
import ctypes as C
import os
from pathlib import Path
import re
import sys
import threading
ROOT = Path(__file__).resolve().parent.parent
NATIVE = Path(os.environ.get('FRAME_PC_NATIVE', ROOT / 'desktop' / 'bundle'))
LIBRARY = NATIVE / ('pc-host.dll' if sys.platform == 'win32' else 'pc-host.so')
class Encoded(C.Structure):
_fields_ = [('data', C.c_void_p), ('size', C.c_int), ('key', C.c_int),
('width', C.c_int), ('height', C.c_int), ('pts', C.c_int64)]
GATE = C.CFUNCTYPE(C.c_int, C.c_int, C.c_int64, C.c_int64, C.c_int64)
class Native:
def __init__(self, path=LIBRARY):
self.dll_dirs = []
if sys.platform == 'win32':
# Set process coordinates before loading GStreamer: a plugin may
# create a helper HWND during discovery, after which it is too late.
user = C.WinDLL('user32')
user.SetProcessDpiAwarenessContext.argtypes = [C.c_void_p]
user.SetProcessDpiAwarenessContext(C.c_void_p(-4))
for folder in (path.parent, path.parent / 'bin'):
self.dll_dirs.append(os.add_dll_directory(str(folder)))
self.lib = C.CDLL(str(path))
signatures = {
'fc_now': (C.c_int64, []), 'fc_gst_init': (None, []),
'fc_has_element': (C.c_int, [C.c_char_p]),
'fc_has_property': (C.c_int, [C.c_char_p, C.c_char_p]),
'fc_new': (C.c_void_p, [C.c_int, C.c_int]), 'fc_free': (None, [C.c_void_p]),
'fc_ceiling': (None, [C.c_void_p, C.c_int]),
'fc_gate': (C.c_int, [C.c_void_p, C.c_int64, C.c_int]),
'fc_capture': (None, [C.c_void_p, C.c_int64]),
'fc_sent': (None, [C.c_void_p, C.c_uint32, C.c_int, C.c_int64]),
'fc_ack': (C.c_int, [C.c_void_p, C.c_uint32, C.c_int64]),
'fc_update': (C.c_int, [C.c_void_p, C.c_int64]),
'fc_value': (C.c_int64, [C.c_void_p, C.c_int]),
'fc_capture_open': (C.c_void_p, [C.c_char_p, GATE, C.c_char_p, C.c_int]),
'fc_capture_pull': (C.c_int, [C.c_void_p, C.POINTER(Encoded)]),
'fc_capture_error': (C.c_char_p, [C.c_void_p]),
'fc_capture_bitrate': (None, [C.c_void_p, C.c_int]),
'fc_capture_key': (None, [C.c_void_p]),
'fc_capture_test': (None, [C.c_void_p, C.c_uint32]), 'fc_capture_close': (None, [C.c_void_p]),
}
if sys.platform.startswith('linux'):
signatures.update({
'fc_portal_select': (C.c_void_p, [C.c_char_p, C.c_int]),
'fc_portal_close': (None, [C.c_void_p]),
'fc_portal_refresh': (C.c_int, [C.c_void_p, C.c_char_p, C.c_int]),
'fc_portal_value': (C.c_int, [C.c_void_p, C.c_int]),
'fc_portal_input': (C.c_int, [C.c_void_p, C.c_int, C.c_double, C.c_double, C.c_int, C.c_int]),
})
for name, (result, args) in signatures.items():
fn = getattr(self.lib, name)
fn.restype, fn.argtypes = result, args
self.lib.fc_gst_init()
def has(self, name):
return bool(self.lib.fc_has_element(name.encode()))
def has_property(self, element, name):
"""Check optional element properties in the initialized capture DLL."""
return bool(self.lib.fc_has_property(element.encode(), name.encode()))
def now(self):
return self.lib.fc_now()
class Controller:
def __init__(self, lib, fps, ceiling):
# close() may run on the stream thread while /status reads state();
# after close every entry point is a no-op, never a NULL dereference.
self.lib, self.lock, self.fps = lib, threading.RLock(), fps
self.enabled = os.environ.get('FRAME_MAC_VIEW_ADAPT') != '0'
self.ptr = lib.fc_new(fps, self.enabled)
if not self.ptr:
raise MemoryError('Unable to allocate the streaming controller')
lib.fc_ceiling(self.ptr, ceiling)
self.events = []
def state(self):
with self.lock:
names = ['target', 'ceiling', 'tier', 'fps', 'scale', 'baseRtt', 'inFlight', 'slack']
if not self.ptr:
out = dict.fromkeys(names, 0)
out.update(fps=self.fps, scale=100)
else:
out = {k: self.lib.fc_value(self.ptr, i) for i, k in enumerate(names)}
out.update(scale=out['scale'] / 100, baseRtt=out['baseRtt'] / 1000,
slack=out['slack'] / 1000, adapt=self.enabled)
return out
def call(self, name, *args):
with self.lock:
return getattr(self.lib, 'fc_' + name)(self.ptr, *args) if self.ptr else 0
def update(self, now):
with self.lock:
if not self.ptr:
return 0
old = self.state()
result = self.lib.fc_update(self.ptr, now)
state = self.state()
if state['target'] < old['target'] or state['tier'] != old['tier']:
self.events.append({'t': now, 'e': 'target %s bit/s; tier %s' % (state['target'], state['tier'])})
self.events = self.events[-200:]
return result
def close(self):
with self.lock:
if self.ptr:
self.lib.fc_free(self.ptr)
self.ptr = None
def dimensions(w, h, maximum):
scale = min(1, maximum / max(w, h))
return max(2, int(w * scale) // 2 * 2), max(2, int(h * scale) // 2 * 2)
def pipeline(source, platform, encoder, w, h, fps, bitrate, codec='h264'):
"""Only locally constructed numeric source IDs enter the pipeline parser."""
if source['src'] == 'test':
capture = 'videotestsrc name=source is-live=true pattern=ball'
elif platform == 'win32':
kind, ident = source['src'].split(':')
if kind not in ('window', 'display') or not re.fullmatch(r'[0-9]+', ident):
raise ValueError('Invalid Windows capture source')
api = source.get('capture_api', 'wgc')
if api not in ('wgc', 'dxgi') or (api == 'dxgi' and kind != 'display'):
raise ValueError('This build can only capture whole screens without Windows Graphics Capture')
prop = 'window-handle' if kind == 'window' else 'monitor-handle'
# DXGI-only builds don't expose capture-api or show-border at all.
mode = ' capture-api=wgc show-border=true' if api == 'wgc' else ''
capture = 'd3d11screencapturesrc%s show-cursor=true %s=%d' % (mode, prop, int(ident))
else:
capture = 'pipewiresrc fd=%d path=%d do-timestamp=true' % (source['fd'], source['node'])
# The source gate runs before conversion/encoding. There is no leaky queue
# of H.264 frames; every encoded reference frame reaches the socket.
raw = '%s ! video/x-raw,framerate=%d/1 ! queue name=raw_queue leaky=downstream max-size-buffers=1 max-size-bytes=0 max-size-time=0 ! identity name=gate ! videoconvert ! videoscale add-borders=false ! video/x-raw,width=%d,height=%d' % (capture, fps, w, h)
if codec == 'jpeg':
enc = 'jpegenc name=enc quality=80'
parse = ''
else:
choices = {
'mfh264enc': 'mfh264enc name=enc low-latency=true bframes=0 gop-size=60',
'vah264enc': 'vah264enc name=enc b-frames=0 key-int-max=60',
'x264enc': 'x264enc name=enc tune=zerolatency speed-preset=ultrafast bframes=0 key-int-max=60',
}
enc = choices[encoder] + ' bitrate=%d' % max(1, bitrate // 1000)
raw += ',format=NV12' if encoder != 'x264enc' else ',format=I420'
parse = ' ! h264parse config-interval=-1 ! video/x-h264,stream-format=byte-stream,alignment=au'
return raw + ' ! ' + enc + parse + ' ! appsink name=out sync=false max-buffers=2 drop=false'
# X11 keysyms work on Wayland through the RemoteDesktop portal too. The host's
# keyboard layout handles physical keys; Unicode text is sent as Unicode keysyms.
KEYSYMS = {'Enter': 0xff0d, 'Tab': 0xff09, 'Backspace': 0xff08, 'Escape': 0xff1b,
'Delete': 0xffff, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52, 'ArrowRight': 0xff53,
'ArrowDown': 0xff54, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
'PageDown': 0xff56, 'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffeb}
class PortalInput:
def __init__(self, native, source):
self.lib, self.source = native.lib, source
self.buttons, self.keys = set(), set()
def emit(self, kind, x=0, y=0, code=0, down=0):
if not self.lib.fc_portal_input(self.source['portal'], kind, x, y, code, down):
raise RuntimeError('The desktop portal refused input; check the sharing permission')
def release(self):
error = None
for values, kind in ((self.buttons, 1), (self.keys, 3)):
for code in list(values):
try:
self.emit(kind, code=code, down=0)
values.discard(code)
except RuntimeError as e:
error = e
if error:
raise error
def handle(self, m):
t = m['t']
if t == 'release':
return self.release()
if t in ('m', 'wheel'):
x, y = (max(0, min(1, float(m.get(k, 0)))) for k in ('x', 'y'))
self.emit(0, x * (self.source['w'] - 1), y * (self.source['h'] - 1))
if t == 'm' and m.get('e') in ('up', 'down'):
b = {0: 0x110, 1: 0x112, 2: 0x111}.get(m.get('b', 0))
if b is not None:
down = m['e'] == 'down'
self.emit(1, code=b, down=down)
(self.buttons.add if down else self.buttons.discard)(b)
elif t == 'wheel':
self.emit(2, max(-4096, min(4096, float(m.get('dx', 0)))), max(-4096, min(4096, float(m.get('dy', 0)))))
elif t == 'k':
key = str(m.get('key', ''))
k = KEYSYMS.get(key) or (ord(key) if len(key) == 1 and ord(key) < 256 else
0x1000000 + ord(key) if len(key) == 1 else 0)
if k:
down = m.get('e') == 'down'
self.emit(3, code=k, down=down)
(self.keys.add if down else self.keys.discard)(k)
elif t == 'text':
for ch in str(m.get('s', ''))[:4096]:
k = ord(ch) if ord(ch) < 256 else 0x1000000 + ord(ch)
self.emit(3, code=k, down=1)
self.emit(3, code=k, down=0)
class Windows:
"""WGC source handles and SendInput. No elevation or global input hook."""
def __init__(self):
from ctypes import wintypes as W
self.W = W
self.user = C.WinDLL('user32', use_last_error=True)
self.dwm = C.WinDLL('dwmapi')
self.user.IsWindow.argtypes = [W.HWND]
self.user.IsWindowVisible.argtypes = [W.HWND]
self.user.IsIconic.argtypes = [W.HWND]
self.user.GetWindowTextLengthW.argtypes = [W.HWND]
self.user.GetWindowTextW.argtypes = [W.HWND, W.LPWSTR, C.c_int]
self.user.GetWindowRect.argtypes = [W.HWND, C.POINTER(W.RECT)]
self.user.SetForegroundWindow.argtypes = [W.HWND]
self.user.GetForegroundWindow.argtypes = []
self.user.GetForegroundWindow.restype = W.HWND
self.dwm.DwmGetWindowAttribute.argtypes = [W.HWND, W.DWORD, C.c_void_p, W.DWORD]
self.callback = C.WINFUNCTYPE(W.BOOL, W.HWND, W.LPARAM)
self.monitor_callback = C.WINFUNCTYPE(W.BOOL, W.HMONITOR, W.HDC, C.POINTER(W.RECT), W.LPARAM)
self.user.EnumWindows.argtypes = [self.callback, W.LPARAM]
self.user.EnumDisplayMonitors.argtypes = [W.HDC, C.c_void_p, self.monitor_callback, W.LPARAM]
class Mouse(C.Structure):
_fields_ = [('dx', W.LONG), ('dy', W.LONG), ('data', W.DWORD), ('flags', W.DWORD),
('time', W.DWORD), ('extra', C.c_size_t)]
class Key(C.Structure):
_fields_ = [('vk', W.WORD), ('scan', W.WORD), ('flags', W.DWORD), ('time', W.DWORD), ('extra', C.c_size_t)]
class Union(C.Union):
_fields_ = [('mouse', Mouse), ('key', Key)]
class Input(C.Structure):
_fields_ = [('type', W.DWORD), ('data', Union)]
self.Mouse, self.Key, self.Input = Mouse, Key, Input
self.user.SendInput.argtypes = [W.UINT, C.POINTER(Input), C.c_int]
self.user.SendInput.restype = W.UINT
def rect(self, hwnd):
r = self.W.RECT()
if not self.user.IsWindow(hwnd) or self.user.IsIconic(hwnd):
raise RuntimeError('The captured window closed or was minimized')
# WGC captures the visible extended frame, excluding invisible resize borders.
if self.dwm.DwmGetWindowAttribute(hwnd, 9, C.byref(r), C.sizeof(r)):
if not self.user.GetWindowRect(hwnd, C.byref(r)):
raise RuntimeError('Cannot locate the captured window')
return r.left, r.top, r.right - r.left, r.bottom - r.top
def sources(self):
windows, displays = [], []
def window(hwnd, _):
if not self.user.IsWindowVisible(hwnd) or self.user.IsIconic(hwnd):
return True
n = self.user.GetWindowTextLengthW(hwnd)
if n:
title = C.create_unicode_buffer(n + 1)
self.user.GetWindowTextW(hwnd, title, n + 1)
try:
x, y, w, h = self.rect(hwnd)
if w > 0 and h > 0:
windows.append(dict(src='window:%d' % hwnd, id=hwnd, title=title.value,
app='Windows', w=w, h=h, x=x, y=y))
except RuntimeError:
pass
return True
def monitor(handle, dc, rect, data):
r = rect.contents
displays.append(dict(src='display:%d' % handle, name='Display %d' % (len(displays) + 1),
x=r.left, y=r.top, w=r.right-r.left, h=r.bottom-r.top))
return True
self.user.EnumWindows(self.callback(window), 0)
self.user.EnumDisplayMonitors(None, None, self.monitor_callback(monitor), 0)
return windows, displays
def send(self, mouse=None, key=None):
inp = self.Input()
inp.type = 0 if mouse else 1
if mouse:
inp.data.mouse = self.Mouse(*mouse, 0, 0)
else:
inp.data.key = self.Key(*key, 0, 0)
if self.user.SendInput(1, C.byref(inp), C.sizeof(inp)) != 1:
raise RuntimeError('Windows refused input (elevated apps and the secure desktop cannot be controlled)')
class WindowsInput:
def __init__(self, host, source):
self.host, self.source = host, source
self.buttons, self.keys = set(), set()
def release(self):
error = None
for button in list(self.buttons):
try:
self.host.send(mouse=(0, 0, 0, {0: 4, 1: 0x40, 2: 0x10}[button]))
self.buttons.discard(button)
except RuntimeError as e:
error = e
for vk in list(self.keys):
try:
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
self.host.send(key=(vk, 0, 2 | extended))
self.keys.discard(vk)
except RuntimeError as e:
error = e
if error:
raise error
def focus(self):
if not self.source['src'].startswith('window:'):
return
hwnd = int(self.source['src'].split(':')[1])
user = self.host.user
if user.GetForegroundWindow() != hwnd and not user.SetForegroundWindow(hwnd):
raise RuntimeError('Windows could not focus this window. Bring it to the front on your PC, then press Stop and Show again.')
if user.GetForegroundWindow() != hwnd:
raise RuntimeError('The selected window is not in front. Reopen the view after bringing it to the front on your PC.')
def handle(self, m):
t, u = m['t'], self.host.user
if t == 'release':
return self.release()
if t in ('m', 'wheel'):
source = self.source
if source['src'].startswith('window:'):
hwnd = int(source['src'].split(':')[1])
x, y, w, h = self.host.rect(hwnd)
if m.get('e') == 'down' or t == 'wheel':
self.focus()
else:
x, y, w, h = (source[k] for k in ('x', 'y', 'w', 'h'))
x += max(0, min(1, float(m.get('x', 0)))) * (w - 1)
y += max(0, min(1, float(m.get('y', 0)))) * (h - 1)
left, top, width, height = (u.GetSystemMetrics(i) for i in (76, 77, 78, 79))
self.host.send(mouse=(round((x-left)*65535/max(1,width-1)), round((y-top)*65535/max(1,height-1)), 0, 0xc001))
if t == 'm' and m.get('e') in ('up', 'down'):
b = m.get('b', 0)
if b in (0, 1, 2):
down = m['e'] == 'down'
self.host.send(mouse=(0, 0, 0, {0: 2, 1: 0x20, 2: 8}[b] * (1 if down else 2)))
(self.buttons.add if down else self.buttons.discard)(b)
elif t == 'wheel':
for name, flag, direction in (('dy', 0x800, -1), ('dx', 0x1000, 1)):
delta = int(max(-4096, min(4096, float(m.get(name, 0))))) * direction
if delta:
self.host.send(mouse=(0, 0, delta & 0xffffffff, flag))
elif t == 'k':
code, down = str(m.get('code', '')), m.get('e') == 'down'
if down:
self.focus()
special = {'Enter': 13, 'Escape': 27, 'Tab': 9, 'Backspace': 8, 'Space': 32,
'ArrowLeft': 37, 'ArrowUp': 38, 'ArrowRight': 39, 'ArrowDown': 40,
'Delete': 46, 'Home': 36, 'End': 35, 'PageUp': 33, 'PageDown': 34,
'ShiftLeft': 160, 'ShiftRight': 161, 'ControlLeft': 162, 'ControlRight': 163,
'AltLeft': 164, 'AltRight': 165, 'MetaLeft': 91, 'MetaRight': 92}
vk = special.get(code, 0)
if re.fullmatch(r'Key[A-Z]', code) or re.fullmatch(r'Digit[0-9]', code):
vk = ord(code[-1])
if vk:
extended = 1 if vk in (33, 34, 35, 36, 37, 38, 39, 40, 45, 46, 91, 92, 163, 165) else 0
self.host.send(key=(vk, 0, extended | (0 if down else 2)))
(self.keys.add if down else self.keys.discard)(vk)
elif down and len(str(m.get('key', ''))) == 1:
self.handle({'t': 'text', 's': m['key']})
elif t == 'text':
self.focus()
raw = str(m.get('s', ''))[:4096].encode('utf-16-le')
for i in range(0, len(raw), 2):
unit = int.from_bytes(raw[i:i+2], 'little')
self.host.send(key=(0, unit, 4))
self.host.send(key=(0, unit, 6))
+130
View File
@@ -0,0 +1,130 @@
"""PC in the headset (Experimental, Windows only), behind the existing MacView
tunnel/panel controller.
The public /api/macview name and viewer URL are the Mac feature's. Only helper
launch, platform availability and source validation are different. No second
viewer, SSH supervisor or benchmark launcher.
It is off until the person turns it on in Tools: until then nothing starts,
no native library is loaded and no window is listed. The Linux adapter from
PR #38 is in the tree but never offered (host_view only picks this on Windows).
"""
import os
import sys
import subprocess
from frame_macview import MacView, MacViewError, ROOT
from frame_pc_capture import LIBRARY, NATIVE
def prepend(env, name, path):
"""An empty entry means the current directory to the loader; never add one."""
rest = env.get(name, '')
env[name] = str(path) + (os.pathsep + rest if rest else '')
OFF = 'Off. Turn it on to list your windows and screens.'
class PCView(MacView):
viewer_profile = 'pc-view'
host = 'windows' if sys.platform == 'win32' else 'linux'
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.prefer_usb = False # Mac networksetup probe is platform-specific
self.enabled = False
def unavailable(self):
if sys.platform != 'win32':
return 'PC in the headset is only available on Windows for now.'
if not self.enabled:
return OFF
if not LIBRARY.is_file():
return 'The PC streaming libraries are missing from this build. See docs/pc-in-headset.md.'
return None
def state(self):
result = super().state()
result.update(host=self.host, experimental=True, enabled=self.enabled)
return result
def set_enabled(self, on):
"""Turning it off ends every stream and the helper; nothing restarts
it until it's turned on again (ensure_agent asks unavailable())."""
if on:
with self.lock:
self.enabled = True
return self.state()
try:
self.stop() # while still enabled, so the viewers on the Frame close too
except MacViewError:
pass
with self.lock:
self.enabled = False
self._drop_tunnel()
agent, self.agent = self.agent, None
if agent and agent.poll() is None:
# EOF gives the host a chance to release held input, including on
# Windows where terminate is uncatchable.
if agent.stdin:
agent.stdin.close()
try:
agent.wait(10)
except subprocess.TimeoutExpired:
agent.kill()
agent.wait(5)
return self.state()
def build(self):
pass # native libraries are built and bundled with the app
def agent_command(self):
return [sys.executable, str(ROOT / 'ui' / 'frame_pc_agent.py')]
def agent_environment(self):
env = super().agent_environment()
env['GST_PLUGIN_PATH_1_0'] = str(NATIVE / 'lib' / 'gstreamer-1.0')
env['GST_PLUGIN_SYSTEM_PATH_1_0'] = ''
cache = os.path.join(os.path.expanduser('~'), '.cache', 'frame-control')
os.makedirs(cache, exist_ok=True)
env['GST_REGISTRY_1_0'] = os.path.join(cache, 'gstreamer-registry.bin')
env['GST_REGISTRY_FORK'] = 'no'
if sys.platform == 'win32':
prepend(env, 'PATH', NATIVE / 'bin')
else:
prepend(env, 'LD_LIBRARY_PATH', NATIVE / 'lib')
env['PIPEWIRE_MODULE_DIR'] = str(NATIVE / 'lib' / 'pipewire-0.3')
env['SPA_PLUGIN_DIR'] = str(NATIVE / 'lib' / 'spa-0.2')
env['PIPEWIRE_CONFIG_DIR'] = str(NATIVE / 'share' / 'pipewire')
env['PIPEWIRE_CONFIG_NAME'] = 'client.conf'
return env
def shutdown(self):
self.closing = True
try:
self.stop()
except MacViewError:
pass
if self.tunnel and self.tunnel.poll() is None:
self.tunnel.terminate()
if self.agent and self.agent.poll() is None:
# EOF gives the host a chance to release held input and portal
# sessions, including on Windows where terminate is uncatchable.
if self.agent.stdin:
self.agent.stdin.close()
try:
self.agent.wait(10)
except subprocess.TimeoutExpired:
self.agent.kill()
self.agent.wait(5)
def _show(self, src, quality, width, height):
if src.startswith('separate:'):
raise MacViewError('Separate virtual displays are a Mac-only feature. Choose a window or screen.')
return super()._show(src, quality, width, height)
def host_view(*args, **kwargs):
"""Windows gets the PC host; everywhere else keeps the Mac view, which
reports itself unavailable off macOS."""
return (PCView if sys.platform == 'win32' else MacView)(*args, **kwargs)
+29 -8
View File
@@ -45,19 +45,40 @@ def num(path, scale=1.0):
def battery():
for d in glob.glob("/sys/class/power_supply/*"):
# Prefer the documented Frame fuel gauge over controller/peripheral batteries.
supplies = sorted(glob.glob("/sys/class/power_supply/*"),
key=lambda d: (os.path.basename(d) != "max1720x_bat_7-36", d))
for d in supplies:
if read(d + "/type") != "Battery":
continue
cap = read(d + "/capacity")
if read(d + "/scope") == "Device" or read(d + "/present") == "0":
continue
def value(name, scale=1, minimum=None, maximum=None):
n = num(d + "/" + name, scale)
if n is None or not math.isfinite(n):
return None
if (minimum is not None and n < minimum) or (maximum is not None and n > maximum):
return None
return n
# max1720x reports microvolts/microamps; current is positive while charging.
volts, amps = num(d + "/voltage_now", 1e-6), num(d + "/current_now", 1e-6)
return {"percent": int(cap) if cap and cap.isdigit() else None,
volts, amps = value("voltage_now", 1e-6, 0), value("current_now", 1e-6)
full = value("charge_full", 1e-3, 0)
design = value("charge_full_design", 1e-3, 0)
return {"source": d,
"percent": value("capacity", minimum=0, maximum=100),
"status": read(d + "/status"),
"watts": round(volts * amps, 2) if volts is not None and amps is not None else None,
"timeToFull": num(d + "/time_to_full_now"),
"timeToEmpty": num(d + "/time_to_empty_now"),
"tempC": num(d + "/temp", 0.1),
"health": read(d + "/health")}
# Reject negative/unsigned sentinel estimates and implausible temperatures.
"timeToFull": value("time_to_full_now", minimum=1, maximum=604800),
"timeToEmpty": value("time_to_empty_now", minimum=1, maximum=604800),
"tempC": value("temp", 0.1, -273.15, 200),
"health": read(d + "/health"),
"cycleCount": value("cycle_count", minimum=0),
"fullMah": full if full else None,
"designMah": design if design else None,
"capacityHealthPercent": round(full / design * 100, 1) if full and design else None}
return None
+152
View File
@@ -0,0 +1,152 @@
"""Read-only headset storage inventory, piped over SSH as python3 -.
No recursive size scans, Steam IPC, subprocesses or writes. Manifest sizes are
Steam's recorded content sizes, not filesystem allocation measurements.
"""
import json
import os
from pathlib import Path
import re
import time
HOME = Path.home()
STEAM = HOME / '.local/share/Steam'
MAX_TEXT = 2 * 1024 * 1024
TOOLS = re.compile(r'^(Steam Linux Runtime|Proton|Steamworks Common|FEX$|Lepton Development$)')
def state(error):
if isinstance(error, PermissionError):
return 'permission-denied'
if isinstance(error, FileNotFoundError):
return 'missing'
return 'unavailable'
def read_vdf(path):
"""Small strict KeyValues reader; reject truncated/malformed input explicitly."""
with open(path, encoding='utf-8', errors='strict') as stream:
text = stream.read(MAX_TEXT + 1)
if len(text) > MAX_TEXT:
raise ValueError('metadata too large')
tokens = []
pos = 0
pattern = re.compile(r'\s+|//[^\n]*|"((?:[^"\\]|\\.)*)"|([{}])')
while pos < len(text):
match = pattern.match(text, pos)
if not match:
raise ValueError('invalid KeyValues')
pos = match.end()
if match.group(1) is not None:
tokens.append(('text', re.sub(r'\\(["\\])', r'\1', match.group(1))))
elif match.group(2):
tokens.append(('brace', match.group(2)))
index = 0
def pairs(depth=0):
nonlocal index
if depth > 20:
raise ValueError('metadata too deep')
result = {}
while index < len(tokens):
key_type, key = tokens[index]
index += 1
if key_type == 'brace' and key == '}':
if not depth:
raise ValueError('unexpected brace')
return result
if key_type == 'brace' or index >= len(tokens):
raise ValueError('missing value')
value_type, value = tokens[index]
index += 1
if value_type == 'brace' and value == '{':
value = pairs(depth + 1)
elif value_type == 'brace':
raise ValueError('missing value')
if key.lower() in result:
raise ValueError('duplicate key')
result[key.lower()] = value
if depth:
raise ValueError('unclosed object')
return result
return pairs()
def capacity(path):
try:
s = os.statvfs(path)
return {'state': 'ok', 'total': s.f_blocks * s.f_frsize,
'free': s.f_bavail * s.f_frsize,
'used': (s.f_blocks - s.f_bfree) * s.f_frsize,
'filesystem': str(os.stat(path).st_dev)}
except (OSError, AttributeError) as error:
return {'state': state(error), 'total': None, 'free': None, 'used': None,
'filesystem': None}
def inventory(home=None, steam=None):
home, steam = Path(home or HOME), Path(steam or STEAM)
paths = [steam]
discovery = 'ok'
try:
folders = read_vdf(steam / 'steamapps/libraryfolders.vdf').get('libraryfolders')
if not isinstance(folders, dict):
raise ValueError('missing libraryfolders')
for key, value in folders.items():
if not key.isdigit():
continue
path = value.get('path') if isinstance(value, dict) else value
if not isinstance(path, str) or not os.path.isabs(path) or '\x00' in path:
raise ValueError('invalid library path')
paths.append(Path(path))
except OSError as error:
discovery = state(error)
except (ValueError, UnicodeError):
discovery = 'invalid-metadata'
libraries, seen = [], set()
for path in paths:
canonical = os.path.realpath(path)
if canonical in seen:
continue
seen.add(canonical)
lib = {'path': str(path), 'capacity': capacity(path), 'state': 'ok',
'apps': [], 'issues': [], 'recordedBytes': 0, 'unknownSizes': 0,
'gameBytes': 0, 'toolBytes': 0}
try:
with os.scandir(path / 'steamapps') as entries:
manifests = sorted(entry.path for entry in entries
if re.fullmatch(r'appmanifest_\d+\.acf', entry.name))
for manifest in manifests:
try:
fields = read_vdf(manifest).get('appstate')
if not isinstance(fields, dict):
raise ValueError('missing AppState')
appid = fields.get('appid', '')
if not isinstance(appid, str) or not appid.isdigit() or Path(manifest).name != f'appmanifest_{appid}.acf':
raise ValueError('invalid appid')
name = fields.get('name') or appid
if not isinstance(name, str):
raise ValueError('invalid name')
raw = fields.get('sizeondisk')
size = int(raw) if isinstance(raw, str) and raw.isascii() and raw.isdigit() and len(raw) <= 18 else None
kind = 'tool' if TOOLS.match(name) else 'game'
lib['apps'].append({'appid': appid, 'name': name, 'bytes': size, 'kind': kind})
if size is None:
lib['unknownSizes'] += 1
else:
lib['recordedBytes'] += size
lib['toolBytes' if kind == 'tool' else 'gameBytes'] += size
except (OSError, ValueError, UnicodeError) as error:
lib['issues'].append({'file': Path(manifest).name,
'state': state(error) if isinstance(error, OSError) else 'invalid-metadata'})
lib['apps'].sort(key=lambda app: (app['bytes'] is None, -(app['bytes'] or 0), app['name'].lower()))
if lib['issues']:
lib['state'] = 'partial'
except OSError as error:
lib['state'] = state(error)
libraries.append(lib)
return {'time': time.time(), 'home': capacity(home), 'discovery': discovery, 'libraries': libraries}
if __name__ == '__main__':
print(json.dumps(inventory()))
+88
View File
@@ -0,0 +1,88 @@
"""The desktop stream's timing records, in the Mac viewer/bench schema.
Times are host monotonic microseconds. Zero means unknown, never a fabricated
capture/display timestamp. Storage is bounded to the same 4096/512 records as
Stats.swift. The existing macview-bench.py grades these records unchanged.
"""
from collections import OrderedDict
import threading
def distribution(values):
values = sorted(v for v in values if v is not None)
if not values:
return {}
return {key: round(values[min(len(values)-1, int((len(values)-1)*p + .5))], 1)
for key, p in (('p50', .5), ('p95', .95))}
class Stats:
def __init__(self, now):
self.now, self.lock = now, threading.RLock()
self.frames, self.inputs = OrderedDict(), OrderedDict()
self.captured = self.skipped = self.dropped = 0
self.rtt, self.decoder, self.synced = 0, '', False
self.sequence = 0
def add(self, **values):
with self.lock:
self.sequence = (self.sequence + 1) & 0xffffffff
record = dict.fromkeys(('s', 'k', 'b', 'w', 'h', 'cap', 'arr', 'e0', 'e1', 'snd',
'wire', 'rx', 'dec', 'drw', 'vs', 'echo', 'tier', 'br'), 0)
record.update(values, s=self.sequence)
self.frames[self.sequence] = record
while len(self.frames) > 4096:
self.frames.popitem(last=False)
for event in self.inputs.values():
if not event['frame'] and event['inj'] <= record['cap']:
event.update(frame=self.sequence, cap=record['cap'])
record['echo'] = event['id']
return record
def report(self, message):
with self.lock:
if message['t'] == 'rx':
frame = self.frames.get(message.get('s'))
if frame and isinstance(message.get('r'), (int, float)):
frame['rx'] = int(message['r'])
elif message['t'] == 'fd':
for item in message.get('f', [])[:4096]:
if isinstance(item, list) and len(item) >= 4:
frame = self.frames.get(item[0])
if frame:
for name, val in zip(('dec', 'drw', 'vs'), item[1:4]):
if isinstance(val, (int, float)):
frame[name] = int(val)
self.dropped += max(0, int(message.get('drop', 0)))
elif message['t'] == 'clock':
self.rtt = float(message.get('rtt', 0))
self.decoder = str(message.get('dec', ''))[:1024]
self.synced = True
def input(self, m):
if not isinstance(m.get('i'), int) or not m['i']:
return
with self.lock:
self.inputs[m['i']] = dict(id=m['i'], kind=m['t'], tv=m.get('tv') or 0,
inj=self.now(), frame=0, cap=0)
while len(self.inputs) > 512:
self.inputs.popitem(last=False)
def summary(self):
with self.lock:
now = self.now()
frames = [f for f in self.frames.values() if f['cap'] >= now-2000000]
out = dict(captured=self.captured, skipped=self.skipped, dropped=self.dropped,
rtt=self.rtt, decoder=self.decoder, synced=self.synced,
fps=sum(f['vs'] > 0 for f in frames)/2, sentFps=len(frames)/2,
mbps=round(sum(f['b'] for f in frames)*4/1e6, 2))
for key, start, end in (('capture', 'cap', 'arr'), ('queue', 'arr', 'e0'),
('encode', 'e0', 'e1'), ('network', 'e1', 'rx'),
('decode', 'rx', 'dec'), ('draw', 'dec', 'drw'), ('total', 'cap', 'drw')):
out[key] = distribution([(f[end]-f[start])/1000 for f in frames if f[start] and f[end]])
return out
def snapshot(self, since=0, settle=1500000):
with self.lock:
return dict(frames=[dict(f) for f in self.frames.values() if f['s'] > since and f['cap'] <= self.now()-settle],
inputs=[dict(i) for i in self.inputs.values()], captured=self.captured, summary=self.summary())
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more