Compare commits

...
Author SHA1 Message Date
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 fa40e30f3c Force quit never lists or quits SteamVR: on the Frame it's the headset view, not a game
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:38:14 +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 d7a887b98c Force quit's unit tests run on Windows too: no SIGKILL or getuid there
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:29:10 +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 a5980d8f98 Force quit a running game from Games and the Control Center
Running now, at the top of Games, lists what's running on the Frame (Steam
games, sideloaded titles, non-Steam shortcuts, Android apps), with Force quit
on each, on a game's library tile and in a sideloaded title's menu. The
Control Center gets a Force quit a game card. Each asks first: unsaved
progress will be lost. Nothing running says so.

ui/frame_running.py finds games by Steam's `reaper SteamLaunch AppId=<id>`
process and its tree, never by name. It asks Steam to quit
(SteamClient.Apps.TerminateApp), then sends SIGTERM and SIGKILL to the tree,
skipping init, the SSH session, other users and Steam, gamescope, SteamVR,
sshd and the session. Android apps also get the Apps tab's container stop.

The fake Frame runs games under a stand-in reaper and lists them in
SteamUIStore.RunningApps, so tests/e2e/test_running.py force quits real
process trees. Not tested on a headset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 23:17:59 +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
Alex Southwell fce1800dcf Merge pull request #111 from saphid/feat/audit-ui-structure
Frame Control 0.4.9: Apps / Games / Tools / Settings, one Add / Install flow, whole-app review fixes
2026-10-20 13:08:58 +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
100 changed files with 16344 additions and 181 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/
+31
View File
@@ -0,0 +1,31 @@
# Changelog
## Unreleased
- 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.
+18 -7
View File
@@ -50,7 +50,7 @@ Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and w
<td valign="top">
**🎮 Steam games**<br>
Everything you own with its Steam Frame rating. Install onto the headset with live progress, and search the store.
Everything you own with its Steam Frame rating. Install onto the headset with live progress, search the store, and force quit a game that stops responding.
</td>
<td valign="top">
@@ -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.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.9",
"version": "0.4.10",
"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.10",
"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
+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`
+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.
+33 -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,11 +49,27 @@ 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,
the desktop, Lepton and xrdp are running.
- **Library** shelf with Steam cover art and a Play button (`steam://rungameid`).
- **Force quit** a game that has stopped responding: Steam games, sideloaded
titles, non-Steam shortcuts and Android apps. **Running now** at the top of
Games lists what's running; each game's own **Force quit…** (on its library
tile, or in a sideloaded title's ⋯ menu) and the Control Center's **Force quit
a game** card do the same. Each asks first, because unsaved progress is lost.
Steam is asked to quit the game, then its processes get SIGTERM and SIGKILL;
see [steam-games.md](steam-games.md#force-quitting-a-running-game).
- **Get games**: every game you own with its Steam Frame rating (Verified,
Playable, Unsupported, Unknown). Install on Frame downloads it to the headset
with live progress. Search the Steam store with prices and Frame ratings; Buy
@@ -80,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
@@ -127,6 +149,12 @@ GPU time is render time, not GPU utilisation. The temperature is the hottest
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
not a guessed universal temperature threshold.
**Force quit a game** lists what's running only when you choose **Check what's
running**, and says so when nothing is. Each **Force quit…** names the game and
warns that unsaved progress will be lost before anything is sent; it uses the
selected headset, refuses a second quit while one is running, and drops the
list when you switch headsets.
**Audio** controls the default output sink through the existing `/api/volume`
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
and **Unmute** send explicit mute states. The UI captures the selected headset,
@@ -220,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.
+9
View File
@@ -287,3 +287,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.
+2 -1
View File
@@ -30,7 +30,8 @@ it.) A dialog shows:
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
with Launch, Remove and, for a game that has stopped responding, **Force
quit…** in its ⋯ menu ([how](steam-games.md#force-quitting-a-running-game)). **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
+56
View File
@@ -101,6 +101,62 @@ Store search uses `store.steampowered.com/api/storesearch/?term=…&cc=…`. It
returns nothing without `cc`, so Frame Control takes the country from
`SteamClient.User.GetIPCountry()` on the Frame.
## Force quitting a running game
**Running now**, at the top of Games, lists what's running on the Frame: Steam
games, sideloaded titles, non-Steam shortcuts and Android apps. A game's library
tile, a sideloaded title's ⋯ menu and the Control Center's **Force quit a game**
card have **Force quit…** too. Each asks first ("Unsaved progress will be
lost"); from a game's own menu it checks that the game is running and says so if
it isn't. SteamVR (app 250820, and its beta 1009850) is never listed or quit:
on the Frame it shows the headset view, so quitting it isn't a way out of a game.
`ui/frame_running.py` does the work on the Frame, piped over SSH with
`frame_steam.py` as its DevTools client:
```sh
scp ui/frame_steam.py ui/frame_running.py frame:/tmp/
ssh frame 'python3 /tmp/frame_running.py list' # {"running": [{appid, gameid, name, kind, ...}]}
ssh frame 'python3 /tmp/frame_running.py quit 620' # {"name", "method", "message"}
```
1. **What's running.** Steam starts every game, shortcut and devkit title under
its `reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper
stays the parent of the game's processes. So a running game is one of your
own processes whose program is named `reaper`, with `SteamLaunch` and an
`AppId=` before `--`; the game is that reaper's process tree. Steam's own list
(`SteamUIStore.RunningApps`) and Android apps whose Lepton container is up
(`podman ps`) are added. Names come from `appStore`, else the app manifest,
the Android app's `meta.json` or the devkit title's folder.
2. **Steam first.** `SteamClient.Apps.TerminateApp(<game id>, false)`, the
call Steam's own Stop makes for Android apps ([vr-apks.md](vr-apks.md)). The
game id is the appid, or `(appid << 32) | 0x02000000` for a non-Steam
shortcut. An Android app's container is also stopped, as the Apps tab's Stop
does (`podman stop -t 5 lepton-steamlaunch-<instance>`).
3. **Then signals.** If the game's processes are still there after 5 seconds,
they get SIGTERM, deepest first; whatever is left 3 seconds later gets
SIGKILL, the reaper last, so nothing it holds is orphaned unsignalled. A pid
is only signalled while its start time still matches, so a reused pid is safe.
Nothing is found by name. These are never signalled, even inside a game's tree:
init, the helper itself and every process above it (the SSH session), other
users' processes, and anything named `steam`, `steamwebhelper`, `gamescope`,
`gamescope-wl`, `gamescopereaper`, `gamescope-session`, `sshd`, `sshd-session`,
`ssh-agent`, `systemd`, `dbus-daemon`, `dbus-broker`, `pipewire`,
`pipewire-pulse`, `wireplumber`, `plasmashell`, `kwin_wayland`, `kwin_x11`,
`Xwayland`, `sddm`, `vrserver`, `vrcompositor`, `vrmonitor`, `vrdashboard`,
`vrwebhelper` or `vrstartup`, with everything under them.
**Not tested on a headset.** The reaper command line is how Steam launches games
on Linux desktops and the Steam Deck, **inferred** for the Frame.
`SteamUIStore.RunningApps` comes from Steam's client code as plugin loaders use
it and hasn't been seen on the Frame's build; without it, the reapers alone
decide. `TerminateApp` with a shortcut's game id is the call Steam's Stop was
seen making for an Android app (docs/vr-apks.md); for Steam games it is
**inferred**. Unit tests (`tests/test_force_quit.py`) cover the parsing, the
process selection and the escalation; the fake Frame launches its games under a
stand-in `reaper` so `tests/e2e/test_running.py` force quits a real process tree.
## Not yet checked
- Free-to-play games: `steam://install` should stop at state 3 (free license)
+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
+129 -6
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
@@ -76,8 +90,8 @@ its guards want.
| `sshd` with key and password logins, `rsync`, `python3` | Real |
| Valve's steamos-devkit-service on port 32000 and its hooks, vendored unmodified in `tests/fakeframe/steamos-devkit-service` | Real; only its `dbus` import (for mDNS through systemd-resolved) is a stand-in that logs the registration |
| Valve's devkit-utils, copied over by Frame Control itself | Real |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF | The JS engine is real; the objects are fake |
| **fakesteam**: `~/.steam/steam.pid`, `steam.token` and the `steam.pipe` FIFO; answers `approve-ssh-key`, `create-shortcut`, `run-game`, `list-shortcuts` and `delete-shortcut` with the response files devkit-utils waits for; takes `steam://rungameid`, `install` and `store` URLs. Games it really runs start under a stand-in `reaper SteamLaunch AppId=<id> -- <command>` (bash with that command line), so force quit finds a real process tree | Fake |
| DevTools on `127.0.0.1:8080` with a `SharedJSContext` target. The JavaScript Frame Control sends runs for real in Node against stand-in `SteamClient`, `appStore` and `downloadsStore` objects (`cef_shim.js`), so async functions, optional chaining and `Map`s behave as in Steam's CEF. `SteamUIStore.RunningApps` lists launches that haven't exited; `TerminateApp` ends only launches that never ran a process, so a running program needs Frame Control's signals | The JS engine is real; the objects are fake |
| `steam`, `wpctl`, `flatpak`, `podman`, `nmcli`, `qdbus6`, `gamescopectl`, SteamOS's `steamos-enable-sshd` helper, and Lepton's launcher | Stubs that record their calls |
| Battery, charger and thermal zones under `/sys/class` | Files the supervisor writes. `/sys` is read-only in a container and Docker's AppArmor profile refuses writes under it, so each folder is a volume mounted twice: over `/sys/class/...` for `frame_status.py` to read, and under `/var/lib/fakeframe/sys` for the supervisor to write |
| `vrserver` and `plasmashell` | Renamed `sleep` processes, so the status page and the clipboard find them |
@@ -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:
@@ -267,6 +350,17 @@ connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Force quit
`tests/test_force_quit.py` feeds `ui/frame_running.py` made-up process tables:
the `reaper SteamLaunch AppId=` parsing, naming from Steam, manifests, Android
`meta.json` and devkit folders, and a Frame's worth of Steam, gamescope, SteamVR,
sshd and session processes that must never be signalled. Processes that ignore
SIGTERM, survive SIGKILL or exit and have their pid reused check the escalation.
The Control Center's confirmation, duplicate and headset-switch handling run in
`tests/control_center.cjs`. `tests/e2e/test_running.py` force quits real process
trees on the fake Frame. None of it has run on a headset.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
@@ -314,3 +408,32 @@ 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.
+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")]
+110 -2
View File
@@ -64,11 +64,11 @@ 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};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, page = 'control';
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], androidSeq = 0, titlesSeq = 0, runningGames = [], runningSeq = 0, page = 'control';
const androidPending = new Set();`,context);
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
@@ -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});
@@ -449,3 +493,67 @@ test('every Control Center workflow reaches its intentional workspace', () => {
}
assert.equal(f.run('PAGES.length'),4);
});
// Force quit: an explicit check, a confirmation naming the game, and the captured headset.
function quitPage({confirmAnswer=true, running=[{appid:620,name:'Portal <2>',kind:'steam'}], quit} = {}) {
const asked=[], calls=[];
const f=mounted({confirm:text=>{asked.push(text);return confirmAnswer;},
api:async(path,body,device)=>{calls.push({path,body,device});
if(body===undefined)return {running};
return quit ? quit(body) : {message:'Quit Portal 2',method:'steam'};}});
return Object.assign(f,{asked,apiCalls:calls,click:appid=>f.element('ccRunningList').onclick({target:{closest:()=>({dataset:{ccQuit:String(appid)}})}})});
}
test('force quit lists what is running and says so when nothing is', async t => {
const f=quitPage({running:[]});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
assert.deepEqual(f.apiCalls,[{path:'/api/running',body:undefined,device:'frame-a'}]);
assert.equal(f.element('ccRunningResult').textContent,'Nothing is running on the Frame.');
assert.equal(f.element('ccRunningList').innerHTML,'');
const g=quitPage();t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();
assert.match(g.element('ccRunningList').innerHTML,/Portal &lt;2&gt;<small>Steam game<\/small>.*data-cc-quit="620"/);
assert.match(g.element('ccRunningResult').textContent,/^1 running/);
});
test('force quit confirms with the unsaved-progress warning before any request', async t => {
const f=quitPage({confirmAnswer:false});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
f.click(620);await flush();
assert.deepEqual(f.asked,['Force quit Portal <2>? Unsaved progress will be lost.']);
assert.equal(f.apiCalls.length,1,'declining sends nothing');
const g=quitPage();t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();
g.click(620);await flush();
assert.deepEqual(g.apiCalls[1],{path:'/api/running',body:{action:'quit',appid:'620'},device:'frame-a'});
assert.equal(g.element('ccRunningResult').textContent,'Quit Portal 2');
assert.equal(g.element('ccRunningList').innerHTML,'');
g.click(999);await flush();
assert.equal(g.apiCalls.length,2,'an id that was not listed is ignored');
});
test('force quit shows errors, refuses duplicates and offline use, and ignores a switched headset', async t => {
const pending=deferred();
const f=quitPage({quit:()=>pending.promise});t.after(()=>f.ui.destroy());
await f.element('ccRunningCheck').onclick();
f.click(620);f.click(620);await flush();
assert.equal(f.asked.length,1);
assert.match(f.element('ccRunningList').innerHTML,/disabled/);
pending.reject(Error("That game isn't running any more"));await flush();
assert.equal(f.element('ccRunningResult').textContent,"Couldn’t force quit Portal <2>: That game isn't running any more");
assert.doesNotMatch(f.element('ccRunningList').innerHTML,/disabled/);
// A switch while quitting: the old answer is dropped and the list cleared.
const late=deferred(),g=quitPage({quit:()=>late.promise});t.after(()=>g.ui.destroy());
await g.element('ccRunningCheck').onclick();g.click(620);await flush();
g.context={online:true,generation:2,device:'frame-b'};g.ui.reset();
late.resolve({message:'Quit Portal 2'});await flush();
assert.equal(g.element('ccRunningResult').textContent,'');
assert.equal(g.element('ccRunningList').innerHTML,'');
const h=quitPage();t.after(()=>h.ui.destroy());h.context={online:false,generation:1,device:'frame-a'};
await h.element('ccRunningCheck').onclick();
assert.equal(h.apiCalls.length,0);
assert.match(h.element('ccRunningResult').textContent,/Connect to the Frame/);
const controller=cc.createController({api:async()=>assert.fail('no request'),getContext:()=>({online:true,generation:1})});
await assert.rejects(controller.forceQuit({appid:'620; reboot'}),/Choose a running game/);
});
test('the Control Center finds force quit by what a stuck game looks like', () => {
const item=cc.catalog.find(i=>i.id==='forcequit');
for(const q of ['frozen game','hung','not responding','kill','stuck app'])assert.ok(cc.matches(item,q,'all'),q);
assert.ok(cc.matches(item,'','apps'));
});
+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()
+82
View File
@@ -0,0 +1,82 @@
"""Force quit (ui/frame_running.py) through the server's HTTP API, against games the
fake Steam client really runs under its stand-in reaper (fakesteam.REAPER)."""
import os
import unittest
import harness
from harness import api, install_title, launches, ok, ssh, state, wait_for
harness.require()
class Running(harness.FrameTestCase):
def script_title(self, name, body):
folder = self.path(name)
os.makedirs(folder)
with open(os.path.join(folder, 'run.sh'), 'w') as f:
f.write('#!/bin/sh\n' + body)
os.chmod(os.path.join(folder, 'run.sh'), 0o755)
plan, job = install_title(folder)
self.assertIsNone(job['error'], job)
return plan['id'], plan['name']
def running(self, match):
return next((g for g in ok('GET', '/api/running')['running'] if match(g)), None)
def launch_title(self, gid):
ok('POST', '/api/titles', {'action': 'launch', 'id': gid})
game = wait_for(lambda: self.running(lambda g: g.get('title') == gid), 20, f'{gid} to be running')
# The fake's reset doesn't stop programs: don't leave one running for the next test.
self.addCleanup(ssh, f"pkill -KILL -f '[d]evkit-game/{gid}/' || true", check=False)
return game
def assert_steam_alive(self, pid):
self.assertEqual(ssh(f'kill -0 {pid} && echo alive').strip(), 'alive')
def test_nothing_running(self):
self.assertEqual(ok('GET', '/api/running'), {'running': [], 'message': 'Nothing is running on the Frame'})
status, out, _ = api('POST', '/api/running', {'action': 'quit', 'appid': '620'})
self.assertEqual(status, 409, out)
def test_sigterm_quits_a_sideloaded_game(self):
gid, name = self.script_title('Hang Game', 'sleep 600 &\nwait\n')
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
self.assertEqual((game['kind'], game['name']), ('devkit', name))
self.assertGreaterEqual(game['processes'], 3) # reaper, run.sh, sleep
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'term', out)
self.assertEqual(out['message'], f'Force quit {name}')
# Steam was asked first, with the shortcut's game id.
self.assertEqual(state()['steam']['last_terminate'], game['gameid'])
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
done = wait_for(lambda: launches('devkit')[-1].get('exit') is not None and launches('devkit')[-1],
10, 'the reaper to report the exit')
self.assertIsNotNone(done['exit'])
def test_sigkill_when_it_ignores_sigterm(self):
gid, name = self.script_title('Stuck Game', "trap '' TERM\nsleep 600 &\nwait\n")
steam = ssh('cat ~/.steam/steam.pid').strip()
game = self.launch_title(gid)
out = ok('POST', '/api/running', {'action': 'quit', 'appid': str(game['appid'])})
self.assertEqual(out['method'], 'kill', out)
self.assertEqual(out['message'], f'Force quit {name} (it had to be killed)')
self.assertIsNone(self.running(lambda g: g['appid'] == game['appid']))
self.assertEqual(ssh("pgrep -fc '[s]leep 600' || true").strip(), '0')
self.assert_steam_alive(steam)
def test_steam_game_quits_through_steam(self):
ok('POST', '/api/launch', {'appid': 620980})
game = wait_for(lambda: self.running(lambda g: g['appid'] == 620980), 20, 'the Steam game to be running')
self.assertEqual((game['kind'], game['gameid']), ('steam', '620980'))
out = ok('POST', '/api/running', {'action': 'quit', 'appid': '620980'})
self.assertEqual(out['method'], 'steam', out)
self.assertEqual(state()['steam']['last_terminate'], '620980')
self.assertEqual(launches('rungameid')[-1]['exit'], 'terminated')
self.assertEqual(ok('GET', '/api/running')['running'], [])
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)
@@ -79,6 +79,12 @@ function build(steam) {
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
},
},
// Guess: the running list as Steam's client code and plugin loaders read it
// (SteamUIStore.RunningApps of app overviews); not seen on the Frame. fakesteam
// passes the ids of launches that haven't exited.
SteamUIStore: {
get RunningApps() { return (steam.running || []).map(id => allApps().find(a => a.appid === id) || { appid: id }); },
},
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
@@ -109,7 +115,8 @@ function build(steam) {
if (s?.artwork) delete s.artwork[type];
},
// Container fallback in frame_android.stop performs the simulated stop.
TerminateApp(gameid) { steam.last_terminate = gameid; },
// fakesteam ends pid-less launches for the ids in `terminated`.
TerminateApp(gameid) { steam.last_terminate = gameid; (steam.terminated ||= []).push(String(gameid)); },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
@@ -47,6 +47,12 @@ COMPAT_LOG = fs.STEAM_ROOT + '/logs/compat_log.txt'
DEVTOOLS_PORT = 8080
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
GAME_LOGS = '/var/log/fakeframe'
# Steam starts a game under its reaper, `reaper SteamLaunch AppId=<id> -- <command>`,
# which stays the game's parent until it ends (guess for the Frame: that's how Steam
# launches on Linux desktops and the Steam Deck; docs/steam-games.md). The fake's
# reaper is bash with that command line: it waits for the game and exits with its status.
REAPER = fs.STEAM_ROOT + '/ubuntu12_32/reaper'
REAPER_SH = 'while [ "$1" != "--" ]; do shift; done; shift; "$@" & wait $!'
# The 403 text /register returned while Steam wasn't on "Pair new host"
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
@@ -105,11 +111,24 @@ class JS:
self._start()
self.next += 1
with fs.update() as state:
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
# What Steam counts as running: launches that started and haven't exited.
running = sorted({l['appid'] for l in state['launches']
if l.get('started') and l.get('appid') and l.get('exit') is None})
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression, 'awaitPromise': await_promise,
'steam': dict(state['steam'], running=running)}) + '\n')
self.proc.stdin.flush()
reply = json.loads(self.proc.stdout.readline())
state['steam'] = reply['steam']
steam = reply['steam']
steam.pop('running', None)
for gid in map(int, steam.pop('terminated', [])):
appid = gid >> 32 if gid >= 1 << 32 else gid
# Guess: TerminateApp ends a launch the fake didn't really run (a Steam
# game, a Proton title). A real process it leaves alone, so Frame
# Control's own fallback (SIGTERM, SIGKILL) is what the e2e tests see.
for l in state['launches']:
if l.get('appid') == appid and l.get('exit') is None and not l.get('pid'):
l['exit'] = 'terminated'
state['steam'] = steam
return reply['result']
@@ -253,8 +272,10 @@ def add_launch(record):
os.makedirs(GAME_LOGS, exist_ok=True)
with open(f'{GAME_LOGS}/{label}.log', 'ab') as log:
try:
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
argv = [REAPER, '-c', REAPER_SH, 'reaper', 'SteamLaunch', f"AppId={record['appid']}", '--', path]
proc = subprocess.Popen(argv, executable='/bin/bash', cwd=cwd if os.path.isdir(cwd) else HOME,
env={**os.environ, **env}, stdin=subprocess.DEVNULL, stdout=log, stderr=log,
start_new_session=True)
record['pid'] = proc.pid
except OSError as e:
record.update(pid=None, exec_error=str(e))
+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,'');
}
});
+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()
+398
View File
@@ -0,0 +1,398 @@
"""Force quit (ui/frame_running.py and /api/running) without a headset: running-game
detection, which processes may be signalled, SIGTERM-then-SIGKILL, and the Android path.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import os
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_running # noqa: E402
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
ME, OTHER = 1000, 0
HOME = frame_running.HOME
REAPER = f"{HOME}/.local/share/Steam/ubuntu12_32/reaper"
def proc(ppid, comm, argv=None, uid=ME, start=None, state="S"):
return {"ppid": ppid, "comm": comm, "argv": argv if argv is not None else [comm], "uid": uid,
"start": start or 1000 + ppid, "state": state}
def frame():
"""A Frame's processes, as Steam on Linux launches games: Steam's reaper with
AppId= is the parent of the game (Portal 2 here, appid 620)."""
return {
1: proc(0, "systemd", uid=OTHER),
200: proc(1, "sshd", uid=OTHER),
201: proc(200, "sshd-session"),
202: proc(201, "bash"),
203: proc(202, "python3", ["python3", "-", "quit", "620"]), # this helper
300: proc(1, "systemd", ["/usr/lib/systemd/systemd", "--user"]),
301: proc(300, "gamescope-session"),
302: proc(301, "gamescope"),
303: proc(302, "steam", [f"{HOME}/.local/share/Steam/ubuntu12_32/steam", "-cef-enable-debugging"]),
304: proc(303, "steamwebhelper"),
305: proc(300, "vrserver"),
306: proc(300, "plasmashell"),
307: proc(300, "pipewire"),
400: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "steam-launch-wrapper", "--", "portal2.sh"]),
401: proc(400, "steam-launch-wr", ["steam-launch-wrapper", "--", "portal2.sh"]),
402: proc(401, "portal2.sh", ["/bin/sh", "portal2.sh"]),
403: proc(402, "portal2_linux", ["./portal2_linux", "-game", "portal2"]),
404: proc(403, "steam", ["steam"]), # a child named like Steam: left alone
405: proc(404, "crashhandler"), # and so is anything under it
# Another game, and processes that only look like launches.
500: proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=999", "--", "other"]),
501: proc(500, "other"),
600: proc(1, "reaper", [REAPER, "SteamLaunch", "AppId=620", "--", "x"], uid=OTHER),
601: proc(300, "bash", ["bash", "-c", "echo reaper SteamLaunch AppId=620"]),
602: proc(300, "fakereaper", ["/tmp/notreaper", "SteamLaunch", "AppId=620"]),
}
class Parsing(unittest.TestCase):
def test_launch_appid(self):
cases = [
([REAPER, "SteamLaunch", "AppId=620", "--", "game"], 620),
(["reaper", "SteamLaunch", "AppId=3346865537", "--", "lepton-app.sh"], 3346865537),
([REAPER, "AppId=620", "--", "game"], None), # no SteamLaunch
([REAPER, "SteamLaunch", "--", "game", "AppId=620"], None), # the game's own arguments
(["/usr/bin/steam", "SteamLaunch", "AppId=620"], None), # not the reaper
(["/tmp/reaper-ish", "SteamLaunch", "AppId=620"], None),
([REAPER, "SteamLaunch", "AppId=62x0"], None),
([REAPER, "SteamLaunch", "AppId=12345678901"], None), # more than 10 digits
([], None),
]
for argv, want in cases:
with self.subTest(argv=argv):
self.assertEqual(frame_running.launch_appid(argv), want)
def test_reapers_are_this_users_live_launches(self):
procs = frame()
procs[700] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=777"], state="Z")
self.assertEqual(frame_running.reapers(procs, ME), {620: [400], 999: [500]})
def test_steamvr_is_never_a_game(self):
procs = frame()
procs[710] = proc(303, "reaper", [REAPER, "SteamLaunch", "AppId=250820", "--", "vrstartup"])
procs[711] = proc(710, "vrcompositor-launcher")
self.assertNotIn(250820, frame_running.reapers(procs, ME))
found = frame_running.running(procs, ME, steam=lambda ids: ([250820], {}),
android=dict, containers=set)
self.assertNotIn(250820, [a["appid"] for a in found])
def test_gameid(self):
self.assertEqual(frame_running.gameid(620), "620")
# A non-Steam shortcut: (appid << 32) | 0x02000000, as steam_shortcuts.py stop sends.
self.assertEqual(frame_running.gameid(3346865537), str((3346865537 << 32) | 0x02000000))
def test_snapshot_reads_proc(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/42")
os.makedirs(f"{tmp}/self")
fields = " ".join(["S", "7"] + ["0"] * 17 + ["123456"])
Path(f"{tmp}/42/stat").write_text(f"42 (my (game) x) {fields} 0 0\n")
Path(f"{tmp}/42/cmdline").write_bytes(b"reaper\0SteamLaunch\0AppId=5\0--\0g\0")
os.makedirs(f"{tmp}/43") # vanished between listdir and read
got = frame_running.snapshot(tmp)
self.assertEqual(list(got), [42])
self.assertEqual(got[42]["comm"], "my (game) x")
self.assertEqual((got[42]["ppid"], got[42]["start"], got[42]["state"]), (7, 123456, "S"))
self.assertEqual(got[42]["argv"], ["reaper", "SteamLaunch", "AppId=5", "--", "g"])
class Detection(unittest.TestCase):
def run_list(self, procs, steam=(None, {}), android=None, containers=()):
return frame_running.running(procs, ME, steam=lambda ids: steam, android=lambda: android or {},
containers=lambda: set(containers))
def test_steam_game_named_by_steam(self):
got = self.run_list(frame(), steam=([620], {620: "Portal 2", 999: "Other"}))
self.assertEqual([(a["appid"], a["kind"], a["name"]) for a in got],
[(620, "steam", "Portal 2"), (999, "steam", "Other")])
self.assertEqual(got[0]["gameid"], "620")
self.assertEqual(got[0]["processes"], 6)
def test_without_steam_the_manifest_names_it(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/steamapps")
Path(f"{tmp}/steamapps/appmanifest_620.acf").write_text('"AppState"\n{\n\t"appid"\t"620"\n\t"name"\t"Portal 2"\n}')
with mock.patch.object(frame_running, "STEAM", tmp):
got = self.run_list(frame())
self.assertEqual([(a["appid"], a["name"]) for a in got], [(620, "Portal 2"), (999, "App 999")])
def test_nothing_running(self):
procs = {pid: p for pid, p in frame().items() if pid < 400}
self.assertEqual(self.run_list(procs), [])
def test_steam_only_listing_counts(self):
# Steam says it's running but no reaper was found: listed, with no processes.
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, steam=([620], {620: "Portal 2"}))
self.assertEqual(got, [{"appid": 620, "gameid": "620", "processes": 0, "kind": "steam", "name": "Portal 2"}])
def test_devkit_title_and_shortcut(self):
sid, other = 0x80001234, 0x80005678
procs = {1: proc(0, "systemd", uid=OTHER),
10: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={sid}", "--", f'"{HOME}/devkit-game/Cool_Game/run.sh"']),
11: proc(10, "run.sh", ["/bin/sh", f"{HOME}/devkit-game/Cool_Game/run.sh"]),
20: proc(1, "reaper", [REAPER, "SteamLaunch", f"AppId={other}", "--", "/usr/bin/flatpak", "run", "x"])}
got = self.run_list(procs)
self.assertEqual([(a["kind"], a.get("title"), a["name"]) for a in got],
[("devkit", "Cool_Game", "Cool_Game"), ("shortcut", None, f"Shortcut {other}")])
self.assertEqual(got[0]["gameid"], str((sid << 32) | 0x02000000))
def test_android_app_from_its_container(self):
meta = {"package": "org.example.game", "label": "Example", "shortcut": 3346865537, "instance": 2812345678}
apps = {3346865537: meta, 2812345678: meta}
got = self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps,
containers={"lepton-steamlaunch-2812345678"})
self.assertEqual(got, [{"appid": 3346865537, "gameid": str((3346865537 << 32) | 0x02000000), "processes": 0,
"kind": "android", "package": "org.example.game", "name": "Example"}])
# Its container stopped: not running.
self.assertEqual(self.run_list({1: proc(0, "systemd", uid=OTHER)}, android=apps, containers=()), [])
def test_android_meta_reads_shortcut_and_instance(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(f"{tmp}/org.example.game")
Path(f"{tmp}/org.example.game/meta.json").write_text(json.dumps(
{"package": "org.example.game", "shortcut": 3346865537, "instance": 2812345678}))
os.makedirs(f"{tmp}/broken")
Path(f"{tmp}/broken/meta.json").write_text("{")
with mock.patch.object(frame_running, "ANDROID", tmp):
self.assertEqual(sorted(frame_running.android_apps()), [2812345678, 3346865537])
class World:
"""Processes that react to signals: kill() removes them, unless they ignore it."""
def __init__(self, procs, ignore=(), unkillable=()):
self.procs, self.ignore, self.unkillable = dict(procs), set(ignore), set(unkillable)
self.sent, self.now = [], 0.0
def scan(self):
return dict(self.procs)
def kill(self, pid, sig):
if pid not in self.procs:
raise ProcessLookupError
self.sent.append((pid, sig))
if pid in self.unkillable or (sig == frame_running.SIGTERM and pid in self.ignore):
return
self.procs.pop(pid)
# A reaper exits once its last child has.
for rid, p in list(self.procs.items()):
if p["comm"] == "reaper" and not any(c["ppid"] == rid for c in self.procs.values()):
self.procs.pop(rid)
def sleep(self, seconds):
self.now += seconds
def escalate(self, appid=620):
return frame_running.escalate(appid, me=203, uid=ME, scan=self.scan, kill=self.kill,
sleep=self.sleep, clock=lambda: self.now)
class Escalation(unittest.TestCase):
GAME = {401, 402, 403}
def assert_only_game(self, world, allowed=GAME | {400}):
signalled = {pid for pid, _ in world.sent}
self.assertTrue(signalled, "nothing was signalled")
self.assertLessEqual(signalled, allowed)
protected = frame_running.protected(frame(), 203, ME)
for pid in (1, 200, 201, 202, 203, 300, 301, 302, 303, 304, 305, 306, 307, 404, 405, 500, 501, 600, 601, 602):
self.assertNotIn(pid, signalled)
self.assertTrue({1, 201, 202, 203, 302, 303, 304, 305, 306, 307, 404, 600}.issubset(protected))
def test_sigterm_ends_it(self):
world = World(frame())
self.assertEqual(world.escalate(), "term")
self.assert_only_game(world)
self.assertEqual({pid for pid, sig in world.sent}, self.GAME)
self.assertTrue(all(sig == frame_running.SIGTERM for _, sig in world.sent))
# Deepest first, and the reaper isn't signalled while its children are alive.
self.assertEqual([pid for pid, _ in world.sent], [403, 402, 401])
self.assertNotIn(400, world.procs)
self.assertIn(404, world.procs) # the "steam" child under the game is never touched
def test_sigkill_after_a_grace_period(self):
world = World(frame(), ignore={403})
self.assertEqual(world.escalate(), "kill")
self.assert_only_game(world)
self.assertIn((403, frame_running.SIGTERM), world.sent)
self.assertIn((403, frame_running.SIGKILL), world.sent)
self.assertGreaterEqual(world.now, frame_running.TERM_WAIT)
kills = [pid for pid, sig in world.sent if sig == frame_running.SIGKILL]
self.assertEqual(kills[0], 403)
def test_survivor_is_an_error(self):
world = World(frame(), unkillable={403})
with self.assertRaisesRegex(frame_running.Fail, "still running after SIGKILL"):
world.escalate()
self.assert_only_game(world)
def test_reused_pid_is_not_signalled(self):
world = World(frame(), ignore={402, 403})
real_scan = world.scan
def scan():
procs = real_scan()
if world.sent and 402 in procs: # 402 exits and its pid goes to an unrelated program
procs[402] = proc(300, "editor", start=99999)
world.procs[402] = procs[402]
return procs
world.scan = scan
world.escalate()
self.assertNotIn((402, frame_running.SIGKILL), world.sent)
def test_nothing_left(self):
world = World({pid: p for pid, p in frame().items() if pid < 400})
self.assertEqual(world.escalate(), "gone")
self.assertEqual(world.sent, [])
def test_lingering_reaper_alone(self):
procs = {pid: p for pid, p in frame().items() if pid not in self.GAME and pid not in (404, 405)}
world = World(procs)
self.assertEqual(world.escalate(), "term")
self.assertEqual(world.sent, [(400, frame_running.SIGTERM)])
def test_protected_never_signalled_even_as_the_root(self):
# A reaper whose only children are protected: nothing to signal but the reaper itself.
procs = frame()
procs[402]["comm"] = "gamescope"
world = World(procs)
world.escalate()
self.assertNotIn(402, {pid for pid, _ in world.sent})
self.assertNotIn(403, {pid for pid, _ in world.sent})
class Quit(unittest.TestCase):
def setUp(self):
self.page = mock.MagicMock()
patches = [mock.patch.object(frame_running, "Page", return_value=self.page),
mock.patch.object(frame_running, "running", return_value=[
{"appid": 620, "gameid": "620", "name": "Portal 2", "kind": "steam", "processes": 4}]),
mock.patch.object(frame_running.os, "getuid", return_value=ME, create=True)] # none on Windows
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_steam_terminate_first(self):
scans = iter([frame(), {}])
self.page.eval.return_value = {"running": [], "names": {}}
with mock.patch.object(frame_running, "snapshot", side_effect=lambda: next(scans)), \
mock.patch.object(frame_running, "escalate") as escalate:
out = frame_running.quit_game(620, sleep=lambda s: None)
self.assertEqual(out["method"], "steam")
self.page.eval.assert_any_call('SteamClient.Apps.TerminateApp("620", false)')
escalate.assert_not_called()
def test_signals_when_steam_leaves_it_running(self):
now = [0.0]
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
mock.patch.object(frame_running, "escalate", return_value="kill") as escalate:
out = frame_running.quit_game(620, sleep=lambda s: now.__setitem__(0, now[0] + s), clock=lambda: now[0])
escalate.assert_called_once_with(620)
self.assertGreaterEqual(now[0], frame_running.STEAM_WAIT)
self.assertEqual(out["message"], "Force quit Portal 2 (it had to be killed)")
def test_without_steam_goes_straight_to_signals(self):
frame_running.Page.side_effect = frame_running.Fail("no SharedJSContext page")
with mock.patch.object(frame_running, "snapshot", return_value=frame()), \
mock.patch.object(frame_running, "escalate", return_value="term") as escalate:
out = frame_running.quit_game(620, sleep=self.fail)
escalate.assert_called_once_with(620)
self.assertEqual(out["method"], "term")
def test_not_running(self):
frame_running.running.return_value = []
with mock.patch.object(frame_running, "escalate") as escalate:
self.assertEqual(frame_running.quit_game(620)["method"], "gone")
escalate.assert_not_called()
self.page.eval.assert_not_called()
class Script(unittest.TestCase):
def test_server_sends_it_with_frame_steam_inside(self):
import server
with mock.patch.object(server, "ssh", return_value='{"running": []}') as ssh:
self.assertEqual(server.running_games(), {"running": [], "message": "Nothing is running on the Frame"})
source = ssh.call_args.kwargs["stdin"]
self.assertEqual(ssh.call_args.args[0], "python3 - list")
# The same script runs as `python3 -`: frame_steam resolves, and bad usage is one JSON error.
r = subprocess.run([sys.executable, "-I", "-", "bogus"], input=source, capture_output=True, text=True,
cwd=tempfile.gettempdir(), timeout=30)
self.assertEqual(r.returncode, 1, r.stderr)
self.assertEqual(json.loads(r.stdout), {"error": "usage: list | quit APPID"})
class Server(unittest.TestCase):
def setUp(self):
import server
self.server = server
def quit(self, listed, quit_result=None, stop_error=None):
calls = []
def frame(*args, timeout=40):
calls.append(args)
return {"running": listed} if args == ("list",) else quit_result
with mock.patch.object(self.server, "running_frame", side_effect=frame), \
mock.patch.object(self.server, "ensure_master"), \
mock.patch.object(self.server.frame_android, "stop", side_effect=stop_error) as stop:
out = self.server.force_quit({"action": "quit", "appid": "3346865537"})
return out, calls, stop
def test_android_app_stops_its_container_first(self):
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
out, calls, stop = self.quit([app], {"name": "Example", "method": "gone", "message": "That game isn't running"})
stop.assert_called_once_with("org.example.game")
self.assertEqual(calls, [("list",), ("quit", "3346865537")])
self.assertEqual(out["message"], "Stopped Example")
def test_android_stop_failure_falls_back_to_processes(self):
app = {"appid": 3346865537, "kind": "android", "package": "org.example.game", "name": "Example"}
err = self.server.frame_android.FrameError("podman failed")
out, calls, _ = self.quit([app], {"name": "Example", "method": "term", "message": "Force quit Example"}, err)
self.assertEqual(calls[-1], ("quit", "3346865537"))
self.assertEqual(out["message"], "Force quit Example")
def test_other_games_skip_the_android_path(self):
app = {"appid": 3346865537, "kind": "devkit", "title": "Cool_Game", "name": "Cool_Game"}
out, _, stop = self.quit([app], {"name": "Cool_Game", "method": "steam", "message": "Quit Cool_Game"})
stop.assert_not_called()
self.assertEqual(out["message"], "Quit Cool_Game")
def test_not_running_is_a_conflict(self):
with self.assertRaises(self.server.Failure) as cm:
self.quit([])
self.assertEqual(cm.exception.status, 409)
class Routes(test_server.ServerGuards):
"""Validation runs before any SSH."""
def test_input_validation(self):
for body in ({"action": "quit", "appid": "620; reboot"}, {"action": "quit", "appid": ""},
{"action": "kill", "appid": "620"}, {"appid": "620"}, {"action": "quit", "appid": "12345678901"}):
status, payload = self.post("/api/running", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
def test_routes_need_custom_header(self):
self.assertEqual(self.request("GET", "/api/running")[0], 403)
self.assertEqual(self.request("POST", "/api/running", {"action": "quit", "appid": "620"})[0], 403)
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
@@ -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');
});
+4 -2
View File
@@ -6,9 +6,9 @@ const pick = (s,a,b) => s.slice(s.indexOf(a),s.indexOf(b,s.indexOf(a)));
const tick = async () => { for (let i=0;i<16;i++) await Promise.resolve(); };
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,volTimer:null,refreshing:null,lastImg:null,lastShot:null,
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()=>{
+2
View File
@@ -47,3 +47,5 @@
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
.cc-running { list-style:none; margin:12px 0 0; padding:0; display:grid; gap:6px; }.cc-running:empty { display:none; }.cc-running li { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 10px; border-radius:6px; background:#ffffff0a; }.cc-running span { min-width:0; overflow-wrap:anywhere; }.cc-running small { display:block; font-size:11px; color:var(--muted); }.cc-running button { border-radius:5px; flex:none; }
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-running button { min-height:44px; } }
+92 -17
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']]},
@@ -54,6 +54,9 @@
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
{id:'forcequit', category:'apps', icon:'power', title:'Force quit a game', kind:'control', label:'Available here',
description:'See what’s running on the Frame and quit a game or app that has stopped responding. Steam is asked first; if the game stays open, its own processes are stopped. Unsaved progress is lost.',
keywords:'hang hung frozen freeze crash stuck kill close stop end task running not responding exit', special:'forcequit'},
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
@@ -68,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']],
@@ -85,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']],
@@ -129,6 +134,8 @@
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
}
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);
const KINDS = {steam:'Steam game', devkit:'Sideloaded game', shortcut:'Non-Steam shortcut', android:'Android app'};
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
@@ -137,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',
@@ -148,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),
@@ -160,9 +170,30 @@
// Strict validation and captured device identity apply even if a stale enabled
// button is clicked during a disconnect, headset switch, or pending request.
function createController({api, getContext}) {
let busy = false;
let busy = false, quitting = false;
const connected = message => {
const context = getContext();
if (context.online !== true) throw new Error(message);
return context;
};
return {
get busy() { return busy; },
get quitting() { return quitting; },
async running() {
const context = connected('Connect to the Frame to see what’s running.');
const result = await api('/api/running', undefined, context.device);
return getContext().generation === context.generation ? result : null;
},
async forceQuit(app) {
if (quitting) throw new Error('A force quit is still in progress.');
if (!/^\d{1,10}$/.test(String(app?.appid))) throw new Error('Choose a running game to quit.');
const context = connected('Connect to the Frame before quitting a game.');
quitting = true;
try {
const result = await api('/api/running', {action:'quit', appid:String(app.appid)}, context.device);
return getContext().generation === context.generation ? result : null;
} finally { quitting = false; }
},
async audio(change) {
if (busy) throw new Error('An audio change is still in progress.');
const context = getContext();
@@ -180,16 +211,18 @@
},
};
}
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform, confirm = text => globalThis.confirm(text)}) {
const get = id => document.getElementById(id), page = get('controlCenter');
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
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('');
const specials = {audio, performance:perf, health, storage};
const specials = {audio, performance:perf, health, storage, forcequit};
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
// Host-only features stay searchable with a reason; only their unsupported
// action disappears. The host API, rather than viewport/UA, decides availability.
@@ -285,6 +318,48 @@
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
} finally { audioPending = false; renderSample(); }
}
// Force quit: an explicit check, then a confirmation for each game. A headset
// switch drops the list, so a stale entry can't quit the other Frame's game.
let runningApps = [];
function renderRunning(message) {
get('ccRunningList').innerHTML = runningApps.map(app=>`<li><span>${esc(app.name)}<small>${KINDS[app.kind] || 'Running'}</small></span><button class="small danger" type="button" data-cc-quit="${esc(app.appid)}"${controller.quitting ? ' disabled' : ''}>Force quit…</button></li>`).join('');
if (message !== undefined) get('ccRunningResult').textContent = message;
}
async function checkRunning() {
const generation = getContext().generation;
get('ccRunningCheck').disabled = true;
get('ccRunningResult').textContent = 'Checking…';
try {
const result = await controller.running();
if (!result || generation !== getContext().generation) return;
runningApps = Array.isArray(result.running) ? result.running : [];
renderRunning(runningApps.length ? `${runningApps.length} running. Force quit only a game that has stopped responding.` : 'Nothing is running on the Frame.');
} catch (e) {
if (generation !== getContext().generation) return;
runningApps = []; renderRunning(`Couldn’t check what’s running: ${e.message}`);
} finally { get('ccRunningCheck').disabled = false; }
}
async function forceQuit(appid) {
const app = runningApps.find(item=>String(item.appid) === String(appid));
if (!app || controller.quitting) return;
if (!confirm(`Force quit ${app.name}? Unsaved progress will be lost.`)) return;
const generation = getContext().generation, pending = controller.forceQuit(app);
renderRunning(`Quitting ${app.name}…`);
try {
const result = await pending;
if (!result || generation !== getContext().generation) return;
runningApps = runningApps.filter(item=>item !== app);
renderRunning(result.message || `Quit ${app.name}`);
} catch (e) {
if (generation !== getContext().generation) return;
renderRunning(`Couldn’t force quit ${app.name}: ${e.message}`);
} finally { if (generation === getContext().generation) renderRunning(); }
}
get('ccRunningCheck').onclick = checkRunning;
get('ccRunningList').onclick = event => {
const button = event.target.closest('[data-cc-quit]');
if (button) forceQuit(button.dataset.ccQuit);
};
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
@@ -310,7 +385,7 @@
},
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; runningApps = []; renderRunning(''); renderSample(); },
destroy() { clearInterval(timer); },
};
}
+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 "
+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)
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env python3
"""Runs ON the Frame (piped over SSH, with frame_steam.py): what game is running,
and force quit it.
Steam starts every game, non-Steam shortcut and devkit title under its own
`reaper`, as `reaper SteamLaunch AppId=<id> -- <command>`, and the reaper stays
the parent of the game's processes. That's how Steam starts games on Linux
desktops and the Steam Deck; on the Frame it is **inferred, not yet seen**. So a
running game is a reaper with an AppId, and the game is that reaper's process
tree. Nothing is matched by name, so Steam, gamescope, SteamVR, sshd or the
session can't be picked by mistake, and the names in PROTECTED are skipped
even inside a game's tree.
Quitting asks Steam first (SteamClient.Apps.TerminateApp, what Steam's own Stop
uses for Android apps, docs/vr-apks.md). If the game's processes are still
there a few seconds later, they get SIGTERM, then SIGKILL.
Usage: python3 - list # {"running": [{appid, gameid, name, kind, ...}]}
python3 - quit APPID # {"name", "method": "steam"|"term"|"kill"|"gone", "message"}
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
"""
import glob
import json
import os
import re
import signal
import subprocess
import sys
import time
from frame_steam import Fail, Page
# The Frame is Linux; "/" also keeps the unit tests' paths the same on Windows.
HOME = os.path.expanduser("~")
STEAM = HOME + "/.local/share/Steam"
DEVKIT = HOME + "/devkit-game"
ANDROID = HOME + "/Applications/Android" # frame_android.APPS_DIR
SIGTERM, SIGKILL = signal.SIGTERM, getattr(signal, "SIGKILL", 9) # Windows (unit tests) has no SIGKILL
SHORTCUT_BIT = 0x80000000 # non-Steam shortcut ids have the top bit set
APPID_ARG = re.compile(r"AppId=(\d{1,10})")
# Never signalled, even when found under a game's reaper: Steam, the
# compositors, SteamVR, the session, audio, sshd and init.
PROTECTED = frozenset({
"steam", "steamwebhelper", "gamescope", "gamescope-wl", "gamescopereaper", "gamescope-session",
"sshd", "sshd-session", "ssh-agent", "systemd", "init", "dbus-daemon", "dbus-broker", "dbus-broker-lau",
"pipewire", "pipewire-pulse", "wireplumber", "plasmashell", "kwin_wayland", "kwin_x11", "Xwayland",
"sddm", "vrserver", "vrcompositor", "vrmonitor", "vrdashboard", "vrwebhelper", "vrstartup",
})
# Steam launches SteamVR under a reaper too, but on the Frame it's the headset's view,
# not a game: it's never listed or quit (SteamVR and SteamVR Beta).
NOT_GAMES = frozenset({250820, 1009850})
STEAM_WAIT = 5 # seconds for Steam's own terminate to end the game
TERM_WAIT = 3 # then SIGTERM, and this long before SIGKILL
KILL_WAIT = 2
RUNNING_JS = """((ids) => {
const store = globalThis.SteamUIStore;
const running = Array.isArray(store?.RunningApps) ? store.RunningApps.map(a => a.appid) : null;
const names = {};
for (const id of new Set([...ids, ...(running || [])])) {
const a = appStore.GetAppOverviewByAppID(id);
if (a) names[id] = a.display_name;
}
return {running, names};
})(%s)"""
def read(path, mode="r"):
try:
with open(path, mode) as f:
return f.read()
except OSError:
return None
def snapshot(proc="/proc"):
"""pid -> {ppid, start, state, uid, comm, argv} for every process that can be read."""
out = {}
for d in os.listdir(proc):
if not d.isdigit():
continue
stat = read(f"{proc}/{d}/stat")
try:
# comm is in parentheses and may hold spaces or ')': split at the last ')'.
comm = stat[stat.index("(") + 1:stat.rindex(")")]
fields = stat[stat.rindex(")") + 2:].split()
uid = os.stat(f"{proc}/{d}").st_uid
except (AttributeError, ValueError, OSError):
continue
argv = (read(f"{proc}/{d}/cmdline", "rb") or b"").split(b"\0")
out[int(d)] = {"ppid": int(fields[1]), "state": fields[0], "start": int(fields[19]), "uid": uid,
"comm": comm, "argv": [a.decode(errors="replace") for a in argv if a]}
return out
def launch_appid(argv):
"""The AppId of `reaper SteamLaunch AppId=<id> -- ...`, or None for any other process."""
if not argv or os.path.basename(argv[0]) != "reaper" or "SteamLaunch" not in argv:
return None
for arg in argv[1:]:
if arg == "--":
break
m = APPID_ARG.fullmatch(arg)
if m:
return int(m.group(1))
return None
def reapers(procs, uid):
"""appid -> [reaper pid], for this user's game launches."""
out = {}
for pid, p in procs.items():
appid = launch_appid(p["argv"])
if appid and appid not in NOT_GAMES and p["uid"] == uid and p["state"] != "Z":
out.setdefault(appid, []).append(pid)
return out
def children(procs):
kids = {}
for pid, p in procs.items():
kids.setdefault(p["ppid"], []).append(pid)
return kids
def protected(procs, me, uid):
"""Pids never to signal: init, this helper and its parents (the SSH session),
other users' processes and anything named in PROTECTED."""
keep, pid = {0, 1}, me
while pid > 1 and pid not in keep:
keep.add(pid)
pid = procs.get(pid, {}).get("ppid", 0)
for pid, p in procs.items():
argv0 = os.path.basename(p["argv"][0]) if p["argv"] else ""
if p["uid"] != uid or p["comm"] in PROTECTED or argv0 in PROTECTED:
keep.add(pid)
return keep
def tree(procs, roots, keep):
"""The roots and their descendants, deepest first, leaving out kept processes
and everything under them."""
kids, out, level = children(procs), [], [r for r in roots if r not in keep]
while level:
out = level + out
level = [c for pid in level for c in kids.get(pid, ()) if c not in keep and procs[c]["state"] != "Z"]
return out
def alive(procs, pid, start):
p = procs.get(pid)
return bool(p) and p["start"] == start and p["state"] != "Z"
def escalate(appid, me=None, uid=None, scan=snapshot, kill=os.kill, sleep=time.sleep, clock=time.monotonic):
"""SIGTERM the game's processes, then SIGKILL what's left. The reaper itself
is only killed last, so its orphans can't escape to init before they're
signalled. Returns "term", "kill" or "gone"; raises Fail if any survive."""
me = os.getpid() if me is None else me
uid = os.getuid() if uid is None else uid
seen = {} # pid -> start time, so a reused pid is never signalled
def targets(include_roots):
procs = scan()
keep = protected(procs, me, uid)
roots = reapers(procs, uid).get(appid, [])
for pid in tree(procs, roots, keep):
seen.setdefault(pid, procs[pid]["start"])
return procs, [pid for pid, start in seen.items() if alive(procs, pid, start) and pid not in keep
and (include_roots or pid not in roots)]
def wait(seconds, include_roots):
end = clock() + seconds
while True:
procs, left = targets(include_roots)
if not left or clock() >= end:
return left
sleep(0.2)
method = "gone"
for sig, seconds, include_roots in ((SIGTERM, TERM_WAIT, False), (SIGKILL, KILL_WAIT, True)):
_, left = targets(include_roots)
if not left:
# The reaper exits when its last child does; a lingering one is all that's left.
_, left = targets(True)
if not left:
return method
for pid in left:
try:
kill(pid, sig)
except ProcessLookupError:
pass
except PermissionError:
continue
method = "term" if sig == SIGTERM else "kill"
if not wait(seconds, True):
return method
raise Fail(f"{len(targets(True)[1])} of its processes are still running after SIGKILL")
def gameid(appid):
"""The game id Steam's Apps API takes: the appid, or (appid << 32) | 0x02000000
for a non-Steam shortcut (docs/apks.md)."""
return str((appid << 32) | 0x02000000 if appid >= SHORTCUT_BIT else appid)
def android_apps():
"""Shortcut or instance id -> Frame Control's Android app meta."""
out = {}
for path in glob.glob(os.path.join(ANDROID, "*/meta.json")):
try:
m = json.loads(read(path) or "null")
except ValueError:
continue
if isinstance(m, dict) and m.get("package"):
for key in ("shortcut", "instance"):
if str(m.get(key) or "").isdigit():
out[int(m[key])] = m
return out
def lepton_containers():
try:
r = subprocess.run(["podman", "ps", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return set()
return set(r.stdout.split())
def manifest_name(appid):
m = re.search(r'"name"\s+"([^"]*)"', read(os.path.join(STEAM, f"steamapps/appmanifest_{appid}.acf")) or "")
return m.group(1) if m else None
def devkit_title(procs, pids):
"""A devkit title's id from its processes' arguments (~/devkit-game/<id>/...)."""
prefix = DEVKIT + "/"
for pid in pids:
for arg in procs[pid]["argv"]:
arg = arg.strip('"')
if arg.startswith(prefix) and "/" in arg[len(prefix):]:
return arg[len(prefix):].split("/", 1)[0]
return None
def steam_view(ids, page=None):
"""Steam's own running list and app names, or (None, {}) without Steam."""
try:
r = (page or Page()).eval(RUNNING_JS % json.dumps(sorted(ids)))
except (Fail, OSError):
return None, {}
return r.get("running"), {int(k): v for k, v in (r.get("names") or {}).items()}
def running(procs=None, uid=None, steam=steam_view, android=android_apps, containers=lepton_containers):
"""Every game running now: reapers on this Frame, Steam's running list, and
Android apps whose Lepton container is up."""
procs = snapshot() if procs is None else procs
uid = os.getuid() if uid is None else uid
found = reapers(procs, uid)
apps = android()
up = containers() if apps else set()
for m in apps.values():
if f"lepton-steamlaunch-{m.get('instance')}" in up and str(m.get("shortcut") or "").isdigit():
found.setdefault(int(m["shortcut"]), [])
listed, names = steam(set(found))
for appid in listed or ():
if int(appid) not in NOT_GAMES:
found.setdefault(int(appid), [])
out = []
for appid, roots in sorted(found.items()):
pids = tree(procs, roots, set())
app = {"appid": appid, "gameid": gameid(appid), "processes": len(pids)}
meta = apps.get(appid)
title = devkit_title(procs, pids)
if meta:
app.update(kind="android", package=meta["package"], name=names.get(appid) or meta.get("label") or meta["package"])
elif title:
app.update(kind="devkit", title=title, name=names.get(appid) or title)
elif appid >= SHORTCUT_BIT:
app.update(kind="shortcut", name=names.get(appid) or f"Shortcut {appid}")
else:
app.update(kind="steam", name=names.get(appid) or manifest_name(appid) or f"App {appid}")
out.append(app)
return out
def quit_game(appid, sleep=time.sleep, clock=time.monotonic):
apps = {a["appid"]: a for a in running()}
app = apps.get(appid)
if not app:
return {"name": None, "method": "gone", "message": "That game isn't running"}
name = app["name"]
try:
page = Page()
page.eval(f"SteamClient.Apps.TerminateApp({json.dumps(app['gameid'])}, false)")
except (Fail, OSError):
page = None # Steam isn't answering: the processes are all that's left to stop
end = clock() + (STEAM_WAIT if page else 0)
while True:
procs = snapshot()
left = reapers(procs, os.getuid()).get(appid) or (page and appid in (steam_view({appid}, page)[0] or ()))
if not left:
return {"name": name, "method": "steam" if page else "gone", "message": f"Quit {name}"}
if clock() >= end:
break
sleep(0.5)
if not reapers(procs, os.getuid()).get(appid):
# Steam still lists it, but there's no process of it to stop.
raise Fail(f"Steam didn't quit {name}, and none of its processes were found on the Frame")
method = escalate(appid)
return {"name": name, "method": method,
"message": f"Force quit {name}" + (" (it had to be killed)" if method == "kill" else "")}
def main():
try:
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "list":
out = {"running": running()}
elif cmd == "quit" and len(sys.argv) == 3 and sys.argv[2].isdigit() and len(sys.argv[2]) <= 10:
out = quit_game(int(sys.argv[2]))
else:
raise Fail("usage: list | quit APPID")
except (Fail, OSError) as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)
except Exception as e: # keep the one-JSON-object contract for the server
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
sys.exit(1)
print(json.dumps(out))
if __name__ == "__main__":
main()
+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())
+548 -36
View File
@@ -388,6 +388,7 @@
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
.popt b { font-weight: 600; color: var(--text); }
.sync-note { margin: 10px 0 0; }
.popt .sub { grid-column: 2; line-height: 1.45; }
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
@@ -1017,6 +1018,9 @@
#appsLinux .row { flex-wrap:wrap!important; }
}
#storage .card { padding:16px; border:1px solid rgba(255,255,255,.1); border-radius:8px; margin-top:16px; }
#storage .grow, #storage .sub { overflow-wrap:anywhere; }
#storage .item > span { flex-shrink:0; }
</style>
<link rel="stylesheet" href="/control-center.css">
</head>
@@ -1123,7 +1127,10 @@
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
<div><dt>Charge cycles</dt><dd data-cc-metric="cycles">Unavailable</dd></div><div><dt>Full charge capacity</dt><dd data-cc-metric="fullCapacity">Unavailable</dd></div>
<div><dt>Design capacity</dt><dd data-cc-metric="designCapacity">Unavailable</dd></div><div><dt>Full / design capacity</dt><dd data-cc-metric="capacityHealth">Unavailable</dd></div>
<div><dt>Battery source</dt><dd data-cc-metric="batterySource">Unavailable</dd></div>
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Unavailable sensors are 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.</p>
<div class="cc-services" id="ccServices"></div>
</details>
</section>
@@ -1143,7 +1150,7 @@
<div class="workspace-heading"><div><span class="eyebrow">Your Steam Frame</span><h1 id="homeTitle">Let's connect</h1><p id="homeReadiness" role="status">Checking your headset…</p></div><a class="workspace-link" href="#devices">Devices &amp; connection</a></div>
<div class="home-grid"><section class="panel home-activity"><span class="eyebrow">Current activity</span><h2 id="homeActivity">Connect to see what's running</h2><p id="homeActivityNote">Your installed apps and current downloads appear here.</p><div id="homeRunning" class="home-running"></div><div class="home-actions"><a href="#library" class="workspace-link primary-link">Open Games →</a><a href="#discover" class="workspace-link">Find something new</a></div></section>
<section class="panel home-session"><span class="eyebrow">Background tasks</span><h2 id="homeJobs">No active tasks</h2><p id="homeDownload">Install progress stays here while you explore.</p><button class="small" id="homeLog">View activity log</button></section></div>
<div class="quick-routes"><a href="#capture"><span>Capture &amp; Live</span><small>See and share the headset view</small></a><a href="#pad"><span>Keyboard &amp; trackpad</span><small>Control apps on your Frame</small></a><a href="#comfort"><span>Family &amp; comfort</span><small>Sessions, breaks and alerts</small></a><a href="#control"><span>Control Center</span><small>Search settings and live metrics</small></a></div>
<div class="quick-routes"><a href="#capture"><span>Capture &amp; Live</span><small>See and share the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#comfort"><span>Family &amp; comfort</span><small>Sessions, breaks and alerts</small></a><a href="#control"><span>Control Center</span><small>Search settings and live metrics</small></a></div>
<section class="panel now" id="device">
<div class="shelf-head"><h2>Right now</h2></div>
<p class="now-state" id="nowState">Waiting for the Frame…</p>
@@ -1159,7 +1166,7 @@
<button data-go="media">Play a video or photo</button>
<button data-go="macview" id="nowMac" hidden>Show a Mac window</button>
<button id="nowSend">Send files…</button>
<button data-go="pad">Type on the Frame</button>
<button data-go="pad" title="Type on the headset (T)">Type on the headset</button>
</div>
<p class="hint">Casting shows everything the wearer sees, including anything private.</p>
</section>
@@ -1182,6 +1189,7 @@
<span class="view-actions">
<button class="action" id="shotBtn">Capture</button>
<button id="liveBtn" title="Keep updating, as video">Live</button>
<button id="recBtn" title="Record what the headset shows, with its sound, to ~/Videos/SteamFrame on this computer">Record</button>
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
<button id="saveBtn" disabled>Save</button>
@@ -1226,6 +1234,7 @@
<button data-ctrl-key="28" title="Enter">Enter</button>
</span>
</div>
<div class="hint" id="recStatus" role="status" hidden></div>
<div class="hint" id="viewHint"></div>
</section>
@@ -1245,14 +1254,29 @@
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="sub sync-note" id="shotsSyncNote" hidden></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
</section>
<section id="clips">
<div class="shelf-head"><h2>Steam clips</h2><span class="count" id="clipCount"></span><span class="spacer"></span>
<button class="small" id="clipsRefresh">Refresh</button>
<button class="small" id="videosFolder" title="Open the SteamFrame folder in your Videos">Show folder</button>
</div>
<div class="list" id="clipList"><div class="sub">Loading…</div></div>
<div class="sub sync-note" id="clipsSyncNote" hidden></div>
<div class="hint">Clips and background recordings from Steam's game recording in the headset. Export joins one into a single <code>.mp4</code> in <code>~/Videos/SteamFrame</code>, however long it is, without re-encoding it. Recordings you make with <b>Record</b> go there too.</div>
</section>
</div>
<div class="page" data-page="games">
<div class="workspace-heading"><div><span class="eyebrow">Play on your Frame</span><h1>Games</h1><p>Your Steam library and games you add yourself.</p></div><button class="action add-install" data-open-install>+ Add / Install</button></div>
<div class="library-controls"><div class="scope-tabs" id="libraryScope" aria-label="Game library"><button data-scope="installed" aria-pressed="true">Installed</button><button data-scope="owned" aria-pressed="false">Owned</button><button data-scope="store" aria-pressed="false">Store</button></div>
<div class="scope-tabs" id="librarySources" aria-label="Installed content source"><button data-library-source="all" aria-pressed="true">All</button><button data-library-source="steam" aria-pressed="false">Steam</button><button data-library-source="sideloaded" aria-pressed="false">Sideloaded</button></div></div>
<section class="panel now-running" id="nowRunning" aria-labelledby="nowRunningTitle" hidden>
<div class="shelf-head"><h2 id="nowRunningTitle">Running now</h2><span class="spacer"></span><button class="small" id="runningCheck">Check again</button></div>
<div class="list" id="runningList"></div>
<div class="hint">Force quit a game that has stopped responding. Steam is asked to quit it first; unsaved progress is lost.</div>
</section>
<div id="libraryOwned"></div><div id="steamDiscover" hidden></div>
<section id="library">
<div class="shelf-head"><h2>Steam games</h2><span class="count" id="gameCount"></span></div>
@@ -1402,7 +1426,7 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad"><span>Keyboard & trackpad</span><small>Type and point in desktop apps</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="vrTools">
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
@@ -1479,6 +1503,15 @@
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
else to install. <a href="#" data-about>Licences</a></div>
</div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
<button class="small" id="clipFrom">Copy the headset's clipboard here</button>
</div>
<div class="hint">Text goes both ways between here and the headset's desktop: copy in an app on the Frame, then
Copy the headset's clipboard. Needs the desktop panel open in the headset. Text only, up to 1 MB.</div>
</section><section id="androidSettings"> <h2 class="lib-section">Display and reports</h2>
<div class="lib-manage">
<section class="panel disp" id="display">
@@ -1541,13 +1574,7 @@
<option value="vr">VR app</option>
</select>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:18px"><h2>Clipboard</h2></div>
<textarea id="clipText" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
<button class="small" id="clipMac">Send this computer's clipboard</button>
</div>
<div class="hint">Clipboard needs the desktop panel open in the headset.</div>
<div class="hint">Clipboard text, both ways, is in <a href="#pad">Type on the headset</a>.</div>
</section>
<section class="panel" id="media">
@@ -1599,6 +1626,21 @@
<div id="steamGridStatus" class="hint" role="status"></div>
<button id="refreshAndroidArt" class="small" style="margin-top:12px">Refresh artwork for installed apps and titles</button>
</section>
<section class="panel" id="autoSync">
<div class="shelf-head"><h2>Copy to this computer</h2></div>
<p class="sub">Copy new screenshots and clips to this computer automatically, about once a minute while a headset is connected.</p>
<label class="popt"><input type="checkbox" id="syncShots"><b>Screenshots</b>
<span class="sub">Into <code>~/Pictures/SteamFrame</code>, as Save does.</span></label>
<label class="popt"><input type="checkbox" id="syncClips"><b>Steam clips</b>
<span class="sub">Clips you save in Steam, as <code>.mp4</code> files in <code>~/Videos/SteamFrame</code>, once Steam has finished
writing them. Steam's background recording isn't copied.</span></label>
<div class="row"><button class="small" id="syncNow">Copy everything now</button>
<button class="small action" id="syncResume" hidden>Resume</button></div>
<div class="hint" id="syncStatus" role="status"></div>
<div class="hint">Only what you take after turning it on is copied; Copy everything now copies the rest, once. A file you
delete from this computer isn't copied again. It copies from the headset you're connected to, and waits while an
install, an export or a recording is running.</div>
</section>
<section class="panel" id="power">
<div class="shelf-head"><h2>Remote access</h2></div>
<div class="actions">
@@ -1631,9 +1673,66 @@
Saved spatial layouts are not available yet.</div>
</section>
<section class="panel" id="storage" hidden>
<div class="shelf-head"><h2>Game storage</h2><span class="spacer"></span><button class="small" id="storageRefresh">Refresh</button></div>
<p class="sub">Read-only snapshot. Steam's recorded install sizes may be stale and exclude saves, shaders, downloads, Android and sideloaded content. No files are scanned recursively or changed.</p>
<div id="storageResult" role="status" aria-live="polite">Open this tool to read headset storage.</div>
<div class="card" style="margin-top:16px"><h3>Move a game with Steam</h3>
<p>On the headset, open Steam → Settings → Storage, choose the source drive, select the game, then choose Move and a destination. If Storage is unavailable in the headset UI, use desktop Steam’s Settings → Storage.</p>
<p class="sub">Steam offers destinations it supports. Let Steam finish and handle any errors, then Refresh here. Frame Control cannot verify a supported move API or its recovery behaviour, so it never moves or deletes game files.</p>
<a href="https://help.steampowered.com/en/faqs/view/4BD4-4528-6B2E-8327#move" target="_blank" rel="noopener">Steam's storage and moving guide ↗</a>
</div>
</section>
<section class="panel" id="backup" hidden>
<div class="shelf-head"><h2>Back up the Frame</h2><span class="spacer"></span>
<button class="small" id="backupFolder">Open backups folder</button></div>
<p class="sub">Copies game saves, settings and Android app data from the headset to a dated folder on this computer.
It only reads the headset. Games themselves aren't copied: reinstall them from Steam.</p>
<div class="chips" id="backupKinds">
<label><input type="checkbox" value="saves" checked> Game saves</label>
<label><input type="checkbox" value="settings" checked> Steam and desktop settings</label>
<label><input type="checkbox" value="android" checked> Android app data</label>
</div>
<div class="row" style="margin-top:12px"><button class="small" id="backupPreview">See what's included</button>
<button class="small action" id="backupStart">Back up now</button></div>
<div class="sub" id="backupProgress" role="status" aria-live="polite"></div>
<div class="list" id="backupSummary"></div>
<div class="hint">Never copied: Steam sign-in files (steam.token, config.vdf, loginusers.vdf), SSH keys,
keyrings and other passwords. Backups aren't encrypted, and game folders can hold launcher logins, so keep them private.
Close Android apps first: open ones are skipped.</div>
<div class="shelf-head" style="margin-top:20px"><h2>Restore</h2><span class="spacer"></span>
<button class="small" id="backupRefresh">Refresh</button></div>
<div class="list" id="backupList"><div class="sub">Open this tool to see your backups.</div></div>
<div id="restorePlan"></div>
<div class="hint">After a factory reset, sign in to Steam, reinstall your games and start each once, and reinstall
your Android apps. Then restore. Parts that can't go back yet are skipped, not failed. Files a restore replaces are
kept on the headset in ~/.local/share/frame-control/restore-previous.</div>
<div class="shelf-head" style="margin-top:20px"><h2>Stuck in Desktop Mode?</h2><span class="spacer"></span>
<button class="small" id="sessionCheck">Check</button></div>
<div class="sub" id="sessionState">Check which mode the headset is in.</div>
<div class="row" style="margin-top:12px"><button class="small" id="sessionGame" disabled>Return to Game Mode</button></div>
<div class="hint">Uses the same switch as Valve's developer tools (<code>steamos-session-select gamescope</code>).
It closes the desktop and anything open in it. If the headset can't be reached, hold Power for 10 seconds to
restart it; <a href="https://github.com/saphid/frame-control/blob/main/docs/backup-and-recovery.md#stuck-in-desktop-mode" target="_blank">more ways back</a>.</div>
</section>
<section class="panel" id="macview" hidden>
<div class="shelf-head"><h2>Mac in the headset</h2><span class="count" id="mvCount"></span><span class="spacer"></span>
<div class="shelf-head"><h2 id="mvTitle">Mac in the headset</h2><span class="tag" id="mvTag" hidden>Experimental</span><span class="count" id="mvCount"></span><span class="spacer"></span>
<button class="small" id="mvRefresh">Refresh</button></div>
<div id="mvPc" hidden>
<p class="hint" style="color:var(--warn)">Experimental. Shows a whole PC screen as a panel in the
headset, with laser and keyboard input. Individual windows are offered only when this build supports them.
Tested in a Windows VM; physical PCs, hardware encoding and laser clicks remain unverified. Clicks and keys
can't reach administrator apps or the sign-in screen.
<a href="https://github.com/saphid/frame-control/blob/main/docs/pc-in-headset.md" target="_blank" rel="noopener noreferrer">What's verified</a></p>
<label class="mv-opt"><input type="checkbox" id="mvPcOn"> Turn on PC in the headset
<span class="sub">off by default; nothing runs and no windows are listed until it's on</span></label>
<div class="sub" id="mvPcState" role="status" aria-live="polite" hidden></div>
</div>
<div id="mvMain">
<label class="mv-opt"><input type="checkbox" id="mvSeparate" checked> Give each window its own display
<span class="sub">its menus and sheets come along, nothing covers it, and it goes back when you stop</span></label>
<div class="row" style="margin:0 0 12px"><label for="mvQuality">Picture quality</label>
@@ -1650,7 +1749,8 @@
<button class="small danger" data-mv="stopall">Stop all</button>
</div>
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on your computer's keyboard.</div>
</div>
</section>
<section class="panel" id="privacy">
@@ -1686,7 +1786,7 @@
</div>
<div class="page" data-page="settings">
<div class="workspace-heading"><div><a href="#settings" id="settingsBack" class="workspace-link" hidden>← All settings</a><h1 id="settingsTitle">Settings</h1><p id="settingsSubtitle">Connection, headset controls and Frame Control preferences.</p></div></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsDirectory" class="quick-routes"><a href="#devices"><span>Devices &amp; connection</span><small>Connect a Frame or switch headsets</small></a><a href="#control"><span>Control Center</span><small>Live metrics and searchable controls</small></a><a href="#home"><span>Frame activity</span><small>Readiness, running apps and downloads</small></a><a href="#display"><span>App display &amp; reports</span><small>Resolution, scale and compatibility</small></a><a href="#artworkSettings"><span>Library artwork</span><small>Images and artwork sources</small></a><a href="#autoSync" id="syncLink"><span>Copy to this computer</span><small>New screenshots and clips, automatically</small></a><a href="#privacy"><span>Privacy &amp; updates</span><small>Sharing preferences and app updates</small></a><a href="#power"><span>Remote &amp; power</span><small>Remote access, sleep and restart</small></a></div>
<div id="settingsWorkspace" hidden></div>
</div>
@@ -1898,6 +1998,8 @@
<input type="text" id="caLabel" maxlength="60" placeholder="Label, e.g. Home" aria-label="Label for the new address">
<button class="small" type="submit">Add</button></form>
<details class="conn-more" id="connMore"><summary>Connection steps</summary>
<p class="sub">Trouble setting up or pairing? <a href="https://github.com/saphid/frame-control/blob/main/docs/frame-doctor.md" target="_blank" rel="noopener noreferrer">Read-only diagnostic guide</a>
explains how to check a connection without sharing passwords or keys.</p>
<ol class="stages" id="connStages"></ol>
<h3>This computer</h3>
<dl class="facts" id="connNet"></dl>
@@ -2013,11 +2115,14 @@ function applyHostWording(h) {
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
$("recBtn").hidden = $("clips").hidden = !!h.mobile; // recordings are saved to a computer
$("syncLink").hidden = !!h.mobile; // and so are automatic copies
$("quest").hidden = !!h.mobile;
$("metaLib").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
if (!h.mobile) $("shotsFolder").textContent = $("videosFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
$("clipMac").textContent = `Send ${h.computer} clipboard`;
$("clipFrom").textContent = `Copy the headset's clipboard to this ${h.computer}`;
$('mobileConnection').hidden = !h.mobile;
$('desktopConnections').hidden = !!h.mobile;
if (h.mobile) {
@@ -2128,7 +2233,7 @@ function setOnline(ok, why) {
reloadAll();
}
}
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); }
function reloadAll() { loadTitles(); loadOwned(); loadAndroid(); loadDisplays(); loadShots(); loadClips(); }
$("offRetry").onclick = () => refresh();
if (window.frameApp && window.frameApp.setUpConnection) {
$("offSetup").hidden = false;
@@ -2312,10 +2417,16 @@ function showNow(to) {
showPage();
}
// Home's shortcuts to the things people come to do next.
// Type on the headset: Home's button and the T key turn the keyboard on and put the cursor in its field.
function typeOnHeadset() {
showNow("pad");
if (pad.state === "off" || pad.state === "error") padStart();
$("padType").focus({ preventScroll: true });
}
document.querySelectorAll("[data-go]").forEach(b => b.onclick = () => {
const to = b.dataset.go;
if (to === "pad") return typeOnHeadset();
showNow(to);
if (to === "pad") { if (pad.state === "off" || pad.state === "error") padStart(); $("padType").focus({ preventScroll: true }); }
});
$("nowSend").onclick = () => $("fileInput").click();
$("castBtn").onclick = () => {
@@ -2330,12 +2441,11 @@ $("castBtn").onclick = () => {
// ---- battery ----
function battery(b, power) {
const pct = b?.percent;
const full = b?.status === "Full" || (!!power && pct >= 100);
const full = b?.status === "Full";
const charging = !full && b?.status === "Charging";
// A weak charger can lose to the load: plugged in but still draining.
// Topped up on a charger it reports Discharging at ~0 W: that's idle, not draining.
const idle = !!power && b?.watts != null && Math.abs(b.watts) < 0.5;
const draining = !full && !charging && !idle && (!power || b?.status === "Discharging" || b?.watts < 0);
const low = !charging && !full && pct != null && pct <= 15;
const color = full || charging ? "#59bf40" : low ? "#d94126" : pct != null && pct <= 30 ? "#d9a23a" : "#dcdedf";
@@ -2345,7 +2455,8 @@ function battery(b, power) {
else if (charging) {
label = b.watts > 0 ? `Charging · ${b.watts.toFixed(1)} W` : "Charging"; cls = "charging";
if (b.timeToFull > 0) detail.push(`Full in ${dur(b.timeToFull)}`);
} else if (!draining) { label = "Plugged in, not charging"; cls = "draining"; }
} else if (b.status === "Not charging" || idle) { label = "Not charging"; cls = "draining"; }
else if (b.status !== "Discharging") { label = "Charging state unavailable"; cls = "draining"; }
else {
label = low ? "Battery low" : power ? "Plugged in, still draining" : "On battery";
cls = low ? "low" : "draining";
@@ -2415,6 +2526,7 @@ function render(s) {
<div class="name">${esc(g.name)}</div>
<div class="size">${gb(g.size)}</div>
<button class="play small" data-launch="${g.appid}" data-name="${esc(g.name)}">▶ Play</button>
<button class="small danger" data-force-quit-app="${g.appid}" data-name="${esc(g.name)}" title="Quit it if it has stopped responding">Force quit…</button>
</div>
</div>`).join("") : `<div class="sub">No games installed.</div>`;
// Tall library art, falling back to the wide header, then to the name card.
@@ -3207,7 +3319,7 @@ $("ctrlKeys").addEventListener("click", e => {
});
// ---- controls ----
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); }; // titles too: the Frame may have been asleep at start
$("refreshAll").onclick = () => { refresh(); loadTitles(); loadAndroid(); loadRunning().catch(() => {}); }; // titles too: the Frame may have been asleep at start
document.addEventListener("keydown", e => {
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
});
@@ -3215,6 +3327,7 @@ document.body.addEventListener("click", async (e) => {
const b = e.target.closest("button");
if (!b) return;
if (b.dataset.launch) act(`Launch ${b.dataset.name}`, () => api("/api/launch", { appid: b.dataset.launch }), b);
else if (b.dataset.forceQuitApp) forceQuitFrom(g => String(g.appid) === b.dataset.forceQuitApp, b.dataset.name, b);
else if (b.dataset.uninstall) {
if (confirm(`Remove ${b.dataset.name} from the Frame?`))
act(`Remove ${b.dataset.name}`, () => api("/api/flatpak", { action: "uninstall", id: b.dataset.uninstall }), b).then(refresh);
@@ -3357,6 +3470,15 @@ async function sendComputerClipboard() {
return api("/api/clipboard", { text }, device);
}
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
// The other way. The app (or the iPhone app) writes the clipboard itself; in a browser, the
// server does, on the computer it runs on. The text is never logged.
async function copyHeadsetClipboard() {
if (!window.frameApp?.writeClipboard) return api("/api/clipboard", { toComputer: true });
const { text, message } = await api("/api/clipboard", { read: true });
await window.frameApp.writeClipboard(text);
return { message };
}
$("clipFrom").onclick = () => act("Copy the headset's clipboard", copyHeadsetClipboard, $("clipFrom"));
// ---- file drop ----
const drop = $("drop");
@@ -3783,6 +3905,7 @@ async function loadTitles() {
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">Play</button>
<details class="lib-more"><summary aria-label="More for ${esc(t.id)}">⋯</summary><div class="lib-menu">
${t.art_missing ? `<button data-title="refresh-art" data-id="${esc(t.id)}">Add artwork<small>Its Steam library tile has none yet</small></button>` : ""}
<button class="danger" data-title="force-quit" data-id="${esc(t.id)}">Force quit…<small>If it has stopped responding</small></button>
<button class="danger" data-title="remove" data-id="${esc(t.id)}">Remove…<small>Deletes the game and its files</small></button>
</div></details>
</div>`).join("") : `<div class="sub">None yet.</div>`;
@@ -3795,6 +3918,8 @@ $("titleList").onclick = async e => {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else if (b.dataset.title === "force-quit") {
forceQuitFrom(g => g.kind === "devkit" && g.title === id, id, b);
} else if (b.dataset.title === "refresh-art") {
await runJob(`Add Steam artwork for ${id}`, "art-" + id, () => api("/api/titles", { action: "refresh-art", id }));
loadTitles();
@@ -3803,6 +3928,48 @@ $("titleList").onclick = async e => {
loadTitles();
$("titleAdd").onclick = () => $("fileInput").click();
// ---- what's running, and force quit (frame_running.py on the Frame) ----
let runningGames = [], runningSeq = 0;
async function loadRunning() {
const seq = ++runningSeq;
try {
const list = (await api("/api/running")).running;
if (seq === runningSeq) runningGames = list;
} catch (e) {
if (seq === runningSeq && !e.stale) runningGames = [];
throw e;
} finally { if (seq === runningSeq) renderRunning(); }
return seq === runningSeq;
}
const RUNNING_KIND = { steam: "Steam game", devkit: "Sideloaded game", shortcut: "Non-Steam shortcut", android: "Android app" };
function renderRunning() {
$("nowRunning").hidden = !runningGames.length;
$("runningList").innerHTML = runningGames.map(g => `
<div class="item"><div class="grow"><div class="t">${esc(g.name)}</div><div class="s">${RUNNING_KIND[g.kind] || "Running"} · running on the Frame</div></div>
<button class="small danger" data-force-quit="${esc(g.appid)}">Force quit…</button></div>`).join("");
}
async function forceQuit(appid, btn) {
const g = runningGames.find(x => String(x.appid) === String(appid));
if (!g) return;
if (!confirm(`Force quit ${g.name}? Unsaved progress will be lost.`)) return;
await act(`Force quit ${g.name}`, () => api("/api/running", { action: "quit", appid: String(g.appid) }), btn);
loadRunning().catch(() => {});
if (g.kind === "android") loadAndroid();
}
// From a game's own menu: check it's running first, so a stopped game says so.
async function forceQuitFrom(match, name, btn) {
if (btn) btn.disabled = true;
try { if (!await loadRunning()) return; }
catch (e) { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); return; }
finally { if (btn) btn.disabled = false; }
const g = runningGames.find(match);
if (!g) { log(`${name} isn't running`); toast(`${name} isn't running`); return; }
forceQuit(g.appid, btn);
}
$("runningList").onclick = e => { const b = e.target.closest("[data-force-quit]"); if (b) forceQuit(b.dataset.forceQuit, b); };
$("runningCheck").onclick = () => loadRunning().then(() => { if (!runningGames.length) toast("Nothing is running on the Frame"); },
e => { if (!e.stale) toast(`Couldn't check what's running: ${e.message}`, true); });
// ---- Steam games: install owned games, find and buy on the store ----
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
@@ -5114,6 +5281,146 @@ $("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/ap
// Watch for new shots: a cheap listing over the shared SSH connection while the
// window is visible and the Frame is reachable, and again on coming back to it.
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
// ---- Copying new screenshots and clips here on their own (ui/frame_autosync.py) ----
const autoSync = { s: null, copied: null };
const syncWhen = t => new Date(t * 1000).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
function renderAutoSync(s) {
autoSync.s = s;
$("syncShots").checked = s.screenshots; $("syncClips").checked = s.clips;
$("syncResume").hidden = !s.paused;
const on = s.screenshots || s.clips, lines = [];
if (s.paused) lines.push(s.paused);
else if (s.error) lines.push(`Last problem: ${s.error}`);
if (s.running || s.pending) lines.push("Copying…");
if (s.copied_time) lines.push(`Last copied ${s.copied} file${s.copied === 1 ? "" : "s"} on ${syncWhen(s.copied_time)}.`);
if (on && s.checked) lines.push(`Last checked ${syncWhen(s.checked)}.`);
if (on && s.waiting) lines.push(`${s.waiting} clip${s.waiting === 1 ? " is" : "s are"} still being written; copied once Steam finishes.`);
if (on && s.since) lines.push(`Copying what's new since ${syncWhen(s.since)}.`);
$("syncStatus").textContent = lines.length ? lines.join(" ") : "Off.";
for (const [id, key, what] of [["shotsSyncNote", "screenshots", "New screenshots"], ["clipsSyncNote", "clips", "New Steam clips"]]) {
$(id).hidden = !s[key] || !!HOST.mobile;
$(id).innerHTML = s.paused ? `${esc(s.paused)} <a href="#autoSync">Settings</a>`
: `${what} are copied to this ${esc(HOST.computer)} automatically${s.copied_time ? `; last copy ${esc(syncWhen(s.copied_time))}` : ""}. <a href="#autoSync">Change</a>`;
}
// Something new arrived here: show it as saved.
if (autoSync.copied !== null && s.copied_time !== autoSync.copied) { loadShots(true); if (!HOST.mobile) loadClips(); }
autoSync.copied = s.copied_time;
}
async function loadAutoSync() {
try { renderAutoSync(await api("/api/autosync")); } catch {} // not on the iPhone app's server
}
async function setAutoSync(body, label) {
try { renderAutoSync(await api("/api/autosync", body)); }
catch (e) { toast(`${label} failed: ${e.message}`, true); loadAutoSync(); }
}
$("syncShots").onchange = e => setAutoSync({ action: "settings", screenshots: e.target.checked }, "Saving");
$("syncClips").onchange = e => setAutoSync({ action: "settings", clips: e.target.checked }, "Saving");
$("syncNow").onclick = async e => { const r = await act("Copy everything now", () => api("/api/autosync", { action: "now" }), e.currentTarget); if (r) renderAutoSync(r); };
$("syncResume").onclick = async e => { const r = await act("Resume copying", () => api("/api/autosync", { action: "resume" }), e.currentTarget); if (r) renderAutoSync(r); };
loadAutoSync();
setInterval(() => { const s = autoSync.s; if (!document.hidden && s && (s.screenshots || s.clips || s.pending || s.running)) loadAutoSync(); }, 20000);
// ---- Recording the headset view, and Steam's own clips (server.py, frame_clips.py) ----
// A recording runs on the Frame until Stop; the server then copies the .mp4 here
// and the page follows it by polling /api/record.
const rec = { state: "idle", timer: null };
const clock = t => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`;
function showRec(s) {
const was = rec.state;
rec.state = s.state;
const busy = ["starting", "recording", "stopping", "saving"].includes(s.state);
const b = $("recBtn");
b.textContent = s.state === "recording" ? "Stop" : "Record";
b.classList.toggle("on", s.state === "recording");
b.disabled = busy && s.state !== "recording";
const line = $("recStatus");
line.textContent = s.state === "recording" ? `Recording ${clock(s.elapsed || 0)} (stops by itself at ${clock(s.limit)})` +
(s.audio ? "" : ". No sound: the Frame's audio couldn't be read, so this is video only.")
: s.state === "starting" ? "Starting the recording…"
: s.state === "stopping" ? "Stopping the recording…"
: s.state === "saving" ? `Copying the recording to this ${HOST.computer}…`
: s.state === "saved" ? s.message
: s.state === "failed" ? `Recording failed: ${s.error}` : "";
line.hidden = !line.textContent;
if (was !== s.state && was !== "idle" && s.state === "saved") { log(s.message, "ok"); toast(s.message); }
if (was !== s.state && was !== "idle" && was !== "starting" && s.state === "failed") { log(line.textContent, "e"); toast(line.textContent, true); }
clearTimeout(rec.timer);
if (busy) rec.timer = setTimeout(pollRec, 1000);
}
async function pollRec() {
if (HOST.mobile) return;
clearTimeout(rec.timer);
let s;
try { s = await api("/api/record"); }
catch { if (rec.state !== "idle") rec.timer = setTimeout(pollRec, 3000); return; }
showRec(s);
}
$("recBtn").onclick = async e => {
const start = rec.state !== "recording";
const res = await act(start ? "Start recording" : "Stop recording",
() => api("/api/record", { action: start ? "start" : "stop" }), e.currentTarget);
if (res) showRec(res); else pollRec();
};
const clipsState = { list: [], gen: 0, exporting: new Set() };
async function loadClips() {
if (HOST.mobile) return;
const gen = clipsState.gen;
$("clipsRefresh").disabled = true;
let got;
try { got = await api("/api/clips"); }
catch (e) { if (gen === clipsState.gen) failed($("clipList"), e); return; }
finally { $("clipsRefresh").disabled = false; }
if (gen !== clipsState.gen) return;
clipsState.list = got.clips;
drawClips();
}
// Forget the clips of a headset we've switched away from.
function resetClips() {
clipsState.gen++; clipsState.list = [];
$("clipCount").textContent = "";
$("clipList").innerHTML = `<div class="sub">Loading…</div>`;
}
const megabytes = n => n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : `${Math.max(1, Math.round(n / 1e6))} MB`;
function drawClips() {
const list = clipsState.list;
$("clipCount").textContent = list.length ? `${list.length} on the Frame` : "";
$("clipList").innerHTML = list.length ? list.map((c, i) => `<div class="item"><div class="grow">
<div class="t">${esc(c.appid ? shotApp(c.appid) : "Steam recording")} · ${c.kind === "clip" ? "Clip" : "Background recording"}</div>
<div class="s" title="${esc(new Date(c.time * 1000).toLocaleString())}">${esc([shotTime(c.time), c.duration ? clock(c.duration) : "",
megabytes(c.size), c.gaps ? `${c.gaps} pieces missing` : ""].filter(Boolean).join(" · "))}</div></div>
${c.saved ? `<span class="tag">On ${HOST.computer}</span>` : ""}
<button class="small" data-clip-export="${i}"${clipsState.exporting.has(c.id) ? " disabled" : ""}>${c.saved ? "Export again" : "Export"}</button>
</div>`).join("")
: `<div class="sub">No Steam clips or background recordings on the Frame.</div>`;
}
async function exportClip(c, btn) {
const label = `Export ${c.file}`;
clipsState.exporting.add(c.id); btn.disabled = true;
log(label + "…"); toast(label + "…");
try {
const { job } = await api("/api/clips/export", { id: c.id });
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
if (!j.done) continue;
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
break;
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
} finally { clipsState.exporting.delete(c.id); loadClips(); }
}
$("clipList").onclick = e => {
const b = e.target.closest("[data-clip-export]");
if (b) exportClip(clipsState.list[+b.dataset.clipExport], b);
};
$("clipsRefresh").onclick = () => loadClips();
$("videosFolder").onclick = e => act($("videosFolder").textContent, () => api("/api/open", { what: "videos" }), e.currentTarget);
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
// ---- Panel switcher: our UI over SteamVR's panel API ----
@@ -5137,8 +5444,173 @@ $("panelList").onclick = e => {
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
};
// ---- Read-only game storage ----
let storageSeq = 0;
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
unavailable:'Unavailable', 'invalid-metadata':'Invalid Steam metadata', partial:'Partial inventory', ok:'Available'}[s] || 'Unknown');
const storageBytes = n => typeof n === 'number' && Number.isFinite(n) && n >= 0 ? sizeText(n) : 'Unknown';
function storageCapacity(c) {
if (!c || c.state !== 'ok') return esc(storageState(c?.state));
return `${storageBytes(c.free)} free / ${storageBytes(c.total)} total · ${storageBytes(c.used)} used`;
}
function renderStorage(r) {
return `<div class="card"><h3>Headset user filesystem</h3><p>${storageCapacity(r.home)}</p>
<p class="sub">Filesystem space available to your user; excludes separate system partitions. Reserved space can make used + free less than total.</p></div>` +
(r.discovery !== 'ok' ? `<p class="sub">Library discovery: ${esc(storageState(r.discovery))}. Additional libraries may be absent from this view.</p>` : '') +
r.libraries.map(lib => `<div class="card" style="margin-top:16px"><h3 style="overflow-wrap:anywhere">${esc(lib.path)}</h3>
<p>${storageCapacity(lib.capacity)}</p><p class="sub">Filesystem containing this library path; paths on the same filesystem share this space. Capacities must not be added together.</p>
<p>${esc(storageState(lib.state))} · Known games/apps: ${storageBytes(lib.gameBytes)} · Known runtimes / tools (by name): ${storageBytes(lib.toolBytes)}</p>
<p class="sub">Known recorded content: ${storageBytes(lib.recordedBytes)} · ${lib.unknownSizes} unknown sizes · ${lib.issues.length} unreadable / invalid manifests. Other usage is unmeasured.</p>
<div class="list">${lib.apps.map(app => `<div class="item"><div class="grow">${esc(app.name)}<div class="sub">Steam ${esc(app.appid)} · ${app.kind === 'tool' ? 'Runtime / tool' : 'Game / app'}</div></div><span>${storageBytes(app.bytes)}</span></div>`).join('') || '<p class="sub">No readable Steam manifests found; this does not prove the library is empty.</p>'}</div>
${lib.issues.map(i => `<p class="sub">${esc(i.file)}: ${esc(storageState(i.state))}</p>`).join('')}</div>`).join('') +
`<p class="sub">Snapshot received ${esc(new Date().toLocaleTimeString())}. Refresh after installs, downloads or moves.</p>`;
}
async function loadStorage() {
const seq = ++storageSeq, gen = devGen;
$('storageResult').textContent = 'Reading headset storage…';
try {
const result = await api('/api/storage');
if (seq === storageSeq && gen === devGen) $('storageResult').innerHTML = renderStorage(result);
} catch (error) {
if (seq === storageSeq && gen === devGen) $('storageResult').textContent = `Storage unavailable: ${error.message}`;
}
}
$('storageRefresh').onclick = loadStorage;
// ---- Backup and recovery kit (ui/frame_backup.py) ----
const BACKUP_KIND = {saves: "Game saves", settings: "Settings", android: "Android app data"};
function sizeText(n) {
if (n == null) return "";
for (const unit of ["bytes", "KB", "MB", "GB"]) {
if (n < 1000 || unit === "GB") return unit === "bytes" ? `${n} bytes` : `${n.toFixed(1)} ${unit}`;
n /= 1000;
}
}
const backupKinds = () => [...$("backupKinds").querySelectorAll("input:checked")].map(i => i.value);
let backupSeq = 0, backupBusy = false;
async function loadBackups() {
const seq = ++backupSeq;
try {
const r = await api("/api/backup", {action: "list"});
if (seq !== backupSeq) return;
$("backupList").innerHTML = r.backups.length ? r.backups.map(b => `<div class="item"><div class="grow">
<div class="t">${esc(b.name)}</div><div class="s">${b.error ? esc(b.error) :
esc([b.headset, b.build && `SteamOS build ${b.build}`, `${b.parts} part${b.parts === 1 ? "" : "s"}`, sizeText(b.bytes)].filter(Boolean).join(" · "))}</div></div>
${b.error ? "" : `<button class="small" data-backup-plan="${esc(b.name)}">Restore…</button>`}</div>`).join("")
: `<div class="sub">No backups in ${esc(r.folder)} yet.</div>`;
} catch (e) { if (seq === backupSeq) failed($("backupList"), e); }
}
function backupPartRow(p, restore) {
const detail = restore
? (p.status === "ok" ? [p.replace ? `replaces ${p.replace} file${p.replace === 1 ? "" : "s"}` : "", p.new ? `${p.new} new` : "",
p.held && p.held.reference ? `${p.held.reference} kept for reference` : ""].filter(Boolean).join(", ") || "ready"
: p.reason || p.status)
: [`${p.files} file${p.files === 1 ? "" : "s"}`, sizeText(p.bytes), p.skipped && p.skipped.length ? `${p.skipped.length} too big, skipped` : ""].filter(Boolean).join(" · ");
const box = restore ? `<input type="checkbox" data-restore-part="${esc(p.id)}" ${p.status === "ok" ? "checked" : "disabled"} aria-label="Restore ${esc(p.label)}"> ` : "";
return `<div class="item"><div class="grow"><div class="t">${box}${esc(p.label)}</div><div class="s">${esc(detail)}</div></div></div>`;
}
$("backupPreview").onclick = e => act("Check what a backup includes", async () => {
const r = await api("/api/backup", {action: "preview"}), kinds = backupKinds();
const rows = r.parts.filter(p => kinds.includes(p.kind)).map(p => backupPartRow(p, false));
if (kinds.includes("android")) rows.push(...r.android.map(a => `<div class="item"><div class="grow"><div class="t">${esc(a.label)} (Android app data)</div>
<div class="s">${a.running ? "Open now: close it or it's skipped" : "Saved while closed"}</div></div></div>`));
if (r.android_error && kinds.includes("android")) rows.push(`<div class="sub">Android apps couldn't be listed: ${esc(r.android_error)}</div>`);
$("backupSummary").innerHTML = (rows.join("") || '<div class="sub">Nothing to back up for that choice.</div>') +
`<details><summary>Never copied</summary><ul>${r.excluded.map(x => `<li>${esc(x)}</li>`).join("")}</ul></details>`;
return {message: `SteamOS build ${r.build || "unknown"}: ${rows.length} part${rows.length === 1 ? "" : "s"} to back up`};
}, e.currentTarget);
// Like runJob, without counting as an install in the header.
async function backupJob(label, start) {
log(label + "…");
try {
const { job } = await start();
for (let misses = 0; ;) {
await new Promise(ok => setTimeout(ok, 1500));
let j;
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
backupProgress(j);
if (!j.done) continue;
if (j.error) throw new Error(j.error);
log(j.message, "ok"); toast(j.message);
return j.result || {};
}
} catch (e) {
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
}
}
function backupProgress(j) {
$("backupProgress").textContent = j.done ? "" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`;
}
$("backupStart").onclick = async e => {
const kinds = backupKinds();
if (!kinds.length) return toast("Choose something to back up", true);
if (backupBusy) return;
backupBusy = true; e.currentTarget.disabled = true;
try {
const r = await backupJob("Back up the Frame", () => api("/api/backup", {action: "backup", kinds}));
if (r) {
$("backupSummary").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}${s.path ? `: ${esc(s.path)}` : ""}</div>
<div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
loadBackups();
}
} finally { backupBusy = false; $("backupStart").disabled = false; $("backupProgress").textContent = ""; }
};
$("backupFolder").onclick = e => act("Open backups folder", () => api("/api/backup", {action: "open"}), e.currentTarget);
$("backupRefresh").onclick = loadBackups;
let restoreName = null;
$("backupList").onclick = async e => {
const b = e.target.closest("[data-backup-plan]"); if (!b) return;
restoreName = b.dataset.backupPlan;
$("restorePlan").innerHTML = '<div class="sub">Checking what can go back…</div>';
try {
const r = await api("/api/backup", {action: "plan", name: restoreName});
if (r.name !== restoreName) return;
$("restorePlan").innerHTML = `<h3>Restore ${esc(r.name)}</h3>` + r.parts.map(p => backupPartRow(p, true)).join("") +
`<div class="row" style="margin-top:12px"><button class="small danger" id="restoreStart">Restore selected</button>
<button class="small" data-backup-open="${esc(r.name)}">Show files</button></div>`;
$("restorePlan").plan = r;
} catch (err) { failed($("restorePlan"), err); }
};
$("restorePlan").onclick = async e => {
const open = e.target.closest("[data-backup-open]");
if (open) return act("Show backup files", () => api("/api/backup", {action: "open", name: open.dataset.backupOpen}), open);
if (!e.target.closest("#restoreStart") || backupBusy) return;
const plan = $("restorePlan").plan;
const parts = [...$("restorePlan").querySelectorAll("[data-restore-part]:checked")].map(i => i.dataset.restorePart);
if (!plan || !parts.length) return toast("Choose something to restore", true);
const chosen = plan.parts.filter(p => parts.includes(p.id));
const replacing = chosen.reduce((n, p) => n + (p.replace || 0), 0);
if (!confirm(`Restore ${chosen.length} part${chosen.length === 1 ? "" : "s"} from ${plan.name}?\n\n` +
`${replacing ? `This replaces ${replacing} file${replacing === 1 ? "" : "s"} or app data folder${replacing === 1 ? "" : "s"} on the headset. ` : ""}` +
"Replaced files are kept on the headset. Close the games and apps you're restoring first.")) return;
backupBusy = true;
try {
const r = await backupJob("Restore from backup", () => api("/api/backup", {action: "restore", name: plan.name, parts, confirm: true}));
if (r) $("restorePlan").innerHTML = `<div class="sub">${esc(r.message)}</div>` + (r.skipped || []).map(s =>
`<div class="item"><div class="grow"><div class="t">${esc(s.label || s.id)}</div><div class="s">Skipped: ${esc(s.reason)}</div></div></div>`).join("");
} finally { backupBusy = false; $("backupProgress").textContent = ""; }
};
const SESSION_TEXT = {game: "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
desktop: "Desktop Mode: the headset started the full desktop instead of Game Mode.",
unknown: "Couldn't tell which mode the headset is in."};
$("sessionCheck").onclick = async e => {
e.currentTarget.disabled = true;
try {
const s = await api("/api/backup", {action: "session"});
$("sessionState").textContent = SESSION_TEXT[s.mode] + (s.selector ? "" : " This SteamOS build has no session switch command; use the manual steps.");
$("sessionGame").disabled = s.mode !== "desktop" || !s.selector;
} catch (err) { failed($("sessionState"), err); }
finally { $("sessionCheck").disabled = false; }
};
$("sessionGame").onclick = e => {
if (!confirm("Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.")) return;
act("Return to Game Mode", () => api("/api/backup", {action: "gamemode", confirm: true}), e.currentTarget);
};
// ---- Mac in the headset: windows and displays as panels (ui/frame_macview.py) ----
let mvSeq = 0, mvTimer = 0;
let mvSeq = 0, mvTimer = 0, mvPcAsked = false;
// streaming: the src it's shown as (a window may be "separate:<id>"), or "".
// How a live stream is doing: frames shown, Mac-to-headset delay, bitrate, and
// whether the network made it step down (fewer frames, then fewer pixels).
@@ -5164,9 +5636,27 @@ async function loadMacView(restart) {
try { s = await api("/api/macview" + (restart ? "?restart=1" : "")); } catch (e) { err = e; }
if (seq !== mvSeq || location.hash !== "#macview") return;
const box = $("macview");
if (err) { box.hidden = false; return failed($("mvWindows"), err); }
if (!s.available) { box.hidden = true; return; }
if (err) { box.hidden = $("mvMain").hidden = false; return failed($("mvWindows"), err); }
// PC in the headset (Windows, Experimental) is off until turned on here.
const pc = !!s.host && s.host !== "mac";
$("mvTitle").textContent = pc ? "PC in the headset" : "Mac in the headset";
$("mvTag").hidden = $("mvPc").hidden = !pc;
$("mvPcOn").checked = !!s.enabled;
if (pc && !s.enabled && clientPreferences.get("pcView") === "1" && !restart && !mvPcAsked) {
mvPcAsked = true; // once per page: a failure to start shouldn't loop
return mvPcEnable(true, true);
}
if (!s.available) {
box.hidden = !pc;
$("mvMain").hidden = true;
$("mvCount").textContent = "";
if (pc) { $("mvPcState").textContent = s.reason || ""; $("mvPcState").hidden = !s.reason; }
return;
}
box.hidden = false;
$("mvMain").hidden = false;
$("mvPcState").hidden = true;
$("mvSeparate").closest("label").hidden = pc; // separate virtual displays are a Mac feature
const live = new Set((s.streams || []).map(x => x.src));
const byStream = src => (s.streams || []).find(x => x.src === src);
$("mvCount").textContent = live.size ? `${live.size} showing${s.route === "usb" ? " · over USB-C" : ""}` : "";
@@ -5176,6 +5666,7 @@ async function loadMacView(restart) {
$("mvPerm").hidden = !perm.length;
$("mvPerm").innerHTML = perm.join(" ") + `<div class="row"><button class="small action" data-mv="perm">Allow…</button>
<span class="sub">Then press Refresh. macOS may ask you to reopen Frame Control.</span></div>`;
if (pc) $("mvPerm").hidden = true; // Windows asks for no permission; elevated apps just refuse input
const liveAs = src => live.has(src) ? src : src.startsWith("window:") && live.has("separate:" + src.slice(7)) ? "separate:" + src.slice(7) : "";
$("mvDisplays").innerHTML = (s.displays || []).map(d =>
mvRow(d.src, `Whole screen: ${d.name}`, `${d.w}×${d.h}${d.main ? " · main display" : ""}`, d.w, d.h, liveAs(d.src), byStream(liveAs(d.src)))).join("");
@@ -5183,16 +5674,26 @@ async function loadMacView(restart) {
$("mvWinCount").textContent = wins.length ? `${wins.length}` : "";
$("mvWindows").innerHTML = !s.screen ? `<div class="sub">Windows appear here once Screen Recording is allowed.</div>`
: wins.length ? wins.map(w => mvRow(w.src, w.title || w.app, `${w.title ? w.app + " · " : ""}${w.w}×${w.h}`, w.w, w.h, liveAs(w.src), byStream(liveAs(w.src)))).join("")
: `<div class="sub">No windows open on this Mac's current desktop.</div>`;
: `<div class="sub">${pc && s.windowCaptureReason ? esc(s.windowCaptureReason) : `No windows open on this ${pc ? "PC" : "Mac's current desktop"}.`}</div>`;
// Keep the numbers fresh while something is in the headset and the card is on screen.
clearTimeout(mvTimer);
const tick = () => { mvTimer = document.hidden || !box.offsetParent ? setTimeout(tick, 3000) : (loadMacView(), 0); };
if (live.size) mvTimer = setTimeout(tick, 3000);
}
$("mvRefresh").onclick = () => loadMacView(true);
// The choice is remembered on this computer; the server starts with it off.
async function mvPcEnable(on, quiet) {
if (!quiet) clientPreferences.set("pcView", on ? "1" : "0");
const turn = () => api("/api/macview", { action: "enable", on });
if (quiet) { try { await turn(); } catch {} }
else await act(on ? "Turn on PC in the headset" : "Turn off PC in the headset", async () =>
({ ...await turn(), message: on ? "PC in the headset is on." : "PC in the headset is off. Nothing is shared." }), $("mvPcOn"));
loadMacView();
}
$("mvPcOn").onchange = e => mvPcEnable(e.target.checked);
// ---- client preferences: native storage survives changing localhost origins ----
const clientPreferences = (() => {
const keys = ['mvSeparate', 'padUsed'], values = {}, changed = new Set();
const keys = ['mvSeparate', 'padUsed', 'pcView'], values = {}, changed = new Set();
const valid = value => value === '0' || value === '1';
const remember = (key, value) => {
values[key] = value;
@@ -5229,7 +5730,7 @@ $("macview").onclick = async e => {
const b = e.target.closest("[data-mv]"); if (!b) return;
const src = b.dataset.src;
if (b.dataset.mv === "show") {
const as = $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
const as = !$("mvSeparate").closest("label").hidden && $("mvSeparate").checked && src.startsWith("window:") ? "separate:" + src.slice(7) : src;
await act(`Show ${b.dataset.title} in the headset`, async () => {
const r = await api("/api/macview", { action: "show", src: as, quality: $("mvQuality").value,
w: +b.dataset.w || undefined, h: +b.dataset.h || undefined });
@@ -5250,10 +5751,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork",
pad:"Keyboard & trackpad", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates"};
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", backup:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -5339,7 +5840,7 @@ function showPage(e) {
if (page === 'games') selectLibrary(workspace.source, workspace.scope, false);
// Another computer may have added or removed a title: phones have no Refresh button. Only
// on navigating here (e: the hashchange); at startup the first load is already on its way.
if (e && page === 'games' && previousPage !== 'games') loadTitles();
if (e && page === 'games' && previousPage !== 'games') { loadTitles(); loadRunning().catch(() => {}); }
if (page === 'apps') {
selectApps(workspace.apps, false);
if ((previousPage !== 'apps' || id === 'androidLibrary') && workspace.apps === 'installed') loadAndroid();
@@ -5359,6 +5860,8 @@ function showPage(e) {
}
if (tool === 'macview') loadMacView();
if (tool === 'panels') loadPanelSwitcher();
if (tool === 'backup') loadBackups();
if (tool === 'storage') loadStorage();
if (tool === 'androidSettings') {
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
}
@@ -5684,6 +6187,10 @@ clientPreferences.ready.then(async () => {
showPage();
document.addEventListener("keydown", e => {
if (e.metaKey || e.ctrlKey || e.altKey || /INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) || document.querySelector("dialog[open]")) return;
if ((e.key === "t" || e.key === "T") && !(ctrl.on && document.activeElement === $("viewer"))) {
e.preventDefault(); // or the T lands in the field it focuses, and goes to the Frame
return typeOnHeadset();
}
const n = [...PAGES, 'devices', 'control'][+e.key - 1];
if (n) location.hash = n;
});
@@ -6039,7 +6546,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
refresh().then(() => { loadShots(); loadClips(); pollRec(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -6330,9 +6837,11 @@ function onConnection(s) {
disp.list = []; disp.port = null;
// The lists behind those panels too, so filters can't bring the old ones back.
gm.storeDraft = ""; gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.shownBySource = {owned:0, store:0}; gm.seq++;
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots();
androidApps = []; androidSeq++; androidPending.clear(); $('installedDetail').close(); resetShots(); resetClips();
renderHome();
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
titlesSeq++; disp.seq++; runningSeq++; // answers to loads already on their way are ignored
$('storageResult').textContent = 'Headset changed. Open Game storage or Refresh to read this headset.';
runningGames = []; $("nowRunning").hidden = true; $("runningList").innerHTML = "";
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
// Confirmations still open were checked against the other headset.
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
@@ -6746,6 +7255,9 @@ api("/api/host").then(h => {
Object.assign(HOST, h);
window.frameControlCenter?.host(h);
$("macToolLink").hidden = h.mobile || h.os !== "macOS";
$("pcToolLink").hidden = h.mobile || h.os !== "Windows";
if (!h.mobile && h.os === "Windows") TOOL_TITLES.macview = "PC in the headset";
$("backupToolLink").hidden = !!h.mobile; // backups are folders on a computer
if (h.mobile) return;
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
watchConnection();
@@ -6756,7 +7268,7 @@ api("/api/host").then(h => {
<script>
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || "", confirm: text => confirm(text)});
window.frameControlCenter.host(HOST);
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
+5 -5
View File
@@ -22,7 +22,7 @@
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<div id="note">Connecting to the Mac…</div>
<div id="note">Connecting to your computer…</div>
<div id="hud" hidden></div>
<script>
"use strict";
@@ -243,14 +243,14 @@ async function connect() {
// Refused before ever getting a key: the ticket expired or was revoked,
// and retrying can't help.
if (!reconnectKey && ++failedStarts >= 3) {
show("This view has expired. Press Show in Frame Control on the Mac to open it again.");
show("This view has expired. Press Show in Frame Control on your computer to open it again.");
return;
}
hideNoteOnFrame = true;
// The Mac may be asleep or the tunnel restarting: keep trying.
retry = Math.min(retry + 1, 6);
// Keep the Mac's reason (a missing permission, a closed window) on screen.
show(lastError ? `${lastError} Retrying…` : "Lost the Mac. Reconnecting…");
show(lastError ? `${lastError} Retrying…` : "Lost the computer. Reconnecting…");
setTimeout(connect, 500 * 2 ** retry);
};
}
@@ -269,7 +269,7 @@ function control(m) {
document.title = tag ? `${name} [${tag}]` : name;
if (!m.input) {
hideNoteOnFrame = false; // a warning, not a connection notice: let it stay its 8 s
show("Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
show(m.inputMessage || "Clicks and keys need Accessibility permission on the Mac (Frame Control asks for it).", 8000);
}
} else if (m.t === "error") {
stats.errors.push(m.message);
@@ -391,7 +391,7 @@ addEventListener("keydown", e => onKey(e, true));
addEventListener("keyup", e => onKey(e, false));
addEventListener("blur", () => send({ t: "release" }));
show("Connecting to the Mac…");
show("Connecting to your computer…");
connect();
</script>
</body>
+340 -22
View File
@@ -45,10 +45,13 @@ import frame_agent # noqa: E402
import frame_assistant # noqa: E402
import frame_android # noqa: E402
import frame_android_import # noqa: E402
import frame_backup # noqa: E402
from apk_sources import search as apk_search, SourceError # noqa: E402
from apk_sources import fdroid as apk_fdroid # noqa: E402
import frame_apk_versions # noqa: E402
import frame_catalog # noqa: E402
import frame_clips # noqa: E402
import frame_autosync # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
@@ -56,6 +59,7 @@ import frame_contact # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
import frame_pcview # noqa: E402
import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_quest # noqa: E402
@@ -746,6 +750,125 @@ def stream_command(query):
f"exec >&-; cat >/dev/null; kill $p 2>/dev/null; wait $p")
# Recordings of the headset view with its sound, and Steam's own clips, saved as .mp4
# in ~/Videos/SteamFrame (frame_clips.py has the commands and the Frame-side half).
def fetch_file(remote, local):
"""Copy one file from the Frame (a path relative to its home) to `local`."""
ensure_master()
try:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell. -p keeps its time.
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], f"{FRAME}:{remote}", str(local)], capture_output=True,
stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {Path(remote).name} timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr or r.stdout).strip() or f"scp exited {r.returncode}")
def _record_channel(command, stderr):
ensure_master()
proc = subprocess.Popen([*SSH, FRAME, command], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr)
_live_tunnels.add(proc) # killed if the server stops: the Frame then finishes the file
return proc
recorder = frame_clips.Recorder(_record_channel, lambda cmd, timeout: ssh(cmd, timeout=timeout), fetch_file,
lambda fn: busy_thread(fn).start(), release=lambda proc: _live_tunnels.discard(proc))
def _clips_here():
if LOCAL:
raise Failure("Recordings are saved to a computer: use Frame Control on your computer", 400)
def record(body):
"""{"action": "start"|"stop"}: record what the headset shows, with its sound."""
_clips_here()
action = body.get("action")
if action not in ("start", "stop"):
raise Failure("action must be start or stop", 400)
try:
return recorder.start() if action == "start" else recorder.stop()
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
def _clips_frame(args, timeout):
return steam_frame(*args, timeout=timeout, script="frame_clips.py")
def list_clips():
_clips_here()
try:
return frame_clips.list_clips(_clips_frame)
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
def export_clip(body):
"""{"id"}: stitch one of Steam's recordings on the Frame and copy the .mp4 here (a job)."""
_clips_here()
clip_id = body.get("id")
name = frame_clips.export_name(clip_id) # ValueError, so 400, for a bad id
def work():
try:
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file)
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
return start_job(f"Export {name}", work)
# Copying new screenshots and clips here on their own (frame_autosync.py). Each copy
# counts as work on the headset it was listed on, so a switch can't send it elsewhere.
def _sync_device():
if not LINK or LINK.snapshot().get("phase") != "connected":
return None
return LINK.active_device().get("id")
def _sync_shots(device, ids):
with working(device):
return save_shots({"ids": ids})
def _sync_clip(device, clip_id):
with working(device):
return frame_clips.export_clip(clip_id, _clips_frame, lambda cmd, timeout: ssh(cmd, timeout=timeout),
fetch_file)
def _sync_list(listing):
def run():
with working(_sync_device()):
return listing()
return run
_stop_sync = threading.Event()
autosync = frame_autosync.AutoSync(_sync_list(list_shots), _sync_shots, _sync_list(list_clips), _sync_clip,
_sync_device, lambda: _work[0] > 0, offline=lambda e: unreachable(str(e)),
shots_folder=SHOTS_DIR, clips_folder=frame_clips.VIDEOS_DIR)
def auto_sync(body):
"""{"action": "settings", "screenshots", "clips"} | {"action": "now"} | {"action": "resume"}."""
_clips_here()
action = body.get("action")
if action == "settings":
return autosync.update({k: body[k] for k in ("screenshots", "clips") if isinstance(body.get(k), bool)})
if action == "now":
if not _sync_device():
raise Failure("Connect a headset first", 409)
return dict(autosync.copy_all(), message="Copying everything that isn't on this computer yet…")
if action == "resume":
return dict(autosync.resume(), message="Copying again")
raise Failure("action must be settings, now or resume", 400)
def launch(body):
appid = str(body.get("appid", ""))
if not APPID.match(appid):
@@ -754,12 +877,12 @@ def launch(body):
return {"message": f"Launching {appid}"}
def steam_frame(*args, timeout=40, script="frame_steam.py"):
def steam_frame(*args, timeout=40, script="frame_steam.py", source=None):
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
helper with the same JSON contract, on the Frame and return its JSON."""
try:
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
stdin=(HERE / script).read_text(), timeout=timeout)
stdin=source or (HERE / script).read_text(), timeout=timeout)
except Failure as e:
# The helper prints {"error": ...} on stdout when it fails, but ssh()
# reports stderr instead if there was any, so look in both.
@@ -772,6 +895,43 @@ def steam_frame(*args, timeout=40, script="frame_steam.py"):
return json.loads(out)
def running_frame(*args, timeout=40):
"""frame_running.py on the Frame, with frame_steam.py (its DevTools client) as a module."""
source = ("import sys, types\nm = types.ModuleType('frame_steam'); sys.modules['frame_steam'] = m\n"
f"exec({(HERE / 'frame_steam.py').read_text()!r}, m.__dict__)\n" + (HERE / "frame_running.py").read_text())
return steam_frame(*args, timeout=timeout, source=source)
def running_games():
games = running_frame("list")["running"]
return {"running": games, "message": None if games else "Nothing is running on the Frame"}
def force_quit(body):
"""Quit a running game: Steam's own terminate, then its processes (frame_running.py).
An Android app's Lepton container is stopped the way the Apps tab's Stop does."""
appid = str(body.get("appid", ""))
if body.get("action") != "quit":
raise Failure("action must be quit", 400)
if not APPID.match(appid):
raise Failure("bad appid", 400)
app = next((a for a in running_frame("list")["running"] if str(a.get("appid")) == appid), None)
if not app:
raise Failure("That game isn't running any more", 409)
stopped = False
if app.get("kind") == "android" and app.get("package"):
ensure_master()
try:
frame_android.stop(app["package"])
stopped = True
except frame_android.FrameError:
pass # the process fallback below still runs
res = running_frame("quit", appid, timeout=60)
if res.get("method") == "gone":
res["message"] = f"{'Stopped' if stopped else 'Quit'} {app['name']}"
return dict(res, appid=int(appid), name=app["name"])
def steam(body):
"""Steam games: install an owned game, or open its store page in the headset."""
appid, action = str(body.get("appid", "")), body.get("action")
@@ -856,20 +1016,22 @@ def set_volume(body):
return {"message": "Volume updated"}
# Runs on the Frame, clipboard text on stdin. Verified 2026-09-25 (SteamOS 0.3.0
# vr, build 20260922): the headset desktop is a nested Plasma Wayland session
# inside gamescope with its own D-Bus bus, and wl-copy/xclip are not installed.
# Klipper (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so
# borrow plasmashell's bus address. Same as scripts/paste-to-frame.sh.
PASTE = r"""set -u
text=$(cat; printf x); text=${text%x}
pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
# The headset desktop's session bus. Verified 2026-09-25 (SteamOS 0.3.0 vr, build
# 20260922): the headset desktop is a nested Plasma Wayland session inside
# gamescope with its own D-Bus bus, and wl-copy/xclip are not installed. Klipper
# (org.kde.klipper, served by plasmashell) is reachable with qdbus6, so borrow
# plasmashell's bus address. Same as scripts/paste-to-frame.sh.
DESKTOP_BUS = r"""pid=$(pgrep -u "$(id -u)" -x plasmashell | head -n 1)
if [ -z "$pid" ]; then
echo "The headset's desktop isn't open (plasmashell is not running). Open the desktop in the headset, then try again." >&2
exit 2
fi
bus=$(tr '\0' '\n' < "/proc/$pid/environ" | sed -n 's/^DBUS_SESSION_BUS_ADDRESS=//p')
if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
"""
# Runs on the Frame, clipboard text on stdin.
PASTE = r"""set -u
text=$(cat; printf x); text=${text%x}
""" + DESKTOP_BUS + r"""if DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
org.kde.klipper.klipper.setClipboardContents "$text" >/dev/null; then
echo "copied via Klipper (${#text} chars)"
else
@@ -877,12 +1039,77 @@ else
exit 2
fi
"""
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE says when the headset's desktop isn't open
DESKTOP_CLOSED = "plasmashell is not running" # what PASTE and COPY say when the headset's desktop isn't open
# base64 keeps the script intact through every local shell's quoting rules.
PASTE_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(PASTE.encode()).decode()
# The other way: Klipper's getClipboardContents, printed by qdbus6 with a newline
# after it, sent back as base64 so no shell or console encoding touches it.
# Not tested on a headset: the method is Klipper's, the same interface PASTE uses.
CLIPBOARD_LIMIT = 1_000_000 # bytes of UTF-8, about a 500-page book
CLIPBOARD_TOO_BIG = "too much to copy"
COPY = r"""set -u
""" + DESKTOP_BUS + r"""out=$(mktemp) || exit 2
trap 'rm -f "$out"' EXIT
if ! DBUS_SESSION_BUS_ADDRESS=$bus qdbus6 org.kde.klipper /klipper \
org.kde.klipper.klipper.getClipboardContents >"$out"; then
echo "Klipper call failed (bus: ${bus:-none})" >&2
exit 2
fi
if [ "$(wc -c <"$out")" -gt %d ]; then
echo "The headset's clipboard holds %s (over 1 MB). Copy less, or send it as a file." >&2
exit 3
fi
base64 -w 0 <"$out"
""" % (CLIPBOARD_LIMIT + 1, CLIPBOARD_TOO_BIG)
COPY_CMD = 'bash -c "$(echo %s | base64 -d)"' % base64.b64encode(COPY.encode()).decode()
# Klipper's text for an image it holds, e.g. "▨ 640x480 32bpp" (HistoryImageItem::text in
# Klipper's source; not seen on a headset).
KLIPPER_IMAGE = re.compile(r"▨ \d+x\d+ \d+bpp")
CLIPBOARD_EMPTY = "The headset's clipboard is empty"
def desktop_failure(e):
"""The headset's desktop being closed is the person's to change, not a fault: a 409 isn't reported as one."""
if DESKTOP_CLOSED in str(e):
return Failure(str(e), 409)
if CLIPBOARD_TOO_BIG in str(e):
return Failure(str(e), 413)
return None
def headset_clipboard():
"""The text on the headset desktop's clipboard. Never logged: it can hold anything."""
try:
out = ssh(COPY_CMD, timeout=30)
except Failure as e:
raise desktop_failure(e) or e from None
try:
text = base64.b64decode(out.strip(), validate=True).decode("utf-8")
except (ValueError, UnicodeDecodeError):
raise Failure("The headset's clipboard couldn't be read as text", 502) from None
text = text[:-1] if text.endswith("\n") else text # qdbus6's newline, not the clipboard's
if KLIPPER_IMAGE.fullmatch(text):
raise Failure("The headset's clipboard holds an image. Only text can be copied.", 400)
if not text:
raise Failure(CLIPBOARD_EMPTY + " (or holds something other than text)", 400)
return text
def clipboard(body):
if body.get("read") is True:
# The page puts it on the clipboard where it runs (the app, or the iPhone).
text = headset_clipboard()
return {"text": text, "message": f"Copied {len(text):,} characters from the headset's clipboard"}
if body.get("toComputer") is True:
if LOCAL:
raise Failure("This server runs on the headset: copy from the Frame Control app instead", 400)
text = headset_clipboard()
try:
frame_host.set_clipboard_text(text)
except frame_host.HostError as e:
raise Failure(str(e), 500)
return {"message": f"Copied {len(text):,} characters from the headset to this computer's clipboard"}
if body.get("fromMac") or body.get("fromComputer"):
try:
text = frame_host.clipboard_text()
@@ -897,10 +1124,7 @@ def clipboard(body):
try:
return {"message": ssh(PASTE_CMD, stdin=text, timeout=30).strip()}
except Failure as e:
if DESKTOP_CLOSED in str(e):
# The headset's state, for the person to change, not a fault: a 409 isn't reported as one.
raise Failure(str(e), 409) from None
raise
raise desktop_failure(e) or e from None
# ---- keyboard and pointer (KDE Connect on the Frame, see frame_input_agent.py) ----
@@ -1468,6 +1692,10 @@ def open_thing(body):
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(SHOTS_DIR)
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
if what == "videos":
frame_clips.VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(frame_clips.VIDEOS_DIR)
return {"message": f"Opened {frame_clips.VIDEOS_DIR} in {frame_host.FILE_MANAGER}"}
if what == "shot":
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
if not saved.exists():
@@ -2469,6 +2697,73 @@ def panels_action(body):
return result
# ---- Backup and recovery kit (frame_backup.py) ------------------------------
#
# Backups are folders on this computer, so they run from the computer app only. One backup
# or restore at a time: both are long jobs the page follows through /api/job.
_backup_lock = threading.Lock()
def backup_action(body):
action = body.get("action")
if LOCAL:
raise Failure("Backups are made from Frame Control on your computer", 400)
if action in ("plan", "restore") or (action == "open" and body.get("name") is not None):
try:
frame_backup.backup_path(body.get("name")) # refused before anything reads or runs
except frame_backup.BackupError as e:
raise Failure(str(e), 400)
if action in ("gamemode", "restore") and body.get("confirm") is not True:
raise Failure("Confirm first: this changes the headset", 400)
if action == "list":
return frame_backup.list_backups()
if action == "preview":
return frame_backup.preview()
if action == "plan":
return frame_backup.restore_plan(body.get("name"))
if action == "session":
return frame_backup.session_status()
if action == "gamemode":
return frame_backup.return_to_game_mode(confirmed=True)
if action == "open":
folder = frame_backup.backup_root()
if body.get("name") is not None:
folder = frame_backup.backup_path(body.get("name"))
folder.mkdir(parents=True, exist_ok=True)
try:
frame_host.open_path(folder)
except frame_host.HostError as e:
raise Failure(str(e), 500)
return {"message": f"Opened {folder} in {frame_host.FILE_MANAGER}"}
if action == "backup":
kinds = body.get("kinds", list(frame_backup.KINDS))
if not isinstance(kinds, list) or not kinds or any(k not in frame_backup.KINDS for k in kinds):
raise Failure("Choose what to back up", 400)
name = agent_target()["name"]
work = lambda report: frame_backup.backup(report, kinds, headset=name) # noqa: E731
label = "Back up the Frame"
elif action == "restore":
name, ids = body.get("name"), body.get("parts")
if not isinstance(ids, list) or not ids or not all(
isinstance(i, str) and frame_backup.PART.fullmatch(i) for i in ids):
raise Failure("Choose what to restore", 400)
work = lambda report: frame_backup.restore(name, ids, True, report) # noqa: E731
label = "Restore from backup"
else:
raise Failure("Unknown backup action", 400)
def exclusive(report):
if not _backup_lock.acquire(blocking=False):
raise Failure("A backup or restore is already running", 409)
try:
return work(report)
finally:
_backup_lock.release()
ensure_master()
return start_job(label, exclusive, progress=True)
# ---- Our Frame-side media player -----------------------------------------
_MEDIA_LOCK = threading.Lock()
@@ -2538,12 +2833,13 @@ def push_media(path):
"id": token + "/" + name}
# ---- Mac in the headset (frame_macview.py) ----------------------------------
# ---- Mac in the headset (frame_macview.py), PC in the headset (frame_pcview.py) ----
# The tunnel gets its own connection: the shared master's options would win
# over anything added after them.
macview = frame_macview.MacView(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin, timeout=timeout),
macview = frame_pcview.host_view(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8"],
lambda remote, stdin=None, timeout=30: ssh(remote, stdin=stdin.encode("utf-8") if stdin is not None else None,
timeout=timeout, text=False).decode("utf-8", errors="replace"),
FRAME, track=_live_tunnels.add)
@@ -2560,7 +2856,8 @@ def macview_state(query=None):
def macview_action(body):
"""{action: show|stop|permissions, src, quality, w, h}."""
"""{action: show|stop|permissions, src, quality, w, h} | {action: enable, on}.
enable turns PC in the headset (Experimental, Windows only) on or off."""
if LOCAL:
raise Failure("Runs on your computer, not the headset.", 400)
action = body.get("action")
@@ -2573,6 +2870,10 @@ def macview_action(body):
return macview.stop(body.get("src") or None)
if action == "permissions":
return macview.request_permissions()
if action == "enable":
if not isinstance(macview, frame_pcview.PCView):
raise Failure("PC in the headset is only available on Windows.", 400)
return macview.set_enabled(body.get("on") is True)
except frame_macview.MacViewError as e:
raise Failure(str(e), 502)
raise Failure("unknown action", 400)
@@ -2701,14 +3002,15 @@ def source_manage(body):
POST = {
"/api/android/backup": android_backup,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
"/api/input": remote_input, "/api/touch": remote_touch,
"/api/settings/artwork": frame_steamgriddb.save_settings,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/meta": meta, "/api/queue": queue_action, "/api/titles": titles, "/api/launch": launch, "/api/running": force_quit, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
"/api/record": record, "/api/clips/export": export_clip, "/api/autosync": auto_sync,
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
"/api/webinstall/cancel": webinstall_cancel,
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
@@ -2991,6 +3293,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
elif path == "/api/status":
self.send_json(status({}))
elif path == "/api/storage":
with working(self.headers.get("X-Frame-Device")):
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_storage.py").read_text(), timeout=20)))
elif path == "/api/running":
self.send_json(running_games())
elif path == "/api/steam/owned":
self.send_json(steam_frame("owned"))
elif path == "/api/steam/search":
@@ -3001,6 +3308,14 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(list_shots())
elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query))
elif path == "/api/record":
_clips_here()
self.send_json(recorder.status())
elif path == "/api/clips":
self.send_json(list_clips())
elif path == "/api/autosync":
_clips_here()
self.send_json(autosync.state())
elif path == "/api/stream":
self.stream_video(url.query)
elif path == "/api/screenshot" and parse_qs(url.query).get("view") == ["panel"]:
@@ -3291,6 +3606,8 @@ def main():
frame_report.link = LINK # its connection summary goes in every report's diagnostics
frame_report.start_ssh_check() # ...with which ssh this is, found once in the background
LINK.start()
if not PRIVATE: # one worker per user, in the app's server
threading.Thread(target=autosync.run, args=(_stop_sync,), daemon=True).start()
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
@@ -3313,6 +3630,7 @@ def main():
# mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_stop_sync.set()
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
# The master was started with -N, so it stays up until told to exit.