Compare commits

...
Author SHA1 Message Date
SAPHID c6215712fe Bump Frame Control to 0.4.13 2026-10-24 05:02:27 +00:00
Alex SouthwellandSAPHID cd7ab2d9f7 Import standalone APK/OBB bundles through the current backup flow (#136)
* feat(android): inspect standalone APK and OBB bundles safely

* test: decode Android page fixture as UTF-8

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:01:32 +00:00
Alex SouthwellandSAPHID 4b68971383 Add read-only power and heat sampler for issue #52 (#135)
* Add read-only power and heat sampler for issue 52

* Make power sampler IIO fixture portable on Windows

* test: normalize Windows fixture paths for power sampler

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:02:39 +00:00
Alex Southwell 4aa9f18fd1 Merge pull request #117 from saphid/feat/force-quit
Force quit a running game from Games and the Control Center
2026-10-23 23:47:42 +00:00
SAPHIDandClaude Opus 5.5 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
saphidandClaude Opus 5.5 3f377d8f56 Cast to this screen shows Capture once too, through the shortcuts' showNow
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:54:15 +11:00
saphidandClaude Opus 5.5 9060142f5d Test: a Home shortcut keeps its destination's scroll and loads its tool once, even when hashchange beats the next frame
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:00 +11:00
saphidandClaude Opus 5.5 172851971c Frame Control 0.4.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:25:36 +11:00
saphidandClaude Opus 5.5 48e9ce58ee Home shortcuts show their tool once: pushState instead of a second showPage on hashchange
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:21:39 +11:00
saphidandClaude Opus 5.5 9a5d697579 Merge main (0.4.8: Android game backups) into the four-tab structure; Home's shortcuts focus on the first tap
Main's Import Android game panel joins Quest and Meta under Apps > Browse,
and its Import button (androidBackupOpen, which lived in the old Android
store header and would have thrown at startup here) is now an Add / Install
choice. docs/android-backups.md points to the new places.

Home's shortcuts (Type on the Frame and the rest) now show their tool before
focusing it, so typing works on the first tap and phones open the keyboard
(Sol review P3). A tool panel already in its workspace isn't moved again, which
also dropped focus on the follow-up hashchange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:12:49 +11:00
saphidandClaude Opus 5.5 71ef6a57c7 Service regression tests: Windows has no execute bit to check after an APK rollback
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:04:18 +11:00
saphidandClaude Opus 5.5 be9e7b5cda Merge feat/android-store-redesign (with main) into the Apps / Games / Tools / Settings structure
Keeps the four primary destinations and ports main's newer work into them:
the Home "Right now" panel (volume, cast, quick actions) replaces the old
device block, whose readings now live in the header's headset popover; the
install queue sits above the Apps views; Import from my Quest and My Meta
library sit with Browse; the pad, panel switcher, Mac view and media panels
take main's markup. Scrolling follows main's #scroll container. Settings
stays a tab, so main's header gear is not added. The old .home-status
toggle is gone (main's startup test caught it touching a missing node).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:49:13 +11:00
saphidandClaude Opus 5.5 5c44d56f53 Sentence-case headings and tabs, the headset named once on phones, sideloaded games get Play and a menu
Section headings, the tabs, the brand and the activity bar drop small capitals
for sentence case at one heading size. On a phone with a headset picker, the
connection pill keeps only its status dot so the name shows once. Sideloaded
titles get Play and a ⋯ menu (Add artwork, Remove) like installed apps. Manage
sources moves under Source & compatibility. Library artwork is a Settings
page, so Tools no longer lists it.

Refs #101, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:20:38 +11:00
saphidandClaude Opus 5.5 fed2249776 Apps / Games / Tools / Settings, one Add / Install flow, and the whole-app review fixes
Primary tabs become Apps (Installed / Browse / Linux apps), Games (Installed /
Owned / Store), Tools and Settings, with shortcuts 1-4 and one Add / Install
chooser for catalogs, Steam, Linux apps, files and install links. Installed
rows show identity, one state and Open/Stop; the rest moves to app details.

Review fixes (#106): input, touch, approvals, artwork repair, keyboard helper
delivery and catalogue jobs stay bound to the headset they were meant for;
report preview follows the latest consent; failed Store reads are not an empty
library; keyboard access to device details and the Store sheet; the iOS
bundle ships the artwork renderer and OpenXR resources; typed addresses and
custom SSH ports survive setup; WebKit crashes recover; native preferences
survive relaunch; APK update rollback and repair stay retryable.

Refs #101, #104, #106

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:08:09 +11:00
Alex Southwell 1e44a74165 Merge pull request #110: import Android APK and OBB backups together
Import Android APK and OBB backups together
2026-10-20 04:06:25 +00:00
SAPHID 2a3e52f708 Detect atomic device registry saves with identical timestamps 2026-10-20 03:55:08 +00:00
SAPHID b2485500e1 Import Android APK and OBB backups together 2026-10-20 03:54:17 +00:00
SAPHID c5521a917f fix(ui): reconcile PR 97 with current main 2026-10-18 00:37:46 +00:00
SAPHID a13f4fafab fix(ui): reconcile PC host controls with current main 2026-10-18 00:36:47 +00:00
Alex Southwell f9496d67da Merge pull request #56 from saphid/ui-4-store
Make the Android store look and read like the rest of the app
2026-10-18 11:31:01 +11:00
Alex Southwell 7eae209b1c Merge pull request #55 from saphid/ui-3-home
Lead Home with what's happening, and give settings a page
2026-10-18 11:30:16 +11:00
Alex Southwell 28bc0506a6 Merge pull request #54 from saphid/ui-2-connection
Change the headset's addresses from the connection pill
2026-10-18 11:29:47 +11:00
Alex Southwell 0a4f008d03 Merge pull request #53 from saphid/ui-1-layout
Fit the window at every size and show the battery once
2026-10-18 11:26:14 +11:00
SAPHID ea0b6a80bd Merge main and resolve native host documentation and build triggers 2026-10-18 00:06:01 +00:00
SAPHID a8e5d7cf61 merge: preserve repository logins alongside store refinements 2026-10-18 00:01:10 +00:00
SAPHID 5cdfee4f62 merge: integrate verified connection base into home and settings 2026-10-18 00:00:08 +00:00
SAPHID dd80ca7b74 test(connection): align pill expectations with connection redesign 2026-10-17 23:59:41 +00:00
SAPHID 612a766057 test(layout): provide page helpers in isolated control center fixtures 2026-10-17 23:58:37 +00:00
SAPHID d546568a11 fix(store): preserve resolver cleanup and test lookup budgets deterministically 2026-10-17 23:58:05 +00:00
SAPHID 75acf4a3e9 merge: resolve #56 conflicts with ui-3-home 2026-10-17 22:36:26 +00:00
SAPHID 629a2fafb6 merge: resolve #55 conflicts with ui-2-connection 2026-10-17 22:34:58 +00:00
SAPHID b0d848dc5c merge: resolve #54 conflicts with ui-1-layout 2026-10-17 22:30:09 +00:00
SAPHID 57a9d8c717 chore: resolve conflicts with main 2026-10-17 22:10:19 +00:00
SAPHID fa9ee01cc1 chore: resolve conflicts with main 2026-10-17 22:10:07 +00:00
SAPHID b351fe7ad6 chore: resolve conflicts with main 2026-10-17 22:09:58 +00:00
saphidandClaude Opus 5.5 baaddd7622 Headset store: pending game data belongs to the installed package, in every view, and goes when the app does
From review round 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:48:00 +11:00
saphidandClaude Opus 5.5 07fc7ca3ff Headset store review fixes: ship ui/apk_sources in the Frame bundle; live installed state; GitHub installs remembered; game data its own job; no stuck loading; lookup waits count against the budget
The iPhone app's Frame bundle (also used by store-on-frame.sh) left out
ui/apk_sources, so ui/server.py couldn't start from it; a test now runs
the server's imports from an extracted bundle.

From review round 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:42:09 +11:00
saphidandClaude Opus 5.5 e885e9adba APK Store in the headset on the new store; images load first time; phone layout
- ui/store.html, scripts/store-on-frame.sh and the /store route (built in
  another thread and left uncommitted in the main checkout) join this branch,
  rebuilt on /api/search: the same sources, categories, phone-only rule and
  artwork as the Store, as a 1280x800 laser panel. A category rail with
  Installed, eight art cards a page, a featured VR app on Home, a detail
  sheet with screenshots, install progress and game data, the thumbstick
  turning pages and the on-screen keyboard.
- Artwork: the proxy allowed four name lookups at once and failed the rest
  immediately, so a page of cards lost most of its images until the retry.
  Public answers are now kept for five minutes and a lookup waits for a slot
  within its own budget.
- Phones: one-line shelf headings, a shorter store header, wider shelf
  cards, and a two-column library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:31:34 +11:00
saphidandClaude Opus 5.5 7aa7c55be9 Store categories: "does not require root" isn't a root tool
From review round 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:08:05 +11:00
saphidandClaude Opus 5.5 2554309c7d Store categories: searches always find phone-only apps; messengers and broad-only sources escape a phone side; name-only phone words; hardware the Frame lacks
From review round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:44 +11:00
saphidandClaude Opus 5.5 adc1e44cf1 Store categories: keep useful apps with a phone side, ignore "no root", map IzzyOnDroid's Office; keep sidebar focus; never feature an app that won't run
From review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:10 +11:00
saphidandClaude Opus 5.5 7f46cd54a4 Android tab: a Store with Frame categories and a card Library
The store browsed F-Droid alphabetically, so it opened on proxies, port
checkers and root tools. It now groups apps by what a headset is good for
(VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web &
chat, Tools) from F-Droid's own categories, hides phone-only apps unless
asked, and browses each source's best-illustrated, newest apps first.

- Store: a category sidebar (chips on narrow windows), one shelf per
  category on the home page with no repeats, a featured VR app, and cards
  that show a banner, phone screenshots side by side, or the icon over its
  own blur instead of a letter, plus whether it fits the Frame.
- Library: its own view with art cards, Play, and a menu for Test, Report
  and Remove; filters for VR, flat and running apps; display settings and
  reports below. Artwork comes from the saved source indexes.
- Images: listings register three screenshots per app instead of twelve,
  handles are indexed by URL, and the bound is 16,384, so a large browse no
  longer forgets its own images before the page loads them.
- F-Droid cache version 3 keeps each app's categories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:37:27 +11:00
saphid 87884f382b Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:26:07 +10:00
saphid f98f3e728b Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:21:07 +10:00
saphidandClaude Opus 5.5 90034540ae Keep the toast's fade-in, and join the test's threads on failure
From the review: opening the toast popover with .show already set skipped the
fade-in; it now opens hidden and then shows. And the superseded-test test now
joins its threads in finally, so a failure can't leave them running into
cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:21:07 +10:00
saphid cc9af9eba4 Merge branch 'ui-3-home' into ui-4-store 2026-10-01 23:08:06 +10:00
saphid bc0e0fc043 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 5b4efc87b3 Show toasts above open dialogs, and harden the superseded-test test
From the review of the Reconnect change:

- A toast raised while a dialog was open (Reconnect refused during an install,
  say) sat under the dialog's backdrop, dimmed. The toast is now a manual
  popover, raised again when a dialog has opened since, so it shows on top.
- The new test could wait for ever if the first test never reached its probe,
  and didn't check its threads finished. It now waits on events with time
  limits, releases everything in finally, and also checks the first test
  finishing doesn't mark the second, still running, done.
- docs/devices.md: Reconnect applies your changes by reconnecting; which
  address wins still depends on ranking and timing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 23:03:08 +10:00
saphidandClaude Opus 5.5 c2c1cf0168 Check every JavaScript script type, and keep the name check without acorn
From the review of the parser change: scripts typed text/ecmascript,
application/x-javascript and the other JavaScript MIME types run as classic
scripts too, so they're checked now. And the duplicate-name check (V8 only)
no longer skips with the var check when a Node lacks acorn.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:43 +10:00
saphid 01ba796881 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 b9f96866e5 Replace Use now with a plain Reconnect, and ignore superseded tests
Three review rounds kept finding ways Use now could point at an address a
reconnect wouldn't pick: the page was predicting the outcome of the server's
race from test results that could be unfinished, from another network, or out
of date after a reorder or a Tailscale change. Adding the offered LAN address
first in the list is what makes it win; Use now only hurried that along.

The dialog's Retry button now also shows while connected, as Reconnect: it tries
the addresses again in their current order and promises nothing about which
answers first. The test results go back to plain rows.

The review also found a test started earlier could overwrite a newer one still
running, and mark it done. Each headset's tests now have a generation; only the
newest one publishes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:37:12 +10:00
saphid 3fb876ad5a Merge branch 'ui-3-home' into ui-4-store 2026-10-01 22:37:12 +10:00
saphidandClaude Opus 5.5 9bd6890fe8 Find var declarations with a parser, and check only classic inline scripts
The fix review showed the var regex flagged the word in comments and strings and
missed var/*c*/x and var {x}. The test now uses the acorn parser Node ships
(--expose-internals; it skips if a Node ever drops it) to find real var
declarations, and reads the page with html.parser so it checks inline classic
scripts only: no src, and no module or JSON data type.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:29:12 +10:00
saphidandClaude Opus 5.5 0d181b676e Retry from the hero after a failed install, and keep var out of the page
From the fix review:

- The hero's Install skipped installing whenever a job existed, including one
  that had failed, so it only opened the details. It now tries again after a
  failure, and still only opens the details while installing or once installed.
- Declaring a var twice is legal, so the strict-mode check couldn't see it. The
  page's one var (a block-local in the capture code) is now let, and the test
  rejects var outright (CSS var() in strings is fine). It also checks every
  inline script, whatever its attributes.
- Clear filters still set the removed source filter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:04:52 +10:00
saphid 8de0292568 Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 22:03:10 +10:00
saphidandClaude Opus 5.5 8e8c24f370 Don't break startup at Games links, and reload titles in the iPhone app too
The fix review found the Games reload added to showPage() read `link`, which
the page declares later: opening Frame Control at #games, #library,
#sideloaded or #getgames threw at startup and skipped the rest of the setup.
And the iPhone app never watches the connection, so `link.s` stayed null and
titles still never reloaded there.

The reload now runs only when navigating to Games (the hashchange), where the
first load is already done, and doesn't depend on connection state; a failed
load shows its error in the list as Refresh did. tests/test_page_startup.py runs
the real showPage() at every page and section link with everything declared
later still uninitialised, and fails on the old line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 22:02:53 +10:00
saphid 79dda60bde Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 22:00:27 +10:00
saphidandClaude Opus 5.5 0bb2f9eb03 Offer Use now from the finished test's own order
The fix review found Use now could still point the wrong way: the button was
worked out from per-address ranks in the devices list while the test was still
running (a better-ranked address might yet answer), and the test's successes
changed those ranks before the list was reloaded.

The test now finishes by publishing the order a reconnect on this network would
try, worked out after it has recorded where each address works, together with
the network it ran on. The page offers Use now only once the test is done, only
for that network, and only on the first address in that order that passed. The
ranks in the devices list are gone again. docs/devices.md no longer promises
where a reconnect lands: a slow address loses to a later one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:54:16 +10:00
saphid 30509da71f Merge branch 'ui-3-home' into ui-4-store 2026-10-01 21:32:42 +10:00
saphid d4b34e1bad Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:32:42 +10:00
saphid 22548bc42f Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 21:32:41 +10:00
saphidandClaude Opus 5.5 1863c92101 Show the focus ring on the title form's and display picker's selects too
The fix review found two older rules (#titleForm select:focus, .disp select:focus)
that outranked the new select:focus-visible outline and still removed it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:32:28 +10:00
saphidandClaude Opus 5.5 497b5d9ae6 Don't launch from the hero's Install, and check page names with V8
From the independent review of this PR:

- After installing the featured app, the hero's Install on the Frame took the
  installed branch and launched it on the headset. It now opens the details,
  whose button says Open in Steam, without acting.
- The duplicate-name test matched declarations at column zero only, so an
  indented second loadPanels (the bug it was written for) passed, and so did
  classes, destructuring and later declarators. It now compiles both inline
  scripts as one strict-mode block, where V8 rejects any name declared twice
  in the shared scope, with fixtures for each case and for same-named helpers
  in different functions (which are fine).
- A card's "Won't run" overstated the check's "Might not work"; it now says
  May not run.
- Removed styles and the source-filter state that nothing used any more.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:21:34 +10:00
saphid 04995c066f Merge branch 'ui-3-home' into ui-4-store
# Conflicts:
#	ui/index.html
2026-10-01 21:10:07 +10:00
saphidandClaude Opus 5.5 0bceb5c928 Reload titles on Games, show Home uploads' progress, reset the comfort form
From the independent review of this PR:

- Phones lost the Refresh button, and nothing else reloaded sideloaded titles,
  so one added or removed from another computer never showed. Opening Games
  now reloads them.
- Send files... on Home uploaded with its progress bar in the hidden Tools
  page, and phones have no Activity bar. Choosing files from Home now opens
  the file panel on Tools.
- A session that finished while the page was open left its settings (1 / 0 / 0
  after a test) in the form. The form goes back to its defaults when the
  session ends. The test now covers that, and its mock uses the page's real
  break default (20 minutes, not 30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:09:58 +10:00
saphidandClaude Opus 5.5 22bb9c6469 Offer Use now only where a reconnect goes, and keep newer edits and headsets apart
From the second independent review of this PR:

- Use now sends a plain reconnect, which picks the first-ranked address that
  answers, but it was offered on every tested address listed above the one in
  use. The devices list now carries each address's rank on the current network
  (frame_devices.order_addresses), and only the address a reconnect would pick
  gets the button.
- Saving one address, cancelling, then editing another: the first save's answer
  closed the second editor and lost what was typed. Each edit now has its own
  session, and a late answer leaves a newer one alone.
- Switching headsets with the dialog open drew the address offer from the
  previous headset's status before it was cleared, so Add could save its IP to
  the new headset. The dialog now renders after the old status is cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:03:13 +10:00
saphid fab04f0558 Merge branch 'ui-2-connection' into ui-3-home 2026-10-01 21:03:13 +10:00
saphidandClaude Opus 5.5 c0ae75d56b Keep the headset menu reachable and on screen, and keep focus visible
From the independent review of this PR:

- Without a battery reading the chip was hidden, and with it the only way to
  the headset's details. It now stays, labelled Headset details.
- On a 375 px phone the menu lined up with the chip and ran 82 px off the
  left edge. It now stays 12 px inside the window.
- A long headset name pushed the menu wider; it's now cut short with an
  ellipsis (the full name is in the tooltip).
- Reduced motion turned off smooth scrolling for the page but not for the
  new scroll area.
- Selects lost their focus ring; keyboard focus now shows a blue outline.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 20:56:32 +10:00
saphid 0520082586 Merge branch 'ui-1-layout' into ui-2-connection 2026-10-01 20:56:32 +10:00
saphidandClaude Opus 5.5 36dd5a05f2 Make the offered home address the one that gets used, and keep edits safe
An interrupted review pass pointed at four problems in the pill's dialog, each
confirmed against the Frame:

- The offer to add the Frame's LAN address appended it after the Tailscale name,
  which then kept winning on that network, so nothing changed. The offer now adds
  it first in the list (address-add takes first: true); away from home the
  Tailscale name still leads. A tested address that ranks above the one in use
  gets a Use now button that reconnects through it.
- A half-typed edit was thrown away when the connection state changed after
  focus left the input, and when the server refused the save. The edit row now
  stays until it is saved or cancelled.
- Pressing Enter twice sent the update twice.
- The offer's button could act on the previously selected headset.

Keyboard focus also stays on the same button of the same address when the rows
are rebuilt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 21:28:38 +10:00
saphid 002c040223 Merge branch 'ui-3-home' into ui-4-store 2026-09-30 20:58:23 +10:00
saphidandClaude Opus 5.5 4b1bb1557d Make room in the header for the settings button
With one more button in the header the pill lost its route at a few widths
(970-1020, 1160-1180 and 1350-1410 px). The header's spacing now tightens below
1500 px, the shortcut hints go at 1200, and the compact header starts at 1030,
so the pill reads "Connected · Tailscale" in full at every width above that,
including the 1400 px default window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:58:23 +10:00
saphid b151ae691e Merge branch 'ui-2-connection' into ui-3-home
# Conflicts:
#	ui/index.html
2026-09-30 20:40:03 +10:00
saphidandClaude Opus 5.5 36db6b1ac6 Never cut the pill's status word short
At 1260 to 1400 px wide the header was a few pixels too tight and the pill read
"Connected · Tails…"; on a phone it read "Connected · T…". The status word now
always shows whole: when the route doesn't fit beside it, the route drops out
instead of being clipped. The wordmark gives way a little earlier so the route
shows at every desktop width above 960 px, and the smallest phones lose the
logo rather than squeeze the pill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:43 +10:00
saphidandClaude Opus 5.5 12aaec7888 Keep tool headings on one line and label the battery temperature
Mac in the headset's picture quality moves into the panel with a label, and the
panel switcher's Open in headset button sits under its list, so neither heading
wraps in a narrow column. Headings line up across panels whether or not they
hold a button. An odd last figure in a stats grid spans the row instead of
leaving a hole. The battery menu says which temperature is the battery's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:31:02 +10:00
saphid 7ea999d5b4 Merge branch 'ui-1-layout' into ui-2-connection 2026-09-30 20:31:02 +10:00
saphidandClaude Opus 5.5 9e25ae9025 Make the Android store look and read like the rest of the app
Apps without a banner or screenshot show their icon on a blurred wash of
itself, or a VR or app glyph, rather than a giant letter. Every card says
whether it will run on the Frame (works, should install, not checked yet,
won't run), in place of "Free" on every card.

The featured app isn't repeated in the rows below, and no row repeats
another. Its buttons stay in the app: Install on the Frame opens the details
with the install under way, and Details opens them. It's chosen from apps
that can run, with real artwork, VR first.

The stray "Browse SideQuest" link goes; the Sources dialog lists SideQuest
with a link to its website instead of a switch that did nothing. The "All
sources" drop-down goes too, as the Sources dialog already chooses them.
The copy is plain and British ("Apps and games for the Frame", no "Find
your next favorite"), and Games and Android point at each other.

Two functions were each declared twice across the page's scripts, so the
later one silently replaced the earlier everywhere: the store's details
dialog opened empty (the Devices tab's renderDetail ran instead), and Home's
Desktop view never found the headset's panels (the panel switcher's loader
ran instead). They're renamed, and a test
fails on any top-level name declared twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 16:05:43 +10:00
saphidandClaude Opus 5.5 ed4a0ad5cd Lead Home with what's happening, and give settings a page
Home answers "what's my headset doing, and what do I want to do now". A
Right now card says whether SteamVR is running and holds the volume and the
next things people do: cast to this screen, play a video or photo, show a
Mac window, send files, type on the Frame. With nothing captured, the
headset view is a short strip rather than a big black box; the keyboard and
trackpad fold to one line until turned on; VR performance folds away.
Screenshots and Family and comfort follow.

Tools is grouped: in the headset (Mac in the headset, the media player, the
panel switcher), then sending files, Linux apps, remote and power. Privacy
and updates, library artwork and the assistant move to a Settings page,
opened by a gear in the header (key 6), with About and licences.

Family and comfort no longer fills the form with a finished session's
settings, so it shows 30 / 20 / 30 rather than the last test's 1 / 0 / 0,
and its recent events carry their times.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:57 +10:00
saphidandClaude Opus 5.5 cb0a90a9c3 Change the headset's addresses from the connection pill
The pill says plainly whether the headset is connected and how
("Connected · Tailscale", or the network's name), and never shows a
truncated address. Its dialog now leads with that, then lists the headset's
addresses with what each answered; they can be added, edited, reordered and
removed right there, and a new one is tested straight away. When the Frame
reports a LAN IP on this computer's network that isn't saved, it offers to
add it, so at home the app connects directly rather than over Tailscale.

The connection steps and this computer's network fold away while it's
connected and open when it isn't. Retry now and Set Up Connection only show
when they'd help; the buttons stay in reach when the dialog scrolls.

On the Devices tab the SSH alias, user, port and Forget identity move under
Advanced, and the address kind is worked out from the address. Report a
problem is a speech bubble rather than a warning sign.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:39:52 +10:00
saphidandClaude Opus 5.5 32fbbfe475 Fit the window at every size and show the battery once
The page now scrolls between the header and the Activity bar on a computer,
so the bar never covers content, and nothing overflows sideways from the
760 x 560 minimum up. The header gives way as the window narrows: the
wordmark goes, the tabs tighten, and below 960 px the logo, Refresh (R still
works) and the pill's second line go. Phones keep scrolling the window.

The battery shows once, in the header chip. Clicking it opens a menu with
the headset's storage, memory, temperature, Wi-Fi, uptime, SteamOS build and
services; the big battery card is gone from Home, and the VR performance
table no longer repeats battery and temperature.

Heading rows wrap their buttons onto a new line instead of squeezing the
heading, and every drop-down is dark.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:19:19 +10: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
169 changed files with 24740 additions and 962 deletions

No files matched your search

+1
View File
@@ -22,6 +22,7 @@ desktop or panels.
| See the Frame from the Mac, or the Mac inside the Frame | `docs/streaming.md` | `scripts/run-on-frame.sh mac-screen` |
| Files and clipboard | `docs/file-transfer.md` | `scripts/push.sh`, `scripts/paste-to-frame.sh` |
| Android apps (Lepton) | `docs/apks.md` | `scripts/install-apk.sh` |
| Browse and install Android apps from inside the headset (APK Store panel) | `docs/apks.md` | `scripts/store-on-frame.sh` |
| Reach the Frame off the home LAN (Tailscale) | `docs/tailscale.md` | `scripts/tailscale-on-frame.sh` |
| Install or buy Steam games, Frame ratings | `docs/steam-games.md` | `ui/frame_steam.py` |
| Flatpaks | `docs/streaming.md` | `scripts/install-apps.sh` |
+2 -2
View File
@@ -43,8 +43,8 @@ jobs:
run: python -m unittest discover -s tests -v
- name: App syntax
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
- name: Updater tests
run: node --test app/test/updater.test.js
- name: App tests
run: node --test app/test/*.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
+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/
+55
View File
@@ -0,0 +1,55 @@
# Changelog
## Unreleased
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
existing preview and game-data setup flow. Unsafe archives, split sets and
Android/data restores are refused with guidance. This does not establish RE4
compatibility or diagnose its reported failure (issue #102).
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
battery, CPU, named temperatures, fan and available power rails (issue #52).
JSON samples/summaries and CSV exports retain explicit unavailable states;
active smoke-test locks stop measurement. No power settings are changed.
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
still apply. Public copies use this computer's reports, not private shared ratings.
- Fix two cases where optional diagnostic logs could retain part of a headset
display name or an SSH host name, while preserving existing privacy redaction
(issue #81).
- Keep the full host-key recovery explanation readable in Devices, including
long addresses, and remove doubled punctuation from the connection tooltip
(issue #87). Refresh stays within the header and switching headsets selects
the new headset's details.
- Fix experimental PC screen capture on Windows builds whose streaming libraries
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
windows are hidden with an explanation instead of failing when shown. A Windows
VM streamed a real desktop to the Frame; physical input remains unverified.
- Add read-only battery health in Tools: charging state, temperature, cycle count,
and available capacity with explicit unknown states. The parser was checked
on a real Frame; the connected desktop UI was tested with synthetic readings.
- Add Tools → Game storage: read-only headset filesystem capacity and Steam
library/game install-size breakdown, with unknown sizes, partial inventories
and permission/missing-drive states. Includes instructions for Steam's own
Move UI; Frame Control does not move or delete game files. The read-only
parser was checked on a real Frame; the connected UI and game moves were not.
- Add `scripts/frame-doctor.sh` for read-only setup and pairing diagnostics,
with verified SSH identity, private summaries and actionable next steps
(issue #34). Connection steps links to the diagnostic guide. Repairs remain
manual; restart-loop evidence stops deeper diagnostics. The real Frame
verified the SSH connection and safety stop; deeper checks were skipped.
- Fixed headset smoke cleanup reporting success while a smoke-title process
could remain running after its files were removed. Cleanup now stops and
rechecks processes tied to exact reserved test-title paths, uses PID handles
for safe TERM/KILL escalation, and records stopped PIDs. Unrelated titles and
shared Wine services are left alone. Verified with local Linux process
regressions and a 13/13 real Frame smoke run that stopped five test-owned
Proton process PIDs before removing the Windows test title.
+22 -8
View File
@@ -44,13 +44,16 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
For repeatable read-only battery, power-rail, temperature and fan measurements,
use the [power and heat sampler](docs/power-and-heat.md).
</td>
</tr>
<tr>
<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 +66,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 +92,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>
@@ -189,7 +200,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
This is a first public test, so reports are really useful, especially from
Windows and Linux. The quickest way is **Report a problem** in the app (the
warning-sign button at the top, or **Help → Report a Problem…**). It adds
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
diagnostics with personal details removed, shows you exactly what's included,
and sends it privately to the maintainer; nothing is published. Without the app,
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
@@ -218,6 +229,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 +278,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.
+10
View File
@@ -11,6 +11,7 @@ const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { preferenceStore } = require("./preferences");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -28,6 +29,7 @@ const LOG_DIR = IS_MAC ? path.join(os.homedir(), "Library", "Logs", "Frame Contr
const LOG = path.join(LOG_DIR, "server.log");
const BG = "#0d1117";
const FRAME = process.env.FRAME_ALIAS || "frame";
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
let server = null;
let url = null;
@@ -280,6 +282,14 @@ 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.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.7",
"version": "0.4.13",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+12 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.7",
"version": "0.4.13",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -39,6 +39,7 @@
"preload.js",
"install-link.js",
"updater.js",
"preferences.js",
"meta-signin.js",
"package.json",
"build/icon.png"
@@ -197,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,
+27
View File
@@ -0,0 +1,27 @@
// 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", "pcView"];
const valid = value => value === "0" || value === "1";
function preferenceStore(file) {
function read() {
try {
const saved = JSON.parse(fs.readFileSync(file, "utf8"));
if (!saved || typeof saved !== "object" || Array.isArray(saved)) return {};
return Object.fromEntries(KEYS.filter(key => valid(saved[key])).map(key => [key, saved[key]]));
} catch { return {}; }
}
function write(key, value) {
if (!KEYS.includes(key) || !valid(value)) throw new Error("Invalid preference");
const saved = { ...read(), [key]: value };
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = file + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(saved), { mode: 0o600 });
fs.renameSync(tmp, file);
return true;
}
return { read, write };
}
module.exports = { preferenceStore };
+7 -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
@@ -11,8 +11,13 @@
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
preferences: {
read: () => ipcRenderer.invoke("preferences:read"),
write: (key, value) => ipcRenderer.invoke("preferences:write", key, value),
},
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
+68
View File
@@ -0,0 +1,68 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { preferenceStore } = require("../preferences");
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-preferences-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return path.join(dir, "client-preferences.json");
}
test("desktop packaging includes every local application module dependency", () => {
const root = path.resolve(__dirname, "..");
const packaged = new Set(require("../package.json").build.files);
for (const file of ["main.js", "preload.js"]) {
const source = fs.readFileSync(path.join(root, file), "utf8");
for (const match of source.matchAll(/require\(["'](\.\/[^"']+)["']\)/g)) {
const target = match[1].slice(2) + ".js";
assert.ok(packaged.has(target), `${file} needs packaged ${target}`);
}
}
});
test("client choices survive a new native session and local server origin", t => {
const file = fixture(t);
const first = preferenceStore(file);
assert.deepEqual(first.read(), {});
first.write("mvSeparate", "0");
first.write("padUsed", "1");
// A new process/session reads the same client store, regardless of its URL.
const second = preferenceStore(file);
assert.deepEqual(second.read(), { mvSeparate: "0", padUsed: "1" });
second.write("mvSeparate", "1");
assert.deepEqual(first.read(), { mvSeparate: "1", padUsed: "1" });
});
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/);
}
assert.equal(fs.existsSync(file), false);
fs.writeFileSync(file, JSON.stringify({ token: "discard", mvSeparate: "0", padUsed: 1 }));
assert.deepEqual(store.read(), { mvSeparate: "0" });
store.write("padUsed", "1");
assert.deepEqual(JSON.parse(fs.readFileSync(file, "utf8")), { mvSeparate: "0", padUsed: "1" });
});
test("a missing or damaged preference file retains usable defaults", t => {
const file = fixture(t), store = preferenceStore(file);
assert.deepEqual(store.read(), {});
for (const bad of ["{broken", "null", "[]", '"text"']) {
fs.writeFileSync(file, bad);
assert.deepEqual(store.read(), {});
}
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
+1 -1
View File
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
## Assistant panel
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
To put the same page in the headset, with the HTTP server still running:
```sh
+57
View File
@@ -0,0 +1,57 @@
# Import an Android game backup
A SideQuest backup can contain a small APK and much larger `main.…obb` and
`patch.…obb` files. All of them belong to the same game. Installing only the APK
can leave the game unable to start because its assets are missing.
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
You can also drop them anywhere in the window. In the desktop app, dropping the
APK alone finds matching OBB files beside it; dropping a folder containing the
APK and OBBs also works. Browsers can read only the files you explicitly choose.
The import screen shows the game app and its game-data files together. **Import
game & data** installs the APK, starts its own Steam library instance, waits for
Android and the installed package, copies the OBBs, and closes the game. Open it
again from the Steam library to play. The screen explains this before import;
choosing files alone never starts a game.
Files are matched using the package inside the APK and the standard
`main|patch.<version>.<package>.obb` name, not the APK's filename. Both main and
patch files are preserved. OBB version numbers need not equal the APK's current
version code: updates can reuse older expansion files. The copy uses the existing
SHA-256 transfer check inside Android and writes to that game's own
`/sdcard/Android/obb/<package>` folder.
If no data files are selected, the screen reminds you to include any OBBs in the
backup before offering **Install game app**. An APK cannot reliably tell Frame
Control whether separate expansion files are required. Game-data files are never
silently sent to Downloads by this flow.
If setup fails, the import screen keeps the error and retry instructions visible.
The APK may already be installed, but the game is not reported ready. Import the
same backup again, or choose just its OBB files to add data to an existing install.
The importer checks the installed package and version before writing data and
closes the instance after a failed copy. Unrelated or duplicate files, running
games that would be replaced, and downgrades are rejected. Uploaded staging files
are discarded after use or cancellation; abandoned imports expire after an hour
when the importer is next used, and dead-server staging folders are swept at
startup.
This flow handles one ordinary APK and standard OBB files for one game at a time.
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
archives need their own import handling. PC ZIP/folder/EXE imports keep their
existing route. Quest USB and Meta library imports keep their existing workflow.
Validation covers actual APK parsing, the three-file backup layout, package
matching, sibling discovery, browser upload staging and cleanup, instance/version
guards and failure recovery with mocked headset operations. Browser verification
uses the real page and upload/inspection endpoints with simulated installation.
**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`:
F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic
install/start/copy/close sequence. Reopening its persistent instance confirmed
both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app,
shortcut and instance were removed afterward, the shared device lock was
released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json).
The headset was unworn; this checks transfer and persistence, not rendering,
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
flow does not change APK compatibility or remove licence checks.
+132
View File
@@ -183,6 +183,92 @@ See
[compat-db/README.md](../compat-db/README.md) and
[apk-catalog/README.md](../apk-catalog/README.md).
## Store and Library in Frame Control
The Android tab has two views. **Store** finds apps across the sources (F-Droid,
IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on the
Frame as cards, with Play and a menu for Test, Report and Remove; the display
settings and recent reports are below it.
Store search cards and app details also show **Reported not working** or
**Reported issues** when saved compatibility reports say so. Details include
the report date, version and notes. These are advisories: Install remains
available unless the selected build has an existing compatibility blocker
(for example, no arm64-v8a build or Android API above 30). Reports never clear
those blockers. Apps reported not working are excluded from Home's featured
app and recommendation shelves; search and category results still show them.
An unrated install failure caused by the wrong APK architecture or Android API
is ignored, using the same rules as the catalogue. A person's rating takes
priority over automated results, with the latest rating winning.
Reports warm in a background worker at server start. Search and details read
only the last complete in-memory snapshot and schedule a single background
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
the report database. During initial loading they show the usual build verdict;
after a refresh, the next search or detail request sees the reports. Failed
refreshes keep the previous snapshot and retry after another 60 seconds.
**Availability:** public copies benefit from their own saved reports, including
reports for apps outside the bundled catalogue, across every source with the
same package name. This does not give public users the private shared ratings.
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
report verdicts, so it cannot supply a useful public report snapshot. No private
reports, free-text notes or credentials are exported or published. The existing
maintainer loader still uses its configured database access in the background;
the search feature adds no sharing, endpoint or network destination. Tests leave
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
server subprocesses) and use synthetic reports to exercise refresh behavior.
The store groups apps by what a headset is good for, not by F-Droid's list:
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
F-Droid's own categories decide, the finer one first (an app in *Multimedia* and
*Social Network* is Web & chat).
Apps built around a phone are hidden from browsing unless **Show phone-only
apps** is on; a search always finds them, labelled *Phone app*. Hidden: apps in
a category that only makes sense in a phone's own system (launchers, keyboards,
wallpapers, Xposed modules), apps in a phone-side category (calls and SMS, VPNs,
fitness, alarms, system tools) unless a useful category says otherwise
(FluffyChat is in Phone & SMS, but it's a messenger), apps named for a phone
part (a widget, a launcher, a battery tool), and apps that need hardware the
Frame lacks (an IR blaster, a watch, NFC, root). They would install and run in
Lepton, but do nothing useful on the Frame. In the October 2026 indexes that is
about 1,600 of F-Droid's 4,500 apps and 550 of IzzyOnDroid's 1,300.
Without a search, each source offers its best 300 for the chosen category:
apps with a banner first, then with screenshots, then the newest.
## The store in the headset: Frame Control's store as a panel (inferred, not yet run on the Frame)
The **APK Store** panel is the Store above, drawn for the laser: the same
sources, categories and artwork, with Install, Open, Stop and Remove, opened as
its own floating panel:
```sh
scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
scripts/store-on-frame.sh close
```
`open` copies the same bundle the iPhone app uses
(`ios/scripts/make_frame_bundle.py`) to
`~/.local/share/frame-control/store-server/<version>` on the Frame. It then
starts `ui/server.py` there with `FRAME_LOCAL=1` on `127.0.0.1:47812`, with a
fresh `X-Frame-UI` key. Chromium XR (or the `org.chromium.Chromium` Flatpak)
opens `/store` (`ui/store.html`) in a 1280×800 app window, tagged as panel
`2001947812` ([panels.md](panels.md)). Downloads, SHA-256 checks and installs
all run on the Frame, so the Mac can go away after `open`.
The page has a category rail (Home, the store's categories, Installed), eight
art cards a page, and a featured VR app on Home. It turns pages instead of
scrolling, because a tagged panel gets mouse clicks and a drag would select
text; the thumbstick (a mouse wheel) turns pages, and an on-screen keyboard is
there for search. **Checked on the Mac only** (2026-10-14, 1280×800, against
the real sources): Home, categories, Installed, the detail sheet. **Still to
check in the headset:** that the panel opens and takes laser clicks, that an
install completes when run on the Frame, and that **Open** brings up the app's
panel.
## In-headset app store: F-Droid 1.17 (verified 2026-09-25)
F-Droid 1.23 uses an old Compose and crashes on launch. **F-Droid 1.17.2**, the
@@ -345,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
Confirming authorizes installing the app, starting it to prepare Android,
checking the running package/version, copying OBBs through the existing
verified data helper, and closing the game. Open it from Steam afterwards.
If setup fails, read the error and retry the same backup; do not uninstall
first, since that deletes saves. An app that cannot start may also prevent
data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
and runtime compatibility remain unknown. Record those details, Frame Control
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
Successful installation or fixture tests do not establish RE4 compatibility;
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
+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`
+20 -3
View File
@@ -3,8 +3,9 @@
Frame Control can manage more than one Steam Frame, and each headset can be
reached at more than one address: a LAN IP at home, another at the office, its
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
tab (key 5) lists them, and the connection pill in the header shows what the
app is doing to reach the one in use, step by step, as it happens.
tab (key 5) lists them. The connection pill in the header says whether the one in
use is connected and how (Tailscale, or the network's name); click it to add,
edit or reorder that headset's addresses, or to see each step of connecting.
The code is in three modules, all stdlib-only Python on your computer:
@@ -57,7 +58,7 @@ and ranks them:
5. addresses that only ever worked on other networks;
6. Tailscale addresses while Tailscale is off.
Your order on the Devices tab breaks ties. The best-ranked address that answers
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
still trying. If SSH to the winner fails in a way another address could fix
(a different device answered there, or the link dropped), the next one that
@@ -72,6 +73,16 @@ status --json`, including the Mac app's own CLI) with buttons to add their
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
services and checks `ALIAS.local` and `frame.local`.
**The pill's dialog** lists the same addresses, with what each one answered, and
can add, edit, reorder and remove them without leaving the page you're on. When
the headset reports a LAN IP on the same network as this computer and that IP
isn't saved, it offers to add it. The offer puts the address first in the list,
so on that network it wins over the Tailscale name; away from home the Tailscale
name still leads. A new or edited address is tested straight away. While
connected, **Reconnect** applies your changes now rather than at the next
connection: it tries the addresses again, ranked as above, and the best-ranked
one that answers promptly wins. It doesn't pick a particular address.
## Networks
A network is told apart by its default gateway: the router's IP address plus its
@@ -86,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
## The connection, stage by stage
When a connection fails, the header pill keeps its compact offline state and
retry countdown; its tooltip and connection dialog contain the full reason.
Devices shows a short reason in the headset row (with the full reason in its
tooltip) and a wrapping recovery explanation in the selected headset's details.
**Use this headset** also selects that headset's details.
The connector runs in the server (`frame_link.Link`) and moves through:
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
+34
View File
@@ -0,0 +1,34 @@
{
"date": "2026-10-20T03:53:25Z",
"build_id": "20260925.6191901",
"app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)",
"input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050",
"sibling_discovery": true,
"automatic_import": "passed",
"closed_after_import": true,
"independent_readback": [
"5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb",
"aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb"
],
"cleanup": {
"test_install_removed": true,
"running_instances_before": {},
"running_instances_after": {},
"battery_before": [
"100",
"Full",
"Charging"
],
"battery_after": [
"99",
"Discharging",
"Charging"
],
"shared_lock_released": true
},
"limits": [
"No headset rendering or gameplay assertion; headset unworn.",
"Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.",
"A game that exits before its Lepton instance can accept data was not tested."
]
}
+149
View File
@@ -0,0 +1,149 @@
{
"date": "2026-10-24",
"osVersion": "0.5.6",
"osBuild": "20261015.6141207",
"commands": [
"scripts/frame-power-sample.sh --count 3 --interval 1",
"scripts/frame-power-sample.sh --count 1 --csv"
],
"jsonSampleCount": 3,
"elapsedSeconds": [
0.078011,
1.088542,
2.107291
],
"stopped": null,
"unavailableGroups": [
[
"proximity"
],
[
"proximity"
],
[
"proximity"
]
],
"selectedSummary": {
"battery.percent": {
"unit": "%",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 99,
"max": 99,
"mean": 99.0,
"first": 99,
"last": 99
},
"battery.status": {
"unit": "state",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"states": [
"Not charging"
]
},
"battery.tempC": {
"unit": "C",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 26.400000000000002,
"max": 26.400000000000002,
"mean": 26.4,
"first": 26.400000000000002,
"last": 26.400000000000002
},
"battery.watts": {
"unit": "W",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": -0.12,
"max": -0.02,
"mean": -0.07,
"first": -0.02,
"last": -0.07
},
"cpu.percent": {
"unit": "%",
"sources": [
"/proc/stat"
],
"available": 2,
"unavailable": 1,
"min": 8.7,
"max": 11.0,
"mean": 9.85,
"first": 11.0,
"last": 8.7
},
"fan.pwmRaw": {
"unit": "raw",
"sources": [
"/sys/class/hwmon/hwmon50/pwm1"
],
"available": 3,
"unavailable": 0,
"min": 41,
"max": 41,
"mean": 41.0,
"first": 41,
"last": 41
},
"fan.slg4ax46073v.fan1_input": {
"unit": "RPM",
"sources": [
"/sys/class/hwmon/hwmon50/fan1_input"
],
"available": 3,
"unavailable": 0,
"min": 8624,
"max": 8638,
"mean": 8628.666667,
"first": 8638,
"last": 8624
},
"proximity.raw": {
"unit": "raw",
"sources": [
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
],
"available": 0,
"unavailable": 3,
"states": []
},
"rail.max34417_9_10.vph.power1": {
"unit": "W",
"sources": [
"/sys/class/hwmon/hwmon53/power1_input"
],
"available": 3,
"unavailable": 0,
"min": 4.656299,
"max": 4.804066,
"mean": 4.705555,
"first": 4.656299,
"last": 4.804066
}
},
"csvRows": 92,
"csvSummaryMetrics": 91,
"csvHasExplicitUnavailable": true,
"limits": [
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
"No settings, services, device files or application state changed; no remote installation.",
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
]
}
+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)
+1 -1
View File
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
## Casting
**Cast headset view** starts the existing headset Live view and requests full
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
screen where supported. Show that screen to people in the room, or use the
computer/phone's own screen mirroring. It creates no new stream transport,
public URL or LAN server. iPhone uses the inline viewer if full screen is not
+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.
+49 -15
View File
@@ -17,15 +17,21 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
The window has six tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
the catalogue, display settings, reports), **Tools** (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. Files can be dropped anywhere in the window. A
connection pill in the header always shows which headset, which network this
computer is on, the address in use or being tried, and each step of connecting
as it happens; click it for the whole timeline. When the Frame can't be
The window has six tabs: **Home** (what the headset's doing and what you'd do
next, the headset view, screenshots, family and comfort, the keyboard and
trackpad, and VR performance, folded away), **Games** (installed games,
sideloaded titles, getting games), **Android** (apps, the catalogue, display
settings, reports), **Tools** (in the headset: your computer's windows, the media
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, 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
connection pill in the header always shows which headset and whether it's
connected, and how (Tailscale, or the network's name). Click it to add, edit or
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
reached, a banner says why in plain words, what was tried, and counts down to
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
@@ -43,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
@@ -74,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
@@ -121,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,
@@ -133,7 +167,7 @@ rejects invalid types, out-of-range levels and invalid mute states before SSH.
Other settings link to the existing forms and their validation, progress,
confirmations and reset behavior: per-instance Android resolution/density/text,
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
sources and artwork. Android display links reveal **On your Frame** before
sources and artwork. Android display links reveal the **Library** before
scrolling to the form. Mac streaming remains searchable on all platforms, with
its action shown only when the server is on macOS.
@@ -214,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
@@ -239,5 +273,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
require a separate user approval; power also retains its password prompt. The
assistant uses a user-chosen endpoint and sends nothing until the user opts in
for a message. Screenshot context is separately opt-in. Model replies cannot
operate the headset. Tools → Open assistant opens the page; the linked guide
operate the headset. Settings → Open assistant settings opens the page; the linked guide
covers putting it in a Chromium panel on the Frame.
+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)
+5 -4
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.
@@ -114,8 +115,8 @@ don't.
## Family and comfort
The shared Home card sets session limits, breaks and check-ins, and offers
**Cast headset view**. **Enable / test notifications** requests iOS notification
The shared Home card sets session limits, breaks and check-ins; **Cast to this
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
permission and sends a local test. These are local notifications, not APNs push;
iOS background suspension can interrupt phone alerts. The headset timer still
runs. See [the behavior and verification limits](family-comfort.md).
+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
+1 -1
View File
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
separate from Frame Control. Public reports describe some Proton support;
Windows-only does not by itself prove a Frame app cannot run. Local status
and sources are in [VR utilities](vr-utilities.md).
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
headset creates its own gamescope panel using built-in tools. It needs no
third-party overlay app. [Metrics and verification](vr-utilities.md).
+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.
+122
View File
@@ -0,0 +1,122 @@
# Read-only power and heat measurements
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
over the existing `ssh frame` alias. It needs Python 3 on both computers and
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
and batch mode; it never pairs, prompts for a password or changes SSH security.
```sh
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
scripts/frame-power-sample.sh > power.json
# Five-minute observation: samples at 0, 5, ... 300 seconds:
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
# Long-form CSV samples; JSON summary goes separately to stderr:
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
```
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
are target start-to-start intervals, not guarantees: read latency can delay a
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
The first sample has no CPU utilization: two valid `/proc/stat` observations
are required. CPU utilization then covers the interval between observations,
averaged across the machine, rather than the utilization of one process.
## What it reads
- Battery percentage, driver charging state, signed battery watts and battery
temperature, using the same parser as Frame Control's battery details.
Positive battery watts mean charging on the Frame fuel gauge; negative means
discharge. `Not charging` alone does not prove a charger is connected.
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
- Named thermal zones in °C, with zone names retained. A thermal zone is not
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
temperature threshold.
- hwmon power channels, labels and driver names in watts. `vph` is the measured
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
readings; do not sum all channels as if they were independent power consumers.
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
is assumed. The historical Frame driver reported a percent-like value of 41.
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
worn/unworn threshold.
Each metric includes its unit, source path and availability state. Missing,
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
lists, logs, screenshots and application inventories are not collected.
The JSON report contains raw samples and a summary per metric: available and
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
observed charging states. Means exclude unavailable readings. They are sample
means, not time-weighted energy or battery-runtime estimates. Different sample
availability and intervals must be considered when comparing sessions.
Numeric validity does not establish sensor accuracy. Historical readings
already showed an apparently absent/broken right-display thermistor near
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
Those raw numbers remain visible for diagnosis; neither should be used to
infer cooling performance or safety. This tool does not diagnose heat, explain
a spike, predict battery health, or promise power savings.
## Safety and shared-device use
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
The Python sources are sent on SSH stdin and not installed on the headset.
There is no sudo, OpenVR initialization, service call, settings write, process
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
automatic measurement is added. Redirects above write only to your computer.
The observer itself uses a little CPU and SSH traffic, so label baseline
conditions and avoid interpreting very small changes as savings.
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
the Frame before any probe and before each sample. If a smoke session is active,
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
samples, sets `stopped`, and exits 75. It never removes or acquires another
session's lock. This is an advisory check, not an atomic lease: a smoke session
can start during a file read. Coordinate sessions and defer measurement when
timing overlaps. It does not block someone else from starting a smoke test.
SSH failures/timeouts return a nonzero exit and an explanation, without
presenting a zero or an old report as a successful measurement. Fully missing
sensors can still produce a successful report with explicit unavailable states.
On a connection failure, partial samples buffered on the headset can be lost.
## Evidence and remaining work
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
fan, GPU clock, board temperatures and charger current through the final
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
see [the recorded results](evidence/power-sampler-2026-10-24.json).
No smoke lock was present at the preflight or sampler checks. No settings,
services, application state or device files were changed. This short run is
a parser/transport check, not a controlled baseline or a worn-headset test.
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
differ from the current check; comparisons do not establish a regression.
This change completes only the sampler portion of “Plan: leave it on, but
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
are separate work. No controls are offered here. Follow the shared-device
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
Offline validation:
```sh
python3 -m unittest discover -s tests -p test_power_sample.py -v
bash -n scripts/frame-power-sample.sh
python3 -m py_compile scripts/frame_power_sample.py
```
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
readings, named/discovered channels, unknown charging state, CPU warmup,
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
+16 -5
View File
@@ -132,8 +132,8 @@ runs.
## Report a problem
**Report a problem** is the warning-sign button in the header, also in the
Privacy panel and under **Help → Report a Problem…**. It sends the report
**Report a problem** is the speech-bubble button in the header, also in the
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
in some other wording can still get through, so check the log lines in **Show
exactly what's included** before sending. A failure that repeats on every
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
are hidden even when they contain a scrubber token such as `<host>` or overlap
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
and keys. A host name ssh mentions in some other wording can still get through,
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
retry is written at most once every 5 minutes.
Everything is scrubbed like error details and limited to what fits in the
@@ -287,3 +289,12 @@ then every 6 hours: the latest release's `update.json` on GitHub, or
`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
Those requests carry no id. To stop it, set
`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
## Screenshots and clips
Copying screenshots and Steam clips to your computer, by hand or with
**Settings → Copy to this computer**, goes straight from the headset to your
computer over the same SSH connection as everything else. Nothing is uploaded
anywhere. What automatic copying has copied is listed, by file id, in
`auto-sync.json` in Frame Control's data folder; see
[recording.md](recording.md#copying-new-ones-automatically).
+165
View File
@@ -0,0 +1,165 @@
# Recording and Steam clips
Frame Control can record what the headset shows, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
Both are on **Tools → Capture & Live**. Everything is saved to
`~/Videos/SteamFrame`, next to the screenshots in `~/Pictures/SteamFrame`.
Nothing needs installing on the computer or on the Frame: the work is done by
the `ffmpeg` that SteamOS ships.
**Recording is tested on a headset; Steam clip export isn't yet.** On a Frame
running SteamOS 0.5.6 (build 20261015.6141207), Record and Stop saved an
8-second 1080p H.264 recording with AAC sound to the computer, Stop took about a
second, and nothing was left running or stored on the Frame. That Frame had no
Steam recordings, so the clip list came back empty and export has only been
tested with files in Steam's format made by `ffmpeg`. The parts we're least sure
of are listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
Press **Record** next to **Live**. The button turns into **Stop**, and a line
under the viewer counts the time. Press **Stop**: the recording is finished on
the Frame, converted to `.mp4` there (without re-encoding) and copied to
`~/Videos/SteamFrame/rec-<date>-<time>.mp4`. The line says when it's saved.
- **What's in it**: the same picture as **Live**, SteamVR's copy of the headset
view (one undistorted 1920×1080 eye, from `/dev/video99`), at 30 fps and
8 Mbit/s, encoded with x264 (the Frame's hardware encoder crashes `ffmpeg`).
The sound is what the headset plays: the monitor of PipeWire's default
output, read through its PulseAudio server (`-f pulse -i @DEFAULT_MONITOR@`).
The microphone isn't recorded.
- **Without sound**: before each recording Frame Control checks for a fraction
of a second that `ffmpeg` can read that audio. If it can't, the recording goes
ahead as video only, and the page and the saved message say so.
- **Limits**: a recording stops by itself after 20 minutes and is saved as if
you'd pressed Stop. It needs about 2.9 GB free on the Frame to start (room for
a full-length recording twice, while it's converted), and checks the space on
your computer before copying. One recording runs at a time.
- **Where it waits on the Frame**: `~/.cache/frame-control/video/`, not `/tmp`,
which is a RAM disk on SteamOS. It's deleted once copied.
- **If the connection drops or the app quits** while recording, the Frame
stops `ffmpeg` cleanly when the SSH channel closes: it types `q` into
`ffmpeg`'s input, as at its keyboard, and kills it if it's still there 10
seconds later. Not a signal: SteamOS's `ffmpeg` 7.0 catches SIGTERM and
SIGINT and carries on recording. A recording that was never copied is
deleted when the next one starts.
Recording and **Live** can run together; both read the same device.
## Steam clips
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
clips and background recordings, newest first, with the game, length and size.
**Export** joins one into a single `.mp4` in `~/Videos/SteamFrame`, named after
its folder (for example `clip_620_20261020_202134.mp4`), however long it is.
Steam's own export is reported to stop at about 60 seconds. Exports run in the
background; ones already on your computer are marked.
How it works: Steam keeps each recording as MPEG-DASH segments, one folder per
recording session.
```
~/.local/share/Steam/userdata/<account>/gamerecordings/
video/bg_<appid>_<YYYYMMDD>_<HHMMSS>/ background recording
clips/clip_<appid>_<YYYYMMDD>_<HHMMSS>/video/bg_<...>/ a saved clip
session.mpd
init-stream0.m4s chunk-stream0-00001.m4s chunk-stream0-00002.m4s ...
init-stream1.m4s chunk-stream1-00001.m4s ...
```
Each stream's `init-stream` segment followed by its chunks, in number order, is
a playable fragmented MP4 of that stream. On the Frame, `ui/frame_clips.py`
builds those files and has `ffmpeg` put the video and audio into one `.mp4`
with `-c copy`, so nothing is re-encoded and it takes seconds. The result is
copied to the computer and deleted from the Frame. The export needs free space
on the Frame of about twice the recording's size while it runs.
The listing doesn't rely on the folder names: any folder under `clips/` or
`video/` (up to four levels down) holding `init-stream*.m4s` counts. A stream
missing its init segment or its chunks is left out. Missing chunk numbers are
shown as "pieces missing"; the rest still exports.
## Copying new ones automatically
**Settings → Copy to this computer** copies new screenshots and Steam clips
here on their own. It's off until you turn it on, with a switch each for
screenshots and Steam clips. While a headset is connected, Frame Control looks
about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
- **Deleted stays deleted.** What it has copied is remembered, per headset, in
`auto-sync.json` in Frame Control's data folder, so a file you delete here
isn't copied again (**Copy everything now** would bring it back).
- **Saved clips only.** Steam's background recording is a rolling buffer, not
something you chose to keep, so it isn't copied; export it by hand. A clip
Steam wrote to in the last 30 seconds is still being saved and waits for the
next look.
- **It gives way.** It waits while an install, an export, a recording or
anything else is running on the headset, and stops between files if one
starts. While a copy is running, switching headsets waits for it, as it does
for an export. When the headset can't be reached it waits longer each time,
up to 15 minutes, and starts over once it answers.
- **When this computer is full** (under 512 MB free, or a copy runs out of
space) it pauses and says so in Settings and above the lists. Free some space
and press **Resume**.
- **With several headsets** it copies from the one you're connected to.
Settings shows when it last checked, how many files it last copied and when,
and the last problem it had. The screenshot and clip lists say when it's on.
## For developers
`ui/frame_clips.py` holds the logic, so other features can reuse it (automatic
copying, in `ui/frame_autosync.py`, does):
- `list_clips(run_frame)` and `export_clip(id, run_frame, run, fetch)` are the
computer's side; the server passes how to reach the Frame.
- `list_recordings(userdata)` and `export(userdata, id)` are the Frame's side,
run as `python3 - list` / `python3 - export ID` with the file on stdin.
- `Recorder` runs one recording from Start to the copy.
- `parse_id` checks a clip id (`<account>/<clips|video>/<folder>...`, plain
names only, no `.` or `..`) before it reaches a path or a shell.
The API, with the same guards as every other request:
| Request | Does |
|---|---|
| `GET /api/record` | The recording's state: `idle`, `starting`, `recording` (with `elapsed`, `audio`, `limit`), `stopping`, `saving`, `saved` (with `file`) or `failed` (with `error`) |
| `POST /api/record {"action": "start"\|"stop"}` | Start or stop |
| `GET /api/clips` | Steam's recordings on the Frame, each with `saved` |
| `POST /api/clips/export {"id"}` | Starts an export; poll `/api/job` |
| `POST /api/open {"what": "videos"}` | Opens `~/Videos/SteamFrame` |
| `GET /api/autosync` | Automatic copying: `screenshots`, `clips`, `since` (the watermark), `checked`, `copied`, `copied_time`, `error`, `paused`, `waiting` (clips still being written) |
| `POST /api/autosync {"action": "settings", "screenshots", "clips"}` | Turn either on or off; turning it on sets `since` to now |
| `POST /api/autosync {"action": "now"\|"resume"}` | Copy everything not here yet, once; or resume after running out of space |
They aren't available when the server runs on the Frame itself (the iPhone
app), since they save to a computer.
Tests: `tests/test_clips.py`, and `tests/test_autosync.py` for automatic
copying (with a fake headset). They run the recording's shell under bash with a
stand-in `ffmpeg`, so Stop, the time limit and the clean-up are tested as they
run on the Frame. Where `ffmpeg` is installed, they also make a 65-second DASH
recording with Steam's file names and export it with the real `ffmpeg`.
## What hasn't been checked
The 2026-10-24 read-only clip list on build `20261015.6141207` again returned
zero clips: its `gamerecordings` directory held only `gamerecording.pb`. No
Steam recording was created during that check, so a real Steam clip export is
still unverified. The recording and Stop path were verified earlier on that
build, as described above. Remaining checks:
- Audio and video sync. Both inputs are stamped with the wall clock, because
the camera device and the audio server otherwise count from different clocks.
- The exact layout of `gamerecordings` on the Frame's SteamOS build, and
whether a clip's segments start mid-recording. The parser is deliberately
loose about the layout.
- How much x264 at 1080p30 costs the Frame while a game is running.
- Automatic copying: that 30 seconds without a write is a safe sign Steam has
finished saving a clip, and how a once-a-minute listing feels on the Frame.
+3
View File
@@ -4,6 +4,9 @@ Where to get the Steam Frame's operating system, what's inside it, and how to
run it for testing without the headset. For recovering a Frame that won't boot,
see the boot menu and boot-loop entries in
[how-the-frame-works.md](how-the-frame-works.md#facts-worth-knowing).
To keep your game saves, settings and Android app data through a factory reset
(`Erase User Data`) or a re-image, and to get out of Desktop Mode, see
[backup-and-recovery.md](backup-and-recovery.md).
## Downloads
+5
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.
@@ -89,6 +93,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| Script | Runs on | Purpose |
|---|---|---|
| `scripts/tailscale-on-frame.sh` | Mac → Frame | Install Tailscale in `~` as a userspace user service so `frame` works from anywhere; `--uninstall` (**verified** on the LAN) |
| `scripts/store-on-frame.sh` | Mac → Frame | Open the APK Store (Frame Control's Android store) as a panel in the headset; installs run on the Frame (**inferred**; see [docs/apks.md](apks.md)) |
| `scripts/connect.sh` | Mac | Discover, set up key and `~/.ssh/config`, copy key, optional `--harden` (**verified**; `--harden` untested) |
| `scripts/install-apps.sh` | Mac → Frame | Install Flatpaks (Remmina, Moonlight, …) on the Frame over SSH as `--user` (**verified** with Remmina) |
| `scripts/paste-to-frame.sh` | Mac → Frame | Send the Mac clipboard (or stdin) to the Frame clipboard (**verified**) |
+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:
+6 -1
View File
@@ -24,7 +24,7 @@ No SideQuest implementation code was copied.
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. |
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
@@ -65,6 +65,8 @@ flow, not evidence of an anonymous download API. It is not implemented here.
- `search()` raises a user-readable `SourceError` with the browse URL (zero
limit returns no rows). It does not invent app results or report a false
“no matching games”. The aggregate search UI should surface this source error.
In the app, Discover doesn't link to it; the **Sources** dialog lists SideQuest
with a link to its website instead of an on/off switch.
- `details()` accepts a numeric listing id and returns its canonical page link,
`downloadable: False`, empty versions/tags/headsets and the `images` shape
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
@@ -83,6 +85,9 @@ local SHA-256 alone must not be called publisher verification.
## OBB files
For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md).
The CLI helper below remains available for a running instance.
```sh
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
+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
+150 -6
View File
@@ -12,6 +12,12 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
The read-only power/heat sampler has fixture and transport tests in
`tests/test_power_sample.py`. Run
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
real-device verification limits.
```sh
python3 -m unittest discover -s tests
```
@@ -44,6 +50,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 +96,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 +184,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 +244,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 +293,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 +356,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 +414,47 @@ Chromium rendering and the same helper through HTTP. Close the switcher after
testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
cover actual focus, HTTP guards and an OpenXR sample transition, and separately
identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
## Backup and recovery kit
`tests/test_backup.py` runs the headset-side script `ui/frame_backup_remote.py`
for real against a temporary folder laid out like the Frame's home, with only
SSH faked: path selection and the secrets it must never copy, the manifest and
checksums, restore checks after a reset, restore writing only its own files,
unsafe archives and backup names, and the exact Game Mode command.
`tests/e2e/test_recovery_kit.py` backs up and restores a Proton save on the fake
Frame through the server. See [backup-and-recovery.md](backup-and-recovery.md#verification).
A full backup and an identical-file Proton save restore were checked on a
real Frame running build `20261015.6141207`; an after-reset restore and
Android data restore remain unverified. See the linked device evidence.
## Game storage
`python3 -m unittest discover -s tests -p test_storage.py -v` exercises the
read-only metadata probe, guarded HTTP endpoint and Node renderer/navigation
regressions. All headset reads are temporary fixtures or mocked SSH. See
[game storage](game-storage.md#verification-and-limits) for coverage and device
verification limits. This suite does not move games or contact a headset.
## Battery monitoring
`tests/test_battery.py` covers real file parsing with temporary sysfs-like
files, source preference, missing/invalid sensors and permission errors.
`tests/control_center.cjs` covers charging states and optional health fields,
alongside existing stale/offline handling. No headset is used. See
[battery monitoring](battery-health.md) for sources, units and limitations.
## Host-key recovery presentation
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
connection/banner/selection functions in Node and checks the recovery CSS guard.
`test_link.py` covers connection failures and short probe reasons with fake SSH
and local sockets. Neither suite uses a real headset.
Browser verification on 2026-10-24 used the current page's markup, styles and
recovery renderers with synthetic changed-key state and two headsets at
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
rule, which the original source-only assertion missed. Check computed wrapping,
the complete explanation, tooltip punctuation, Refresh bounds and selection
after **Use this headset** when changing these styles. This fixture does not
exercise Electron integration or a real SSH identity change.
+1 -1
View File
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
an installed app with a blank tile.
Artwork preference is **SteamGridDB → source images → generated fallback**.
Set the optional free key in Frame Control's **Library artwork settings**, or
Set the optional free key in Frame Control's **Settings → Library artwork**, or
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
settings override the saved key. Without a key there are no provider calls or
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
+2 -2
View File
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
## Our performance HUD
On **Home → VR comfort and performance**, the app shows a timestamped sample
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
@@ -26,7 +26,7 @@ it twice reuses the existing process.
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
OpenVR uses background application mode, which does not start SteamVR or keep
it running. This mode also returned live timing in a read-only device probe.
+1 -1
View File
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
## Use it
In **Tools → Media in the headset**, send a file, choose its layout and press
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
**Stop** removes both. Refresh reads the library and the player's state.
The screen follows your head; it isn't a saved world-space panel.
@@ -7,6 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1234567890ABCDEF0123456 /* NativeSessionTests.swift */; };
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1234567890ABCDEF0123456 /* NativeSessionState.swift */; };
0DEE50BD563B1D8C328C4C0A /* HeadsetServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */; };
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16644E7FDA7ADD5B232EB700 /* FrameLink.swift */; };
1A07EC692B0FF723907EA77B /* WebShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4E6C28315CA8729FCAAEA /* WebShell.swift */; };
@@ -35,6 +37,8 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionTests.swift; sourceTree = "<group>"; };
B1234567890ABCDEF0123456 /* NativeSessionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSessionState.swift; sourceTree = "<group>"; };
16644E7FDA7ADD5B232EB700 /* FrameLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameLink.swift; sourceTree = "<group>"; };
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameControlTests.swift; sourceTree = "<group>"; };
237D9AF04EEA257AB382F60E /* Keys.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keys.swift; sourceTree = "<group>"; };
@@ -100,6 +104,7 @@
isa = PBXGroup;
children = (
8A11F3431826A26B247C0695 /* AppModel.swift */,
B1234567890ABCDEF0123456 /* NativeSessionState.swift */,
93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */,
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */,
);
@@ -111,6 +116,7 @@
children = (
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
D1234567890ABCDEF0123456 /* NativeSessionTests.swift */,
);
path = FrameControlTests;
sourceTree = "<group>";
@@ -260,6 +266,7 @@
buildActionMask = 2147483647;
files = (
F94D0252F8CC5854314B84B2 /* AppModel.swift in Sources */,
A1234567890ABCDEF0123456 /* NativeSessionState.swift in Sources */,
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */,
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */,
12B21D3319BAF5AE79948560 /* FrameLink.swift in Sources */,
@@ -279,6 +286,7 @@
files = (
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
C1234567890ABCDEF0123456 /* NativeSessionTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+16 -12
View File
@@ -19,6 +19,20 @@ final class AppModel: ObservableObject {
/// Install links that arrived before the page was ready for them.
@Published var pendingInstallLinks: [InstallLink] = []
/// Native recovery remains reachable even when WebKit cannot draw a page.
@Published private(set) var webRecoveryMessage: String?
@Published private(set) var webReloadID = 0
func webContentFailed(at url: URL, message: String) {
guard phase == .ready(url) else { return }
webRecoveryMessage = message
}
func retryWebContent() {
webRecoveryMessage = nil
webReloadID += 1
}
private var link: FrameLink?
private var server: HeadsetServer?
private var forwarder: PortForwarder?
@@ -99,18 +113,7 @@ final class AppModel: ObservableObject {
/// "host", "host:port" or "[v6]:port", plus a user name.
nonisolated static func parse(host: String, user: String) -> FrameSettings? {
var target = FrameSettings(host: host.trimmingCharacters(in: .whitespaces), user: user.trimmingCharacters(in: .whitespaces))
if target.host.hasPrefix("["), let close = target.host.firstIndex(of: "]") {
let rest = target.host[target.host.index(after: close)...]
if rest.hasPrefix(":"), let port = Int(rest.dropFirst()) { target.port = port }
target.host = String(target.host[target.host.index(after: target.host.startIndex)..<close])
} else if target.host.filter({ $0 == ":" }).count == 1, let colon = target.host.lastIndex(of: ":"),
let port = Int(target.host[target.host.index(after: colon)...]) {
target.port = port
target.host = String(target.host[..<colon])
}
guard !target.host.isEmpty, !target.user.isEmpty, (1...65535).contains(target.port) else { return nil }
return target
FrameSettings.parse(host: host, user: user)
}
/// This phone's line for ~/.ssh/authorized_keys on the Frame.
@@ -145,6 +148,7 @@ final class AppModel: ObservableObject {
private func invalidate() {
attempt += 1
retrying = false
webRecoveryMessage = nil
}
/// quiet: a background retry, which leaves the failure screen up until it works.
@@ -0,0 +1,142 @@
import Foundation
/// The persisted SSH target and its editable, lossless address form.
struct FrameSettings: Codable, Equatable {
var host: String
var port: Int = 22
var user: String = "steamos"
var address: String {
let name = host.contains(":") ? "[\(host)]" : host
return port == 22 ? name : "\(name):\(port)"
}
static func parse(host: String, user: String) -> FrameSettings? {
var host = host.trimmingCharacters(in: .whitespacesAndNewlines)
let user = user.trimmingCharacters(in: .whitespacesAndNewlines)
var port = 22
if host.hasPrefix("[") {
guard let close = host.firstIndex(of: "]") else { return nil }
let rest = host[host.index(after: close)...]
if !rest.isEmpty {
guard rest.hasPrefix(":"), let value = Int(rest.dropFirst()) else { return nil }
port = value
}
host = String(host[host.index(after: host.startIndex)..<close])
} else if host.filter({ $0 == ":" }).count == 1, let colon = host.lastIndex(of: ":") {
guard let value = Int(host[host.index(after: colon)...]) else { return nil }
port = value
host = String(host[..<colon])
}
guard !host.isEmpty, !user.isEmpty, (1...65535).contains(port) else { return nil }
return FrameSettings(host: host, port: port, user: user)
}
}
/// Each search owns its callbacks. Cancelling invalidates even probes that cannot
/// be cancelled while Network.framework finishes opening a connection.
struct FrameSearchState {
private(set) var generation = UUID()
private(set) var manualAddress: String?
mutating func start(manualAddress: String?) -> UUID {
generation = UUID()
let typed = manualAddress?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
self.manualAddress = typed.isEmpty ? nil : typed
return generation
}
mutating func stop() { generation = UUID() }
func accepts(_ generation: UUID) -> Bool { self.generation == generation }
var discovers: Bool { manualAddress == nil }
static func address(manual: String, discovered: String?) -> String {
let typed = manual.trimmingCharacters(in: .whitespacesAndNewlines)
return typed.isEmpty ? (discovered ?? "frame.local") : typed
}
}
/// One automatic recovery per session; only a deliberate retry or a new session
/// replenishes it. A successful load alone must not create a process-crash loop.
struct WebDocumentState {
struct Delivery: Equatable {
let generation: UUID
let documentID: String
}
private(set) var generation = UUID()
private(set) var documentID: String?
private(set) var delivering = false
private var reloads = 0
mutating func beginDocument() {
generation = UUID()
documentID = nil
delivering = false
}
mutating func resetSession() {
reloads = 0
beginDocument()
}
mutating func shouldReloadAfterFailure() -> Bool {
beginDocument()
guard reloads < 1 else { return false }
reloads += 1
return true
}
mutating func ready(_ id: String, generation: UUID) {
guard self.generation == generation else { return }
documentID = id
}
mutating func beginDelivery() -> Delivery? {
guard !delivering, let documentID else { return nil }
delivering = true
return Delivery(generation: generation, documentID: documentID)
}
/// Only acknowledgement from the same live document may consume a link.
mutating func finishDelivery(_ delivery: Delivery, acknowledged: Bool) -> Bool {
guard delivery.generation == generation, delivering, delivery.documentID == documentID else { return false }
delivering = false
if !acknowledged { documentID = nil }
return acknowledged
}
}
/// Small app-scoped preferences survive the tunnel's intentionally ephemeral port.
struct FramePreferences {
private let defaults: UserDefaults
private static let keys = ["mvSeparate", "padUsed"]
private static let prefix = "frame.control.preference."
init(defaults: UserDefaults = .standard) { self.defaults = defaults }
func read() -> [String: String] {
var result: [String: String] = [:]
for key in Self.keys {
if let value = defaults.object(forKey: Self.prefix + key) as? String, ["0", "1"].contains(value) {
result[key] = value
}
}
return result
}
@discardableResult
func write(key: String, value: String) -> Bool {
guard Self.keys.contains(key), ["0", "1"].contains(value) else { return false }
defaults.set(value, forKey: Self.prefix + key)
return true
}
}
/// True for the first caller only.
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}
+24 -16
View File
@@ -1,3 +1,4 @@
import Combine
import Foundation
import Network
@@ -18,20 +19,30 @@ final class FrameFinder: ObservableObject {
private var browser: NWBrowser?
private var probeTask: Task<Void, Never>?
private var fallback = "frame.local"
private var search = FrameSearchState()
private let probe: @Sendable (String) async -> Bool
private let discoveryEnabled: Bool
func start(fallback: String?) {
init(probe: @escaping @Sendable (String) async -> Bool = { await FrameFinder.sshAnswers(host: $0) },
discoveryEnabled: Bool = true) {
self.probe = probe
self.discoveryEnabled = discoveryEnabled
}
func start(fallback: String?, manual: Bool = false) {
stop()
self.fallback = (fallback?.isEmpty == false ? fallback : nil) ?? "frame.local"
let mine = search.start(manualAddress: manual ? fallback : nil)
let host = FrameSearchState.address(manual: search.manualAddress ?? "", discovered: fallback)
found = nil
since = Date()
browse()
if search.discovers && discoveryEnabled { browse(generation: mine) }
probeTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
let host = self.fallback
if self.found == nil, await Self.sshAnswers(host: host) {
self.found = Found(host: host, name: host)
guard let self, self.search.accepts(mine) else { return }
if self.found == nil {
let answers = await self.probe(host)
guard !Task.isCancelled, self.search.accepts(mine) else { return }
if answers, self.found == nil { self.found = Found(host: host, name: host) }
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
@@ -39,20 +50,21 @@ final class FrameFinder: ObservableObject {
}
func stop() {
search.stop()
browser?.cancel()
browser = nil
probeTask?.cancel()
probeTask = nil
}
private func browse() {
private func browse(generation: UUID) {
let browser = NWBrowser(for: .bonjour(type: "_steamos-devkit._tcp", domain: nil), using: .tcp)
browser.browseResultsChangedHandler = { [weak self] results, _ in
for result in results {
guard case let .service(name, _, _, _) = result.endpoint else { continue }
Self.resolve(result.endpoint) { host in
Task { @MainActor in
guard let self, let host else { return }
guard let self, self.search.accepts(generation), self.search.discovers, let host else { return }
// A found device is used over the fallback probe.
self.found = Found(host: host, name: name)
}
@@ -91,12 +103,8 @@ final class FrameFinder: ObservableObject {
/// Whether something answers on the SSH port of "host" or "host:port" within a few seconds.
nonisolated static func sshAnswers(host address: String) async -> Bool {
var host = address, port: UInt16 = 22
if let target = AppModel.parse(host: address, user: "steamos") {
host = target.host
port = UInt16(target.port)
}
return await sshAnswers(host: host, port: port)
guard let target = FrameSettings.parse(host: address, user: "steamos") else { return false }
return await sshAnswers(host: target.host, port: UInt16(target.port))
}
nonisolated static func sshAnswers(host: String, port: UInt16) async -> Bool {
-13
View File
@@ -4,13 +4,6 @@ import Foundation
import NIOCore
import NIOSSH
/// Where the Frame is and who to log in as.
struct FrameSettings: Codable, Equatable {
var host: String
var port: Int = 22
var user: String = "steamos"
}
struct FrameFailure: LocalizedError {
let message: String
/// Retrying can't help: the Frame's identity changed, or it refused this phone's login.
@@ -140,12 +133,6 @@ final class FrameLink: @unchecked Sendable {
}
}
/// True for the first caller only.
final class Once: @unchecked Sendable {
private let lock = NSLock()
private var done = false
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
}
func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
+85 -47
View File
@@ -10,15 +10,31 @@ struct RootView: View {
case .setup:
SetupView(model: model)
case .connecting(let step):
ConnectingView(step: step, host: model.settings?.host) { model.showSetup() }
ConnectingView(step: step, host: model.settings?.address) { model.showSetup() }
case .failed(let message) where model.retrying && !model.needsPairing:
WaitingView(host: model.settings.map { $0.port == 22 ? $0.host : "\($0.host):\($0.port)" } ?? "", detail: message, deviceName: model.deviceName,
WaitingView(host: model.settings?.address ?? "", detail: message, deviceName: model.deviceName,
reachable: { Task { await model.connect(quiet: true) } }, change: { model.showSetup() })
case .failed(let message):
FailedView(message: message, canRetry: model.settings != nil, retrying: model.retrying, needsPairing: model.needsPairing,
retry: { Task { await model.connect() } }, change: { model.showSetup() })
case .ready(let url):
WebShell(url: url, model: model).ignoresSafeArea()
.accessibilityHidden(model.webRecoveryMessage != nil)
.allowsHitTesting(model.webRecoveryMessage == nil)
if let message = model.webRecoveryMessage {
RecoveryLayout {
VStack(spacing: 16) {
Image(systemName: "arrow.clockwise.circle").font(.system(size: 44)).foregroundStyle(.orange)
Text("Reload Frame Control").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
Button("Try again") { model.retryWebContent() }
.buttonStyle(.borderedProminent).controlSize(.large)
Button("Change headset") { model.showSetup() }
}
.padding(32).frame(maxWidth: 480)
}
.background(Color.frameBackground.ignoresSafeArea())
}
}
}
.preferredColorScheme(.dark)
@@ -33,20 +49,37 @@ extension Color {
static let frameMuted = Color(red: 0.561, green: 0.596, blue: 0.627)
}
/// Recovery controls remain reachable on short screens and with large text.
private struct RecoveryLayout<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
GeometryReader { geometry in
ScrollView {
content
.frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}
}
}
}
struct ConnectingView: View {
let step: String
let host: String?
let cancel: () -> Void
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
RecoveryLayout {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76).clipShape(RoundedRectangle(cornerRadius: 17))
ProgressView().controlSize(.large)
Text(step).font(.headline).multilineTextAlignment(.center)
if let host { Text(host).font(.subheadline).foregroundStyle(Color.frameMuted) }
Button("Change headset", action: cancel).padding(.top, 8)
}
.padding(32)
}
.padding(32)
}
}
@@ -59,21 +92,23 @@ struct FailedView: View {
let change: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
RecoveryLayout {
VStack(spacing: 16) {
Image(systemName: needsPairing ? "lock.trianglebadge.exclamationmark" : "wifi.exclamationmark")
.font(.system(size: 44)).foregroundStyle(.orange)
Text(needsPairing ? "Pair with the Frame again" : "Can't reach the Frame").font(.title3.bold())
Text(message).multilineTextAlignment(.center).foregroundStyle(Color.frameMuted)
if retrying { Text("Trying again every few seconds.").font(.footnote).foregroundStyle(Color.frameMuted) }
if needsPairing {
Button("Pair again", action: change).buttonStyle(.borderedProminent).controlSize(.large)
} else if canRetry {
Button("Try again", action: retry).buttonStyle(.borderedProminent).controlSize(.large)
}
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
}
if !needsPairing { Button(canRetry ? "Change headset" : "Back", action: change) }
.padding(32)
.frame(maxWidth: 480)
}
.padding(32)
.frame(maxWidth: 480)
}
}
@@ -86,34 +121,37 @@ struct WaitingView: View {
let reachable: () -> Void
let change: () -> Void
@State private var pulse = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(pulse ? 1 : 0.55)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
RecoveryLayout {
VStack(spacing: 18) {
Image("AppIconImage").resizable().frame(width: 76, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 17))
.opacity(reduceMotion || pulse ? 1 : 0.55)
.animation(reduceMotion ? nil : .easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: pulse)
Text("Waiting for your Frame").font(.title3.bold())
Text("Put the headset on, or press its power button, to wake it. Frame Control connects by itself as soon as it's awake.")
.multilineTextAlignment(.center)
VStack(alignment: .leading, spacing: 10) {
Tip(icon: "wifi", text: "Same Wi-Fi as this \(deviceName), or both on Tailscale.")
Tip(icon: "bolt.horizontal", text: "Asleep, the Frame drops off the network entirely; nothing can wake it remotely.")
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(14)
.background(Color.framePanel, in: RoundedRectangle(cornerRadius: 12))
Text(detail).font(.footnote).foregroundStyle(Color.frameMuted).multilineTextAlignment(.center)
Button("Connect to a different Frame", action: change).font(.footnote)
}
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !host.isEmpty, await FrameFinder.sshAnswers(host: host) {
reachable()
return
.padding(28)
.frame(maxWidth: 480)
.onAppear { pulse = true }
.task(id: host) {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if !Task.isCancelled, !host.isEmpty, await FrameFinder.sshAnswers(host: host), !Task.isCancelled {
reachable()
return
}
}
}
}
+10 -9
View File
@@ -13,10 +13,9 @@ struct SetupView: View {
@State private var showHelp = false
@FocusState private var passwordFocused: Bool
/// Where Connect goes: what the finder saw, else what was typed, else frame.local.
/// An explicit target always wins over discovery.
private var host: String {
let typed = manualHost.trimmingCharacters(in: .whitespaces)
return finder.found?.host ?? (typed.isEmpty ? "frame.local" : typed)
FrameSearchState.address(manual: manualHost, discovered: finder.found?.host)
}
var body: some View {
@@ -34,19 +33,19 @@ struct SetupView: View {
.scrollDismissesKeyboard(.interactively)
.background(Color.frameBackground)
.onAppear {
manualHost = model.settings?.host ?? ""
manualHost = model.settings?.address ?? ""
user = model.settings?.user ?? "steamos"
var fallback = model.settings?.host
var fallback = model.settings?.address
#if DEBUG
fallback = ProcessInfo.processInfo.environment["FRAME_TEST_FALLBACK"] ?? fallback // Simulator test hook
#endif
finder.start(fallback: fallback)
finder.start(fallback: fallback, manual: !manualHost.isEmpty)
}
.onDisappear { finder.stop() }
// After a few seconds of not finding it, say exactly what to check.
.task(id: finder.since) {
try? await Task.sleep(nanoseconds: 8_000_000_000)
showHelp = true
if !Task.isCancelled { showHelp = true }
}
}
@@ -120,7 +119,7 @@ struct SetupView: View {
Text("Address").font(.footnote).foregroundStyle(Color.frameMuted)
TextField("frame.local, an IP, or a Tailscale name", text: $manualHost)
.keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
.onSubmit { finder.start(fallback: manualHost) }
.onSubmit { finder.start(fallback: manualHost, manual: true) }
.padding(10).background(Color.black.opacity(0.28), in: RoundedRectangle(cornerRadius: 8))
TextField("User", text: $user)
.textInputAutocapitalization(.never).autocorrectionDisabled()
@@ -149,7 +148,9 @@ struct SetupView: View {
}
.onChange(of: manualHost) { _, value in
// A typed address replaces the search.
if !value.trimmingCharacters(in: .whitespaces).isEmpty, finder.found?.host != value { finder.start(fallback: value) }
showHelp = false
let typed = value.trimmingCharacters(in: .whitespacesAndNewlines)
finder.start(fallback: typed, manual: !typed.isEmpty)
}
}
+110 -19
View File
@@ -31,14 +31,13 @@ struct WebShell: UIViewRepresentable {
web.isInspectable = true
#endif
context.coordinator.web = web
web.load(URLRequest(url: url))
context.coordinator.load(url, reloadID: model.webReloadID)
return web
}
func updateUIView(_ web: WKWebView, context: Context) {
if context.coordinator.loaded != url {
context.coordinator.loaded = url
web.load(URLRequest(url: url))
if context.coordinator.loaded != url || context.coordinator.reloadID != model.webReloadID {
context.coordinator.load(url, reloadID: model.webReloadID)
}
context.coordinator.deliverInstallLinks()
}
@@ -47,16 +46,22 @@ struct WebShell: UIViewRepresentable {
(() => {
const call = (name, arg) => window.webkit.messageHandlers.frameApp.postMessage({ name, arg: arg ?? null });
let installCb = null;
window.__frameDocumentID = crypto.randomUUID();
window.frameApp = {
platform: "ios",
preferences: {
read: () => call("preferencesRead"),
write: (key, value) => call("preferencesWrite", { key, value }),
},
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),
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady"); },
onInstallLink: (cb) => { installCb = cb; return call("installLinkReady", window.__frameDocumentID); },
};
window.__frameInstallLink = (req) => { if (installCb) installCb(req); };
window.__frameInstallLink = (req) => { if (!installCb) return false; installCb(req); return true; };
})();
"""
@@ -64,7 +69,18 @@ struct WebShell: UIViewRepresentable {
let model: AppModel
weak var web: WKWebView?
var loaded: URL?
private var installReady = false
private(set) var document = WebDocumentState()
private(set) var reloadID = 0
private var activeNavigation: WKNavigation?
private let preferences = FramePreferences()
@MainActor
func load(_ url: URL, reloadID: Int) {
loaded = url
self.reloadID = reloadID
document.resetSession()
activeNavigation = web?.load(URLRequest(url: url))
}
init(model: AppModel) {
self.model = model
@@ -118,8 +134,20 @@ struct WebShell: UIViewRepresentable {
send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
}
}
case "preferencesRead":
replyHandler(preferences.read(), nil)
case "preferencesWrite":
guard let args = arg as? [String: Any], let key = args["key"] as? String,
let value = args["value"] as? String, preferences.write(key: key, value: value) else {
return replyHandler(nil, "Invalid preference")
}
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)
@@ -135,9 +163,20 @@ struct WebShell: UIViewRepresentable {
share(images)
replyHandler(["message": "Choose Save Image to keep \(images.count == 1 ? "it" : "them") in Photos"], nil)
case "installLinkReady":
installReady = true
deliverInstallLinks()
replyHandler(nil, nil)
guard let id = arg as? String, let web,
let data = try? JSONSerialization.data(withJSONObject: id, options: .fragmentsAllowed),
let json = String(data: data, encoding: .utf8) else {
return replyHandler(nil, "Invalid document")
}
let generation = document.generation
web.evaluateJavaScript("window.__frameDocumentID === \(json)") { [weak self] result, error in
guard let self else { return replyHandler(nil, "Page closed") }
if error == nil, result as? Bool == true {
self.document.ready(id, generation: generation)
self.deliverInstallLinks()
}
replyHandler(nil, nil)
}
default:
replyHandler(nil, "unknown request \(name)")
}
@@ -145,13 +184,23 @@ struct WebShell: UIViewRepresentable {
@MainActor
func deliverInstallLinks() {
guard installReady, let web, !model.pendingInstallLinks.isEmpty else { return }
let links = model.pendingInstallLinks
model.pendingInstallLinks = []
for link in links {
let req = ["kind": link.kind.rawValue, "target": link.target]
guard let json = try? JSONSerialization.data(withJSONObject: req), let text = String(data: json, encoding: .utf8) else { continue }
web.evaluateJavaScript("window.__frameInstallLink(\(text))")
guard let web, let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
let link = model.pendingInstallLinks.first, let delivery = document.beginDelivery() else { return }
let req = ["kind": link.kind.rawValue, "target": link.target]
guard let json = try? JSONSerialization.data(withJSONObject: req),
let text = String(data: json, encoding: .utf8),
let idData = try? JSONSerialization.data(withJSONObject: delivery.documentID, options: .fragmentsAllowed),
let id = String(data: idData, encoding: .utf8) else {
_ = document.finishDelivery(delivery, acknowledged: false)
return
}
// Keep the head queued until the live document confirms its callback ran.
web.evaluateJavaScript("window.__frameDocumentID === \(id) && window.__frameInstallLink(\(text)) === true") { [weak self] result, error in
guard let self, let loaded = self.loaded, self.model.phase == .ready(loaded),
self.reloadID == self.model.webReloadID else { return }
guard self.document.finishDelivery(delivery, acknowledged: error == nil && result as? Bool == true) else { return }
if self.model.pendingInstallLinks.first == link { self.model.pendingInstallLinks.removeFirst() }
self.deliverInstallLinks()
}
}
@@ -186,13 +235,55 @@ struct WebShell: UIViewRepresentable {
(root.presentedViewController ?? root).present(sheet, animated: true)
}
#if DEBUG
// A new document cannot receive links using the previous page's callback.
@MainActor
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
activeNavigation = navigation
document.beginDocument()
}
@MainActor
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
recover(webView, message: "The app page stopped responding. Try loading it again, or change headset.")
}
@MainActor
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation: navigation, error: error)
}
@MainActor
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
navigationFailed(webView, navigation: navigation, error: error)
}
@MainActor
private func navigationFailed(_ webView: WKWebView, navigation: WKNavigation?, error: Error) {
guard navigation === activeNavigation, (error as NSError).code != NSURLErrorCancelled else { return }
recover(webView, message: "Couldn't load the app page. \(error.localizedDescription)")
}
@MainActor
private func recover(_ webView: WKWebView, message: String) {
guard let loaded, model.phase == .ready(loaded), reloadID == model.webReloadID,
model.webRecoveryMessage == nil else { return }
activeNavigation = nil
if document.shouldReloadAfterFailure() {
// Reload the original authenticated URL, including its session key.
activeNavigation = webView.load(URLRequest(url: loaded))
} else {
model.webContentFailed(at: loaded, message: message)
}
}
/// Simulator test hook: FRAME_TEST_JS runs in the page once it has loaded.
@MainActor
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
#if DEBUG
guard let js = ProcessInfo.processInfo.environment["FRAME_TEST_JS"] else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 4) { webView.evaluateJavaScript(js) }
#endif
}
#endif
// MARK: navigation: the app's page stays here; other sites open in Safari
@@ -0,0 +1,100 @@
import Foundation
import XCTest
#if !NATIVE_CONTRACT_STANDALONE
@testable import Frame_Control
#endif
final class NativeSessionTests: XCTestCase {
func testSavedAddressesRoundTripWithoutLosingPortOrIPv6() throws {
for host in ["frame.local", "100.100.100.2", "2001:db8::42", "fe80::1%en0"] {
for port in [22, 2222, 65535] {
let target = FrameSettings(host: host, port: port, user: "alex")
let saved = try JSONDecoder().decode(FrameSettings.self, from: JSONEncoder().encode(target))
XCTAssertEqual(FrameSettings.parse(host: saved.address, user: saved.user), target)
}
}
XCTAssertEqual(FrameSettings.parse(host: " [2001:db8::42]:2222\n", user: " steamos "),
FrameSettings(host: "2001:db8::42", port: 2222))
for host in ["", "frame.local:0", "frame.local:65536", "frame.local:no", "[::1]:no", "[::1]junk", "[::1"] {
XCTAssertNil(FrameSettings.parse(host: host, user: "steamos"), host)
}
}
func testManualTargetWinsAndSearchCallbacksExpire() {
XCTAssertEqual(FrameSearchState.address(manual: " 100.100.100.2:2222 ", discovered: "192.168.1.237"), "100.100.100.2:2222")
XCTAssertEqual(FrameSearchState.address(manual: " \n", discovered: "192.168.1.237"), "192.168.1.237")
XCTAssertEqual(FrameSearchState.address(manual: "", discovered: nil), "frame.local")
var search = FrameSearchState()
let bonjour = search.start(manualAddress: nil)
XCTAssertTrue(search.discovers)
let manual = search.start(manualAddress: "100.100.100.2:2222")
XCTAssertFalse(search.discovers)
XCTAssertFalse(search.accepts(bonjour))
XCTAssertTrue(search.accepts(manual))
search.stop()
XCTAssertFalse(search.accepts(manual))
_ = search.start(manualAddress: "")
XCTAssertTrue(search.discovers)
}
func testRecoveryIsBoundedUntilAnExplicitRetry() {
var page = WebDocumentState()
XCTAssertTrue(page.shouldReloadAfterFailure())
page.beginDocument()
page.ready("reloaded", generation: page.generation)
XCTAssertFalse(page.shouldReloadAfterFailure())
XCTAssertFalse(page.shouldReloadAfterFailure())
page.resetSession()
XCTAssertTrue(page.shouldReloadAfterFailure())
}
func testReplacementDocumentsCannotConsumeQueuedLinks() throws {
var page = WebDocumentState()
var queue = ["first", "second"]
XCTAssertNil(page.beginDelivery())
let oldGeneration = page.generation
page.ready("first document", generation: oldGeneration)
let oldDelivery = try XCTUnwrap(page.beginDelivery())
XCTAssertNil(page.beginDelivery(), "Serialize delivery while an acknowledgement is pending")
XCTAssertTrue(page.shouldReloadAfterFailure())
page.ready("stale readiness", generation: oldGeneration)
XCTAssertNil(page.beginDelivery())
if page.finishDelivery(oldDelivery, acknowledged: true) { queue.removeFirst() }
XCTAssertEqual(queue, ["first", "second"])
page.ready("replacement", generation: page.generation)
let failedDelivery = try XCTUnwrap(page.beginDelivery())
if page.finishDelivery(failedDelivery, acknowledged: false) { queue.removeFirst() }
XCTAssertEqual(queue, ["first", "second"])
XCTAssertNil(page.beginDelivery(), "An evaluation error needs fresh readiness")
page.ready("replacement", generation: page.generation)
let liveDelivery = try XCTUnwrap(page.beginDelivery())
if page.finishDelivery(liveDelivery, acknowledged: true) { queue.removeFirst() }
XCTAssertEqual(queue, ["second"])
XCTAssertNotNil(page.beginDelivery())
page.beginDocument()
XCTAssertNil(page.beginDelivery(), "Even healthy main-document navigation resets readiness")
}
func testPreferencesPersistAcrossSessionsAndRejectOtherKeysAndValues() throws {
let suite = "frame-control-contract-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
let first = FramePreferences(defaults: defaults)
XCTAssertEqual(first.read(), [:])
XCTAssertTrue(first.write(key: "mvSeparate", value: "0"))
XCTAssertTrue(first.write(key: "padUsed", value: "1"))
let second = FramePreferences(defaults: try XCTUnwrap(UserDefaults(suiteName: suite)))
XCTAssertEqual(second.read(), ["mvSeparate": "0", "padUsed": "1"])
for (key, value) in [("other", "1"), ("mvSeparate", "false"), ("padUsed", ""), ("padUsed", "2")] {
XCTAssertFalse(second.write(key: key, value: value))
}
XCTAssertEqual(first.read(), ["mvSeparate": "0", "padUsed": "1"])
XCTAssertNil(defaults.object(forKey: "frame.control.preference.other"))
defaults.set("bad", forKey: "frame.control.preference.mvSeparate")
defaults.set("1", forKey: "frame.control.preference.unknown")
XCTAssertEqual(second.read(), ["padUsed": "1"])
defaults.set(true, forKey: "frame.control.preference.padUsed")
XCTAssertEqual(second.read(), [:], "Only stored strings are supported")
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ import tarfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "ui/apk_sources/*.py", "ui/apk_sources/*.json", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py", "frame/android/*.js",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json", "frame/openxr-compat/prebuilt/arm64-v8a/*.so.gz",
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
+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" "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Read-only, bounded power/heat sampler. See docs/power-and-heat.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_power_sample.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())
+211
View File
@@ -0,0 +1,211 @@
"""Bounded read-only Frame measurements; no device files are written.
The host sends this and frame_status.py over one trusted SSH connection. Only
battery() and CPU tick helpers are reused: no OpenVR, services or analytics.
"""
import argparse
import csv
import glob
import io
import json
import math
from pathlib import Path
import subprocess
import sys
import time
LOCK = Path('/tmp/frame-test.lock')
UNAVAILABLE = 'unavailable: not reported, unreadable or invalid'
def read(path):
try:
return Path(path).read_text().strip()
except (OSError, UnicodeError):
return None
def number(path, scale=1, minimum=None, maximum=None):
try:
value = int(read(path)) * scale
if not math.isfinite(value):
return None
if minimum is not None and value < minimum or maximum is not None and value > maximum:
return None
return round(value, 6)
except (TypeError, ValueError, OverflowError):
return None
def snapshot(status, before):
if LOCK.exists():
raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.')
ticks = status.cpu_ticks()
readings = {}
def add(key, value, unit, source):
readings[key] = {'value': value, 'unit': unit, 'source': source,
'state': 'available' if value is not None else UNAVAILABLE}
battery = status.battery() or {}
for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'),
('tempC', 'C')):
value = battery.get(field)
if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'):
value = None
add('battery.' + field, value, unit, battery.get('source'))
add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat')
if before is None:
readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples'
add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0),
'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq')
# Discover by driver name/label, never assume a hwmon index. Keep source
# paths and channel numbers to distinguish duplicate labels.
for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')):
name = read(node + '/name') or Path(node).name
for path in sorted(glob.glob(node + '/power*_input')):
channel = Path(path).name[:-6]
label = read(node + '/' + channel + '_label') or channel
add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path)
for path in sorted(glob.glob(node + '/fan*_input')):
add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path)
# pwm1 on Frame's driver is percent; leave raw to avoid assuming the
# generic hwmon 0..255 scale on other drivers or later firmware.
if name == 'slg4ax46073v':
add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1')
for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')):
name = read(node + '/type') or Path(node).name
add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200),
'C', node + '/temp')
for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')):
for path in sorted(glob.glob(node + '/in_temp_*_input')):
add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200),
'C', path)
for field in ('iin', 'ichg'):
path = node + '/in_current_pm8550b_' + field + '_fb_input'
# Known names, but no fixed IIO index; absent channels are covered
# by group availability below.
if Path(path).exists():
add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path)
if read(node + '/name') == 'vcnl4000':
add('proximity.raw', number(node + '/in_proximity_raw', minimum=0),
'raw', node + '/in_proximity_raw')
unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity')
if not any(k.startswith(group + '.') and v['value'] is not None
for k, v in readings.items())]
os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines()
if '=' in line)
return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None,
'readings': readings, 'unavailableGroups': unavailable}, ticks
def summarize(samples):
result = {}
keys = sorted({key for sample in samples for key in sample['readings']})
for key in keys:
rows = [s['readings'][key] for s in samples if key in s['readings']]
values = [r['value'] for r in rows if r['value'] is not None]
item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}),
'available': len(values), 'unavailable': len(samples) - len(values)}
if values and all(isinstance(v, (int, float)) for v in values):
item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6),
first=values[0], last=values[-1])
else:
item['states'] = sorted(set(values))
result[key] = item
return result
def collect(status, count, interval):
samples, before = [], None
started = time.monotonic()
stopped = None
for index in range(count):
if index:
time.sleep(max(0, started + index * interval - time.monotonic()))
try:
sample, before = snapshot(status, before)
except RuntimeError as exc:
stopped = str(exc)
break
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
samples.append(sample)
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
def csv_samples(report):
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
for sample in report['samples']:
for key, row in sample['readings'].items():
# csv quoting does not prevent spreadsheet formulas in labels.
def cell(value):
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
row['value'], row['unit'], row['source'], row['state'])])
for group in sample['unavailableGroups']:
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
return output.getvalue()
def parser():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
return ap
def remote_payload():
root = Path(__file__).resolve().parents[1]
source = (root / 'ui/frame_status.py').read_text()
# Executing as a module prevents frame_status.main() from probing unrelated
# status. Nothing is installed, cached or logged on the headset.
return ('import types, sys\n'
'status = types.ModuleType("frame_status")\n'
f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n'
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
def main(argv=None):
ap = parser()
args = ap.parse_args(argv)
if not 1 <= args.count <= 720 or not math.isfinite(args.interval) or not 1 <= args.interval <= 60:
ap.error('count must be 1..720 and interval must be finite, 1..60 seconds')
# Also honor a coordinator lock on the computer before opening SSH.
if LOCK.exists():
print('Smoke session active (/tmp/frame-test.lock); no SSH or sensors accessed.', file=sys.stderr)
return 75
if args.probe:
import frame_status
report = collect(frame_status, args.count, args.interval)
else:
command = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5',
'-o', 'ServerAliveCountMax=2', 'frame',
'python3', '-', '--probe', '--count', str(args.count), '--interval', str(args.interval)]
try:
run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True,
timeout=args.count * args.interval + 30)
if run.returncode:
print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'),
file=sys.stderr)
return run.returncode
report = json.loads(run.stdout)
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
print(f'Read-only sample unavailable: {exc}', file=sys.stderr)
return 1
if args.csv:
sys.stdout.write(csv_samples(report))
print(json.dumps({k: v for k, v in report.items() if k != 'samples'}, allow_nan=False), file=sys.stderr)
else:
print(json.dumps(report, allow_nan=False))
return 75 if report['stopped'] else 0
if __name__ == '__main__':
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")]
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env zsh
# Mac-side: the APK Store in the headset. Frame Control's Android catalogue
# (the same rated F-Droid list, Install, Open and Remove) as its own floating
# panel on the Steam Frame, so you can browse and install apps from inside.
# See docs/apks.md → "The store in the headset".
#
# Frame Control's server runs on the Frame itself (FRAME_LOCAL=1, as the iPhone
# app runs it) on the Frame's loopback, and Chromium opens ui/store.html as a
# panel. Installs then download and verify the APK on the Frame; the Mac isn't
# needed after `open`.
#
# Usage:
# scripts/store-on-frame.sh open # copy the server over if it changed, start it, open the panel
# scripts/store-on-frame.sh close # close the panel and stop the server
# scripts/store-on-frame.sh status
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
HERE=${0:A:h}
ROOT=${HERE:h}
PORT=47812 # the Frame's 127.0.0.1 only
APPID=2001947812 # the panel's id (panel-on-frame.sh's range)
# Not ~/.cache/frame-control: the iPhone app prunes other versions there.
REMOTE=.local/share/frame-control/store-server
cmd=${1:-}
# Same bundle the iPhone app copies (ios/scripts/make_frame_bundle.py), so the
# server on the Frame has exactly the files the desktop app ships.
deploy() {
local tmp version
tmp=$(mktemp -d)
trap "rm -rf ${(q)tmp}" EXIT
version=$(python3 "$ROOT/ios/scripts/make_frame_bundle.py" "$tmp/bundle.tar.gz")
if ! ssh "$FRAME_ALIAS" "test -f $REMOTE/$version/ui/server.py"; then
print -u2 "==> Copying Frame Control ($version) to the Frame"
ssh "$FRAME_ALIAS" "set -e; mkdir -p $REMOTE; cd $REMOTE; rm -rf $version.tmp; mkdir $version.tmp
tar xzf - -C $version.tmp; rm -rf $version; mv $version.tmp $version" <"$tmp/bundle.tar.gz"
fi
# Older versions, unless a server is still running from one.
ssh "$FRAME_ALIAS" "cd $REMOTE && for d in */; do d=\${d%/}; [ \"\$d\" = $version ] && continue
pgrep -f \"\$PWD/\$d/\" >/dev/null && continue; rm -rf -- \"\$d\"; done"
print -r -- "$version"
}
# Runs on the Frame. Args: dir port appid key
OPEN=$(cat <<'EOF'
set -u
dir=$HOME/$1 port=$2 appid=$3 key=$4
export DISPLAY=:0 LC_ALL=C.UTF-8
unset WAYLAND_DISPLAY
xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1 || { echo "The headset isn't showing anything (gamescope's :0 isn't up). Wake it and try again." >&2; exit 2; }
if [ -x "$HOME/chromium-xr/chrome" ]; then
# Steam's overlay library crashes Chromium's zygote (docs/webxr-chromium.md).
browser=(env LD_PRELOAD= "$HOME/chromium-xr/chrome" --user-data-dir="$HOME/.local/share/frame-control/store")
elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
browser=(flatpak run org.chromium.Chromium --user-data-dir="$HOME/.var/app/org.chromium.Chromium/frame-store")
else
echo "The store needs a Chromium on the Frame: Chromium XR (docs/webxr-chromium.md) or the org.chromium.Chromium Flatpak (scripts/install-apps.sh)." >&2
exit 3
fi
# A fresh server each time, so it runs this version with this key.
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null
# Match the browser by its profile flag and the server by its script path: this
# launcher's own command line holds the bundle path, so a looser pattern kills it.
pkill -f '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) ' || true
pkill -f '[s]tore-server/[^/ ]*/ui/server[.]py' || true
sleep 0.5
cd "$dir" || exit 1
FRAME_LOCAL=1 FRAME_UI_KEY=$key FRAME_DEVICE=headset setsid nohup python3 -I -u -B "$dir/ui/server.py" --port "$port" \
>/tmp/frame-store-server.log 2>&1 </dev/null &
echo $! > /tmp/frame-store-server.pid
up=
for _ in $(seq 1 40); do
curl -s -o /dev/null "http://127.0.0.1:$port/store" && { up=1; break; }
sleep 0.25
done
[ -n "$up" ] || { echo "Frame Control's server didn't start on the Frame:" >&2; tail -n 15 /tmp/frame-store-server.log >&2; exit 1; }
setsid nohup "${browser[@]}" --ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check \
--password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars \
--disable-features=Translate,MediaRouter --window-size=1280,800 \
"--app=http://127.0.0.1:$port/store?key=$key" >/tmp/frame-store.log 2>&1 </dev/null &
# Tag the window so it's its own panel (docs/panels.md).
for _ in $(seq 1 60); do
sleep 0.5
for win in $(xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}'); do
xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "APK Store" || continue
if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
echo "panel valve.steam.desktopgame.$appid window $win"
exit 0
fi
done
done
echo "Chromium started, but its window didn't appear within 30 s:" >&2
tail -n 15 /tmp/frame-store.log >&2
exit 1
EOF
)
case "$cmd" in
open)
version=$(deploy | tail -n 1)
key=$(openssl rand -hex 16)
ssh "$FRAME_ALIAS" bash -s -- "$REMOTE/$version" "$PORT" "$APPID" "$key" <<<"$OPEN"
;;
close)
ssh "$FRAME_ALIAS" 'pkill -f "[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) " || true
pkill -f "[s]tore-server/[^/ ]*/ui/server[.]py" || true
[ -f /tmp/frame-store-server.pid ] && kill "$(cat /tmp/frame-store-server.pid)" 2>/dev/null; rm -f /tmp/frame-store-server.pid; true'
;;
status)
ssh "$FRAME_ALIAS" "ls $REMOTE 2>/dev/null; curl -s -o /dev/null http://127.0.0.1:$PORT/store && echo 'server up' || echo 'server down'
pgrep -af '[-]-user-data-dir=[^ ]*/(frame-control/store|frame-store) |[s]tore-server/[^/ ]*/ui/server[.]py' | head -n 1 | cut -c1-120"
;;
*) sed -n '2,15p' "$0"; exit 2 ;;
esac
+72
View File
@@ -0,0 +1,72 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const html = fs.readFileSync(require.resolve('../ui/index.html'), 'utf8');
async function session(native, initial = {}, duringRead) {
const saved = { ...initial }, element = { checked: true, disabled: false };
const storage = { getItem: key => saved[key] ?? null, setItem: (key, value) => { saved[key] = String(value); } };
for (const key of ['mvSeparate', 'padUsed']) Object.defineProperty(storage, key, {
get: () => saved[key], set: value => { saved[key] = String(value); },
});
const calls = [];
const context = vm.createContext({ window: { frameApp: native ? { preferences: native } : undefined }, localStorage: storage,
$: () => element, log: (...args) => calls.push(args),
api: async () => ({ state: 'off' }), padStart: () => calls.push('start'), padShow: s => calls.push(s.state) });
const marker = html.indexOf('// ---- client preferences');
const start = marker >= 0 ? marker : html.indexOf('$("mvSeparate").checked =');
vm.runInContext(html.slice(start, html.indexOf('$("macview").onclick', start)), context);
if (duringRead) duringRead(context, element);
await vm.runInContext('typeof clientPreferences === "undefined" ? Promise.resolve() : clientPreferences.ready', context);
return { element, saved, calls, context, async change(value) {
element.checked = value; element.onchange({ target: element });
await new Promise(resolve => setImmediate(resolve));
} };
}
test('a layout choice survives moving from one native local origin to another', async () => {
const values = {};
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
const first = await session(native);
await first.change(false);
assert.equal(first.element.checked, false);
const second = await session(native); // fresh origin storage
assert.equal(second.element.checked, false);
});
test('native values win over stale origin values and missing values migrate', async () => {
const values = { mvSeparate: '0' };
const native = { read: async () => ({ ...values }), write: async (key, value) => { values[key] = value; } };
const page = await session(native, { mvSeparate: '1', padUsed: '1' });
assert.equal(page.element.checked, false);
assert.equal(values.padUsed, '1');
});
test('ordinary browser storage and a failed native read retain usable choices', async () => {
assert.equal((await session(null, { mvSeparate: '0' })).element.checked, false);
const native = { read: async () => { throw Error('Unavailable'); }, write: async () => true };
const page = await session(native, { mvSeparate: '0' });
assert.equal(page.element.checked, false);
assert.equal(page.element.disabled, false);
});
test('starting input during a native read wins over its stale remembered value', async () => {
let resolveRead;
const writes = [];
const native = { read: () => new Promise(resolve => { resolveRead = resolve; }), write: async (key,value) => writes.push({key,value}) };
const page = await session(native, {}, context => {
vm.runInContext("clientPreferences.set('padUsed','1')",context);
resolveRead({padUsed:'0'});
});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
assert.deepEqual(writes,[{key:'padUsed',value:'1'}]);
});
test('one failed migration does not hide another native preference', async () => {
const native = {read:async()=>({padUsed:'1'}),write:async()=>{throw Error('disk unavailable');}};
const page=await session(native,{mvSeparate:'0'});
assert.equal(page.element.checked,false);
assert.equal(vm.runInContext("clientPreferences.get('padUsed')",page.context),'1');
});
+120 -32
View File
@@ -14,7 +14,7 @@ function fixture() {
const elements = new Map();
function element(id) {
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
classList:{toggle(){}},querySelectorAll(){return [];},close(){this.open=false;},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
return elements.get(id);
}
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
@@ -42,7 +42,7 @@ function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
api:(path,body,device)=>{
if(path==='/api/host')return host.promise;
@@ -64,11 +64,12 @@ 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(){},
resetShots(){},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 = {};
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
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, 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);
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
@@ -164,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});
@@ -438,34 +483,77 @@ test('mounted filters show empty state, counts, reset and host-dependent feature
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
});
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
let activePage,scrolled;
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
const sections=new Map();
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
window:{scrollTo(){}},$:id=>{
if(!ids.has(id))return null;
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
return sections.get(id);
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
const androidStart=html.indexOf('function showAndroidTab(');
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
test('every Control Center workflow reaches its intentional workspace', () => {
const {workspaceFixture}=require('./workspace_fixture.cjs');
const f=workspaceFixture();
const expected={view:'capture',shots:'capture',vrTools:'tools',comfort:'tools',pad:'tools',library:'games',getgames:'games',sideloaded:'games',android:'apps',display:'settings',androidLibrary:'apps',appStore:'apps',power:'settings',transfer:'tools',media:'tools',linuxApps:'apps',artworkSettings:'settings',privacy:'settings',panels:'tools',macview:'tools',devices:'devices'};
for(const item of cc.catalog) for(const [href] of item.links||[]) {
if(!href.startsWith('#')||href==='#connection')continue;
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
assert.equal(activePage,expected[id],href);
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
f.navigate(href);assert.equal(f.activePage,expected[href.slice(1)],href);
}
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
assert.equal(vm.runInContext('PAGES[5]',context),'control');
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))
+1 -1
View File
@@ -23,7 +23,7 @@ function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360
const sent = [];
const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
const env = {
ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
devGen: 0, ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
+1 -1
View File
@@ -8,7 +8,7 @@ const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function page(api) {
const pad = { state: "ready", queue: [], sending: false };
const shown = [];
const fns = new Function("pad", "api", "padShow", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; });
const fns = new Function("pad", "api", "padShow", "devGen", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; }, 0);
return { pad, shown, ...fns };
}
+58 -7
View File
@@ -13,19 +13,19 @@ function grab(name) {
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
"storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails"];
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
const code = names.map(grab).join("");
// A fake page: elements by id. The install panel's elements exist only while
// #detailBody holds the real app page, as in the browser.
function page(api = async () => ({})) {
function page(api = async () => ({}), androidApps = []) {
const els = { appDetail: { open: false, scrollTop: 0, showModal() { this.open = true; } } };
const inPage = id => ["detailInstall", "detailInstallNote", "detailProgress", "detailOffers"].includes(id);
const $ = id => inPage(id) && !(els.detailBody?.innerHTML || "").includes(`id="${id}"`) ? null
@@ -34,8 +34,10 @@ function page(api = async () => ({})) {
const document = { activeElement: null, querySelector: () => null };
const console = { error() {} };
const calls = [];
const fns = new Function("$", "sourceState", "document", "console", "api", code + "; return { renderAppDetail, openStoreApp };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); });
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
async (title, key, work) => work(), () => {}, async (title, work) => work(),
{ open() { fail("an in-app installation opened an external page"); } }, 0);
return { els, sourceState, calls, ...fns };
}
@@ -54,6 +56,14 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (html.includes('role="alert"')) fail("a good listing showed the error state");
}
// A catalogue listing that is already installed offers its management page.
{
const p = page(undefined, [{ package: offer.package }]);
p.sourceState.detail = { app: { name: offer.name, offers: [{ ...offer }] }, choice: 0 };
if (p.renderAppDetail() !== true) fail("an installed listing reported a render failure");
if (p.els.detailInstall.textContent !== "View installed app") fail("installed listing offered another install");
}
// A listing that can't render (no offer to show) gets a clear message, not an empty dialog.
{
const p = page();
@@ -68,7 +78,7 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
{
const p = page(async () => { throw new Error("offline"); });
p.sourceState.apps = [{ name: "Broken", offers: [] }];
await p.openStoreApp(0);
await p.openStoreApp(0, true);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('role="alert"')) fail("broken listing didn't open with the error");
if (p.calls.length) fail("asked for details of a listing that couldn't render");
}
@@ -90,4 +100,45 @@ const offer = { source: "fdroid", id: "org.example.brush", source_name: "F-Droid
if (!p.els.detailBody.innerHTML.includes('id="detailTitle">Open Brush<')) fail("page lost after a failed details request");
if (!p.els.detailInstallNote.textContent.includes("couldn't be refreshed")) fail("no note after a failed details request");
}
// The featured Install starts a new or failed job, while a job already running or
// installed opens its details without another install or a launch command.
for (const [job, expected] of [[null, 1], [{ done: false }, 0],
[{ done: true, result: { package: offer.package } }, 0], [{ done: true, error: "offline" }, 1]]) {
const p = page(async path => path === "/api/sources/install" ? { package: offer.package } : {});
p.sourceState.apps = [{ name: "Open Brush", offers: [{ ...offer }] }];
if (job) p.sourceState.jobs.set(`${offer.source}:${offer.id}`, job);
await p.openStoreApp(0, true);
if (!p.els.appDetail.open || !p.els.detailBody.innerHTML.includes('id="detailInstall"')) fail("hero installation didn't open details");
const installs = p.calls.filter(([path]) => path === "/api/sources/install");
if (installs.length !== expected) fail(`expected ${expected} installation requests, got ${installs.length}`);
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
}
// Report warnings appear on real cards, shelves and details; they are advisory.
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
const p = page();
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
const app = {name: o.name, offers: [o]};
p.sourceState.apps = [app];
p.sourceState.detail = {app, choice: 0};
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
if (!p.renderAppDetail()) fail('report detail did not render');
const html = p.els.detailBody.innerHTML;
if (!html.includes('Saved compatibility reports') || !html.includes('Reported &lt;unsafe&gt; notes')) fail('report evidence missing or unescaped');
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
for (const reportKind of [kind, 'works']) {
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
app.offers = [blocked];
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
}
}
console.log("ok");
+1
View File
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
# A maintainer's machine holds the database key; send anything that slips through nowhere.
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
# its lock file) and the other per-user folders some modules use. A server started
+36 -17
View File
@@ -13,11 +13,12 @@ BUILD_ID in tests/smoke/results/<time>-<BUILD_ID>.json (git-ignored):
(someone has to open Settings > Developer > Pair new host and approve it in
the headset), checked, and taken out of authorized_keys again.
Everything it installs is removed again, also when a step fails: the titles
(named fc_smoke_*, and leftovers of an interrupted run first), their Steam
Everything it installs is removed again, also when a step fails: the reserved
fc_smoke_{arm64,x86_64,exe} titles (and legacy leftovers first), their Steam
shortcuts, the paired key, and ~/devkit-utils if it wasn't there before (if it
was, it stays, synced to this checkout as Frame Control always does). A
cleanup that fails is a failed step.
cleanup that fails is a failed step. Test-owned processes are stopped and
rechecked around deletion; shared Proton services are never stopped by name.
Usage: python3 tests/smoke/frame_smoke.py [--pair] (or scripts/frame-smoke.sh)
Env: FRAME_ALIAS (default frame), FRAME_SMOKE_RESULTS (default tests/smoke/results)
@@ -45,11 +46,17 @@ import frame_android # noqa: E402
import frame_connect # noqa: E402
import frame_titles # noqa: E402
import tiny_programs # noqa: E402
import stop_processes # noqa: E402
RESULTS = Path(os.environ.get('FRAME_SMOKE_RESULTS') or HERE / 'results')
PREFIX = 'fc_smoke_'
# Earlier runs named titles fc-smoke*, which Steam refused to register but left on disk.
OLD_PREFIX = 'fc-smoke'
def stop_titles(ids):
"""Run the same bounded, PID-safe helper used by the local process tests."""
return json.loads(frame_android.ssh(
'python3 - ' + ' '.join(shlex.quote(gid) for gid in sorted(ids)),
input=(HERE / 'stop_processes.py').read_text(), timeout=20))
class Smoke:
@@ -172,14 +179,17 @@ def title_cycle(smoke, kind, folder):
return detail
def remove():
stopped = stop_titles({gid})
frame_titles.remove(gid)
# A wrapper could have launched a child just as deletion completed.
after = stop_titles({gid})
left = ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip()
if left:
raise AssertionError(f'left behind: {left}')
if gid in {t['id'] for t in frame_titles.list_titles()}:
raise AssertionError(f'{gid} is still listed')
smoke.installed.discard(gid)
return {'removed': gid}
return {'removed': gid, 'process_cleanup': [stopped, after]}
if smoke.step(f'{kind}: install', install)['ok']:
smoke.step(f'{kind}: launch', launch)
@@ -187,15 +197,21 @@ def title_cycle(smoke, kind, folder):
def cleanup(smoke):
"""Remove this run's titles and any fc_smoke_* an interrupted run left: the folder,
its json files, and (through steamos-delete) Steam's shortcut. Raises if anything stays."""
"""Stop reserved smoke processes and remove their titles and shortcuts.
Legacy fc-smoke-* IDs are included; arbitrary similarly named titles are
never removed. Raises if any owned process, file or shortcut stays.
"""
problems = []
try:
ids = {t['id'] for t in frame_titles.list_titles() if t['id'].startswith((PREFIX, OLD_PREFIX))}
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
except frame_android.FrameError as e:
ids = set()
problems.append(f'could not list titles: {e}')
ids |= smoke.installed
# Scan every reserved ID, even when a prior run removed its folder and
# shortcut but left Proton running. Stop before removing any more files.
stopped = stop_titles(stop_processes.IDS)
gone = set()
for gid in sorted(ids):
try:
@@ -226,9 +242,10 @@ def cleanup(smoke):
smoke.installed.discard(gid)
except (frame_android.FrameError, AssertionError) as e:
problems.append(f"syncing Steam's shortcuts: {e}")
after = stop_titles(stop_processes.IDS)
if problems:
raise AssertionError('; '.join(problems))
return {'removed': sorted(ids)}
return {'removed': sorted(ids), 'process_cleanup': [stopped, after]}
# Runs on the Frame: drop the lines holding one key from authorized_keys, writing a
@@ -307,15 +324,17 @@ def main():
# Frame Control copies Valve's devkit tools to ~/devkit-utils on the first install
# (as Valve's client does). If they weren't there before, they go again at the end.
had_utils = ssh('test -d ~/devkit-utils && echo yes || true').strip() == 'yes'
smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)
ready = smoke.step('cleanup: leftovers from earlier runs', cleanup, smoke)['ok']
folder = tempfile.mkdtemp(prefix='frame-smoke-')
try:
smoke.step('devkit service: properties.json', properties)
smoke.step('status', status, build)
for kind in ('arm64', 'x86_64', 'exe'):
title_cycle(smoke, kind, folder)
if args.pair:
smoke.step('devkit pairing (throwaway key)', pair, folder)
# Do not overwrite a leftover title when safe process cleanup failed.
if ready:
smoke.step('devkit service: properties.json', properties)
smoke.step('status', status, build)
for kind in ('arm64', 'x86_64', 'exe'):
title_cycle(smoke, kind, folder)
if args.pair:
smoke.step('devkit pairing (throwaway key)', pair, folder)
finally:
smoke.step('cleanup: everything this run installed', cleanup, smoke)
if not had_utils:
+91
View File
@@ -0,0 +1,91 @@
"""Linux-only smoke cleanup helper, sent over SSH before deleting test files.
Only exact, reserved smoke title directories establish ownership. Never signal
by process name, process group, Steam app id, or a shared Wine/Proton prefix.
"""
import json
import os
from pathlib import Path
import signal
import sys
import time
IDS = frozenset(prefix + kind for prefix in ('fc_smoke_', 'fc-smoke-')
for kind in ('arm64', 'x86_64', 'exe'))
def owned(pid, roots):
"""Require our uid and an exact cwd/exe or individual absolute argv path.
/proc retains a deleted cwd/exe with ' (deleted)' appended. This also lets
the final sweep catch an older run whose title was already removed.
"""
proc = Path('/proc') / str(pid)
try:
if pid == os.getpid() or proc.stat().st_uid != os.getuid():
return False
state = (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0]
if state == 'Z':
return False
paths = []
for key in ('cwd', 'exe'):
try:
paths.append(os.readlink(proc / key).removesuffix(' (deleted)'))
except (FileNotFoundError, PermissionError):
pass
try:
paths += os.fsdecode((proc / 'cmdline').read_bytes()).split('\0')
except PermissionError:
pass
return any(path == root or path.startswith(root + '/')
for path in paths for root in roots)
except (FileNotFoundError, ProcessLookupError, PermissionError):
return False
def candidates(roots):
return sorted(int(p.name) for p in Path('/proc').iterdir()
if p.name.isdigit() and owned(int(p.name), roots))
def stop(ids, grace=3):
if not ids or not set(ids) <= IDS:
raise ValueError('only reserved smoke title IDs may be stopped')
if not hasattr(os, 'pidfd_open') or not hasattr(signal, 'pidfd_send_signal'):
raise RuntimeError('safe smoke cleanup requires Linux Python with pidfd support')
roots = [str(Path.home() / 'devkit-game' / gid) for gid in ids]
stopped = set()
for sig in (signal.SIGTERM, signal.SIGKILL):
# Re-scan while waiting: wrappers can spawn a child during shutdown.
deadline = time.monotonic() + grace
while True:
pids = candidates(roots)
if not pids:
return {'stopped_pids': sorted(stopped)}
for pid in pids:
try:
fd = os.pidfd_open(pid)
except ProcessLookupError:
continue
try:
# Open first, then revalidate ownership. The handle cannot
# signal an unrelated process if this numeric PID is reused.
if owned(pid, roots):
try:
signal.pidfd_send_signal(fd, sig)
stopped.add(pid)
except ProcessLookupError:
pass
finally:
os.close(fd)
if time.monotonic() >= deadline:
break
time.sleep(0.1)
left = candidates(roots)
if left:
raise RuntimeError(f'smoke processes survived cleanup: {left}')
return {'stopped_pids': sorted(stopped)}
if __name__ == '__main__':
print(json.dumps(stop(sys.argv[1:])))
+219
View File
@@ -0,0 +1,219 @@
// Production standalone scripts with deterministic DOM/network boundaries. No browser/headset.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = process.env.STANDALONE_SOURCE_ROOT || path.resolve(__dirname, '..');
const read = name => fs.readFileSync(path.join(root, 'ui', name), 'utf8');
const script = html => html.match(/<script>([\s\S]*?)<\/script>/)[1];
const tick = async () => { for (let i = 0; i < 16; i++) await Promise.resolve(); };
function fixture(html) {
const elements = new Map(), events = new Map(), requests = [];
const document = {
activeElement: null,
getElementById: id => element(id),
querySelector: selector => selector === 'main' ? element('main') : document.querySelectorAll(selector)[0],
querySelectorAll: selector => [...elements.values()].flatMap(el => el.children).filter(el => el.matches(selector)),
addEventListener: (name, fn) => { if (!events.has(name)) events.set(name, []); events.get(name).push(fn); },
};
class Element {
constructor(id, attrs = '', text = '') {
this.id = id; this.dataset = {}; this.children = []; this.hidden = false; this.open = false;
this.isConnected = true; this.value = ''; this.checked = false; this.disabled = /\bdisabled\b/.test(attrs);
this.textContent = text; this.attrs = {}; this.listeners = new Map();
for (const [, key, value] of attrs.matchAll(/([\w-]+)="([^"]*)"/g)) {
this.attrs[key] = value;
if (key.startsWith('data-')) this.dataset[key.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value;
}
for (const [, key] of attrs.matchAll(/\bdata-([\w-]+)(?=\s|$)/g)) if (!Object.hasOwn(this.dataset, key)) this.dataset[key] = '';
}
matches(s) {
if (s === 'button:not(:disabled)') return this.tag === 'button' && !this.disabled;
if (s.startsWith('#')) return this.id === s.slice(1);
const data = s.match(/\[data-([\w-]+)(?:="([^"]*)")?\]/);
if (data) return Object.hasOwn(this.dataset, data[1]) && (data[2] === undefined || this.dataset[data[1]] === data[2]);
return false;
}
set innerHTML(value) {
this.html = value;
for (const child of this.children) { child.isConnected = false; if (document.activeElement === child) document.activeElement = null; }
this.children = [...value.matchAll(/<(button|h2)\b([^>]*)>([\s\S]*?)<\/\1>/g)].map(([, tag, attrs, text]) => {
const child = new Element((attrs.match(/\bid="([^"]+)"/) || [])[1], attrs, text);
child.tag = tag; child.parent = this; return child;
});
}
get innerHTML() { return this.html || ''; }
querySelectorAll(s) { return this.children.filter(el => el.matches(s)); }
querySelector(s) { return this.querySelectorAll(s)[0] || null; }
contains(el) { return el === this || this.children.includes(el); }
closest(s) { return this.matches(s) ? this : this.parent?.closest(s) || null; }
setAttribute(k, v) { this.attrs[k] = v; }
append(el) { el.parent = this; }
focus() { if (!this.disabled && this.isConnected) document.activeElement = this; }
showModal() { this.open = true; this.modalOpened = true; }
close() { this.open = false; this.onclose?.(); }
addEventListener(name, fn) { this.listeners.set(name, fn); }
reset() {}
}
function element(id) {
const dynamic = [...elements.values()].flatMap(el => el.children).find(el => el.id === id);
if (dynamic) return dynamic;
if (!elements.has(id)) elements.set(id, new Element(id));
return elements.get(id);
}
element('detail').hidden = element('kbd').hidden = true;
document.body = element('body');
// The sheet is stable; its contents are replaced by the production redraw.
element('detail').contains = el => el === element('detail') || element('sheet').contains(el);
const context = {document, location: {search: '', hash: ''}, URLSearchParams,
window: {addEventListener: (name, fn) => events.set(name, [fn])},
setTimeout: () => 1, clearTimeout() {}, Event: class {},
fetch: (url, options) => new Promise((resolve, reject) => requests.push({url, options, resolve, reject})),
};
vm.createContext(context);
vm.runInContext(script(html).replace('__FRAME_KEY__', JSON.stringify('ios +&?key/#')), context);
const run = code => vm.runInContext(code, context);
const answer = async (index, data, ok = true) => { (typeof index === 'number' ? requests[index] : index).resolve({ok, json: async () => data}); await tick(); };
return {element, document, context, requests, run, answer, events};
}
test('Assistant Back encodes the injected key for the same-origin workspace only', () => {
const html = read('assistant.html'), f = fixture(html);
const back = f.element('back');
const fallback = html.match(/<a[^>]*href="([^"]*)"[^>]*>Back to Frame Control/)[1];
const destination = new URL(back.href || fallback, 'http://127.0.0.1:8123/assistant#confirm=private');
assert.equal(destination.origin, 'http://127.0.0.1:8123');
assert.equal(destination.pathname, '/');
assert.equal(destination.hash, '');
assert.equal(destination.searchParams.get('key'), 'ios +&?key/#');
const index = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
const expression = index.match(/const UI_KEY = ([^;]+);/)[1];
assert.equal(vm.runInNewContext(expression, {URLSearchParams, location: {search: destination.search}}), 'ios +&?key/#');
for (const [, href] of html.matchAll(/href="(https?:[^\"]+)"/g)) assert.ok(!href.includes('key='));
assert.match(html, /referrerpolicy="no-referrer"/);
});
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"; render()');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
f.requests[0].reject(Error('offline')); await tick();
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const retry = f.element('content').querySelector('[data-retry-installed]');
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.requests[1].url, '/api/android');
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.run('st.installedState'), 'ready');
const failed = f.run('loadInstalled()');
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
f.requests[2].reject(Error('refresh failed')); await failed;
assert.match(f.element('content').innerHTML, /Cached app/);
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
assert.match(f.element('content').innerHTML, /data-retry-installed/);
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
});
test('newest installed read wins over older success and failure without pruning cached data', async () => {
const f = fixture(read('store.html'));
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installed.has("new.app")'), true);
assert.equal(f.run('st.needsData.has("new.app")'), true);
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
f.requests[3].reject(Error('late failure')); await old;
assert.equal(f.run('st.installedState'), 'ready');
assert.equal(f.run('st.installedError'), '');
assert.equal(f.run('st.installed.size'), 0);
});
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"');
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installedState'), 'error');
assert.equal(f.run('st.installedError'), 'latest offline');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
});
test('catalogue details distinguish unknown and stale installed state', async () => {
const f = fixture(read('store.html'));
f.context.app = {name: 'Example', package: 'example.app', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('openDetail(app)');
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
f.requests[0].reject(Error('offline')); await tick();
assert.doesNotMatch(f.element('sheet').innerHTML, /data-act="install"/);
const loaded = f.run('loadInstalled()'); await f.answer(f.requests.at(-1), {apps: []}); await loaded;
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
});
test('named native Store dialog enters, contains and restores focus across async redraws', async () => {
const html = read('store.html');
assert.match(html.match(/<[^>]*id="detail"[^>]*>/)[0], /<dialog\b[^>]*aria-labelledby="detailTitle"/);
const f = fixture(html);
await f.answer(0, {apps: []});
f.context.app = {name: 'Example', offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; st.loading = false; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
assert.equal(f.element('detail').modalOpened, true);
assert.equal(f.element('toast').parent, f.element('detail'));
assert.equal(f.document.activeElement.id, 'detailTitle');
const install = f.element('sheet').querySelector('[data-act="install"]'); install.focus();
await f.answer(2, {description: 'Details arrived'}); // request 1 is bootstrap search
assert.equal(f.document.activeElement.dataset.act, 'install');
assert.notEqual(f.document.activeElement, install);
const close = f.element('sheet').querySelector('[data-act="close"]'); close.focus();
f.run('st.jobs.set("test:example", {done: false, stage: "Downloading", percent: 20}); drawDetail(); render()');
assert.equal(f.document.activeElement.dataset.act, 'close');
const keydown = f.element('detail').onkeydown || f.element('detail').listeners.get('keydown');
const event = {key: 'Tab', shiftKey: false, preventDefault() {this.prevented = true;}};
keydown(event);
assert.equal(event.prevented, true);
assert.equal(f.document.activeElement.dataset.act, 'close'); // only remaining action
f.run('st.jobs.delete("test:example"); drawDetail()');
const first = f.element('sheet').querySelector('[data-act="install"]'); first.focus();
const reverse = {key: 'Tab', shiftKey: true, preventDefault() {this.prevented = true;}}; keydown(reverse);
assert.equal(reverse.prevented, true); assert.equal(f.document.activeElement.dataset.act, 'close');
first.focus(); f.run('st.jobs.set("test:example", {done: false}); drawDetail()');
assert.equal(f.document.activeElement.dataset.act, 'close'); // focused action disappeared
f.run('closeDetail()');
assert.equal(f.element('detail').open, false);
assert.equal(f.element('toast').parent, f.document.body);
assert.equal(f.document.activeElement.dataset.i, '0');
assert.notEqual(f.document.activeElement, opener); // render replaced the background card
});
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
const f = fixture(read('store.html'));
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
f.requests[0].reject(Error('offline')); await tick();
await f.answer(1, {apps: [f.context.app], sources: []});
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.document.activeElement.dataset.act, 'close');
await f.answer(f.requests.at(-1), {apps: []});
assert.equal(f.document.activeElement.dataset.act, 'close');
assert.match(f.element('sheet').innerHTML, /data-act="install"/);
const cancel = {preventDefault() {this.prevented = true;}};
f.element('detail').oncancel(cancel);
assert.equal(cancel.prevented, true); assert.equal(f.element('detail').open, false);
assert.equal(f.document.activeElement.dataset.i, '0');
// Removing the app while details are open falls back to Search on close.
f.run('openDetail(app); st.apps = []; render(); closeDetail()');
assert.equal(f.document.activeElement, f.element('q'));
});
+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,'');
}
});
+329
View File
@@ -0,0 +1,329 @@
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
import sandbox # noqa: F401
import io
import json
import shutil
import stat
import subprocess
import zipfile
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_android_import as backup
import server
from apk_fixtures import apk, manifest
PKG = 'com.Armature.VR4'
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
'instance': 2800000001, 'shortcut': 123, 'game_id': 456}
class BackupTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.apk = self.root / 'VR4-Android-Shipping-arm64.apk'
self.apk.write_bytes(apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, 26)}))
self.obb = [self.root / f'{kind}.203.{PKG}.obb' for kind in ('main', 'patch')]
for p in self.obb:
p.write_bytes(b'game data')
def test_desktop_apk_finds_matching_siblings_but_browser_requires_selection(self):
(self.root / 'main.1.org.other.game.obb').write_bytes(b'other game')
with patch.object(backup.android, 'ssh') as ssh:
desktop = backup.inspect([str(self.apk)], discover=True)
browser = backup.inspect([str(self.apk)])
selected = backup.inspect([str(self.apk), *map(str, self.obb)])
ssh.assert_not_called()
self.assertEqual(desktop['obb'], self.obb)
self.assertEqual(browser['obb'], [])
self.assertEqual(backup.public(selected)['obb'][0]['name'], self.obb[0].name)
self.assertEqual(backup.public(selected)['package'], PKG)
self.assertNotIn(str(self.root), str(backup.public(selected)))
def test_wrong_data_empty_data_duplicate_names_and_multiple_apks_fail_before_install(self):
other = self.root / 'main.203.org.other.game.obb'
other.write_bytes(b'other game')
with self.assertRaisesRegex(backup.android.FrameError, 'does not belong'):
backup.inspect([str(self.apk), str(other)])
self.obb[0].write_bytes(b'')
with self.assertRaisesRegex(backup.android.FrameError, 'nonempty'):
backup.inspect([str(self.apk), str(self.obb[0])])
second = self.root / 'another.apk'
second.write_bytes(self.apk.read_bytes())
with self.assertRaisesRegex(backup.android.FrameError, 'one game at a time'):
backup.inspect([str(self.apk), str(second)])
self.obb[0].write_bytes(b'data')
folder = self.root / 'copy'
folder.mkdir()
duplicate = folder / self.obb[0].name
duplicate.write_bytes(b'data')
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
with patch.object(backup.android, 'read_meta', return_value=None):
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
backup.inspect(list(map(str, self.obb)))
with patch.object(backup.android, 'read_meta', return_value=META):
plan = backup.inspect(list(map(str, self.obb)))
self.assertIsNone(plan['apk'])
self.assertEqual(plan['checked']['version_code'], 210)
def test_apk_and_both_data_files_install_launch_verify_copy_close_in_order(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
order = []
def added(pkg, checked, files, *args, **kwargs):
order.append('copy-close')
self.assertEqual(pkg, PKG)
self.assertEqual(files, self.obb)
return {'closed': True, 'message': 'copied', 'app': META, 'needs_game_data': False}
with patch.object(backup.quest, '_running', side_effect=[False, False, True]), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', side_effect=lambda *a, **k: order.append('install') or META), \
patch.object(backup.android, 'launch', side_effect=lambda *a: order.append('launch')), \
patch.object(backup.quest, '_wait_ready', side_effect=lambda *a: order.append('ready')), \
patch.object(backup.quest, '_check_android_package', side_effect=lambda *a: order.append('verify')), \
patch.object(backup.quest, 'add_game_data', side_effect=added):
result = backup.install(plan)
self.assertEqual(order, ['install', 'launch', 'ready', 'verify', 'copy-close'])
self.assertIn('2 game-data files', result['message'])
self.assertIn('Ready to open', result['message'])
def test_copy_failure_closes_game_and_keeps_retry_instruction(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
with patch.object(backup.quest, '_running', side_effect=[False, True]), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META), \
patch.object(backup.quest, '_wait_ready'), patch.object(backup.quest, '_check_android_package'), \
patch.object(backup.quest, 'add_game_data', side_effect=backup.android.FrameError('transfer failed')), \
patch.object(backup.quest, '_close', return_value=(True, None)) as close:
with self.assertRaisesRegex(backup.android.FrameError, 'installed, but.*Import the same backup again'):
backup.install(plan)
close.assert_called_once_with(PKG)
def test_changed_files_are_rejected_before_frame_access(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
self.obb[0].write_bytes(b'changed contents')
with patch.object(backup.android, 'ssh') as ssh:
with self.assertRaisesRegex(backup.android.FrameError, 'backup changed'):
backup.install(plan)
ssh.assert_not_called()
def test_running_game_and_newer_installed_version_are_not_replaced(self):
plan = backup.inspect([str(self.apk), *map(str, self.obb)])
newer = {**META, 'version_code': plan['checked']['version_code'] + 1}
for running, existing, message in [(True, None, 'Close'), (False, newer, 'newer')]:
with self.subTest(running=running), \
patch.object(backup.quest, '_running', return_value=running), \
patch.object(backup.android, 'read_meta', return_value=existing), \
patch.object(backup.android, 'install') as install:
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.install(plan)
install.assert_not_called()
def test_apk_without_data_installs_without_launching(self):
plan = backup.inspect([str(self.apk)])
with patch.object(backup.quest, '_running', return_value=False), \
patch.object(backup.android, 'read_meta', return_value=None), \
patch.object(backup.android, 'install', return_value=META), \
patch.object(backup.android, 'launch') as launch:
result = backup.install(plan)
launch.assert_not_called()
self.assertFalse(result['needs_game_data'])
class StagingTests(unittest.TestCase):
setUp = BackupTests.setUp
def tearDown(self):
for entry in server._android_backups.values():
server._discard_android_backup(entry)
server._android_backups.clear()
def test_uploaded_batch_is_inspected_together_and_cancel_removes_all_uploads(self):
token = server.android_backup({'action': 'begin'})['token']
for path in [self.apk, *self.obb]:
class Request:
headers = {'X-Filename': path.name, 'X-Mode': 'android-backup',
'X-Android-Backup': token, 'Content-Length': str(path.stat().st_size)}
rfile = io.BytesIO(path.read_bytes())
server.Handler.upload(Request())
read = server.android_backup({'action': 'inspect', 'token': token})
self.assertEqual(len(read['plan']['obb']), 2)
folders = list(server._android_backups[token]['dirs'])
server.android_backup({'action': 'discard', 'token': token})
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(self.apk.exists())
def test_headset_change_refuses_import_and_pc_folder_is_left_to_title_importer(self):
class Link:
device = 'a'
def active_device(self):
return {'id': self.device}
link = Link()
with patch.object(server, 'LINK', link):
read = server.android_backup({'action': 'inspect', 'paths': [str(self.apk)]})
link.device = 'b'
with self.assertRaisesRegex(server.Failure, 'headset changed'):
server.android_backup({'action': 'install', 'token': read['token']})
pc = self.root / 'pc'
pc.mkdir()
(pc / 'game.exe').write_bytes(b'program')
self.assertEqual(server.android_backup({'action': 'inspect', 'paths': [str(pc)], 'probe': True}),
{'android': False})
class BundleTests(unittest.TestCase):
setUp = BackupTests.setUp
tearDown = StagingTests.tearDown
def bundle(self, entries=None, suffix='.xapk'):
path = self.root / ('backup' + suffix)
path.write_bytes(apk(entries if entries is not None else {
'backup/game.apk': self.apk.read_bytes(),
f'backup/Android/obb/{PKG}/{self.obb[0].name}': b'game data',
'backup/manifest.json': b'{"package_name":"untrusted.metadata"}',
}))
return path
def test_all_explicit_extensions_use_current_preview_and_cancel_cleanup(self):
for suffix in ('.xapk', '.apks', '.apkm'):
with self.subTest(suffix=suffix):
source = self.bundle(suffix=suffix)
with patch.object(backup.android, 'ssh') as ssh:
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
ssh.assert_not_called()
self.assertEqual(read['plan']['package'], PKG)
self.assertEqual(read['plan']['obb'][0]['name'], self.obb[0].name)
folders = list(server._android_backups[read['token']]['dirs'])
server.android_backup({'action': 'discard', 'token': read['token']})
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
source = self.bundle()
token = server.android_backup({'action': 'begin'})['token']
class Request:
headers = {'X-Filename': source.name, 'X-Mode': 'android-backup',
'X-Android-Backup': token, 'Content-Length': str(source.stat().st_size)}
rfile = io.BytesIO(source.read_bytes())
server.Handler.upload(Request())
read = server.android_backup({'action': 'inspect', 'token': token})
self.assertEqual(read['plan']['package'], PKG)
self.assertEqual(len(server._android_backups[token]['dirs']), 2)
folders = list(server._android_backups[token]['dirs'])
server.android_backup({'action': 'discard', 'token': token})
self.assertTrue(all(not Path(p).exists() for p in folders))
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
cases = [
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), '/outside.obb': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
({'manifest.json': b'{}'}, 'standalone APK'),
]
for entries, message in cases:
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
source = self.bundle(entries)
with self.assertRaisesRegex(server.Failure, message):
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
self.assertFalse(server._android_backups)
ssh.assert_not_called()
self.assertTrue(source.exists())
def test_links_limits_bad_zip_and_mixed_selection_are_refused(self):
source = self.bundle({'base.apk': self.apk.read_bytes()})
link = zipfile.ZipInfo('link.apk')
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(source, 'a') as archive:
archive.writestr(link, 'somewhere')
destination = self.root / 'extract'
destination.mkdir()
with self.assertRaisesRegex(backup.android.FrameError, 'links'):
backup.unpack_bundle(source, destination)
self.assertEqual(list(destination.iterdir()), [])
source = self.bundle()
for limit, value, message in [('MAX_FILES', 1, 'too many'), ('MAX_BYTES', 1, 'size limit')]:
with patch.object(backup, limit, value):
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.unpack_bundle(source, destination)
with patch.object(backup.shutil, 'disk_usage', return_value=type('Disk', (), {'free': 0})()):
with self.assertRaisesRegex(backup.android.FrameError, 'staging space'):
backup.unpack_bundle(source, destination)
with self.assertRaisesRegex(server.Failure, 'one complete'):
server.android_backup({'action': 'inspect', 'paths': [str(source), str(self.apk)]})
source.write_bytes(b'not a zip')
with self.assertRaisesRegex(server.Failure, 'Could not read'):
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
self.assertFalse(server._android_backups)
def test_manifest_split_and_required_split_are_refused(self):
from test_quest import axml
for attrs in ({'split': 'config.arm64'}, {'split_required': True},
{'split_required': True, 'resmap': True}):
data = axml(PKG, **attrs)
if attrs.get('resmap'):
# Strip the attribute name, retaining the Android resource ID.
data = data.replace('isSplitRequired'.encode('utf-16-le'),
('x' * len('isSplitRequired')).encode('utf-16-le'))
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
with self.subTest(attrs=attrs):
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
backup.inspect([str(self.apk)])
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
source = self.bundle()
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
entry = server._android_backups[read['token']]
folders = list(entry['dirs'])
def job(label, work, **kwargs):
return work(lambda *args: None)
with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', side_effect=job), \
patch.object(backup, 'install', side_effect=backup.android.FrameError('fixture install failure')) as install:
with self.assertRaisesRegex(backup.android.FrameError, 'fixture install failure'):
server.android_backup({'action': 'install', 'token': read['token']})
install.assert_called_once_with(entry['plan'], 'auto', unittest.mock.ANY)
self.assertFalse(server._android_backups)
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
@unittest.skipUnless(shutil.which('node'), 'needs node')
def test_actual_send_handler_routes_bundle_to_importer_and_pc_zip_to_titles(self):
page = (Path(__file__).resolve().parents[1] / 'ui/index.html').read_text(encoding='utf-8')
function = page[page.index('async function sendFilesOne('):page.index('$("androidBackupChoose").onclick')]
script = r"""
const calls = [];
global.window = {connDevice:'fixture'};
global.$ = () => ({});
global.refresh = () => {};
global.toast = msg => {throw new Error(msg)};
global.TITLE_EXT = /\.(zip|exe)$/i;
global.importAndroidFiles = async files => calls.push(['android', files.map(f => f.name)]);
global.sideload = async file => calls.push(['title', file.name]);
global.act = () => {throw new Error('unexpected copy')};
(async () => {
for (const name of ['game.xapk', 'game.apks', 'game.apkm', 'pc.zip']) {
await sendFilesOne([{name, size:123}], new Set(), 'fixture');
}
await sendFilesOne([{name:'base.apk',size:123},{name:'split.apk',size:123}], new Set(), 'fixture');
process.stdout.write(JSON.stringify(calls));
})().catch(e => {console.error(e); process.exitCode = 1});
"""
result = subprocess.run(['node', '-e', function + script], capture_output=True, text=True, check=True)
self.assertEqual(json.loads(result.stdout), [
['android', ['game.xapk']], ['android', ['game.apks']],
['android', ['game.apkm']], ['title', 'pc.zip'],
['android', ['base.apk', 'split.apk']]])
if __name__ == '__main__':
unittest.main()
+12 -3
View File
@@ -17,9 +17,14 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea
class ArtworkTests(unittest.TestCase):
def setUp(self):
p = patch.object(search, 'compat_reports', None)
p.start()
self.addCleanup(p.stop)
with _images._lock:
_images._urls.clear()
_images._tokens.clear()
_images._cache.clear()
_images._dns.clear()
def test_only_registered_source_images_are_fetchable(self):
with patch.object(_images, 'fetch') as fetch:
@@ -71,9 +76,13 @@ class ArtworkTests(unittest.TestCase):
_images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
def test_handles_are_bounded(self):
for i in range(4100):
_images.register('https://example.com/%d.png' % i)
self.assertEqual(len(_images._urls),4096)
with patch.object(_images, 'MAX_TOKENS', 50):
first = _images.register('https://example.com/0.png')
for i in range(1, 60):
_images.register('https://example.com/%d.png' % i)
self.assertEqual((len(_images._urls), len(_images._tokens)), (50, 50))
self.assertNotEqual(_images.register('https://example.com/0.png'), first) # forgotten, so a new handle
self.assertEqual(_images.register('https://example.com/59.png'), _images.register('https://example.com/59.png'))
def test_plain_language_verdict_is_evidence_based(self):
self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
Loaded 100 of 169 files, more files were not shown because too many files have changed in this diff. Show more