Compare commits

..
Author SHA1 Message Date
SAPHID 0c99722073 Document current Frame panel layout API limits 2026-10-24 10:09:49 +00:00
Alex SouthwellandSAPHID 5b47cd3330 Document packaged Desktop Capture result and visibility limit (#159)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 10:03:36 +00:00
Alex SouthwellandSAPHID aead570b5d Clarify Return to Game Mode switch help (#158)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:47:24 +00:00
Alex SouthwellandSAPHID de931bc56f Document packaged Return to Game Mode button verification (#157)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:39:21 +00:00
Alex SouthwellandSAPHID 71288d8980 Document packaged desktop Steam clip Export verification (#156)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:19:46 +00:00
Alex SouthwellandSAPHID 64b9babe87 Document Linux PC-host feasibility blocker on lxso2 (#155)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:17:14 +00:00
Alex SouthwellandSAPHID 4e456480ae Document real 100-second Steam clip export (#154)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 09:05:12 +00:00
Alex SouthwellandSAPHID 241f538f99 Bump version to 0.4.17 (#153)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:43:00 +00:00
Alex SouthwellandSAPHID 21dacfcbe7 Distinguish unreadable panel discovery from an empty list (#152)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:34:56 +00:00
Alex SouthwellandSAPHID eb181d8996 Add opt-in desktop low-battery comfort alerts (#150)
* Add opt-in desktop low-battery comfort alerts

* Fix desktop battery alert test harness and Windows encoding

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:22:56 +00:00
Alex SouthwellandSAPHID 827ddcf14f Clarify desktop installation and first headset connection (#151)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:08:47 +00:00
Alex SouthwellandSAPHID 7c3894e630 docs: record real Frame clip Export button check (#149)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 08:04:06 +00:00
Alex SouthwellandSAPHID 040581a4c7 Bump Frame Control to 0.4.16 (#148)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:42:37 +00:00
Alex SouthwellandSAPHID 17f6d52717 Document real Steam clip export verification (#147)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:34:34 +00:00
Alex SouthwellandSAPHID b4e80cab0a Escape power report CSV metadata for spreadsheets (#146)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:28:26 +00:00
Alex SouthwellandSAPHID b98e3e496f Bump Frame Control to 0.4.15 (#145)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 07:08:23 +00:00
Alex SouthwellandSAPHID a148e02d39 Add bounded read-only power and heat reports to desktop Tools (#144)
* Add bounded desktop power and heat observations with offline downloads

* Fix desktop power tests on Python 3.9

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:58:19 +00:00
Alex SouthwellandSAPHID 0fb9174c23 Fix Return to Game Mode detection in full Plasma (#143)
* Fix Frame session detection for full Plasma Desktop Mode

* Document real Frame Desktop Mode return verification

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:46:17 +00:00
Alex SouthwellandSAPHID c7489a88eb Bump Frame Control to 0.4.14 (#141)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 06:09:59 +00:00
Alex SouthwellandSAPHID be74850a8e Add accessible offline power sampler exports with partial results (#140)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:59:53 +00:00
Alex SouthwellandSAPHID dc21428f85 Add guarded read-only doctor plans and before/after reports (#139)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:51:49 +00:00
Alex SouthwellandSAPHID fa43c83b06 Recover release uploads from untagged drafts (#138)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:35:49 +00:00
Alex Southwell 1d4c57d648 Merge pull request #137 from saphid/release/0.4.13
Bump Frame Control to 0.4.13
2026-10-24 05:11:37 +00:00
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
63 changed files with 3852 additions and 212 deletions

No files matched your search

+28 -4
View File
@@ -26,7 +26,9 @@ jobs:
# "view || create" could race and make duplicate drafts. The draft is tied to
# the pushed tag (the tag must already exist on GitHub, then tag_name is set
# explicitly), so scripts/publish-release.sh and `gh release upload` find it
# by tag. It is created with the REST API so the id comes straight back: the
# by tag. Editing a draft can make GitHub show an untagged-* name again; the
# upload step below recovers that exact draft if tag lookup fails. It is
# created with the REST API so the id comes straight back: the
# release list can lag a fresh draft (v0.4.2's first run found nothing and
# PATCHed releases/ with an empty id). tests/test_release_workflow.py runs
# this step against tests/fakegh.
@@ -40,8 +42,13 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
tag="${GITHUB_REF_NAME}"
# A rerun finds the draft an earlier attempt made.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq ".[] | select(.tag_name == \"$tag\") | .id" | head -n 1)
# A rerun finds the draft even if GitHub temporarily shows untagged-*.
id=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.tag_name == \"$tag\" or (.draft and (.tag_name | startswith(\"untagged-\")) and .name == \"Frame Control ${tag#v}\")) | .id")
if [ -n "$id" ] && ! [[ "$id" =~ ^[0-9]+$ ]]; then
echo "expected one release for $tag; found: $id" >&2
exit 1
fi
if [ -z "$id" ]; then
# Given a missing tag, GitHub would create it on the default branch.
gh api "repos/$GH_REPO/git/ref/tags/$tag" >/dev/null \
@@ -135,8 +142,25 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
# A release-notes edit can make GitHub expose a draft as untagged-*.
# Keep the normal upload path; recover only that lookup failure.
if result=$(gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber 2>&1); then
printf '%s\n' "$result"
exit 0
fi
printf '%s\n' "$result" >&2
case "$result" in *"release not found"*) ;; *) exit 1 ;; esac
tag="${GITHUB_REF_NAME}"
ids=$(gh api --paginate "repos/$GH_REPO/releases?per_page=100" \
--jq ".[] | select(.draft and .name == \"Frame Control ${tag#v}\" and (.tag_name == \"$tag\" or (.tag_name | startswith(\"untagged-\")))) | .id")
if ! [[ "$ids" =~ ^[0-9]+$ ]]; then
echo "expected one matching draft for $tag; found: ${ids:-none}" >&2
exit 1
fi
gh api -X PATCH "repos/$GH_REPO/releases/$ids" -f tag_name="$tag" >/dev/null
gh release upload "$tag" ${{ matrix.files }} --clobber
# Tagged builds are attached to the release above; only other builds need artifacts.
- uses: actions/upload-artifact@v4
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
+55
View File
@@ -2,6 +2,61 @@
## Unreleased
- Recheck saved spatial layouts on SteamOS 0.5.6 / SteamVR 2.18.2 (issue #31).
Read-only probes expose some panel geometry, but safe restoration of
Steam/gamescope docking and positions remains unverified. Record exact API
results and a reproducible probe; saved layouts remain unavailable.
- Record packaged v0.4.17 Desktop Capture succeeding on a mapped nested KDE
compositor on a real Frame with SteamOS 0.5.6. Headset overlay visibility was
not established; this is evidence, not a new fix or resolution of issue #73.
- Correct the Return to Game Mode help text: Frame Control selects the switch
supported by the headset's SteamOS build, including `steamosctl` on current
Frames. The button's behavior is unchanged.
- Distinguish an unreadable gamescope panel list from a confirmed empty list in
Desktop Capture and Control (issue #73). Failed discovery now asks users to
check SteamVR and report the exact message and SteamOS version, rather than
claiming no panel is open. This does not establish a new capture fix.
- Add an opt-in desktop low-battery alert without starting a family session.
Existing status reads confirm low charge and drain; recovery hysteresis and a
ten-minute cooldown limit repeat alerts. Off on page open/headset switch;
pauses with hidden-page status refreshes. No extra polling or headset changes.
- Clarify first-time desktop downloads, installation and opening on the website
and README, including CPU choices, unsigned-app warnings, pairing fallback
and the source-only iOS build. Keep section navigation available on mobile.
- Treat headset aliases and other CSV metadata as plain text in power and heat
exports, so spreadsheet formula prefixes cannot run when a report is opened.
- Add desktop Tools → Power & heat report: bounded read-only one/five-minute
observations with progress, stop and HTML/JSON/CSV downloads. Received samples
survive stop, disconnect and headset switch, labeled incomplete and tied to the
original headset. Shared sampler/report modules ship with the app; no power or
thermal controls are added. Download before replacing a report or quitting.
- Fix Return to Game Mode misidentifying full Plasma Desktop Mode when its VR
overlay also runs gamescope (issue #142). Session targets now determine the
mode; conflicting or incomplete evidence stays unknown. Use SteamOS's
`steamosctl` switch when available, with older selector commands retained.
- Export read-only power and heat observations to an offline accessible HTML
report with JSON/CSV downloads. `--html FILE` limits sessions to ten minutes;
Ctrl+C saves received samples with an explicit incomplete state. Units,
unknown counts and sources remain visible; no headset controls are added.
- Add a guarded `frame-doctor.sh --plan` with read-only, evidence-specific next
steps and explicit local baseline reports for before/after diagnostics (issue
#34). Restart risk blocks plans/comparisons; no automatic repairs, password
changes or headset security changes are performed.
- Recover a tagged release upload when GitHub temporarily displays its draft
as `untagged-…` after an edit, without creating a duplicate draft or
silently retrying unrelated upload failures.
- 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
+61 -29
View File
@@ -7,9 +7,9 @@
**Manage your Valve Steam Frame from your computer.**<br>
See what the headset sees, install games and Android apps, move files and text across, and check battery and status, all over SSH.
[![Latest release](https://img.shields.io/github/v/release/saphid/steam-frame?label=release&color=1a9fff)](https://github.com/saphid/steam-frame/releases/latest)
[![Latest release](https://img.shields.io/github/v/release/saphid/frame-control?label=release&color=1a9fff)](https://github.com/saphid/frame-control/releases/latest)
[![Platforms](https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-2a475e?label=runs%20on)](#install)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/steam-frame/checks.yml?branch=main&label=checks)](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
[![Checks](https://img.shields.io/github/actions/workflow/status/saphid/frame-control/checks.yml?branch=main&label=checks)](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-66c0f4)](LICENSE)
[**Website**](https://frame-control.pages.dev) · [**Download**](#install) · [Trailer](#trailer) · [Features](#features) · [Set up the headset](#set-up-the-headset) · [Feedback](#feedback) · [Docs](#going-further)
@@ -19,7 +19,7 @@ See what the headset sees, install games and Android apps, move files and text a
<img src="docs/img/frame-control.png" alt="Frame Control's Games tab: installed games, sideloaded titles, and your Steam library with Frame ratings" width="900">
<a id="trailer"></a>
<a href="https://github.com/saphid/steam-frame/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<a href="https://github.com/saphid/frame-control/releases/download/trailer/frame-control-trailer.mp4"><img src="docs/img/trailer.jpg" alt="Watch the Frame Control trailer" width="900"></a>
<sub>The trailer: 66 seconds, with sound. Downloads the MP4 from the trailer release.</sub>
@@ -116,17 +116,30 @@ already ships (sideloading a game copies Valve's own devkit scripts to
## Install
**Install Frame Control on your computer, not on the headset.** The website is
for downloads and instructions; it does not connect to your Frame in a browser.
Choose the operating system and processor of the computer you will use.
| | Download | Needs |
|---|---|---|
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-win-x64.zip) | Nothing extra |
| **Linux** (x64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/steam-frame/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
| **macOS** (Apple Silicon) | [Frame-Control-mac-arm64.dmg](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg) | Nothing extra |
| **Windows** 10 / 11 (x64) | [Frame-Control-Setup-x64.exe](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe) · [portable .zip](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip) | OpenSSH Client (normally installed) |
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
**iPhone and iPad:** the same features from your phone, with nothing to install on
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
**Which build?** On a Mac, check Apple menu → About This Mac: the download is
for Apple Silicon (M-series); no Intel Mac build is published. On Windows,
check Settings → System → About → System type: the release is x64, not a native
Windows ARM build. On Linux, `uname -m` reports `x86_64` (x64) or `aarch64`
(arm64). Use `.deb` on Debian/Ubuntu, or AppImage on other distributions.
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
**iPhone and iPad:** no App Store, TestFlight or `.ipa` download is provided in
the current release. It runs from your phone without a computer after setup.
Build it from [`ios/`](ios) in Xcode on a Mac with your own signing team; see
[docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`. macOS includes SSH; Windows needs the
OpenSSH Client optional feature (normally installed).
From 0.4 it updates itself: when a new version is published, a banner offers
**Update and restart**. It sends anonymous usage statistics, which you can turn
off. Sharing compatibility results and error details is opt-in. See
@@ -139,7 +152,7 @@ distribution's. If you already have `adb`, the app uses yours.
There's no paid Apple developer account behind it, so macOS says the app is
damaged or can't be checked. Drag it to Applications, then clear the download
quarantine once:
quarantine once, only for a download you trust from the releases linked above:
```sh
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
@@ -168,26 +181,44 @@ If it complains about FUSE, install `libfuse2` (Ubuntu 24.04+: `libfuse2t64`),
or run it with `--appimage-extract-and-run`.
</details>
## Open the app
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
open it from Applications. For the unsigned-app warning, use the macOS steps
above. Allow local network access and control of Terminal when prompted.
- **Windows:** run the installer, then open **Frame Control** from Start. For
the portable download, extract the entire `.zip` first and run
`Frame Control.exe` inside it. See the SmartScreen note above.
- **Linux:** install the `.deb` with your package installer and launch
**Frame Control** from your applications menu; or make the AppImage executable
and run it as described above. Install the OpenSSH client if `ssh` is missing.
## Set up the headset
You type one password on the headset, once. Everything else happens on your
computer.
Keep the Frame awake and connect it and your computer to the same trusted local
network. Guest Wi-Fi/client isolation can prevent discovery and connections.
1. **On the Frame:** Steam Settings → System → **Enable Developer Mode**, then
in the Developer section, **Set User Password**. Pick something short:
you'll type it once more on your computer and then never again.
2. **On your computer:** open Frame Control. It offers to **Set Up
Connection**, which finds the headset, creates an SSH key, and asks for that
password once in a terminal window. If it can't find the Frame, type the
IP address from the Frame's Quick Settings.
Before asking for the password it tries Valve's SteamOS devkit pairing: in
the headset, open Steam Settings → Developer → **Pair new host** and approve
the request, and no password is needed. (The service and the pairing-mode
step are verified on a Frame; the approval itself isn't yet. See
[SSH](docs/ssh.md#password-free-pairing-steamos-devkit-service).)
3. That's it. The app now reaches the headset whenever it's awake and on the
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
Developer → **Set User Password**. Choose a password that isn't easy to guess:
it also protects `sudo` and remote desktop, even after SSH keys are set up.
2. **On your computer:** open Frame Control and choose **Set Up Connection…**.
If you dismissed the first-run prompt, use **Frame → Set Up Connection…**.
Setup opens a terminal and tries to find the headset. If it can't, use its IP
address from Quick Settings or Steam Settings → Internet. Windows/Linux setup
prompts for it; macOS exits instead. On macOS, return to the app →
**Devices & connection → + Add a headset…**, set SSH alias to `frame` for
the default headset, fill in **Address**, then choose **Set Up…**.
3. **Pair or enter the password:** setup first tries Valve's devkit pairing.
Open Steam Settings → Developer → **Pair new host** in the headset and approve
only the request you just started. The service and pairing-mode step are
verified on a Frame; approval and key installation through that flow remain
unverified. If pairing fails or times out, setup falls back to asking for the
Developer Mode password in the computer's terminal. Password typing may show
no characters; press Enter when done. See [SSH details](docs/ssh.md#password-free-pairing-steamos-devkit-service).
4. After setup succeeds, return to Frame Control. It uses SSH keys for later
connections whenever the Frame is awake and reachable. If it stays offline,
check Developer Mode, the address and network isolation, then run setup again.
For connections away from home, see [Tailscale](docs/tailscale.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
@@ -236,6 +267,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Power and heat measurements](docs/power-and-heat.md) | Read-only sampling and offline HTML reports with JSON/CSV downloads |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
@@ -251,8 +283,8 @@ Frame's software fits together, all checked against a real headset and labelled
loopback `127.0.0.1:8080`) only through SSH tunnels. The compatibility
database key (maintainer-only) is never written to the repo.
- `steamos` has `sudo`, protected by the same Developer Mode password. Once
you've switched to key auth, a short password still protects `sudo` and
RDP, so pick one that isn't trivially guessable.
you've switched to key auth, that password still protects `sudo` and RDP,
so pick one that isn't trivially guessable.
- Don't port-forward 22, 3389, or 5555–5599 from your router. For remote access,
use Tailscale: `scripts/tailscale-on-frame.sh` (no sudo). In its userspace mode
**every** Frame port is reachable from your tailnet, including Steam's DevTools
+4
View File
@@ -13,6 +13,10 @@ const path = require("path");
const REQUIRED = [
"ui/server.py",
"ui/frame_power.py",
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.12",
"version": "0.4.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.12",
"version": "0.4.17",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.12",
"version": "0.4.17",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -68,7 +68,9 @@
"from": "../scripts",
"to": "scripts",
"filter": [
"*.sh"
"*.sh",
"frame_power_sample.py",
"frame_power_report.py"
]
},
{
+64 -31
View File
@@ -12,15 +12,41 @@ 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.
Android data restore. Unit tests and the fake Frame cover those (see
[Verification](#verification)).
On 2026-10-24, the real-device reproduction in [issue #142](https://github.com/saphid/frame-control/issues/142)
confirmed full Plasma Desktop Mode on the same build: the Plasma session target
was active and the Game Mode target inactive. Desktop Mode also ran gamescope
for `steamvr-plasma.service`, so the old process check incorrectly reported
Game Mode. SteamOS's `steamosctl switch-to-game-mode` restored Game Mode and
reversed the target states; battery stayed at 99%, and the rollback timer and
shared lock were cleaned up. After #143 passed CI, the corrected code was
tested in another full Plasma session under a three-minute rollback timer.
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
the button. Game Mode became active and a second call made no switch. Battery
remained at 99%; the timer and lock were removed. That test used the production
CLI rather than clicking the desktop button.
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
literal packaged-button check on the same Frame/build. With the shared lock and
a verified three-minute rollback timer, the headset entered full Plasma Desktop
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
reported Desktop Mode despite gamescope. A trusted automated pointer click on
the visible **Return to Game Mode** button, accepting its confirmation, returned
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
confirmed Game Mode. A second confirmed button click returned `switched: false`;
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
then reported Game Mode and disabled Return. The test app/server and virtual
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
This used the unchanged extracted AppImage payload with its bundled Python on
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
FUSE mounting, desktop integration, hardware rendering and the view inside a
worn headset were not checked. Older SteamOS builds remain fixture-tested.
## Back up
@@ -118,29 +144,28 @@ 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)).
Full Desktop Mode is a different session. **Check** reads the main user's
systemd session targets over SSH: `gamescope-session.target` and
`plasma-workspace-wayland.target`. Game Mode requires the former active and the
latter inactive; Desktop Mode requires the reverse. This takes precedence over
process names: full Desktop Mode can run gamescope for its VR overlay, and
Game Mode can run a nested Plasma panel. Check changes nothing.
**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.
Conflicting, transitional, failed or unreadable targets produce **unknown**.
On older images where both targets are explicitly absent, the fallback accepts
only gamescope without Plasma/nested desktop as Game Mode, or Plasma without
gamescope/nested desktop as Desktop Mode. Mixed processes remain unknown.
The SDDM autologin file is still reported as a login preference; it cannot
establish which session is running.
**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.
After confirmation, **Return to Game Mode** runs exactly
`steamosctl switch-to-game-mode` when available, or the older
`holo-session-select gamescope` / `steamos-session-select gamescope` command.
It closes the desktop and anything open in it. Frame Control never edits the
SDDM files itself, and runs no switch when the headset is in Game Mode, when
its mode is unknown, or when no supported switch command is installed. A
refused command reports an error; a request being sent does not verify that
the new session has started.
### Manual fallback
@@ -151,7 +176,9 @@ If the button can't help, or the headset isn't reachable:
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`.
`ssh frame steamosctl switch-to-game-mode` (verified on SteamOS 0.5.6).
Older images may instead provide `steamos-session-select gamescope` or
`holo-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
@@ -172,3 +199,9 @@ restore replaces only its own files and keeps the old ones, unsafe archives
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.
Isolated session tests in the same suite reproduce full Plasma plus gamescope,
Game Mode with and without the nested desktop, conflicting/transitional targets,
missing targets on older builds, failed/timed-out systemd reads, selector
compatibility, and the confirmed Return action with SSH mocked. Run them with
`python3 -m unittest discover -s tests -p test_backup.py -v`.
@@ -0,0 +1,38 @@
{
"date_utc": "2026-10-24T07:54:37Z",
"frame_control_tag": "v0.4.16",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"host": "lxso2",
"interface": "source UI served by a temporary private Frame Control server, opened in T3 Code's desktop browser",
"action": "Clicked the visible Export button for Steam's retained real saved clip in Capture & Live",
"clip_id": "10230640/clips/clip_13009719723623972864_20261024_072115",
"api_observed": {
"post_clips_export_http": 200,
"get_job_http": 200,
"get_clips_http": 200
},
"ui_result": "Saved clip_13009719723623972864_20261024_072115.mp4 to ~/Videos/SteamFrame; button changed to Export again",
"output": {
"path": "~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4",
"bytes": 159829,
"sha256": "f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1",
"matches_previously_fully_decoded_export": true,
"duration_seconds": 10.288532,
"video": "H.264 1920x1080",
"audio": "AAC stereo"
},
"cleanup": {
"temporary_server_stopped": true,
"shared_device_lock_released": true,
"battery_percent_after": 99,
"desktop_mode_active_after": false,
"test_title_created": false,
"steam_source_clip_retained": true
},
"limits": [
"The test used the source UI, not a packaged desktop installer.",
"The retained source clip was nearly black; visible gameplay capture remains unverified.",
"Perceptual audio/video sync, long clips, trims and automatic copying were not tested."
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"date_utc": "2026-10-24",
"version": "v0.4.17",
"steam_os_version": "0.5.6",
"steam_os_build": "20261015.6141207",
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
"source": {
"kind": "saved Steam clip",
"duration_seconds": 100.801,
"chunks": 68,
"gaps": 0
},
"export": {
"format": "MP4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"duration_seconds_ffprobe": 100.833335,
"video": "H.264, 1920x1080, 6049 frames",
"audio": "AAC, 48000 Hz, stereo",
"full_ffmpeg_decode": "passed without errors"
},
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
"limits": [
"This exercised the production export code path, not a packaged GUI Export click.",
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
"The newly saved Steam source clip was retained; no user recording was deleted."
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,94 @@
{
"date_utc": "2026-10-24T09:08:35.523Z",
"result": "PASS: packaged Electron visible Export click saved the retained real Steam clip beyond 60 seconds",
"frame_control_tag": "v0.4.17",
"documentation_base_commit": "4e456480aefbe541e334dbd247328537f535bd94",
"host": "lxso2",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"package": {
"asset": "Frame-Control-linux-x86_64.AppImage",
"url": "https://github.com/saphid/frame-control/releases/download/v0.4.17/Frame-Control-linux-x86_64.AppImage",
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
"launch": "Verified release asset; --appimage-extract, then unmodified squashfs-root/AppRun. Electron used the bundled server and Python, not checkout source.",
"display": "Xvfb :137, 1440x1000x24; Electron window 1400x950 including native chrome",
"flags": [
"--no-sandbox",
"--disable-gpu",
"--remote-debugging-port=49371",
"--remote-debugging-address=127.0.0.1",
"--user-data-dir=/tmp/frame-packaged-export-20261024/electron-profile"
],
"isolation": "Throwaway XDG_CONFIG_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; telemetry and update checks disabled; existing SSH identity and normal home Videos destination used."
},
"ui": {
"navigation": "Dismissed optional email prompt with No thanks; clicked Tools, then Capture & Live; scrolled Steam clips into view.",
"row": "Steam recording \u00b7 Clip; Oct 24, 8:53 AM \u00b7 1:40 \u00b7 2 MB",
"action": "Playwright connected to this Electron instance over local CDP; locator.click() clicked its visible button named exactly Export. The test did not directly invoke an export function or API POST.",
"button": "button.small[data-clip-export=\"0\"]",
"click_is_trusted": true,
"button_bounds_css_pixels": {
"x": 1300.390625,
"y": 701.15625,
"width": 61.609375,
"height": 28
},
"post_clips_export_http": 200,
"job": "0ab20b1788adce63",
"job_poll_http": 200,
"saved_message": "Saved clip_15658413054201167872_20261024_085343.mp4 to ~/Videos/SteamFrame",
"final_row": "ON PC; Export again",
"screenshots": [
"clip-packaged-export-2026-10-24-before.png",
"clip-packaged-export-2026-10-24-after.png"
],
"screenshot_scope": "Cropped to Steam clip rows and saved notification; unrelated screenshot thumbnails and host connection identity excluded."
},
"source": {
"clip_id": "10230640/clips/clip_15658413054201167872_20261024_085343",
"duration_seconds": 100.801,
"duration_provenance": "Retained real Steam clip from clip-long-2026-10-24.json; current packaged UI displays 1:40."
},
"output": {
"path": "~/Videos/SteamFrame/clip_15658413054201167872_20261024_085343.mp4",
"bytes": 1377849,
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
"matches_prior_production_export_size_and_sha256": true,
"duration_seconds_ffprobe": 100.833335,
"duration_difference_from_steam_seconds": 0.032335,
"video": "H.264, 1920x1080, 6049 decoded frames",
"audio": "AAC, 48000 Hz, stereo, 4724 decoded audio frames",
"validation": [
"ffprobe -v error -count_frames -show_entries format=duration,size:stream=codec_name,codec_type,width,height,sample_rate,channels,nb_read_frames -of json <output>: exit 0",
"sha256sum <output>: exact match to prior production export",
"ffmpeg -v error -xerror -i <output> -f null -: exit 0, no errors"
]
},
"cleanup": {
"shared_lock": "Acquired with ssh frame mkdir /tmp/frame-test.lock after read-only battery/build/lock checks; released with rmdir after app shutdown; absence read back.",
"battery_percent_before": 99,
"battery_percent_after": 99,
"battery_status": "Not charging; charger Full",
"packaged_app_and_server_exited": true,
"test_xvfb_stopped": true,
"frame_export_cache_directories_remaining": 0,
"ffmpeg_processes_after": 0,
"game_mode_and_steamvr_running_after": true,
"steam_source_clip_retained": true,
"user_recordings_deleted": false,
"headset_install_or_settings_changes": false,
"local_export_retained": true
},
"limits": [
"The unchanged AppImage payload ran after extraction on a virtual X desktop, with Chromium sandbox and GPU disabled. FUSE mounting, desktop integration and hardware rendering were not tested.",
"The click was automated trusted pointer input in a visible Electron window on Xvfb, not a human click on a physical monitor. T3 collaborative preview opened successfully but drives separate browser tabs; Electron-native automation was used for this window.",
"No new game or Steam recording was started. Visible gameplay quality, audible content and perceptual audio/video sync were not assessed.",
"Clips longer than this retained 100-second clip, trims and automatic copying remain unverified.",
"No release, tag or application code was changed."
],
"validation_local": {
"clip_regression_suite": "python3 -m unittest discover -s tests -p test_clips.py -v: 34 tests passed in 20.173s, including real ffmpeg long-recording export",
"json": "python3 -m json.tool: passed",
"diff": "git diff --check: passed"
}
}
@@ -0,0 +1,95 @@
{
"date": "2026-10-24",
"command": "scripts/frame-power-sample.sh --count 2 --interval 1",
"exitCode": 0,
"stopped": null,
"lockPolicy": "Existing sampler checked local and remote /tmp/frame-test.lock before reading; no lock acquired, bypassed or removed. Read-only run only.",
"samples": [
{
"time": 1792828506.4317346,
"osBuild": "20261015.6141207",
"elapsedSeconds": 0.057539,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.06,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
},
{
"time": 1792828507.4357972,
"osBuild": "20261015.6141207",
"elapsedSeconds": 1.061612,
"battery": {
"battery.percent": {
"value": 99,
"unit": "%",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.status": {
"value": "Not charging",
"unit": "state",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.watts": {
"value": -0.05,
"unit": "W",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
},
"battery.tempC": {
"value": 24.0,
"unit": "C",
"source": "/sys/class/power_supply/max1720x_bat_7-36",
"state": "available"
}
}
}
],
"limitations": [
"No real low-charge or thermal condition induced.",
"No native notification delivery verified.",
"No worn-headset or native package tests.",
"Read-only parser/transport check; no background monitor or power-saving claim."
],
"browserCheck": {
"page": "Production ui/index.html served statically; API calls intentionally unavailable",
"viewport": {
"width": 1280,
"height": 800
},
"bridge": "Synthetic desktop notification bridge; no native OS notification sent",
"result": "Initially unchecked and visible only after desktop host/bridge setup. Checkbox enabled through browser. Synthetic 12%, Discharging, -4 W samples at 0/30/60 seconds produced one bridge request, an in-page status and a toast. No horizontal overflow.",
"apiLimit": "Static server returned expected 404/501 API errors; not an end-to-end live desktop connection test."
},
"offlineValidation": [
"test_desktop_battery_comfort.py: 2 tests, production JS with monotonic fake clock and notification bridge",
"test_comfort*.py: 25 tests",
"test_battery.py: 7 tests",
"test_power*.py: 36 tests (loopback HTTP test run outside socket-restricted sandbox)",
"test_ui_workspace.py: 1 Python wrapper / Node workspace suite",
"test_ui_review.py: 1 Python wrapper / 9 Node regression tests, including switch reset and retained cooldown"
]
}
@@ -0,0 +1,206 @@
{
"date_utc": "2026-10-24",
"base_commit": "de931bc",
"issue": 73,
"result": "Packaged Desktop Capture passed for a mapped nested KDE compositor on the real Frame; wearer-visible overlay condition was not established. Do not close #73 from this test.",
"package": {
"tag": "v0.4.17",
"asset": "Frame-Control-linux-x86_64.AppImage",
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
"release_digest_matched": true,
"panel_helper_matches_base": true,
"unmodified_payload": true,
"host": "lxso2",
"display": "Xvfb :139",
"flags": [
"--no-sandbox",
"--disable-gpu"
],
"private_profile_and_data": true,
"telemetry_and_update_checks_disabled": true
},
"preflight": {
"steamos_version": "0.5.6",
"build_id": "20261015.6141207",
"battery_percent": 99,
"battery_status": "Not charging",
"game_mode": true,
"full_plasma_session": false,
"nested_desktop_running": false,
"nested_runtime_present": false,
"lock_present": false,
"steam_pid": 297826,
"vrserver_pid": 297506,
"vrcompositor_pid": 297620,
"gamescope_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
"focusable_windows": [],
"focused_windows": []
},
"session": {
"started_utc": "2026-10-24T09:43:48Z",
"lock_acquired": true,
"launch": [
"systemd-run",
"--user",
"--unit=fc-issue73-desktop-20261024",
"--collect",
"--property=RuntimeMaxSec=180s",
"--property=TimeoutStopSec=15s",
"--property=KillMode=control-group",
"--setenv=DISPLAY=:0",
"--setenv=XDG_RUNTIME_DIR=/run/user/1000",
"--setenv=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus",
"--setenv=XDG_SESSION_TYPE=x11",
"--setenv=XDG_CURRENT_DESKTOP=gamescope",
"/usr/bin/steamos-nested-desktop"
],
"response": "Running as unit: fc-issue73-desktop-20261024.service; invocation ID: 92cff1e57c794212997d46c0a68877ec\n"
},
"discovery": {
"focus": 62914560,
"focus_display": ":0",
"panels": [
{
"window": 62914560,
"display": ":0",
"name": "KDE Wayland Compositor - Press right control key to grab input",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"app": 0,
"focused": true
}
]
},
"window_evidence": {
"xwininfo_map_state": "IsViewable",
"width": 1920,
"height": 1080,
"focused_after_show_request": true,
"steamvr_overlay_key": "valve.steam.desktopgame.0",
"steamvr_overlay_visibility_after_show_request": "not_visible"
},
"capture": {
"repeat_utc": "2026-10-24T09:44:47.323Z",
"route": "/api/screenshot?view=panel&window=62914560&display=%3A0",
"http": 200,
"content_type": "image/png",
"content_length": 1209980,
"source_header": "panel",
"decoded": {
"width": 1920,
"height": 1080,
"source": "panel",
"selected": {
"window": 62914560,
"display": ":0",
"name": "KDE Wayland Compositor - Press right control key to grab input",
"x": 0,
"y": 0,
"width": 1920,
"height": 1080,
"app": 0,
"focused": true
},
"stamp": "9:44:48 AM",
"badge": "Desktop \u00b7 KDE Wayland Compositor - Press right control key to grab input"
},
"canvas_png_bytes": 2011278,
"first_capture_pixel_statistics": {
"width": 1920,
"height": 1080,
"min": 2.3333333333333335,
"max": 255,
"hidden": false
},
"first_capture_click_event": {
"isTrusted": true,
"utc": "2026-10-24T09:44:12.738Z"
},
"actions": "Two Playwright locator.click() actions on the visible #shotBtn in the actual packaged Electron window; first click recorded isTrusted=true. No direct screenshot API invocation by harness."
},
"device_cleanup": {
"utc": "2026-10-24T09:45:33Z",
"before_stop": {
"exit": 0,
"out": "MainPID=300739\nControlGroup=/user.slice/user-1000.slice/user@1000.service/app.slice/fc-issue73-desktop-20261024.service\nActiveState=active\n",
"err": ""
},
"hide_dashboard": {
"exit": 0,
"out": "HideDashboard",
"err": ""
},
"stop_owned_unit": {
"exit": 0,
"out": "",
"err": ""
},
"after_stop": {
"exit": 0,
"out": "MainPID=0\nLoadState=not-found\nActiveState=inactive\n",
"err": ""
},
"desktop_processes": {
"exit": 1,
"out": "",
"err": ""
},
"nested_runtime_exists": false,
"session": {
"exit": 0,
"out": "Id=gamescope-session.target\nActiveState=active\nInvocationID=a2fee8ebf55c42c7b546390d14cd985c\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=\n\nId=gamescope-session.service\nActiveState=active\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n",
"err": ""
},
"steam": {
"exit": 0,
"out": "297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n",
"err": ""
},
"steamvr": {
"exit": 0,
"out": "297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\n",
"err": ""
},
"xprops": {
"exit": 0,
"out": "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
"err": ""
},
"battery": {
"capacity": "99",
"status": "Not charging"
},
"owned_lock_released": true
},
"host_cleanup": {
"electron": {
"pid": 1578431,
"exists_after": false
},
"xvfb": {
"pid": 1578428,
"exists_after": false
},
"x_socket_absent": true
},
"delayed_final_readback": "2026-10-24T09:46:39Z\nshared-lock-absent\nLoadState=not-found\nActiveState=inactive\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n297620 /opt/steamvr/bin/linuxarm64/vrcompositor\nnested-runtime-absent\n100\nFull\nGAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \nGAMESCOPE_FOCUSED_WINDOW(CARDINAL) = \n",
"validation": {
"focused_panel_suite": "42 tests passed in 14.805s (test_touch.py), including production-page Node behavior tests",
"website": "2 tests passed (node --test site/test/*.test.mjs)",
"node_panels": "Passed independently (node tests/page/desk_panels.mjs)",
"metadata_and_links": "JSON parsed; result/cleanup assertions and relative evidence/streaming/testing links passed",
"diff": "git diff --check passed"
},
"limits": [
"SteamVR continued reporting not_visible after ShowDashboard, despite mapped X window and confirmed focus. No worn-headset visual assessment; does not establish the reporter's visible-overlay condition.",
"SteamOS 0.5.6 only; reporter used 0.4.5 with unknown Frame Control version and no reproduction steps.",
"Nested desktop launched with SteamOS steamos-nested-desktop from a bounded transient user unit using Steam display/session variables; not launched through a wearer's Steam UI.",
"Extracted unchanged AppImage on a virtual X display with Chromium sandbox and GPU disabled; no FUSE, physical monitor or hardware-rendering test.",
"No Live video, Control input or full Desktop Mode test.",
"Nested-desktop journal included gamescope WSI/Vulkan-hook warnings. No warning was dismissed; captured compositor pixels were nonblank.",
"Initial CDP response.body() yielded zero bytes despite successful image decode. Repeat verified Content-Length, fresh decoded image, panel selection and PNG export from canvas; the initial raw-body size is not treated as capture failure or proof.",
"No private desktop pixels, issue screenshot or raw desktop journal committed."
]
}
@@ -0,0 +1,100 @@
# Packaged Desktop Capture, 2026-10-24
Base: `de931bc`. Package: Linux x86_64 **v0.4.17**. Device: real Frame,
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**. Host: lxso2.
[Sanitized state and response evidence](desktop-capture-packaged-2026-10-24.json).
## Result and issue boundary
The packaged app discovered the nested KDE compositor and both automated
Desktop **Capture** button clicks succeeded. The repeat returned HTTP 200,
`Content-Type: image/png`, `X-Capture-Source: panel`, and Content-Length
1,209,980. The app decoded a fresh, nonblank 1920×1080 image and displayed
the Desktop panel badge. No further product failure was reproduced or fixed.
This does **not** establish the full visible-desktop condition in
[#73](https://github.com/saphid/frame-control/issues/73). `xwininfo` reported
the desktop window `IsViewable`, and gamescope confirmed focus after
`vrcmd --showdashboard valve.steam.desktopgame.0`. However, SteamVR still
reported that overlay `not_visible`. There was no worn-headset visual check.
The observation proves capture of a mapped nested compositor, not its visible
presentation to a wearer. Do not close #73 from this evidence.
The issue screenshot shows a desktop panel in the headset view while Desktop
Capture logs “no app panel is open in the headset.” The reporter used SteamOS
0.4.5, gave no Frame Control version or reproduction steps, and has not replied
to the maintainer's 0.4.3 update request. The duplicate page function was already
fixed; [PR #152](https://github.com/saphid/frame-control/pull/152) subsequently
improved unknown-state diagnostics. Its [earlier investigation](panel-discovery-2026-10-24.md)
was blocked from starting a desktop by another task's lock. This run adds a
current-package capture observation, without reproducing the older failure.
## Procedure
1. Read-only preflight found Game Mode active, full Plasma inactive, Steam and
SteamVR running, battery 99%, no nested desktop/runtime and no test lock.
2. Compared the retained AppImage's SHA-256 with the live v0.4.17 release asset
digest: `b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d`.
Extracted an independent copy; `cmp` confirmed its `frame_touch.py` matches
the base checkout. No payload was edited.
3. Started the package's own AppRun, bundled server and Python on Xvfb `:139`
with private config/cache/data/profile, telemetry and update checks disabled,
and `--no-sandbox --disable-gpu`. Used the existing authorized `ssh frame`
identity. T3 preview opened the package server page but does not attach to
the existing Electron window. The literal Electron button test used the
same Playwright-over-loopback-CDP workflow as the earlier packaged tests.
4. Acquired `/tmp/frame-test.lock` with atomic `mkdir` and wrote an owner marker.
Rechecked no nested runtime or Plasma was present. Started SteamOS's
`/usr/bin/steamos-nested-desktop` in the owned transient user unit
`fc-issue73-desktop-20261024.service`, using Steam's display/session variables.
`RuntimeMaxSec=180s`, `TimeoutStopSec=15s` and `KillMode=control-group` bounded
the session. This launches a desktop panel inside Game Mode; no full Desktop
Mode switch was sent.
5. In the actual packaged Electron window, navigated Tools → Capture & Live →
Desktop and clicked Capture with `locator.click()`. The first click event
recorded `isTrusted=true`. The page selected the KDE compositor on `:0`;
the production screenshot path used window `62914560`. Requested the desktop
dashboard, read back focus/visibility, and clicked Capture again. Recorded
response metadata, fresh decoded image dimensions and canvas pixel range.
No direct screenshot API call was substituted for these button clicks.
6. Ran the packaged `frame_touch.py panels` read-only over SSH to retain its
exact discovery result: one focused KDE compositor panel, app ID 0, 1920×1080,
display `:0`, with no diagnostic `why` field.
## Cleanup and limits
The owned desktop session ran **09:43:48–09:45:33 UTC** (105 seconds). Hid the
dashboard to restore its initial hidden state, stopped only the owned unit,
and verified it was unloaded/inactive with MainPID 0. No Plasma/KWin desktop
process or nested runtime remained; gamescope's focusable/focused lists were
empty again. Released only this task's lock, after checking its owner marker.
Delayed readback at **09:46:39 UTC** confirmed the lock absent, Game Mode active,
full Plasma inactive and the original Steam, vrserver and vrcompositor PIDs
still running. The Game Mode service invocation was unchanged. Battery was
99% at cleanup and 100%/Full on the delayed check. Closed both owned preview
tabs, Electron and Xvfb; their processes and X socket were gone. No test server
remained. No reboot, installation, security/global setting change or full
Desktop Mode transition was performed.
The nested desktop journal included gamescope WSI/Vulkan-hook warnings. No
warning was dismissed. Capture produced nonblank compositor pixels, but this
run does not assess their usability to a wearer. Private image files stayed
outside the repository; only state and response metadata are published.
The initial CDP `response.body()` read yielded zero bytes despite successful
decoding in the page. The repeat verified the PNG Content-Length, fresh image
dimensions/source/selection and a 2,011,278-byte PNG exported from the canvas.
The initial raw-body measurement is a harness limitation, not evidence of a
product capture failure. No Live video, Control input, physical monitor, FUSE
mounting or hardware rendering was tested. Older SteamOS and other packages
remain outside this observation.
## Offline verification
`python3 -m unittest discover -s tests -p test_touch.py -v`: **42 tests passed**,
including the production page's Node panel behavior tests, duplicate-function
guard, absent versus empty panel diagnostics, alternate displays, capture/input
target validation and discovery deadlines. JSON parsing and `git diff --check`
were also checked. No new regression test was added because this is an
evidence-only change with no reproduced product defect.
@@ -0,0 +1,114 @@
# Linux PC host feasibility on lxso2, 2026-10-24
**Blocked on this host's current desktop environment. No feature enabled and
no feature PR opened.** Investigation for [issue #23](https://github.com/saphid/frame-control/issues/23),
starting at `4e45648` (main after #154). This concerns flat desktop panels,
not Linux PC VR streaming.
## Decision
The smallest useful candidate is one explicitly selected **whole screen**,
captured through the ScreenCast portal's restricted PipeWire connection,
encoded locally with GStreamer/x264, and sent to the existing viewer through
the shared SSH controller. Input, individual windows and virtual displays
would remain unsupported until separately verified. This candidate was **not
implemented**: lxso2 currently cannot exercise the real capture permission path.
Development packages could unblock compilation, but would not create a desktop,
portal backend or consented screen. Synthetic encoding, an Xvfb screen grab or
using the Frame as its own host would not verify Linux PC desktop capture.
No host packages or services were changed.
## Evidence
Host: `lxso2`, Linux x86-64, kernel `6.8.0-139-generic`.
- `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE` were unset. The user bus
was reachable at `/run/user/1000/bus`.
- `/run/user/1000/pipewire-0` and `/run/user/1000/wayland-0` did not exist.
No processes matched Xorg, Xwayland, Xvfb, GNOME Shell, KWin Wayland, Weston,
PipeWire, WirePlumber or xdg-desktop-portal at the process check.
- D-Bus `ListNames` returned D-Bus, systemd and anonymous connections, with no
`org.freedesktop.portal.Desktop`. `ListActivatableNames` also had no desktop
portal. These calls queried D-Bus itself without activating capture services.
- `dpkg-query` found no `xdg-desktop-portal*` or `pipewire*` packages.
`libgstreamer1.0-0:amd64` was installed at `1.24.2-1ubuntu0.1`, but
`gstreamer1.0-tools` was uninstalled. `gst-launch-1.0` and `gst-inspect-1.0`
were absent from PATH. Xvfb was installed, but supplies no portal by itself.
- `pkg-config --modversion` failed for `gstreamer-1.0`,
`gstreamer-app-1.0`, `gstreamer-video-1.0` and `libpipewire-0.3`.
`gio-unix-2.0` reported `2.80.0`.
- `desktop/bundle/pc-host.so` did not exist.
`python3 desktop/build.py --with-x264 --out /tmp/frame-linux-pc-feasibility`
exited **1** before compilation: required GStreamer pkg-config modules were
missing. No native build or encoder runtime success is claimed.
Reproduce the non-activating portal checks in the intended desktop user's
session, using that session's actual runtime directory and bus:
```sh
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListNames
gdbus call --session --dest org.freedesktop.DBus \
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListActivatableNames
pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 gio-unix-2.0
```
## Existing implementation reviewed
- `ui/frame_pcview.py` selects PCView only on Windows and rejects Linux even
when enabled. Linux installers do not carry its native bundle.
- `desktop/portal.c` creates a **RemoteDesktop** session, requests pointer and
keyboard devices, and selects monitors and windows (`types=3`). It is not
ScreenCast-only or screen-only. Its restricted fd/node and fresh-connection
handling are reusable.
- `ui/frame_pc_agent.py` calls the selection a shared window or screen and
picks VA-API when the element is present, falling back to x264. A bounded
x264 slice needs explicit encoder policy and unsupported-input behavior;
exposing the existing Linux path alone would not meet that scope.
- `app/main.js` starts Python with the inherited environment; it does not
supply a compositor, portal backend or capture permission.
- `.github/workflows/pc-host.yml` installs native dependencies and tests
synthetic x264/protocol behavior with fake capture consent/input. This does
not establish interactive Linux desktop capture.
- Issue #23 and its six existing comments distinguish prior synthetic
helper/viewer results from untested Linux desktop capture and consent.
## Verification and limits
`python3 -m unittest discover -s tests -p 'test_pc*.py' -v` exited **0**:
27 tests ran successfully, with one additional native-class setup skip
(`native PC libraries not built on this host`). Coverage includes the compiled
C controller trace, Python adapters, grants and default-off/Windows-only gates.
It does not prove native GStreamer capture or local-client streaming.
No fake/local native stream, consent prompt, real desktop capture, Frame viewer
run, benchmark, input or window mode was tested in this investigation. No SSH
connection to the Frame was needed; no lock was acquired and no headset install,
launch, stop or settings change was attempted. Issue #113 was left alone.
No issue comments or other public messages were sent.
## Concrete unblock and next slice
1. Supply an interactive Linux desktop, physical or VM, with a working
ScreenCast portal/backend, PipeWire and accessible consent dialog. Installing
the portal frontend alone is insufficient. A VM can establish software
capture/x264 behavior while physical-host behavior remains unverified.
2. Install the native dependencies listed in `pc-host.yml` in that environment
and build a local `--with-x264` bundle. Resolve x264 distribution
licensing/source obligations before shipping it: the current build labels
that option test-only and the Windows installer omits it.
3. Implement a ScreenCast-only session with monitor-only selection, no
RemoteDesktop/device request, disabled input in protocol and viewer, hidden
window/virtual-display modes, and explicit x264 selection. Preserve the
default-off gate and existing Mac/Windows paths.
4. Verify consent acceptance/refusal/cancellation, Stop/reopen, connection loss
and portal closure with a local client decoding actual captured content.
Run the shared benchmark and record results without inferring hardware or
physical laser behavior.
5. Only after local capture works, acquire the shared Frame lock once and
follow [the headset procedure](../testing.md#headset-smoke-test) for a brief
viewer test. If occupied, leave the lock alone and defer device testing.
These are prerequisites and proposed scope, not a ready feature. The Linux
host request in #23 remains open.
@@ -0,0 +1,65 @@
# Panel discovery investigation, 2026-10-24
Base: v0.4.16 (`040581a`). Scope: issue #73, Desktop Capture and Control.
## Evidence and change
Issue #73 reports SteamOS 0.4.5 with its desktop visibly running, without a
Frame Control version or steps. The maintainer's 2026-10-12 comment identifies
the duplicate `loadPanels` UI function fixed in 0.4.3 and requests the exact
remaining message and SteamOS build if it persists. There is no reporter
follow-up. At this base, the functions are distinct and the existing Node
regression checks their uniqueness and the Desktop GET request.
Read-only source inspection found a remaining diagnostic ambiguity:
`read_props()` returns `{}` for failed/timed-out X queries and missing root
properties. `panels()` treated that like a confirmed empty focusable-window
list. With no detected Plasma process it claimed no panel was open; with Plasma
it claimed SteamOS was not listing the desktop. Neither claim follows from an
unreadable list.
The change checks for the presence of `GAMESCOPE_FOCUSABLE_WINDOWS` before
those claims. An absent list now reports unknown panel state, suggests checking
SteamVR and retrying/Headset view, and asks for the exact message and SteamOS
version if persistent. Missing-tool and whole-run deadline explanations retain
precedence. No new probes, retries, timeouts, input or capture routes are added.
## Real-device boundary
A preliminary read-only SSH inspection returned SteamOS 0.5.6, BUILD_ID
`20261015.6141207`, battery 99%, discharging, and X0/X1 socket names. It made
no headset changes. The shared lock was absent at that moment.
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with `File exists`.
The guarded command exited with status 3 before the session, X-property or
panel-helper probes ran. The other task's lock was left alone. No application
was installed, started, stopped or focused; no headset settings changed.
That implementation probe was blocked by the occupied shared device. The
coordinator later supplied a separate narrow read-only observation on SteamOS
0.5.6, BUILD_ID `20261015.6141207`, at battery 99%, with the shared lock absent
and both `xprop` and `xwininfo` installed:
- In **Game Mode**, `DISPLAY=:0 xprop -root GAMESCOPE_FOCUSABLE_WINDOWS
GAMESCOPE_FOCUSED_WINDOW` returned both properties **present but empty**.
- On `DISPLAY=:1`, both properties were reported **not found**.
This observation distinguishes a confirmed empty list on `:0` from absent
properties on `:1`. It does **not** reproduce #73's Desktop Mode report on
SteamOS 0.4.5, and it does **not** test this PR's code on the headset. It is a
coordinator-supplied observation, separate from the blocked implementation probe
above; no new headset probe was run for this follow-up.
Remaining limit: there is no reproduction establishing a further
capture/discovery fix for the reported Desktop Mode/build combination. This PR
improves a demonstrated diagnostic error; it must not close #73 as a verified
new capture fix.
## Offline verification
Focused Python regressions distinguish absent, focus-only, failed/timed-out X
queries, confirmed empty and desktop-running lists. Existing tests cover
missing tools, alternate displays, successful discovery and the 12-second
deadline. The production page's Node behavior test verifies that both Capture
and Control preserve the unreadable-list advice. These use fake X/HTTP/DOM
boundaries and do not prove real SteamOS 0.4.5 capture or physical input.
+168
View File
@@ -0,0 +1,168 @@
# Saved panel layouts: feasibility recheck, 2026-10-24
Issue: [#31](https://github.com/saphid/frame-control/issues/31). Base: main
`5b47cd3`. This investigation covers spatial save/restore for existing
Steam/gamescope panels without another overlay application.
## Decision and user behavior
Keep saved layouts unavailable. The current Frame exposes some geometry,
including a standing-space coordinate transform, but this investigation did
not establish a complete dock-state snapshot or a controlled restore path for
these panels. The existing companion/headset switcher still lists panels and
requests focus; users arrange them with SteamVR's dashboard controls. No layout
file, new control, overlay application or background service is added.
This is a limit of the investigated route, not proof that every native
implementation is impossible. In particular, failure of
`GetOverlayTransformAbsolute` does **not** mean all pose readback is denied.
## Device and read-only results
SteamOS **0.5.6**, BUILD_ID **20261015.6141207**; `vrcmd --version`:
**2.18.2**. `gamescope-session.service` was active and
`plasma-workspace.target` inactive: Game Mode. gamescope used the OpenVR backend
and `--virtual-connector-strategy PerAppId`. Battery was 99%, Not charging,
before the probe and 100%, Full, afterwards.
The [exact probe](panel-layout-2026-10-24/probe.py) loads only
`FnTable:IVROverlay_028` from the shipped
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`. It uses application type 3
(`VRApplication_Background`, avoiding runtime startup) and shuts down its own
OpenVR connection in `finally`. Initialization and interface acquisition both
returned 0. It calls getters only, with no image capture or input injection.
[Raw result](panel-layout-2026-10-24/readback.json):
| Getter | `valve.steam.desktopgame.0` | `valve.steam.gamepadui.main` |
|---|---|---|
| FindOverlay | 0, Success | 0, Success |
| Rendering process | `gamescope-wl` | `gamescope-wl` |
| Width | 0; 2.6700000763 m | 0; 2.6670000553 m |
| Curvature | 0; 0 | 0; 0 |
| Transform type | 0; 5, DashboardTab | 0; 5, DashboardTab |
| Absolute transform | 18, WrongTransformType | 18, WrongTransformType |
| Tracked-device-relative transform | 18, WrongTransformType | 18, WrongTransformType |
| Coordinate transform, center, standing origin | 0; identity matrix | 0; scaled/translated matrix |
| Coordinate transform, center, seated origin | 23, RequestFailed | 23, RequestFailed |
| Coordinate transform, center, raw origin | 23, RequestFailed | 23, RequestFailed |
The dashboard and both panels were reported hidden; neither panel was active.
The standing-space matrix is the transform for the overlay point `(0.5, 0.5)`;
it does not identify World/Dashboard/Theater/controller docking or recreate a
dashboard frame. The Steam panel's basis has scale about 0.369. Treat neither
that scaled matrix nor the hidden Gamescope panel's identity as a validated
physical placement to feed to an absolute-transform setter. Visible placement,
matrix freshness and correspondence to dashboard size were not checked by a
wearer.
## API and dashboard boundary
The ABI and result names were checked against Valve's
[OpenVR v2.12.14 C header](https://github.com/ValveSoftware/openvr/blob/v2.12.14/headers/openvr_capi.h)
and [C++ interface comments](https://github.com/ValveSoftware/openvr/blob/v2.12.14/headers/openvr.h).
That header declares `IVROverlay_028`; its SHA-256 is
`d58a2b149e434b2b8ee25cb4253ca6157feed8092f621e78ecfed6846d35d3e4`.
The absolute getter is explicitly limited to absolute transforms. The
coordinate getter returns the 3D transform for a 2D overlay point. Public
`IVROverlay_028` has no getter/setter pair for dashboard dock location or frame
scene-graph state. The probe fails closed if that exact interface is absent.
Read-only inspection of the shipped dashboard
`/opt/steamvr/resources/webinterface/dashboard/chunk~8012d0c89.js`
(SHA-256 `937ec62c67a481f030a416099ceb98b0fe2a702aea5a0705601c6c40d4969f6d`)
found:
- `dockLocation` reads an internal `_getDockLocation(frameID)` store;
`SetDockLocation` updates that store or sends a VRLink request.
- `m_mapLastRelativeTransformForDockLocation` keeps per-location transforms.
World initialization requests a dashboard scene-graph transform through
`requestSGTransform`, then updates the component's `xfTransform`.
- The `vrcmd_dock_overlay` handler accepts `dashboard`, `lefthand`, `righthand`,
`theater` and `world`. It selects the frame associated with the supplied key,
**falls back to the active frame if no match exists**, sets dock location and
may switch frame. This is not an exact pose restore or a readback response.
Those are observations of shipped internal code, not proof that Frame Control
can safely access its state through a supported external API. No dashboard
JavaScript was injected, private store edited or VRLink command sent. The
active-frame fallback is an additional reason not to experiment with docking
commands on a stale key.
The [2026-09-28 probe](../panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route)
on SteamOS 0.4.1 / SteamVR 2.18.1 recorded an absolute setter returning
12 (`PermissionDenied`) on a temporary, test-owned gamescope panel. That setter
result is **historical**, not a new test on 2.18.2. The newer read-only results
above neither overturn nor reverify it.
## Commands, safety and cleanup
Read-only inspection used `ssh frame` with `BatchMode=yes` and
`ConnectTimeout=10`, `cat /etc/os-release`, battery sysfs reads,
`systemctl --user is-active gamescope-session.service plasma-workspace.target`,
process inspection, and `vrcmd --version`, `--help`, `--overlays`. An initial
`--listoverlays` attempt returned `Unknown command`; `--overlays` is the actual
enumeration command. Dashboard sources were read/copied to local `/tmp` for
inspection. No files were installed on the Frame.
Run the retained probe from the repository root, following
[the shared-device rules](../testing.md#headset-smoke-test). Stop if the lock is
occupied or battery is at/below 20%; never remove another task's lock:
```sh
ssh -o BatchMode=yes -o ConnectTimeout=10 frame \
'set -eu; mkdir /tmp/frame-test.lock || exit 3; trap "rmdir /tmp/frame-test.lock" EXIT; timeout 25 python3 -' \
< docs/evidence/panel-layout-2026-10-24/probe.py
```
This command acquired the lock, completed with exit 0 and released it. A
subsequent check confirmed the lock absent, Game Mode active, and Steam,
`vrserver` and `vrcompositor` running. The probe's only headset filesystem
change was creating/removing that shared lock. Its Python source ran from
stdin. There were no test panels, installs, profiles or services to remove.
No mutation test was essential: without verified dock/relative-frame readback,
there was no exact original layout and bounded rollback to justify changing a
panel. Existing user panels were never focused, shown, hidden, resized, moved
or docked. No global settings, security, passwords, runtime processes or modes
were changed; no reboot or reset occurred.
## Validation and remaining gaps
Existing `tests/test_panels.py` covers main-panel enumeration, hidden panels,
focus revalidation, unavailable-runtime errors and HTTP authentication/Host/
Origin guards. `tests/test_page_scripts.py` checks the production page's shared
JavaScript scope. These offline checks do not prove spatial placement. The
retained probe is evidence tooling, not a new product API; no fake layout
backend or layout tests claiming device support were added.
Validation passed:
```sh
python3 -m unittest discover -s tests -p test_panels.py -v # 8 passed
python3 -m unittest discover -s tests -p test_page_scripts.py -v # 5 passed
python3 -m py_compile docs/evidence/panel-layout-2026-10-24/probe.py
python3 -m json.tool docs/evidence/panel-layout-2026-10-24/readback.json
git diff --check
```
The initial panel-suite attempt hit sandbox socket restrictions in four HTTP
tests; rerunning with loopback sockets permitted passed all eight. No real
headset connection is used by those tests. The full suite and fake Frame were
not run for this evidence-only change.
Before shipping layouts, a native route still needs proof of dock state and
relative-frame readback, authorized restore on an owned test panel with exact
rollback, and correspondence between saved size/pose and visible placement.
It also needs stable source identity and a fresh capture-permission/ticket
flow when reopening viewers. Reconnect, panel recreation, reboot recovery,
Steam-launched gameplay, controller docking and worn-headset input were not
tested here. The existing Frame Control-owned media renderer is not proof that
Steam/gamescope panels can be restored. No third-party overlay app was used.
## Announcement
We rechecked saved panel layouts on SteamOS 0.5.6. SteamVR exposes some panel
geometry, but we have not verified safe restoration of Steam's panel positions
and docking. Saved layouts remain unavailable; use SteamVR's dashboard to
arrange panels. The panel switcher continues to list and show open panels.
@@ -0,0 +1,89 @@
"""Read-only issue #31 probe; pipe to `ssh frame 'timeout 25 python3 -'`.
Acquire /tmp/frame-test.lock first per docs/testing.md. No overlay is created,
focused, docked or changed. Background init (3) does not start SteamVR.
ABI: ValveSoftware/openvr v2.12.14 headers/openvr_capi.h, IVROverlay_028.
"""
import ctypes as C
import json
from pathlib import Path
H = C.c_uint64
Matrix = C.c_float * 12
class Vector2(C.Structure):
_fields_ = [('v', C.c_float * 2)]
def probe():
vr = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
vr.VR_InitInternal2.restype = C.c_uint32
vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
vr.VR_GetGenericInterface.restype = C.c_void_p
vr.VR_ShutdownInternal.argtypes = []
vr.VR_ShutdownInternal.restype = None
err = C.c_int()
vr.VR_InitInternal2(C.byref(err), 3, None)
report = {'init_error': err.value, 'interface': 'IVROverlay_028', 'panels': []}
if err.value:
return report
try:
ptr = vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028', C.byref(err))
report['interface_error'] = err.value
if err.value or not ptr:
return report
table = C.cast(ptr, C.POINTER(C.c_void_p))
def function(slot, result, *args):
return C.CFUNCTYPE(result, *args)(table[slot])
def read(slot, handle, output_type):
output = output_type()
rc = function(slot, C.c_int, H, C.POINTER(output_type))(handle, C.byref(output))
return {'rc': rc, 'value': output.value if rc == 0 else None}
report['dashboard_visible'] = function(68, C.c_bool)()
for key in ('valve.steam.desktopgame.0', 'valve.steam.gamepadui.main'):
row = {'key': key}
report['panels'].append(row)
handle = H()
row['find_rc'] = function(0, C.c_int, C.c_char_p, C.POINTER(H))(
key.encode(), C.byref(handle))
if row['find_rc']:
continue
pid = function(10, C.c_uint32, H)(handle)
row['rendering_pid'] = pid
try:
row['rendering_comm'] = Path('/proc/%d/comm' % pid).read_text().strip()
except OSError:
row['rendering_comm'] = None
row['visible'] = function(45, C.c_bool, H)(handle)
row['active_dashboard'] = function(69, C.c_bool, H)(handle)
row['width_m'] = read(23, handle, C.c_float)
row['curvature'] = read(25, handle, C.c_float)
row['transform_type'] = read(32, handle, C.c_int)
for name, slot, scalar in (('absolute', 34, C.c_int),
('tracked_device_relative', 36, C.c_uint32)):
origin = scalar()
matrix = Matrix()
rc = function(slot, C.c_int, H, C.POINTER(scalar), C.POINTER(Matrix))(
handle, C.byref(origin), C.byref(matrix))
row[name] = {'rc': rc, 'origin_or_device': origin.value if rc == 0 else None,
'matrix': list(matrix) if rc == 0 else None}
row['coordinate_transforms'] = []
for origin in (0, 1, 2): # seated, standing, raw/uncalibrated
matrix = Matrix()
rc = function(46, C.c_int, H, C.c_int, Vector2, C.POINTER(Matrix))(
handle, origin, Vector2((.5, .5)), C.byref(matrix))
row['coordinate_transforms'].append({
'tracking_origin': origin, 'coordinates': [.5, .5], 'rc': rc,
'matrix': list(matrix) if rc == 0 else None})
return report
finally:
vr.VR_ShutdownInternal()
if __name__ == '__main__':
print(json.dumps(probe(), indent=2, allow_nan=False))
@@ -0,0 +1,152 @@
{
"init_error": 0,
"interface": "IVROverlay_028",
"panels": [
{
"key": "valve.steam.desktopgame.0",
"find_rc": 0,
"rendering_pid": 297037,
"rendering_comm": "gamescope-wl",
"visible": false,
"active_dashboard": false,
"width_m": {
"rc": 0,
"value": 2.6700000762939453
},
"curvature": {
"rc": 0,
"value": 0.0
},
"transform_type": {
"rc": 0,
"value": 5
},
"absolute": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"tracked_device_relative": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"coordinate_transforms": [
{
"tracking_origin": 0,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
},
{
"tracking_origin": 1,
"coordinates": [
0.5,
0.5
],
"rc": 0,
"matrix": [
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
0.0,
1.0,
0.0
]
},
{
"tracking_origin": 2,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
}
]
},
{
"key": "valve.steam.gamepadui.main",
"find_rc": 0,
"rendering_pid": 297037,
"rendering_comm": "gamescope-wl",
"visible": false,
"active_dashboard": false,
"width_m": {
"rc": 0,
"value": 2.6670000553131104
},
"curvature": {
"rc": 0,
"value": 0.0
},
"transform_type": {
"rc": 0,
"value": 5
},
"absolute": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"tracked_device_relative": {
"rc": 18,
"origin_or_device": null,
"matrix": null
},
"coordinate_transforms": [
{
"tracking_origin": 0,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
},
{
"tracking_origin": 1,
"coordinates": [
0.5,
0.5
],
"rc": 0,
"matrix": [
0.3689989447593689,
0.0,
0.0008894503116607666,
-0.5048462152481079,
0.0,
0.36899998784065247,
0.0,
1.2510287761688232,
-0.0008894503116607666,
0.0,
0.3689989447593689,
-1.1726053953170776
]
},
{
"tracking_origin": 2,
"coordinates": [
0.5,
0.5
],
"rc": 23,
"matrix": null
}
]
}
],
"interface_error": 0,
"dashboard_visible": false
}
@@ -0,0 +1,36 @@
{
"date": "2026-10-24",
"base": "c7489a8 (v0.4.14)",
"feature": "Desktop Tools power and heat observations",
"automated": {
"targeted": "python3 -m unittest discover -s tests -p 'test_power*.py': 35 passed",
"uiAndResourceGuard": "node --test tests/power_desktop.cjs: 7 passed",
"desktopApp": "node --test app/test/*.test.js: 37 passed",
"packaging": "Materialized ui/scripts resources selected by app/package.json; isolated Python -I imported frame_power, constructed remote payload including frame_status, and read idle status. Missing resource detection tested. Shared phone bundle import passed.",
"syntax": "Python compilation, sampler shell syntax and git diff --check passed",
"fullSuite": "python3 -m unittest discover -s tests -v: 1363 tests, OK (skipped=15), 153.274 seconds",
"transientFailure": "An earlier full run had one nested sandbox-test failure: the existing OneServer second-server child exited -11 (SIGSEGV), expected 1. Isolated sandbox rerun passed (6 tests), then the complete suite passed. No unrelated server-lifecycle code was changed."
},
"browser": {
"environment": "T3 collaborative Chromium preview, Linux, local Python Handler server; synthetic observation subprocess at accelerated one-second intervals; normal headset SSH disabled during start/stop/download verification",
"observations": [
"Opened #powerReport and verified labeled duration selector, disabled Stop/downloads before start and accessible sample progress.",
"Started a synthetic observation and observed 11 of 12 samples in progress; completion allowed another start.",
"Started again, observed progress, stopped, and saw 7 of 12 samples retained with explicit incomplete state and original frame alias.",
"Clicked HTML download and saw the UI acknowledge the request; T3's download list did not record the blob download, so filesystem save was not verified.",
"Fetched all three authenticated export endpoints in the browser: HTML 200 (7599 characters), JSON 200 (2618 characters, 7 samples), CSV 200 (977 characters); each retained the stop reason."
]
},
"realFrame": {
"lock": "ssh frame 'mkdir /tmp/frame-test.lock' succeeded before real-device probe; this task removed only that owned empty lock with rmdir after verification",
"probe": "Ran the production desktop Observation using strict, batch SSH to frame. The first attempt exposed a missing structured reason on early remote lock refusal; fixed and reran successfully.",
"result": "0 of 12 samples; running=false; stopped='Smoke session active (/tmp/frame-test.lock); no sensors read'. Exported HTML included the same incomplete reason.",
"scope": "Lock refusal only. No successful real sensor session, installation, app launch/stop, power control or thermal control was performed for this feature."
},
"limitations": [
"Native Electron installers were not built or launched; resource staging and build-hook contracts were tested.",
"One latest report is retained only in server memory. Starting again, restarting the server or quitting discards it; the UI warns users to download first.",
"Stopping ends only the owned local SSH process. The bounded remote reader discovers closure on its next write; immediate remote cancellation is not guaranteed.",
"Phone-hosted observation starts are unsupported; the shared server bundle still imports without desktop sampler resources."
]
}
@@ -0,0 +1,34 @@
{
"date": "2026-10-24",
"scope": "Read-only three-sample HTML export and browser download check; no installs, app launches, or remote process-control commands",
"preflight": {
"smokeLock": "absent",
"version": "0.5.6",
"build": "20261015.6141207"
},
"command": "scripts/frame-power-sample.sh --count 3 --interval 1 --html /tmp/frame-power-export-check.html",
"exitCode": 0,
"sampleCount": 3,
"metricCount": 91,
"unavailableGroups": [
"proximity"
],
"browser": {
"engine": "T3 collaborative Chromium preview",
"widths": [
1280,
390
],
"jsonDownloadMatches": true,
"csvDownloadMatches": true,
"csvDataRows": 276,
"pageScripts": 0,
"horizontalOverflow": "confined to focusable table region"
},
"limits": [
"No real-device Ctrl+C cancellation test; local child termination and retained samples verified with subprocess fixtures",
"No screen-reader, Windows or macOS verification",
"No controlled baseline, load or worn-headset testing",
"Sensor accuracy not established"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,226 @@
{
"date_utc": "2026-10-24T09:27:25.354Z",
"result": "PASS: literal packaged desktop Return to Game Mode button switched the real Frame from full Plasma; a repeat visible click made no second switch",
"documentation_base_commit": "71288d8980040c14f777f0e417f41f7105eaaac4",
"host": "lxso2",
"steamos_version": "0.5.6",
"steamos_build": "20261015.6141207",
"prior_evidence": [
"https://github.com/saphid/frame-control/issues/142",
"https://github.com/saphid/frame-control/pull/143",
"https://github.com/saphid/frame-control/pull/156"
],
"package": {
"tag": "v0.4.17",
"asset": "Frame-Control-linux-x86_64.AppImage",
"url": "https://github.com/saphid/frame-control/releases/download/v0.4.17/Frame-Control-linux-x86_64.AppImage",
"sha256": "b1b8eb82bf73a227b2cd36e8f0ef3a2ebdf7967c5853efb08a9634f38227015d",
"verification": "sha256sum matched the live release asset digest. Independently extracted the retained asset into a new private test directory. cmp confirmed bundled frame_backup.py and frame_backup_remote.py equal the checkout versions. No source or payload was changed.",
"launch": "--appimage-extract, then squashfs-root/AppRun --no-sandbox --disable-gpu --remote-debugging-port=49372 --remote-debugging-address=127.0.0.1 --user-data-dir=/tmp/frame-return-button-20261024/electron-profile --window-size=1400,950",
"display": "Xvfb :138 -screen 0 1440x1000x24 -nolisten tcp; Electron content screenshot 1400x923",
"isolation": "Private XDG_CONFIG_HOME, XDG_CACHE_HOME, FRAME_CONTROL_DATA_DIR and Electron profile; FRAME_CONTROL_TELEMETRY=0 and FRAME_CONTROL_NO_UPDATE_CHECK=1. Existing authorized ssh frame identity retained; bundled server and Python used."
},
"preflight": {
"read_only_first": true,
"initial_mode": "gamescope-session.target=active; plasma-workspace-wayland.target=inactive",
"initial_battery_percent": 99,
"initial_battery_status": "Not charging; charger Full",
"steam_and_steamvr_running": true,
"running_steam_apps": [],
"default_login_mode": "game",
"lock": "mkdir /tmp/frame-test.lock succeeded atomically; no foreign lock was removed. Initial harmless probe attempt released its own lock, then a fresh atomic acquisition covered both full-session attempts."
},
"rollback": {
"unit": "fc-return-button-rollback-20261024",
"probe": "systemd-run --user --unit=fc-return-button-probe-20261024 --on-active=2s --timer-property=AccuracySec=1s --property=Type=oneshot --property=RemainAfterExit=yes /usr/bin/true",
"probe_execution": "Result=success\nExecMainStartTimestamp=Sat 2026-10-24 20:27:07 AEDT\nExecMainExitTimestamp=Sat 2026-10-24 20:27:07 AEDT\nExecMainCode=1\nExecMainStatus=0\n",
"arm": "systemd-run --user --unit=fc-return-button-rollback-20261024 --on-active=180s --timer-property=AccuracySec=1s /bin/sh -c <guarded command>",
"guarded_command": "if [ \"$(systemctl --user is-active gamescope-session.target)\" != active ]; then exec /usr/bin/steamosctl switch-to-game-mode; fi",
"verified_before_desktop": "TimersMonotonic={ OnActiveUSec=3min ; next_elapse=1d 21h 53min 49.130174s }\nNextElapseUSecMonotonic=1d 21h 53min 49.130174s\nBindsTo=\nPartOf=\nActiveState=active\nExecStart={ path=/bin/sh ; argv[]=/bin/sh -c if [ \"$(systemctl --user is-active gamescope-session.target)\" != active ]; then exec /usr/bin/steamosctl switch-to-game-mode; fi ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\nBindsTo=\nPartOf=\n",
"user_manager_linger": "yes",
"desktop_entry": "ssh frame steamosctl switch-to-desktop-mode",
"first_click_utc": "2026-10-24T09:27:27.037Z",
"cancelled_before_deadline": true,
"fired": false,
"limit": "The benign transient timer was observed executing. The actual rollback timer configuration was read back, then cancelled after the UI restored Game Mode; its recovery action was not deliberately allowed to fire."
},
"ui": {
"navigation": "Clicked nav Tools, Backup & recovery, and Check in the actual packaged Electron window. Initial Game Mode disabled Return. After full Plasma entry, Check enabled it.",
"automation": "Playwright connected to the packaged Electron instance over local CDP and used locator.click() on the visible button; accepted the native JavaScript confirm dialog. No Return API/CLI or production handler was directly invoked by the test. T3 preview was inspected separately and closed; it cannot control this Electron window.",
"clicks": [
{
"isTrusted": true,
"utc": "2026-10-24T09:27:27.037Z"
},
{
"isTrusted": true,
"utc": "2026-10-24T09:27:32.942Z"
}
],
"button_bounds_css_pixels": {
"x": 47,
"y": 709.59375,
"width": 156.203125,
"height": 44
},
"confirmation_dialogs": [
{
"type": "confirm",
"message": "Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.",
"utc": "2026-10-24T09:27:27.042Z"
},
{
"type": "confirm",
"message": "Return the headset to Game Mode?\n\nThis closes the desktop and any apps open in it, then starts Steam's Game Mode.",
"utc": "2026-10-24T09:27:32.943Z"
}
],
"desktop_check": {
"http": 200,
"body": {
"mode": "desktop",
"gamescope": true,
"plasma": true,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "inactive",
"plasma-workspace-wayland.target": "active"
},
"mode_source": "session-targets"
}
},
"return_responses": [
{
"http": 200,
"body": {
"message": "Asked the headset to return to Game Mode; it restarts its session in a few seconds",
"state": {
"mode": "desktop",
"gamescope": true,
"plasma": true,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "inactive",
"plasma-workspace-wayland.target": "active"
},
"mode_source": "session-targets"
},
"switched": true,
"command": "steamosctl switch-to-game-mode"
},
"utc": "2026-10-24T09:27:27.316Z"
},
{
"http": 200,
"body": {
"message": "The headset is already in Game Mode. The desktop you see is a panel: close it from the Steam dashboard.",
"state": {
"mode": "game",
"gamescope": true,
"plasma": false,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "active",
"plasma-workspace-wayland.target": "inactive"
},
"mode_source": "session-targets"
},
"switched": false
},
"utc": "2026-10-24T09:27:33.126Z"
}
],
"final_check": {
"http": 200,
"body": {
"mode": "game",
"gamescope": true,
"plasma": false,
"nested_desktop": false,
"configured": "gamescope-wayland.desktop",
"config_file": "/etc/sddm.conf.d/steamos.conf",
"selector": "steamosctl",
"session_targets": {
"gamescope-session.target": "active",
"plasma-workspace-wayland.target": "inactive"
},
"mode_source": "session-targets"
}
},
"final_return_disabled": true,
"final_text": "Game Mode: the headset is running normally. A desktop you see is a panel; close it from the Steam dashboard.",
"screenshots": [
"return-game-mode-packaged-2026-10-24-before.png",
"return-game-mode-packaged-2026-10-24-after.png"
],
"screenshot_scope": "Cropped to the session controls and repeat-action notification; host identity, connection address and backup folder excluded."
},
"systemd_verification": {
"command": "ssh frame systemctl --user show gamescope-session.target plasma-workspace-wayland.target -p Id -p ActiveState -p SubState; ssh frame systemctl --user show gamescope-session.service -p InvocationID",
"before": "Id=gamescope-session.target\nActiveState=inactive\nSubState=dead\n\nId=plasma-workspace-wayland.target\nActiveState=active\nSubState=active\nInvocationID=892b843dbe1a4d2ba91b93057bd38463\n295589 /home/steamos/.local/share/Steam/steamrtarm64/steam -silent -vrdisable -deckard\n295066 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-294978\n",
"after_first_click": "Id=gamescope-session.target\nActiveState=active\nSubState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nSubState=dead\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n",
"after_repeat_click": "Id=gamescope-session.target\nActiveState=active\nSubState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nSubState=dead\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297942 /home/steamos/.local/share/Steam/steamrtarm64/steam -child-update-ui -child-update-ui-socket 8 -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n",
"repeat_did_not_restart_game_mode": true,
"unchanged_game_service_invocation": "e8c1085b3bbd4e79a323b3e4a9bc6355",
"unchanged_steamvr_pid": 297506,
"delayed_final_readback_utc": "2026-10-24T09:28:05Z"
},
"cleanup": {
"complete": true,
"final_readback": "2026-10-24T09:28:05Z\nId=gamescope-session.target\nActiveState=active\n\nId=plasma-workspace-wayland.target\nActiveState=inactive\nInvocationID=e8c1085b3bbd4e79a323b3e4a9bc6355\nNEXT LEFT LAST PASSED UNIT ACTIVATES\n\n0 timers listed.\nId=fc-return-button-rollback-20261024.timer\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-rollback-20261024.service\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-probe-20261024.timer\nLoadState=not-found\nActiveState=inactive\n\nId=fc-return-button-probe-20261024.service\nLoadState=not-found\nActiveState=inactive\n297826 /home/steamos/.local/share/Steam/steamrtarm64/steam -cef-enable-debugging -deckard -gamepadui -steamdeck -steamos3 -vrgamepadui\n297506 /opt/steamvr/bin/linuxarm64/vrserver -waitformonitor -ipccontrolfilepath /tmp/SteamVR-IPCControlFile-297420\n99\nCharging\ngame\n",
"owned_lock_release_exit": 0,
"owned_lock_absent": true,
"host_processes": {
"xvfb": {
"pid": 1562020,
"exists": false,
"cmd": null,
"state": null
},
"electron": {
"pid": 1562023,
"exists": false,
"cmd": null,
"state": null
},
"x_socket_absent": true
},
"remaining_test_servers": [],
"battery_final_percent": 99,
"battery_final_status": "Charging",
"battery_range_observed_percent": [
99,
100
],
"game_mode_steam_steamvr_running": true,
"default_login_mode_after": "game",
"headset_installs_or_global_settings_changed": false,
"rebooted": false,
"notes": "Stopping the timer succeeded. systemctl stop also named its never-started, unloaded service and returned 5; explicit readback confirms both rollback and probe timer/service units are not-found/inactive. No further switch was sent."
},
"attempts_and_harness_limits": [
"Initial harmless timer probe was garbage-collected before its exit metadata could be read. No Desktop Mode switch was attempted. Repeated with RemainAfterExit=yes and verified successful execution.",
"First full Plasma attempt clicked the visible packaged Return button and got HTTP 200 / switched=true. The harness then treated absent pgrep matches during the expected session restart as a fatal error. Independent readback confirmed Game Mode and SteamVR had returned. Rollback remained armed until that readback, then was cancelled. This incomplete attempt was not counted as the final pass.",
"Final full Plasma attempt tolerated transiently absent Steam/SteamVR while polling systemd targets, passed both trusted visible clicks, and completed the final Check. Stopping the unused rollback service returned unit-not-loaded; explicit subsequent readback verified all four owned probe/rollback units not-found/inactive and zero timers. Cleanup was completed before releasing our lock."
],
"limits": [
"Unchanged AppImage payload ran after extraction on a virtual X desktop with Chromium sandbox and GPU disabled. FUSE mounting, desktop integration and hardware rendering were not tested.",
"Automated trusted pointer clicks in a visible Electron window; no physical-monitor human click or worn-headset visual/interaction assessment.",
"Verified only SteamOS 0.5.6 build 20261015.6141207 and the Linux x86_64 v0.4.17 package; older selectors/builds and other desktop platforms remain covered only by their existing tests.",
"No release, tag, application code, passwords or security/global settings changed."
],
"validation": {
"focused_suite": "python3 -m unittest discover -s tests -p test_backup.py -v: 28 tests passed in 2.479s",
"json": "python3 -m json.tool: passed",
"diff": "git diff --check: passed"
}
}
+39
View File
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
![Family and comfort controls in the desktop app](img/comfort-desktop.png)
## Desktop low-battery alert without a session
In the desktop app, open **Tools → Family & comfort** and check **Alert on this
computer at 15% or less**. This separate option does not start a session, install
a worker or change the headset. It starts off whenever the page opens and turns
off when you switch headsets. The choice is only for the current open page.
It consumes the battery parser already used by normal status refreshes (also
shared by the read-only power sampler). It adds no SSH calls, timers, sensor
reads, third-party push or heat thresholds. Normal automatic status refreshes
pause while the page is hidden, so this is **not a background or safety monitor**.
Keep the app visible and connected. Use **Enable / test notifications** to check
system delivery; operating-system settings may block notifications.
Two fresh readings, at least 20 seconds and at most 90 seconds apart, must show
15% or less, driver state `Discharging`, and signed battery power below −0.5 W.
This avoids alerting on plugged-in idle readings, which can report discharging.
Missing, invalid, stale or interrupted readings break confirmation. Disconnect,
a failed status request, hiding the page or toggling the option also breaks
confirmation. Unknown readings never mean zero charge.
An alert appears in the page and requests a native desktop notification once
per low-charge episode. Two readings at 20% or more rearm it, and at least ten
minutes must pass between alerts (including across headset switches). A single
recovery spike cannot rearm it. Toggling off/on does not clear the episode latch.
Notification failure stays visible in the page and does not retry on every
refresh. While an active family session has battery alerts enabled, that session
handles battery alerts instead. Multiple app windows have independent state;
this does not coordinate notifications between computers or windows.
A read-only check on 2026-10-24, build **20261015.6141207**, received two samples
at 99%, `Not charging`, approximately −0.06 W and 24 °C. No low-battery condition
was induced, no notification was sent to the real headset, and no headset state
was changed. The sampler's local and remote smoke-lock checks allowed the run;
no lock was acquired or removed. Deterministic tests exercise low charge,
recovery, missing readings, idle drain, timing and notification denial. See
[recorded evidence](evidence/desktop-battery-comfort-2026-10-24.json). Native OS
notification display, worn use and macOS/Windows packaging were not verified.
## Sessions
Set a limit of 1–240 minutes, optional break and check-in intervals, then
+61 -5
View File
@@ -58,6 +58,44 @@ 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.
## Read-only repair plan and before/after reports
```sh
scripts/frame-doctor.sh --plan --save-report before.json
# Review the plan with the headset user. Any manual action is separate.
scripts/frame-doctor.sh --plan --compare-before before.json --save-report after.json
```
`--plan` prints advice; it never executes repair commands or prompts for approval.
There is no automatic repair mode. Advice about Wi-Fi, display timeouts, low
storage, a missing Steam install marker or keep-awake requires the exact
recognized section, severity and evidence. Other warnings request local review;
unknown evidence never justifies a repair. A signature or missing marker alone
does not establish a cause. Restart risk, rising counters or unknown counters
block all section-specific plans and direct you to the recovery runbook.
Nothing changes passwords, headset security, pairing, services or power settings.
Reports are optional **local** JSON files containing only fixed diagnostic
summaries, a format version and an opaque SSH-target configuration fingerprint.
The fingerprint binds comparisons to the same alias, identity/routing
configuration and fallback addresses; it does not prove physical headset
identity or that the headset was not replaced behind that configuration. Keep
using trusted SSH host keys. Report creation is exclusive (existing files and
symlinks are refused), with owner-only permissions on POSIX. Protect reports
using your local account permissions on Windows. Choose a fresh output filename
for each run. Doctor never writes a report on the headset.
`--compare-before` validates a bounded baseline before connecting and refuses
changed target configuration. It prints each section's before/after status and
prints both fixed evidence messages when they differ, even if the status
stayed the same. Changes are observations, not proof a repair worked or caused them;
recent journal windows can simply age out. There is no intervening repair by
doctor, freshness guarantee, or authenticated report signature. If either run
skipped deeper checks, comparison is blocked and no improvement is claimed.
Unreachable or invalid probes produce no new baseline or comparison. Malformed
baseline/target mismatch/report-write errors exit **2**; otherwise normal
diagnostic exit codes apply. No saved report is needed for ordinary diagnostics.
## First-time setup and pairing
- **No SSH response:** wake the headset with its power button. Check the cable
@@ -89,15 +127,16 @@ 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.
The optional plan and baseline comparison are a guarded next step for issue
#34; automatic fixes and full recovery automation remain unimplemented. 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
a remote report. Only `--save-report` creates a local file. Nothing needs
restoring on the headset after its checks. Manual/device tests must
follow the [shared-device lock and battery procedure](testing.md#headset-smoke-test).
## Privacy and verification
@@ -107,7 +146,9 @@ 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.
personal information. Redirecting stdout or using `--save-report` saves a report
only if you choose it.
Treat the target fingerprint as private metadata; it is a hash, not encryption.
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
@@ -119,3 +160,18 @@ 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.
Follow-up verification on 2026-10-24: `--plan --save-report` and then
`--plan --compare-before` ran read-only on the same real Frame. Both reported
nonempty health-check trackers, skipped deeper checks and exited 1. The plan
was blocked and the comparison reported `risk -> risk` without claiming
improvement. No install, launch, stop or repair was performed; this validates
the guarded report path, not the deeper checks or any manual repair.
The follow-up's 17 focused offline tests passed. All five doctor fake-Frame
tests passed, including a disk-full baseline followed by fault removal and a
fresh comparison. The final evidence-output change also passed that comparison
test again. Because zsh is unavailable on this host, the repository's images
were built and its Compose harness run directly under the isolated project
`frame-doctor-plan-34`; its containers and volumes were removed afterwards.
These storage changes were fake fault controls, not doctor repairs.
+18 -1
View File
@@ -190,7 +190,24 @@ HTTP focus is not evidence of worn-headset laser input. Follow the
## Saved spatial layouts: blocked on the current panel route
**Verified 2026-09-28**, same build, using a temporary xterm panel with
**Rechecked 2026-10-24**, SteamOS 0.5.6, BUILD_ID `20261015.6141207`,
SteamVR 2.18.2: a read-only probe of the existing Gamescope and Steam main
panels still reports type 5 (`DashboardTab`), and both the absolute and
tracked-device-relative getters return 18 (`WrongTransformType`). Width and
curvature read successfully. **Some pose readback is available:**
`GetTransformForOverlayCoordinates` at the panel center succeeds in standing
space; seated and raw origins return 23 (`RequestFailed`). Both panels and the
dashboard were hidden. That matrix snapshot does not identify docking, prove
visible placement, or establish a restore operation. The shipped dashboard
stores dock location and relative transforms internally; no supported external
snapshot/restore pair was established. No setters or docking commands were run
in this recheck. See the [exact probe, results, cleanup and remaining
gaps](evidence/panel-layout-2026-10-24.md). Saved layouts remain unavailable.
### Earlier mutation evidence (2026-09-28)
**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`,
SteamVR 2.18.1, using a temporary xterm panel with
`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from
`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`:
+6
View File
@@ -107,6 +107,12 @@ 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.
The [2026-10-24 lxso2 feasibility check](evidence/linux-pc-host-lxso2-2026-10-24.md)
found no running or activatable desktop portal, no PipeWire socket and missing
native build dependencies. Linux support remains disabled. The evidence records
the build failure and prerequisites for a screen-only, view-only x264 slice;
Linux capture, input and window modes have not been verified there.
## Measuring
The Mac benchmark accepts the PC host:
+104 -2
View File
@@ -24,6 +24,89 @@ 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.
## Start, stop and open an accessible report
From a source checkout with Python 3 and your already trusted `ssh frame`
connection, start a local observation:
```sh
scripts/frame-power-sample.sh --count 12 --interval 5 --html power-report.html
```
Progress is printed on your computer as each sample arrives. Wait for completion,
or press **Ctrl+C once** to stop local collection and save the samples already
received. Open `power-report.html` in your browser using its Open File command.
The file is self-contained and works offline: no server, scripts, external
resources, account or upload. It includes a table with named metrics, source
paths, units, minimum/mean/maximum/last values, and available/unknown counts.
Keyboard users can focus the table region to scroll through its columns.
Charging states and entirely unavailable sensor families remain explicit.
Use **Download JSON samples and summary** or **Download CSV samples** to save
raw measurements. The HTML also embeds those measurements; review it before
sharing. No automatic browser launch occurs.
HTML sessions are limited to `count × interval <= 600` seconds, with a 30-second
transport allowance and at most twelve seconds of local child/pipe cleanup. The default
12 samples at five-second intervals take about 55 seconds. Existing output files
are never overwritten; choose a new filename for each run. `--html` cannot be
combined with `--csv`. The desktop flow below uses the same sampler and renderer.
Stop, timeout, SSH failure or a smoke-test lock marks the report incomplete;
earlier received samples remain downloadable. Exit status is 75 for incomplete
reports, 0 for completion. A connection that cannot start may instead return 1
without an HTML file. Empty reports clearly show zero samples, not a successful
measurement. If saving the file fails, received JSON is printed on stdout as a
recovery path. Do not treat a partial report as a full baseline.
Stopping terminates only this command's local SSH child. It sends no remote
process-control command. The bounded remote Python reader exits when it next
writes to the closed connection (normally at the next sample, up to one interval
later); it can still read sensors before discovering the disconnect. Stop is
not an immediate headset-side cancellation guarantee. The reader still checks
the smoke-test lock before every sample. No lock is acquired or removed by this
flow, and no power, thermal, service, password or security controls are exposed.
## Desktop Tools → Power & heat report
In the packaged desktop app, connect to an already paired Frame, open **Tools →
Power & heat report**, choose **1 minute** (12 samples) or **5 minutes** (60 samples),
and select **Start observation**. Samples arrive every five seconds, starting at
zero (about 55 or 295 seconds total). Progress shows received samples and the
original headset alias. There is at most one observation per local app server;
viewing progress never starts another SSH connection. No terminal, checkout,
extra dependency or password is needed.
Use **Stop and keep samples** to end local collection. Disconnect, timeout,
app shutdown or switching to another headset also ends collection. Switching
network addresses for the same headset does not retarget an existing observation:
it keeps its captured SSH route and may finish incomplete if that route fails.
As with the CLI, only the owned local SSH child is terminated. The remote reader
is bounded and discovers a closed connection on its next write. Desktop sessions
have a 30-second transport allowance and bounded child cleanup. Stream lines are
limited to 262,144 characters, sample events to the requested count, and the receive queue
to count + 2 events; desktop SSH stderr is discarded rather than stored unbounded.
The existing smoke lock is checked locally before start and remotely before each
sample; this feature never creates, bypasses or removes that lock.
**Download HTML report**, **Download JSON** and **Download CSV** save local
snapshots through the browser/app download mechanism. Open the downloaded HTML
in your browser to view the offline table and its embedded JSON/CSV downloads.
Snapshots taken before completion explicitly say incomplete. Desktop CSV includes
`collection.state`, `collection.requestedSamples` and `collection.receivedSamples`
metadata rows with the original headset alias in the source column, even when no
sensor sample arrived. CSV text fields, including the alias, are escaped if they
begin with a spreadsheet formula prefix. JSON records the alias and device ID;
HTML names the alias.
Only the latest observation is retained in server memory, including partial
samples after stop or disconnect. **Download before starting another observation,
restarting the server or quitting the app**: those discard the previous report.
Page reloads retain server state. Download requests include an observation ID, so
an old page cannot silently download or stop a replacement observation. Reports
are not uploaded or automatically opened, and may contain device identifiers,
OS builds and sensor paths. Review before sharing. The phone-hosted UI does not
support starting observations.
## What it reads
- Battery percentage, driver charging state, signed battery watts and battery
@@ -109,14 +192,33 @@ 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.
The accessible HTML export was checked on the same build on **2026-10-24**:
three streamed samples, 91 metric summaries and 276 CSV data rows. Chromium
preview checks at 1280 px and 390 px confirmed the table stays in its scroll
region; both browser downloads matched the original samples. See
[export evidence](evidence/power-export-2026-10-24.json). Ctrl+C retention and
local process cleanup were tested with local subprocess fixtures, not on the
real headset. Screen-reader, Windows and macOS checks were not performed.
Offline validation:
```sh
python3 -m unittest discover -s tests -p test_power_sample.py -v
python3 -m unittest discover -s tests -p 'test_power*.py' -v
bash -n scripts/frame-power-sample.sh
python3 -m py_compile scripts/frame_power_sample.py
python3 -m py_compile scripts/frame_power_sample.py scripts/frame_power_report.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.
Export fixtures also cover HTML escaping, download round trips, zero versus unknown,
stream completion/failure, smoke-lock stop, timeout, Ctrl+C retention, local child
cleanup, duration bounds and refusing to overwrite reports.
Desktop validation on **2026-10-24** covered API start/stop, a synthetic browser
observation with partial exports, shared-module resource staging under isolated
Python and missing-resource build guards. A real Frame probe under this task's
owned smoke lock correctly returned zero samples with an explicit incomplete
reason; no successful live sensor baseline or native installer run was performed.
See [desktop evidence and limitations](evidence/power-desktop-2026-10-24.json).
+62 -16
View File
@@ -8,13 +8,13 @@ Both are on **Tools → Capture & Live**. Everything is saved to
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
**Recording and Steam clip export have each been tested on a headset.** 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).
second, and nothing was left running or stored on the Frame. A later test on
that build created a saved clip with Steam's own recorder and exported it with
Frame Control's production export function. The parts we're least sure of are
listed under [What hasn't been checked](#what-hasnt-been-checked).
## Record
@@ -147,19 +147,65 @@ 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
## Real Steam clip verification and remaining checks
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:
On 2026-10-24, v0.4.15 on build `20261015.6141207` recorded a ten-second
reserved test session through Steam's own game recorder, saved it as a clip,
then exported it with `frame_clips.export_clip`, the same implementation used
by the desktop API. The 159,829-byte MP4 decoded completely: 10.29 seconds,
1920×1080 H.264 video and stereo AAC audio (SHA-256
`f8a476eefb2bde7ba5cb464632ec9b952eea3ab1610b5bed2098bf1f11d62cf1`).
All 616 decoded video frames and the decoded audio matched Steam's source
segments. Recording mode was restored
to off; the reserved title and its processes were removed, the shared test lock
was released, and the battery remained at 99%. The Steam source clip was kept.
- 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.
The inspected frame is nearly black in both Steam's source and the export, so
this establishes file creation and faithful export, **not visible gameplay
capture**. On v0.4.16, the literal desktop **Export** button was also clicked
against that retained real Steam clip in the source UI on lxso2. The job
completed and saved `~/Videos/SteamFrame/clip_13009719723623972864_20261024_072115.mp4`;
its 159,829 bytes and SHA-256 matched the previously decoded export exactly.
The temporary server stopped, the shared device lock was released, and the
headset remained in Game Mode at 99% battery. See
[`docs/evidence/clip-gui-export-2026-10-24.json`](evidence/clip-gui-export-2026-10-24.json)
for the narrow proof. This did not exercise the packaged desktop installer.
On 2026-10-24, the exact v0.4.17 tag exported a second real Steam-saved clip
through `frame_clips.export_clip`, the same production path used by **Export**.
Steam listed the clip at **100.801 seconds**, with 68 chunks and no gaps. The
copied MP4 was **100.833 seconds** by `ffprobe` and decoded completely with
`ffmpeg`; it was not cut at 60 seconds. The test restored Steam's recording
mode, removed its reserved title and processes, and left Game Mode active at
100% battery. See [the long-clip evidence](evidence/clip-long-2026-10-24.json).
That code-path test did not click **Export** in the packaged desktop app or
verify visible gameplay quality or perceptual audio/video sync.
Later that day, the published v0.4.17 x86_64 Linux AppImage also passed the
packaged desktop **Export** check against the retained 100.801-second clip.
The asset SHA-256 was verified before launching its unchanged extracted payload
in an isolated Electron profile on a virtual X desktop. An automated pointer
click on the visible **Tools → Capture & Live → Steam clips → Export** button
saved the MP4 and changed the row to **ON PC / Export again**. The saved file
was 100.833335 seconds, decoded completely, and matched the prior production
export by size and SHA-256. The app and test display were closed,
the Frame export cache was empty, the source clip was retained, and the shared
lock was released at 99% battery with Game Mode and SteamVR still running.
See [the packaged export evidence](evidence/clip-packaged-export-2026-10-24.json)
and its cropped before/after screenshots. This verifies the packaged Electron
button and bundled export path; AppImage FUSE mounting, desktop integration,
physical display/GPU rendering, gameplay quality and perceptual sync remain
unverified. The virtual-display launch used `--no-sandbox --disable-gpu`.
Remaining checks include:
- Perceptual audio/video sync and audible content. Frame Control's separate
**Record** path stamps camera and audio inputs with the wall clock because
they otherwise count from different clocks.
- Clips longer than the 100-second test and trims starting partway through a
Steam recording. Two real saved clips' directory layouts were checked; the
parser remains deliberately loose
for other layouts.
- 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.
+4 -1
View File
@@ -14,7 +14,10 @@ count. So a build reaches people only when you publish it, after testing it.
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. Nobody is
offered a draft.
offered a draft. Editing a draft while installers are uploading can make
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
the affected upload; it refuses ambiguous matches.
2. Download the draft's installers and test them. An installed copy of the
previous version won't offer the draft, so install it directly.
+14
View File
@@ -7,6 +7,9 @@ This covers three directions, plus input:
- **C. iPhone → Frame**: mirror the phone inside the headset.
- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
including Valve's streaming and USB support. No Linux host tested yet.
- **Linux desktop → Frame panels**: [PC-host feasibility on lxso2](evidence/linux-pc-host-lxso2-2026-10-24.md).
Blocked on missing desktop portal/PipeWire and native build dependencies;
Linux desktop capture remains disabled and unverified.
- **Input**: type and point in the Frame from the Mac or iPhone.
- **Live view and Control**: watch a panel flat and tap on it to use it.
@@ -285,6 +288,17 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
[#73](https://github.com/saphid/frame-control/issues/73) (SteamOS 0.4.5, desktop
open but not found). The likely cause there was the page itself: a second
`loadPanels` (the panel switcher's) replaced the Desktop view's, fixed in #84.
If the `GAMESCOPE_FOCUSABLE_WINDOWS` property cannot be read, discovery now
reports that the panel list is unreadable and whether a panel is open is
unknown. A failed X connection or missing property is not evidence that the
desktop is closed. Check that SteamVR is running, retry or use Headset view;
if it persists, report the exact message and SteamOS version. The confirmed
empty-list advice is retained when the property is present but empty.
[2026-10-24 investigation and verification limits](evidence/panel-discovery-2026-10-24.md).
A later [packaged v0.4.17 test on the real Frame](evidence/desktop-capture-packaged-2026-10-24.md)
captured a mapped, focused nested KDE compositor on SteamOS 0.5.6. SteamVR
still reported its overlay hidden, so that test does not establish the
wearer's visible-desktop condition in #73 or justify closing it.
- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
text box in the panel and the text field typed into it; a trackpad move went
exactly (+40, +25).
+13 -2
View File
@@ -60,8 +60,11 @@ 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
faults. The plan/report tests cover exact-evidence advice, blocked plans,
private exclusive baselines, malformed input, target mismatches and comparisons
without repair claims. The fake-Frame storage test records a full-disk baseline,
clears only the fake fault and compares a fresh run. 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
@@ -162,6 +165,14 @@ Record the build, actual interaction results, cleanup and any unworn-headset
limits alongside screenshots or logs. These are caller responsibilities; the
smoke script below does not acquire this shared lock itself.
[Packaged Desktop Capture evidence, 2026-10-24](evidence/desktop-capture-packaged-2026-10-24.md)
records v0.4.17 GUI button captures of a real mapped nested desktop on SteamOS
0.5.6, a bounded owned user unit, and cleanup back to the original Game Mode
state. Check X window mapping/focus and SteamVR overlay visibility separately:
successful capture of a mapped window does not prove the desktop is visible
to a wearer. That run's overlay stayed `not_visible`; #73 remains unverified
for its reported visible-desktop/0.4.5 condition. Only state/log metadata is public.
```sh
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Read-only setup and health diagnostics; see docs/frame-doctor.md.
# Read-only diagnostics, repair plans and explicit local before/after reports; see docs/frame-doctor.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_doctor.py" "$@"
+124 -5
View File
@@ -4,6 +4,7 @@ Only fixed summaries leave the probe: no raw journals, addresses, account IDs,
process arguments, environment, key material or HTTP response bodies.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
@@ -215,16 +216,100 @@ def report(data):
return int(any(c[0] != 'OK' for c in checks))
def valid_data(data):
"""Validate before saving, comparing or deriving any recommendation."""
if (not isinstance(data, dict) or set(data) != {'guard', 'checks'}
or data.get('guard') not in ('stable', 'loop', 'risk', 'unknown')):
return False
checks = data.get('checks')
if data['guard'] != 'stable':
return checks == []
return (isinstance(checks, list) and len(checks) == len(SECTIONS)
and all(isinstance(c, list) and len(c) == 2
and c[0] in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')
and isinstance(c[1], str) and c[1] in MESSAGES for c in checks))
def repair_plan(data):
print('Repair plan (READ-ONLY): no commands executed; no automatic repair is available.')
if data['guard'] != 'stable':
print('BLOCKED: Restart safety is unverified or unsafe. Needs your hands: review the runbook health-check recovery with the headset user; do not restart services.')
return
# Each action needs the exact section, severity AND recognized evidence.
actions = {
(1, 'WARN', 'Wi-Fi is unavailable or disconnected; check Steam Settings > Internet. Never reload ath12k.'):
'Needs your hands: check Steam Settings > Internet. Do not reload ath12k.',
(2, 'WARN', 'Display timeouts observed; follow display recovery before touching Steam.'):
'Needs your hands: inspect the display in-headset and review display recovery before any Steam action.',
(3, 'BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.'):
'Needs your hands: review Game storage and use Steam storage management after choosing what to keep; no files are deleted by doctor.',
(3, 'WARN', 'Less than 256 MiB free; review storage before large installs.'):
'Needs your hands: review Game storage before installing; choose any storage changes yourself.',
(3, 'WARN', 'Steam install marker is missing; do not restart Steam for diagnosis. Consult the runbook.'):
'Needs your hands: inspect Steam installation/extraction locally with the runbook. A missing marker does not authorize repair or restart.',
(4, 'WARN', 'Keep-awake is active; leave it with its owner.'):
'Leave keep-awake with its owner; do not take ownership or change idle settings.',
}
for i, (status, message) in enumerate(data['checks']):
if status == 'OK':
continue
action = actions.get((i, status, message))
if action is None:
action = ('Collect missing evidence locally; no repair is justified.' if status == 'UNKNOWN'
else 'Review this signal locally with the runbook; it does not establish a safe automatic fix.')
print(f'PLAN {i + 1}. {SECTIONS[i]}: {action}')
def compare(before, after):
print('Before/after diagnostics: observations only; changes do not prove repair or causation.')
print('Restart safety: ' + before['guard'] + ' -> ' + after['guard'])
if before['guard'] != 'stable' or after['guard'] != 'stable':
print('Comparison blocked: deeper checks were skipped in at least one sample; no improvement claimed.')
return
for i, label in enumerate(SECTIONS):
a, b = before['checks'][i], after['checks'][i]
detail = 'unchanged' if a == b else 'evidence changed'
print(f'{i + 1}. {label}: {a[0]} -> {b[0]} ({detail})')
if a != b:
print(' Before evidence: ' + a[1])
print(' After evidence: ' + b[1])
def load_baseline(path):
# Local reports are untrusted too; bound input and never echo file contents.
with open(path, 'rb') as stream:
raw = stream.read(65537)
if len(raw) > 65536:
raise ValueError()
saved = json.loads(raw)
if (not isinstance(saved, dict) or saved.get('version') != 1
or not isinstance(saved.get('target'), str)
or not re.fullmatch('[a-f0-9]{64}', saved['target'])
or not valid_data(saved.get('diagnostics'))):
raise ValueError()
return saved
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')
parser.add_argument('--plan', action='store_true', help='Print a read-only repair plan from recognized evidence')
parser.add_argument('--save-report', metavar='FILE', help='Create a local JSON baseline (never overwrite an existing file)')
parser.add_argument('--compare-before', metavar='FILE', help='Compare a saved baseline with this fresh read-only run')
args = parser.parse_args(argv)
baseline = None
if args.compare_before:
try:
baseline = load_baseline(args.compare_before)
except (OSError, ValueError, TypeError, UnicodeError, RecursionError):
print('UNKNOWN Baseline: Cannot read a valid doctor report; no comparison performed.')
return 2
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.')
print('Frame doctor: read-only; no passwords, pairing, repairs or raw logs. Reports are saved only with --save-report.')
if not shutil.which('ssh'):
print('BROKEN Setup: Install an OpenSSH client, then run Set Up Connection.')
return 2
@@ -234,19 +319,42 @@ def main(argv=None):
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)
# Bind reports to the configured logical SSH target, across LAN/USB fallback.
# This is configuration continuity, not proof of physical headset identity.
target = hashlib.sha256(json.dumps([args.alias, identity, settings.get('hostname'),
settings.get('user'), settings.get('port'), args.lan, args.usb]).encode()).hexdigest()
if baseline is not None and baseline['target'] != target:
print('UNKNOWN Baseline: SSH target configuration differs; comparison refused.')
return 2
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):
if not valid_data(data):
raise ValueError()
except ValueError:
except (ValueError, RecursionError):
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)
code = report(data)
if args.plan:
repair_plan(data)
if baseline is not None:
compare(baseline['diagnostics'], data)
if args.save_report:
try:
# Exclusive create prevents clobbering a baseline or following a symlink.
fd = os.open(args.save_report, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, 'w') as stream:
json.dump({'version': 1, 'target': target, 'diagnostics': data}, stream)
stream.write('\n')
print('OK Report: Local baseline created; review it before sharing.')
except OSError:
print('UNKNOWN Report: Could not create local baseline; existing files are never overwritten.')
return 2
return code
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
@@ -268,7 +376,18 @@ def main(argv=None):
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)}
allowed = set()
for function in tree.body:
if not isinstance(function, ast.FunctionDef) or function.name not in ('probe', 'storage', 'signal_check'):
continue
for node in ast.walk(function):
if (isinstance(node, ast.List) and len(node.elts) == 2
and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in node.elts)
and node.elts[0].value in ('OK', 'WARN', 'BROKEN', 'UNKNOWN')):
allowed.add(node.elts[1].value)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'signal_check':
allowed.update(arg.value for arg in node.args[2:4] if isinstance(arg, ast.Constant))
return allowed
if __name__ == '__main__':
+54
View File
@@ -0,0 +1,54 @@
"""Offline, accessible power report. No network requests or external resources."""
import base64
import html
import json
def render(report, csv_text):
def esc(value):
return html.escape(str(value), quote=True)
def download(text, mime, filename, label):
data = base64.b64encode(text.encode('utf-8')).decode('ascii')
return f'<a download="{filename}" href="data:{mime};base64,{data}">{label}</a>'
rows = []
for metric, item in report['summary'].items():
numeric = 'mean' in item
values = [item.get(key) for key in ('min', 'mean', 'max', 'last')]
cells = ''.join(f'<td>{esc(value) if value is not None else "Unknown"}</td>' for value in values)
states = ', '.join(item.get('states', [])) or ('Numeric' if numeric else 'Unknown')
rows.append(f'<tr><th scope="row">{esc(metric)}</th><td>{esc(item["unit"])}</td>'
f'{cells}<td>{item["available"]} / {item["unavailable"]}</td>'
f'<td>{esc(states)}</td><td>{esc(", ".join(item["sources"]))}</td></tr>')
missing = sorted({g for sample in report['samples'] for g in sample['unavailableGroups']})
json_text = json.dumps(report, allow_nan=False, indent=2)
links = download(json_text, 'application/json', 'frame-power.json', 'Download JSON samples and summary')
links += ' · ' + download(csv_text, 'text/csv', 'frame-power.csv', 'Download CSV samples')
return f'''<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>Frame power and heat report</title>
<style>body{{font:16px system-ui,sans-serif;line-height:1.5;margin:24px;color:#18212b;background:#fff}}
a{{color:#0645ad}}a:focus-visible{{outline:3px solid #0645ad;outline-offset:4px}}
table{{border-collapse:collapse;width:100%;min-width:1100px}}th,td{{padding:8px;text-align:left;border:1px solid #adb5bd}}
thead th,td{{white-space:nowrap}}th[scope=row]{{min-width:240px;max-width:300px;overflow-wrap:anywhere}}
td:last-child{{min-width:220px;max-width:300px;white-space:normal;overflow-wrap:anywhere}}
thead{{background:#edf1f5}}.scroll{{overflow:auto}}h1{{line-height:1.2}}</style>
<main><h1>Frame power and heat report</h1>
<p>Headset: {esc(report.get("headset", {}).get("alias", "frame"))}</p>
<p>{len(report['samples'])} of {report['requestedSamples']} requested samples received; target interval {esc(report['intervalSeconds'])} seconds.</p>
<p>Collection: {esc(report['stopped'] or 'Completed')}. Results describe only the samples received.</p>
<p>{links}</p>
<p>Unknown means missing, unreadable or invalid, never zero. Means exclude unknown values and are not time-weighted.
Battery watts: positive charging, negative discharge. C means °C, W watts, A amps, MHz megahertz, RPM revolutions per minute.
Raw readings have no inferred percentage or worn threshold. Rails overlap: do not sum them.</p>
<p>Unavailable sensor groups in one or more samples: {esc(', '.join(missing) or 'None reported')}.</p>
<div class="scroll" role="region" aria-label="Metric summary, scroll horizontally for all columns" tabindex="0">
<table><caption>Received measurements, with units and availability counts</caption>
<thead><tr><th scope="col">Metric</th><th scope="col">Unit</th><th scope="col">Min</th><th scope="col">Mean</th><th scope="col">Max</th><th scope="col">Last</th><th scope="col">Available / unknown</th><th scope="col">States</th><th scope="col">Sources</th></tr></thead>
<tbody>{''.join(rows)}</tbody></table></div>
<p>Sensor accuracy is unverified. Suspect negative thermistors remain visible. No temperature safety limits,
energy use or battery-runtime estimates are inferred. JSON includes actual timing, OS builds and per-sample availability.</p>
<p>This file stays on your computer and works offline. Downloaded files contain sensor source paths and OS builds;
review them before sharing. No settings, services, security or passwords were changed.</p></main></html>'''
+162 -14
View File
@@ -10,8 +10,11 @@ import io
import json
import math
from pathlib import Path
import queue
import subprocess
import sys
import tempfile
import threading
import time
LOCK = Path('/tmp/frame-test.lock')
@@ -117,7 +120,7 @@ def summarize(samples):
return result
def collect(status, count, interval):
def collect(status, count, interval, emit=None):
samples, before = [], None
started = time.monotonic()
stopped = None
@@ -131,23 +134,35 @@ def collect(status, count, interval):
break
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
samples.append(sample)
if emit:
emit({'sample': sample})
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
def csv_samples(report):
# CSV quoting alone does not prevent spreadsheet formulas in text fields.
def cell(value):
if isinstance(value, str) and (value.startswith(('\t', '\r', '\n')) or
value.lstrip(' \t\r\n').startswith(('=', '+', '-', '@'))):
return "'" + value
return value
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
if 'headset' in report:
for key, value in (('collection.state', report['stopped'] or 'Completed'),
('collection.requestedSamples', report['requestedSamples']),
('collection.receivedSamples', len(report['samples']))):
writer.writerow([cell(v) for v in ('', '', key, value, '', report['headset']['alias'],
'incomplete' if report['stopped'] else 'completed')])
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))
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE)])
return output.getvalue()
@@ -156,7 +171,9 @@ def parser():
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('--html', type=Path, metavar='FILE', help='save an offline accessible report with JSON/CSV downloads; Ctrl+C retains received samples (max 10 minutes); never overwrites')
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
ap.add_argument('--stream', action='store_true', help=argparse.SUPPRESS)
return ap
@@ -171,35 +188,166 @@ def remote_payload():
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
def stream_report(command, payload, count, interval, stop_event=None, on_sample=None, discard_errors=False):
"""Receive each sample before the next wait; stop only our local SSH child.
The remote bounded reader exits on its next write to the closed connection.
No remote process-control command is sent. Keep already received samples on
interrupt, transport failure or deadline, clearly marked as incomplete.
"""
if stop_event is not None and stop_event.is_set():
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': 'Stopped before collection; no SSH started', 'samples': [], 'summary': {}}
samples, final, stopped = [], None, None
events = queue.Queue(maxsize=count + 2)
reader_stop = threading.Event()
deadline = time.monotonic() + count * interval + 30
with tempfile.TemporaryFile(mode='w+') as errors:
proc = subprocess.Popen(command + ['--stream'], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL if discard_errors else errors, text=True)
def receive():
try:
for _ in range(count + 2):
line = proc.stdout.readline(262145)
if not line:
break
if len(line) > 262144 or not line.endswith('\n'):
line = 'invalid or oversized sample'
while not reader_stop.is_set():
try:
events.put(line, timeout=0.2)
break
except queue.Full:
continue
if reader_stop.is_set():
return
finally:
while not reader_stop.is_set():
try:
events.put(None, timeout=0.2)
break
except queue.Full:
continue
reader = threading.Thread(target=receive, daemon=True)
reader.start()
def feed():
try:
proc.stdin.write(payload)
proc.stdin.close()
except OSError:
pass # transport exit/EOF below marks the observation incomplete
writer = threading.Thread(target=feed, daemon=True)
writer.start()
try:
while True:
if stop_event is not None and stop_event.is_set() and events.empty():
stopped = 'Stopped; received samples retained'
break
remaining = deadline - time.monotonic()
if remaining <= 0:
stopped = 'Timed out; received samples retained'
break
try:
line = events.get(timeout=min(remaining, 0.2))
except queue.Empty:
continue
if line is None:
break
event = json.loads(line)
if 'sample' in event:
if len(samples) >= count:
raise ValueError('Too many samples received')
samples.append(event['sample'])
if on_sample:
on_sample(event['sample'])
print(f'Received {len(samples)}/{count} samples; Ctrl+C to stop and save.', file=sys.stderr)
elif 'finished' in event:
final = event['finished']
if not stopped:
proc.wait(timeout=max(0.1, deadline - time.monotonic()))
except KeyboardInterrupt:
stopped = 'Stopped by user; received samples retained'
except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
stopped = f'Collection interrupted: {exc}'
finally:
reader_stop.set()
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
writer.join(timeout=3)
if not proc.stdin.closed:
try:
proc.stdin.close()
except OSError:
pass
reader.join(timeout=3)
proc.stdout.close()
if not stopped:
errors.seek(0)
if final and final.get('stopped') and proc.returncode in (0, 75):
stopped = final['stopped']
elif proc.returncode or final is None or len(samples) != count:
stopped = f'Incomplete collection (SSH exit {proc.returncode}): ' + errors.read(2000).strip()
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
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')
if args.html and (args.csv or args.probe or args.count * args.interval > 600):
ap.error('--html requires a host session, no --csv, and count × interval <= 600 seconds')
if args.html and args.html.exists():
ap.error('--html output already exists; choose a new file')
# Also honor a coordinator lock on the computer before opening SSH.
if LOCK.exists():
if args.probe and args.stream:
print(json.dumps({'finished': {'stopped': 'Smoke session active (/tmp/frame-test.lock); no sensors read'}}), flush=True)
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)
emit = (lambda event: print(json.dumps(event, allow_nan=False), flush=True)) if args.stream else None
report = collect(frame_status, args.count, args.interval, emit)
if args.stream:
emit({'finished': {'stopped': report['stopped']}})
return 75 if report['stopped'] else 0
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)
if args.html:
report = stream_report(command, remote_payload(), args.count, args.interval)
else:
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:
if args.html:
from frame_power_report import render
try:
with args.html.open('x', encoding='utf-8') as output:
output.write(render(report, csv_samples(report)))
except OSError as exc:
print(f'Could not save report: {exc}; received JSON report follows on stdout.', file=sys.stderr)
print(json.dumps(report, allow_nan=False))
return 1
print(f'Saved {len(report["samples"])} samples to {args.html}. Open it in your browser.', file=sys.stderr)
elif 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:
+29 -1
View File
@@ -5,7 +5,8 @@ The Frame Control website, <https://frame-control.pages.dev>, on Cloudflare Page
- `public/`: static pages. `/` is the landing page, `/feedback/` the feedback form, `/privacy/` the privacy note.
- `functions/api/feedback.js`: `POST /api/feedback`, which turns the form into a GitHub issue labelled `feedback`.
- `lib/feedback.js`: validation and issue formatting, tested by `test/feedback.test.mjs`.
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons.
- `public/js/site.js`: settings, including the Ko-fi page name for the donate buttons, OS hints and opening-help deep links. OS hints lead to instructions, never directly to an architecture-specific installer.
- `test/onboarding.test.mjs`: desktop/mobile browser hints and opening-help deep links.
## Feedback → GitHub issues
@@ -33,3 +34,30 @@ npx wrangler pages deploy --branch main # production
```
Point `GITHUB_REPO` in `.dev.vars` at a scratch repo when testing locally so test issues don't land on the real tracker.
## First-run verification
Before changing download or setup instructions:
- Compare every linked filename with `gh release view --repo saphid/frame-control --json tagName,assets`.
Browser OS hints cannot establish CPU architecture; keep every platform choice visible.
- Run `node --test site/test/*.test.mjs` from the repo root, plus
`node --check site/public/js/site.js` and `node --check site/public/js/feedback.js`.
- Serve `public/` locally (for example `python3 -m http.server 8793 --directory site/public`
from the repo root). Check 320px, 390px, 768px and desktop widths: section navigation,
the hero link, download cards, opening-help links, feedback and privacy pages. Check
horizontal overflow and that anchor targets clear the sticky header. Repeat with
JavaScript disabled: downloads and navigation must remain available.
- Check setup labels against `app/main.js`, `scripts/connect.sh`, `ui/frame_connect.py`
and the Devices page. macOS discovery failure exits; Windows/Linux prompt for an IP.
The app's Devices → Add a headset dialog can pass an explicit address on all platforms.
- Separate website checks from native installation and real-headset pairing evidence.
A successful page/link check does not verify an installer or headset approval flow.
On 2026-10-24, the first-run update was checked against published v0.4.16 assets:
all seven linked desktop installers returned HTTP 200 after redirects, as did the
linked GitHub documentation and trailer. Local page/resource links and section
fragments were checked; navigation stayed visible without horizontal overflow at
320, 390, 768 and 1280 CSS pixels. Native macOS/Windows installs and real-headset
pairing were not exercised for this documentation change. No site deployment was
performed.
+15 -2
View File
@@ -38,8 +38,13 @@ code { font: 14px ui-monospace, SFMono-Regular, Menlo, monospace; background: rg
.top nav a { color: var(--muted); font-size: 14.5px; font-weight: 500; }
.top nav a:hover, .top nav a[aria-current] { color: var(--bright); }
.top .end { margin-left: auto; display: flex; gap: 10px; align-items: center; }
@media (max-width: 880px) { .top nav { display: none; } }
@media (max-width: 480px) { .top .end .ghost { display: none; } }
@media (max-width: 1050px) {
html { scroll-padding-top: 150px; }
.top .wrap { height: auto; min-height: 64px; flex-wrap: wrap; gap: 8px 16px; padding-top: 12px; padding-bottom: 8px; }
.top nav { order: 3; width: 100%; margin-left: 0; flex-wrap: wrap; gap: 0 18px; }
.top nav a { padding: 8px 0; }
}
@media (max-width: 480px) { .top .end { display: none; } }
/* ---- buttons ---- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 9px; height: 46px; padding: 0 22px; border-radius: 10px;
@@ -199,3 +204,11 @@ textarea { min-height: 180px; resize: vertical; }
.done p { color: var(--muted); margin: 10px auto 24px; max-width: 460px; }
.done .cta { margin-top: 0; }
.page h1 { font-size: clamp(34px, 5vw, 48px); }
/* Opening instructions remain readable on narrow screens. */
.dl p, .setup-note { color: var(--muted); font-size: 15px; margin-top: 16px; }
.dl { min-width: 0; }
code { overflow-wrap: anywhere; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
pre code { background: none; padding: 0; }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
+39 -24
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frame Control: manage your Steam Frame from your computer</title>
<meta name="description" content="Free, open-source app for the Valve Steam Frame. See what the headset sees, install Steam games and Android apps, move files and text across, and check battery and status. macOS, Windows, Linux and iPhone.">
<meta name="description" content="Free, open-source app for the Valve Steam Frame. See what the headset sees, install Steam games and Android apps, move files and text across, and check battery and status. Desktop downloads for macOS, Windows and Linux; iPhone build instructions.">
<meta name="theme-color" content="#0e141b">
<link rel="icon" href="/favicon.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
@@ -41,13 +41,13 @@
<main>
<section class="hero">
<div class="wrap">
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux · iPhone</span></span>
<span class="eyebrow"><b>Free and open source</b><span class="sep"> · </span><span class="plats">macOS · Windows · Linux</span></span>
<h1>Your Steam Frame, <span>managed from your desk.</span></h1>
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. All over SSH, with nothing to install on the Frame.</p>
<p class="lede">See what the headset sees, install games and Android apps, move files and text across, and keep an eye on battery and status. Install Frame Control on your computer, then connect to your headset over SSH. This website provides downloads and instructions; it does not control the headset in your browser.</p>
<div class="cta">
<a class="btn primary" id="hero-download" href="#download">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m0 0-5-5m5 5 5-5M4 20h16"/></svg>
<span data-label>Download Frame Control</span>
<span data-label>Choose your computer’s download</span>
</a>
<a class="btn ghost" href="#trailer">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>
@@ -173,7 +173,7 @@
<div class="head">
<span class="kicker">iPhone and iPad</span>
<h2>Also in your pocket</h2>
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. Build it from the repo in Xcode.</p>
<p>The same features on iPhone and iPad, served from the Frame itself, so there's nothing to run on a computer. There is no App Store, TestFlight or iOS download in the current release. Building it requires a Mac, Xcode and your own signing team. <a href="https://github.com/saphid/frame-control/blob/main/docs/iphone.md">iPhone build and pairing instructions</a>.</p>
</div>
<div class="phones">
<img src="/img/phone-home.jpg" width="600" height="1304" loading="lazy" alt="Frame Control on iPhone: headset view and device status.">
@@ -186,23 +186,24 @@
<div class="wrap">
<div class="head">
<span class="kicker">Setup</span>
<h2>One password, typed once</h2>
<p>Everything else happens on your computer.</p>
<h2>Connect your computer to the Frame</h2>
<p>First <a href="#download">install and open the desktop app</a>. Keep the headset awake, on the same trusted network as your computer. Guest Wi-Fi or client isolation can block the connection.</p>
</div>
<div class="steps">
<div class="card">
<h3>Turn on Developer Mode</h3>
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then <b>Set User Password</b> in the Developer section.</p>
<h3>Prepare the headset</h3>
<p>On the Frame: Steam Settings → System → <b>Enable Developer Mode</b>, then Developer → <b>Set User Password</b>. Choose a password that isn't easy to guess; it also protects remote desktop and <code>sudo</code>.</p>
</div>
<div class="card">
<h3>Set up the connection</h3>
<p>Open Frame Control and choose <b>Set Up Connection</b>. It finds the headset, makes an SSH key, and asks for that password once.</p>
<h3>Run connection setup</h3>
<p>In the desktop app, choose <b>Set Up Connection…</b>. If you skipped the first prompt, use <b>Frame → Set Up Connection…</b>. A terminal opens. If discovery fails, find the Frame's IP in Quick Settings or Steam Settings → Internet. Windows/Linux setup prompts for it. On macOS, return to the app → <b>Devices &amp; connection → + Add a headset…</b>, use SSH alias <code>frame</code> for the default headset, enter the IP in <b>Address</b> and choose <b>Set Up…</b>.</p>
</div>
<div class="card">
<h3>That's it</h3>
<p>The app reaches the headset whenever it's awake and on the same network. For anywhere else, use <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
<h3>Pair, with a password fallback</h3>
<p>Setup tries Valve's pairing first: open Developer → <b>Pair new host</b> in the headset and approve only your request. If it fails or times out, enter the Developer Mode password in the computer's terminal (typing may be invisible), then press Enter.</p>
</div>
</div>
<p class="setup-note">The devkit service and pairing-mode step were checked on a Frame; approval and key installation through that flow remain unverified. <a href="https://github.com/saphid/frame-control/blob/main/docs/ssh.md#password-free-pairing-steamos-devkit-service">Pairing details</a>. After setup succeeds, return to the app; future connections use SSH keys. Still offline? Check Developer Mode, the IP and network isolation, then rerun setup. For use away from home, see <a href="https://github.com/saphid/frame-control/blob/main/docs/tailscale.md">Tailscale</a>.</p>
</div>
</section>
@@ -211,32 +212,39 @@
<div class="head">
<span class="kicker">Download</span>
<h2>Get Frame Control</h2>
<p>Free, with Python and adb built in. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
</div>
<div class="downloads">
<div class="dl" data-os="mac">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.37 12.6c-.02-2.2 1.8-3.26 1.88-3.31-1.02-1.5-2.62-1.7-3.19-1.72-1.36-.14-2.65.8-3.34.8-.69 0-1.75-.78-2.88-.76a4.27 4.27 0 0 0-3.6 2.19c-1.53 2.66-.39 6.6 1.1 8.76.73 1.06 1.6 2.24 2.74 2.2 1.1-.04 1.51-.71 2.84-.71 1.32 0 1.7.71 2.86.69 1.18-.02 1.93-1.08 2.65-2.14.83-1.23 1.18-2.41 1.2-2.47-.03-.01-2.3-.88-2.32-3.5zM14.2 6.13c.6-.73 1.01-1.75.9-2.76-.87.04-1.92.58-2.54 1.3-.56.64-1.05 1.67-.92 2.66.97.08 1.96-.49 2.56-1.2z"/></svg><h3>macOS</h3><span class="tag" hidden>Your system</span></div>
<span class="need">Apple Silicon</span>
<div class="dl" id="download-mac" data-os="mac">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16.37 12.6c-.02-2.2 1.8-3.26 1.88-3.31-1.02-1.5-2.62-1.7-3.19-1.72-1.36-.14-2.65.8-3.34.8-.69 0-1.75-.78-2.88-.76a4.27 4.27 0 0 0-3.6 2.19c-1.53 2.66-.39 6.6 1.1 8.76.73 1.06 1.6 2.24 2.74 2.2 1.1-.04 1.51-.71 2.84-.71 1.32 0 1.7.71 2.86.69 1.18-.02 1.93-1.08 2.65-2.14.83-1.23 1.18-2.41 1.2-2.47-.03-.01-2.3-.88-2.32-3.5zM14.2 6.13c.6-.73 1.01-1.75.9-2.76-.87.04-1.92.58-2.54 1.3-.56.64-1.05 1.67-.92 2.66.97.08 1.96-.49 2.56-1.2z"/></svg><h3>macOS</h3><span class="tag" hidden>OS detected</span></div>
<span class="need">Apple Silicon (M-series) only · no Intel Mac build</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-mac-arm64.dmg">Download .dmg</a>
</div>
<p>Check Apple menu → About This Mac for your chip. Open the DMG, drag Frame Control to Applications, then open it there. Allow local network access and Terminal control when asked.</p>
<p>The app isn't notarized. <a href="#mac-opening">macOS opening instructions</a>.</p>
</div>
<div class="dl" data-os="windows">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>Your system</span></div>
<div class="dl" id="download-windows" data-os="windows">
<div class="os"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5 10 4.5v7H3zM11 4.35 21 3v8.5H11zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.4z"/></svg><h3>Windows</h3><span class="tag" hidden>OS detected</span></div>
<span class="need">Windows 10 or 11, x64</span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-Setup-x64.exe">Download installer</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-win-x64.zip">Portable .zip</a>
</div>
<p>Check Settings → System → About → System type. No native Windows ARM build is published. Run the installer, then open Frame Control from Start. The OpenSSH Client optional feature must be installed (it normally is). For portable use, extract the entire ZIP and run <code>Frame Control.exe</code>.</p>
<p>The installer isn't code-signed. <a href="#windows-opening">SmartScreen instructions</a>.</p>
</div>
<div class="dl" data-os="linux">
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>Your system</span></div>
<div class="dl" id="download-linux" data-os="linux">
<div class="os"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/></svg><h3>Linux</h3><span class="tag" hidden>OS detected</span></div>
<span class="need">x64 or arm64 · needs <code>ssh</code></span>
<div class="links">
<a class="btn primary" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage">AppImage (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb">.deb (x64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage">AppImage (arm64)</a>
<a class="btn ghost" href="https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb">.deb (arm64)</a>
</div>
<p>Run <code>uname -m</code>: <code>x86_64</code> means x64; <code>aarch64</code> means arm64. Install the matching .deb on Debian/Ubuntu and open Frame Control from your applications menu, or use AppImage on other distributions.</p>
<p><a href="#linux-opening">AppImage permissions and FUSE help</a>. On arm64 Debian/Ubuntu, install <code>adb</code> with <code>sudo apt install adb</code> for Android apps.</p>
</div>
</div>
</div>
@@ -249,15 +257,22 @@
<h2>Good questions</h2>
</div>
<div class="faq">
<details>
<details id="mac-opening">
<summary>macOS says the app is damaged or can't be checked</summary>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Drag it to Applications, then clear the download quarantine once in Terminal:</p>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:</p>
<p><code>xattr -dr com.apple.quarantine "/Applications/Frame Control.app"</code></p></div>
</details>
<details>
<details id="windows-opening">
<summary>Windows SmartScreen says it protected my PC</summary>
<div><p>The installer isn't code-signed. Choose <b>More info → Run anyway</b>, or use the portable .zip: unzip it anywhere and run <code>Frame Control.exe</code>.</p></div>
</details>
<details id="linux-opening">
<summary>How do I open the Linux AppImage?</summary>
<div><p>In a terminal in your download folder, use the exact filename for your architecture. For x64:</p>
<pre><code>chmod +x Frame-Control-linux-x86_64.AppImage
./Frame-Control-linux-x86_64.AppImage</code></pre>
<p>If FUSE is missing, install <code>libfuse2</code> (Ubuntu 24.04+: <code>libfuse2t64</code>), or run the same AppImage with <code>--appimage-extract-and-run</code>. Install your distribution’s OpenSSH client if <code>ssh</code> is missing.</p></div>
</details>
<details>
<summary>What does it change on my headset?</summary>
<div><p>Only what you click. Installs go to your user account on the Frame, and nothing needs <code>sudo</code> except the power buttons. On your computer it adds a <code>Host frame</code> entry to <code>~/.ssh/config</code> and two SSH keys.</p></div>
+17 -17
View File
@@ -5,8 +5,6 @@ const SITE = {
kofi: "alexsouthwell",
};
const RELEASE = `https://github.com/${SITE.repo}/releases/latest/download/`;
// Donate buttons.
for (const el of document.querySelectorAll("[data-kofi]")) {
if (SITE.kofi) el.href = `https://ko-fi.com/${SITE.kofi}`;
@@ -15,37 +13,39 @@ for (const el of document.querySelectorAll("[data-kofi]")) {
for (const el of document.querySelectorAll("[data-needs-kofi]")) el.hidden = !SITE.kofi;
for (const el of document.querySelectorAll("[data-no-kofi]")) el.hidden = !!SITE.kofi;
// Best guess at the visitor's platform, for the hero button and the download cards.
// Browser hints identify an OS, not a reliable CPU architecture. Always show
// the install instructions and all choices before starting a download.
function detectPlatform() {
const ua = navigator.userAgent;
const hint = navigator.userAgentData?.platform || navigator.platform || "";
if (/iPhone|iPad|iPod/.test(ua) || (/Mac/.test(hint) && navigator.maxTouchPoints > 1)) return "ios";
if (/Android/.test(ua)) return null;
if (/iPhone|iPad|iPod|Android/.test(ua) || (/Mac/.test(hint) && navigator.maxTouchPoints > 1)) return null;
if (/Mac/.test(hint) || /Macintosh/.test(ua)) return "mac";
if (/Win/.test(hint) || /Windows/.test(ua)) return "windows";
if (/Linux/.test(hint) || /Linux/.test(ua)) return /aarch64|arm64/i.test(ua + hint) ? "linux-arm" : "linux";
if (/Linux/.test(hint) || /Linux/.test(ua)) return "linux";
return null;
}
const PLATFORMS = {
mac: { name: "macOS", file: "Frame-Control-mac-arm64.dmg" },
windows: { name: "Windows", file: "Frame-Control-Setup-x64.exe" },
linux: { name: "Linux", file: "Frame-Control-linux-x86_64.AppImage" },
"linux-arm": { name: "Linux (arm64)", file: "Frame-Control-linux-arm64.AppImage" },
};
const platform = detectPlatform();
const hero = document.getElementById("hero-download");
if (hero && PLATFORMS[platform]) {
hero.href = RELEASE + PLATFORMS[platform].file;
hero.querySelector("[data-label]").textContent = `Download for ${PLATFORMS[platform].name}`;
if (hero && platform) {
hero.href = `#download-${platform}`;
const name = { mac: "macOS", windows: "Windows", linux: "Linux" }[platform];
hero.querySelector("[data-label]").textContent = `Choose a ${name} download`;
}
const card = platform && document.querySelector(`.dl[data-os="${platform.replace("-arm", "")}"]`);
const card = platform && document.querySelector(`.dl[data-os="${platform}"]`);
if (card) {
card.classList.add("mine");
card.querySelector(".tag").hidden = false;
}
// Keep linked opening instructions visible, including direct FAQ deep links.
function revealInstructions() {
const target = document.getElementById(location.hash.slice(1));
if (target?.tagName === "DETAILS") target.open = true;
}
window.addEventListener("hashchange", revealInstructions);
revealInstructions();
// Latest version number, so the page never goes stale. Fails quietly.
const versionEls = document.querySelectorAll("[data-version]");
if (versionEls.length) {
+59
View File
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
const script = readFileSync(new URL('../public/js/site.js', import.meta.url), 'utf8');
const html = readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
function visit(navigator, hash = '') {
const label = { textContent: 'Choose your computer’s download' };
const hero = { href: '#download', querySelector: () => label };
const cards = Object.fromEntries(['mac', 'windows', 'linux'].map(os => [os, {
classes: [], classList: { add(name) { cards[os].classes.push(name); } },
tag: { hidden: true }, querySelector() { return this.tag; },
}]));
const details = { tagName: 'DETAILS', open: false };
const listeners = {};
const context = {
navigator, location: { hash }, window: { addEventListener: (name, fn) => { listeners[name] = fn; } },
document: {
querySelectorAll: () => [],
getElementById: id => id === 'hero-download' ? hero : id === 'mac-opening' ? details : null,
querySelector: selector => cards[selector.match(/data-os="([^"]+)"/)[1]],
},
};
runInNewContext(script, context);
return { hero, cards, details, listeners, context };
}
for (const [name, navigator, expected] of [
['Intel Mac', { platform: 'MacIntel', userAgent: 'Macintosh' }, 'mac'],
['Apple Silicon browser with Intel hint', { platform: 'MacIntel', userAgent: 'Macintosh', userAgentData: { platform: 'macOS' } }, 'mac'],
['Windows', { platform: 'Win32', userAgent: 'Windows NT 10.0' }, 'windows'],
['Linux x64', { platform: 'Linux x86_64', userAgent: 'Linux x86_64' }, 'linux'],
['Linux ARM', { platform: 'Linux aarch64', userAgent: 'Linux aarch64' }, 'linux'],
['iPhone', { platform: 'iPhone', userAgent: 'iPhone' }, null],
['iPad desktop mode', { platform: 'MacIntel', userAgent: 'Macintosh', maxTouchPoints: 5 }, null],
['Android', { platform: 'Linux armv8l', userAgent: 'Linux Android' }, null],
['unknown', { userAgent: '' }, null],
]) {
test(`${name} opens choices instead of guessing an installer architecture`, () => {
const { hero, cards } = visit(navigator);
assert.equal(hero.href, expected ? `#download-${expected}` : '#download');
assert.ok(html.includes(`id="${hero.href.slice(1)}"`));
assert.deepEqual(Object.keys(cards).filter(os => !cards[os].tag.hidden), expected ? [expected] : []);
});
}
test('opening help expands for direct and in-page links; unknown fragments are safe', () => {
const result = visit({ userAgent: '' }, '#mac-opening');
assert.equal(result.details.open, true);
result.details.open = false;
result.context.location.hash = '#unknown';
result.listeners.hashchange();
assert.equal(result.details.open, false);
result.context.location.hash = '#mac-opening';
result.listeners.hashchange();
assert.equal(result.details.open, true);
});
+48 -5
View File
@@ -23,7 +23,8 @@ function fixture() {
const page = element('controlCenter');
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
return {document:{getElementById:element},element,cards,buttons};
const listeners = {};
return {document:{hidden:false,getElementById:element,addEventListener(name,callback){listeners[name]=callback;}},listeners,element,cards,buttons};
}
function mounted(options = {}) {
const f = fixture(), calls = [];
@@ -39,8 +40,10 @@ const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
// rendering helpers, timers and transport are stubbed.
function sharedPage({productionApi=false} = {}) {
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
const f=fixture(), requests=[], writes=[], notifications=[], host=deferred(), job=deferred();
let now=0;
const context={document:f.document,FrameControlCenter:cc,performance:{now:()=>now},
window:{frameApp:{setUpConnection(){},notify:async(message,request)=>{notifications.push({message,request});}}},
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
battery(){},setVolume(){},renderHome(){},gb:String,dur:String,esc:String,meter(){},toast(){},
runJob(title,id){context.installing.add(id);return job.promise;},
@@ -52,7 +55,11 @@ function sharedPage({productionApi=false} = {}) {
watchConnection(){context.watched=true;}};
vm.createContext(context);
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1, comfortActive = false; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
// Load the real alert state machine and DOM wiring used by render, refresh,
// setOnline and onConnection; do not stub away their lifecycle behavior.
const batteryStart=pageSource.indexOf('class DesktopBatteryAlert'),batteryEnd=pageSource.indexOf('// ---- battery ----',batteryStart);
vm.runInContext(pageSource.slice(batteryStart,batteryEnd),context);
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
@@ -80,12 +87,48 @@ function sharedPage({productionApi=false} = {}) {
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
return {...f,context,requests,writes,notifications,host,setNow(value){now=value;},refresh:()=>vm.runInContext('refresh()',context),
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
}
const response = data => ({ok:true,status:200,json:async()=>data});
test('production refresh confirms desktop alerts and failures interrupt confirmation', async () => {
const f=sharedPage({productionApi:true});
const low=sample();low.battery={percent:15,status:'Discharging',watts:-4};
const read=async(now,data=low)=>{
f.setNow(now);const pending=f.refresh();
f.requests.at(-1).resolve(response(data));await pending;
};
await read(0);await read(30000);
assert.equal(f.notifications.length,0,'desktop alert starts off');
f.element('desktopBatteryAlert').checked=true;
f.element('desktopBatteryAlert').onchange();
await read(60000);
const failed=f.refresh();f.requests.at(-1).reject(Error('Status transport failed'));await failed;
await read(90000);
assert.equal(f.notifications.length,0,'failed status interrupts low-battery confirmation');
await read(120000);
assert.equal(f.notifications.length,1);
assert.match(f.notifications[0].message,/15%.*draining/);
assert.equal(f.notifications[0].request,false,'refresh never requests notification permission');
await read(150000);
assert.equal(f.notifications.length,1,'same episode stays latched');
assert.equal(f.requests.length,7,'only explicit existing status reads are used');
});
test('production headset switch clears desktop opt-in and pending confirmation', async () => {
const f=sharedPage({productionApi:true});
f.element('desktopBatteryAlert').checked=true;
const low=sample();low.battery={percent:10,status:'Discharging',watts:-3};
const pending=f.refresh();f.requests[0].resolve(response(low));await pending;
assert.equal(vm.runInContext('desktopBattery.pending.condition',f.context),'low');
f.setNow(30000);f.switchHeadset();
assert.equal(f.element('desktopBatteryAlert').checked,false);
assert.equal(vm.runInContext('desktopBattery.pending',f.context),null);
assert.match(f.element('desktopBatteryStatus').textContent,/Headset changed/);
f.requests[1].resolve(response(low));await flush();
assert.equal(f.notifications.length,0,'new headset cannot inherit the prior opt-in');
});
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
const f=sharedPage({productionApi:true});
+18 -2
View File
@@ -1,6 +1,8 @@
"""Real doctor/SSH against fake-Frame faults; never a real headset."""
import subprocess
import sys
import tempfile
from pathlib import Path
import harness
from harness import ROOT, ctl
@@ -9,9 +11,9 @@ harness.require()
class Doctor(harness.FrameTestCase):
def doctor(self):
def doctor(self, *args):
return subprocess.run([sys.executable, str(ROOT / 'scripts/frame_doctor.py'),
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST],
'--lan', harness.FAKE_HOST, '--usb', harness.FAKE_HOST, *args],
capture_output=True, text=True, timeout=120)
def test_disk_full(self):
@@ -41,3 +43,17 @@ class Doctor(harness.FrameTestCase):
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)
def test_plan_and_before_after_storage(self):
with tempfile.TemporaryDirectory() as folder:
baseline = str(Path(folder) / 'before.json')
ctl('disk-full', 'on')
before = self.doctor('--plan', '--save-report', baseline)
self.assertEqual(before.returncode, 1, before.stdout + before.stderr)
self.assertIn('use Steam storage management', before.stdout)
ctl('disk-full', 'off') # fake fault control, not a doctor repair
after = self.doctor('--plan', '--compare-before', baseline)
self.assertEqual(after.returncode, 1, after.stdout + after.stderr)
self.assertIn('Steam and storage: BROKEN ->', after.stdout)
self.assertNotIn('Steam and storage: BROKEN -> BROKEN', after.stdout)
self.assertIn('no automatic repair is available', after.stdout)
+15 -2
View File
@@ -3,8 +3,8 @@
tests/test_release_workflow.py. Serves the releases in $FAKEGH_RELEASES (a JSON list,
drafts included) and the tags in $FAKEGH_TAGS (space-separated); an upload's body is
written to $FAKEGH_UPLOAD. Every call is appended to $FAKEGH_LOG as a JSON line.
Anything else fails, so the script under test can't fall back to `gh release ...`
(GraphQL) unnoticed. POST .../releases (release.yml's draft step) answers as release
Anything else fails; `gh release upload` works only when a release-workflow test
explicitly sets FAKEGH_ALLOW_RELEASE_UPLOAD=1. POST .../releases (release.yml's draft step) answers as release
$FAKEGH_NEW_ID (default 9001) without adding it to the listing, like GitHub's list
lagging a fresh draft. With jq installed, --jq filters a response the way gh does
(raw strings, compact JSON)."""
@@ -17,6 +17,13 @@ import sys
args = sys.argv[1:]
with open(os.environ["FAKEGH_LOG"], "a") as f:
f.write(json.dumps(args) + "\n")
if args[:2] == ["release", "upload"] and os.environ.get("FAKEGH_ALLOW_RELEASE_UPLOAD") == "1":
releases = json.load(open(os.environ["FAKEGH_RELEASES"]))
if os.environ.get("FAKEGH_UPLOAD_ERROR"):
sys.exit(os.environ["FAKEGH_UPLOAD_ERROR"])
if not any(r.get("tag_name") == args[2] for r in releases):
sys.exit("release not found")
sys.exit(0)
if not args or args[0] != "api":
sys.exit("fakegh: only `gh api` is supported: %r" % args)
@@ -80,6 +87,12 @@ elif method == "PATCH" and endpoint.startswith(repo + "releases/"):
rid = endpoint[len(repo + "releases/"):]
if not rid.isdigit(): # what GitHub said to v0.4.2's PATCH of releases/ (an empty id)
sys.exit("gh: Not Found (HTTP 404)")
if os.environ.get("FAKEGH_ALLOW_RELEASE_UPLOAD") == "1":
for item in releases:
if str(item["id"]) == rid:
item.update(fields)
with open(os.environ["FAKEGH_RELEASES"], "w") as f:
json.dump(releases, f)
page = "https://github.com/saphid/frame-control/releases/tag/%s" % fields.get("tag_name")
answer({"id": int(rid), "tag_name": fields.get("tag_name"), "html_url": page},
"published %s at %s" % (fields.get("tag_name"), page))
+9
View File
@@ -40,6 +40,15 @@ function page(answer) {
if (!p.noPanelWhy().startsWith("The headset's desktop is running")) fail("why for capture: " + p.noPanelWhy());
if (!p.ctrlTarget().why.startsWith("The headset's desktop is running")) fail("why for control: " + p.ctrlTarget().why);
}
// An unreadable list is unknown: both Capture and Control retain that distinction.
{
const why = "Frame Control couldn't read gamescope's panel list, so it can't tell whether an app panel is open. " +
"Check that SteamVR is running in the headset, then try again or use the Headset view. " +
"If this persists, report your SteamOS version and this exact message.";
const p = page(() => ({ panels: [], focus: null, why }));
await p.loadPanels();
if (p.noPanelWhy() !== why || p.ctrlTarget().why !== why) fail("unreadable list reason lost");
}
// An older headset script without "why": the generic advice.
{
const p = page(() => ({ panels: [], focus: null }));
+77
View File
@@ -0,0 +1,77 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {workspaceFixture,html}=require('./workspace_fixture.cjs');
function fixture() {
const f=workspaceFixture();
Object.assign(f.context, {clearTimeout(){},setTimeout(){},UI_KEY:'1',encodeURIComponent});
const formats=['html','json','csv'].map(format=>Object.assign(f.el('download-'+format),{dataset:{powerFormat:format}}));
const query=f.context.document.querySelectorAll;
f.context.document.querySelectorAll=s=>s==='[data-power-format]'?formats:query(s);
f.formats=formats;
f.run(html.slice(html.indexOf('// ---- Read-only power observation ----'),html.indexOf('// ---- Read-only game storage ----')));
return f;
}
const state={id:'abc',headset:{alias:'old-frame'},requested:12,received:3,running:true,stopped:null};
test('Tools navigation loads the report and renders accessible progress',async()=>{
const f=fixture();let calls=0;f.context.api=async()=>{calls++;return state;};
f.navigate('#powerReport');await new Promise(setImmediate);
assert.equal(f.activePage,'tools');assert.equal(f.el('powerReport').hidden,false);
assert.equal(f.el('powerReport').parentNode,f.el('toolWorkspace'));
assert.equal(calls,1);assert.equal(f.el('powerProgress').value,3);
assert.equal(f.el('powerStart').disabled,true);assert.equal(f.el('powerStop').disabled,false);
assert.match(f.el('powerStatus').textContent,/Headset: old-frame.*3 of 12.*Observing/);
});
test('incomplete result preserves original identity and start becomes available',()=>{
const f=fixture();f.context.sample={...state,running:false,stopped:'Headset switched; samples retained'};
f.run('renderPower(sample)');
assert.match(f.el('powerStatus').textContent,/old-frame.*Incomplete: Headset switched/);
assert.equal(f.el('powerStart').disabled,false);assert.equal(f.el('powerStop').disabled,true);
});
test('stop submits observation identity and keeps progress visible',async()=>{
const f=fixture(),calls=[];f.context.sample=state;f.run('renderPower(sample)');
f.context.api=async(path,body)=>{calls.push([path,body]);return {...state,running:false,stopped:'Stopped by user'};};
await f.el('powerStop').onclick();
assert.equal(calls[0][0],'/api/power');assert.equal(calls[0][1].action,'stop');assert.equal(calls[0][1].id,'abc');
assert.equal(f.el('powerProgress').value,3);assert.match(f.el('powerStatus').textContent,/Incomplete/);
});
test('old polls cannot overwrite newer report and retry explains retained samples',async()=>{
const f=fixture(),pending=[];f.context.api=()=>new Promise((resolve,reject)=>pending.push({resolve,reject}));
const first=f.run('loadPower()'),second=f.run('loadPower()');
pending[1].resolve({...state,running:false,stopped:'Disconnected'});await second;
const current=f.el('powerStatus').textContent;pending[0].resolve(state);await first;
assert.equal(f.el('powerStatus').textContent,current);
const next=f.run('loadPower()');pending[2].reject(new Error('Offline'));await next;
assert.match(f.el('powerStatus').textContent,/Offline.*Received samples remain/);
});
test('start failure remains actionable and does not imply success',async()=>{
const f=fixture();f.context.api=async()=>{throw new Error('Smoke session active');};
await f.el('powerStart').onclick();assert.match(f.el('powerStatus').textContent,/Smoke session/);
assert.equal(f.el('powerStart').disabled,false);
});
test('download captures observation ID, fetches an authenticated blob and names the file',async()=>{
const f=fixture(),requests=[],saved=[];
f.context.sample={...state,running:false,stopped:'Disconnected'};f.run('renderPower(sample)');
f.context.fetch=async(url,opts)=>{requests.push([url,opts]);return {ok:true,blob:async()=>({report:true})};};
f.context.URL={createObjectURL:()=> 'blob:report',revokeObjectURL(){}};
f.context.document.createElement=()=>({click(){saved.push({href:this.href,name:this.download});}});
await f.formats[0].onclick();
assert.equal(requests[0][0],'/api/power/report?id=abc&format=html');
assert.equal(requests[0][1].headers['X-Frame-UI'],'1');
assert.deepEqual(saved,[{href:'blob:report',name:'frame-power-abc.html'}]);
assert.match(f.el('powerSaveStatus').textContent,/Download requested/);
f.context.fetch=async()=>({ok:false,json:async()=>({error:'Report replaced'})});
await f.formats[0].onclick();
assert.match(f.el('powerSaveStatus').textContent,/Download failed: Report replaced/);
assert.equal(f.formats[0].disabled,false);
});
test('packaging guard requires shared modules and detects missing resources',()=>{
const fs=require('node:fs'),os=require('node:os'),path=require('node:path');
const guard=require('../app/build/check-resources');const tmp=fs.mkdtempSync(path.join(os.tmpdir(),'power-resources-'));
try {
for(const rel of guard.REQUIRED) {const file=path.join(tmp,rel);fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'test');}
assert.deepEqual(guard.missing(tmp),[]);
for(const rel of ['ui/frame_power.py','ui/frame_status.py','scripts/frame_power_sample.py','scripts/frame_power_report.py']) {
assert.ok(guard.REQUIRED.includes(rel));fs.unlinkSync(path.join(tmp,rel));assert.ok(guard.missing(tmp).includes(rel));
}
} finally {fs.rmSync(tmp,{recursive:true,force:true});}
});
+104 -18
View File
@@ -5,6 +5,7 @@ import io
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
@@ -203,23 +204,106 @@ class Remote(unittest.TestCase):
self.assertEqual(result['written'], 0)
self.assertEqual(result['held'], {'refused': 1, 'reference': 1})
def test_session_reads_the_mode(self):
conf = write(self.home, 'zz-steamos-autologin.conf', '[Autologin]\nSession=plasma-steamos-wayland-oneshot.desktop\n')
running = set()
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n == 'steamos-session-select' else None):
state = remote.session()
self.assertEqual((state['mode'], state['selector']), ('desktop', 'steamos-session-select'))
self.assertEqual(state['configured'], 'plasma-steamos-wayland-oneshot.desktop')
running.update({'gamescope', 'plasmashell'}) # the nested desktop panel
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('gamescope-wl') # gamescope's process name on the Frame, with no desktop panel open
self.assertEqual(remote.session()['mode'], 'game')
running.clear()
running.add('start-gamescope-session')
self.assertEqual(remote.session()['mode'], 'game')
class Session(unittest.TestCase):
def state(self, targets, running=(), configured=None):
with tempfile.TemporaryDirectory() as folder:
conf = write(folder, 'sddm.conf', 'Session=%s\n' % (configured or 'gamescope.desktop'))
with patch.object(remote, 'SDDM_CONFS', (str(conf),)), \
patch.object(remote, '_session_target', side_effect=targets), \
patch.object(remote, '_running', side_effect=lambda n, full=False: n in running), \
patch.object(remote.shutil, 'which', return_value='/usr/bin/steamosctl'):
return remote.session()
def test_full_plasma_with_vr_gamescope_is_desktop(self):
# Issue #142, SteamOS 0.5.6: steamvr-plasma also starts nested scripts.
state = self.state(['inactive', 'active'],
{'gamescope-wl', 'plasmashell', 'steamos-nested-desktop'},
configured='gamescope.desktop')
self.assertEqual(state['mode'], 'desktop')
self.assertTrue(state['gamescope'])
self.assertTrue(state['nested_desktop'])
self.assertEqual(state['selector'], 'steamosctl')
self.assertEqual(state['session_targets']['plasma-workspace-wayland.target'], 'active')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
self.assertTrue(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_called_once_with('steamosctl switch-to-game-mode', timeout=60)
def test_game_target_wins_with_or_without_nested_desktop(self):
for running in ((), {'gamescope-wl'}, {'gamescope', 'plasmashell', 'steamos-nested-desktop'}):
state = self.state(['active', 'inactive'], running, configured='plasma.desktop')
self.assertEqual(state['mode'], 'game')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
self.assertFalse(backup.return_to_game_mode(confirmed=True)['switched'])
ssh.assert_not_called()
def test_conflicting_incomplete_and_transitional_targets_block_return(self):
for targets in (['active', 'active'], ['inactive', 'inactive'], ['active', 'unknown'],
['not-found', 'active'], ['unknown', 'unknown'], ['inactive', 'activating'],
['deactivating', 'active'], ['failed', 'active']):
with self.subTest(targets=targets):
state = self.state(targets, {'gamescope', 'plasmashell'}, 'plasma.desktop')
self.assertEqual(state['mode'], 'unknown')
with patch.object(backup, 'session_status', return_value=state), patch.object(backup, '_ssh') as ssh:
with self.assertRaisesRegex(backup.BackupError, 'nothing was changed'):
backup.return_to_game_mode(confirmed=True)
ssh.assert_not_called()
def test_older_builds_use_only_unambiguous_running_processes(self):
cases = [((), 'unknown'), ({'plasmashell'}, 'desktop'),
({'gamescope'}, 'game'), ({'gamescope-wl'}, 'game'),
({'start-gamescope-session'}, 'game'),
({'gamescope', 'plasmashell'}, 'unknown'),
({'gamescope', 'steamos-nested-desktop'}, 'unknown')]
for running, expected in cases:
with self.subTest(running=running):
state = self.state(['not-found', 'not-found'], running, 'plasma.desktop')
self.assertEqual(state['mode'], expected)
self.assertEqual(state['mode_source'], 'legacy-processes')
def test_target_probe_parses_systemd_and_uses_main_user_bus(self):
cases = [('LoadState=loaded\nActiveState=active\n', 0, 'active'),
('ActiveState=inactive\nLoadState=loaded\n', 0, 'inactive'),
('LoadState=not-found\nActiveState=inactive\n', 0, 'not-found'),
('LoadState=error\nActiveState=active\n', 0, 'unknown'),
('garbage', 0, 'unknown'), ('LoadState=loaded\nActiveState=active', 1, 'unknown')]
for stdout, code, expected in cases:
with self.subTest(stdout=stdout, code=code), \
patch.object(remote.os, 'getuid', return_value=1000, create=True), \
patch.object(remote.subprocess, 'run', return_value=subprocess.CompletedProcess([], code, stdout)) as run:
self.assertEqual(remote._session_target('gamescope-session.target'), expected)
self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'show',
'gamescope-session.target', '--property=LoadState', '--property=ActiveState'])
self.assertEqual(run.call_args.kwargs['timeout'], 5)
self.assertEqual(run.call_args.kwargs['env']['DBUS_SESSION_BUS_ADDRESS'], 'unix:path=/run/user/1000/bus')
self.assertEqual(run.call_args.kwargs['env']['XDG_RUNTIME_DIR'], '/run/user/1000')
for error in (OSError('no systemctl'), subprocess.TimeoutExpired('systemctl', 5)):
with patch.object(remote.os, 'getuid', return_value=1000, create=True), \
patch.object(remote.subprocess, 'run', side_effect=error):
self.assertEqual(remote._session_target('gamescope-session.target'), 'unknown')
def test_selector_preference_and_older_builds(self):
for available, expected in [({'steamosctl', 'steamos-session-select'}, 'steamosctl'),
({'holo-session-select', 'steamos-session-select'}, 'holo-session-select'),
({'steamos-session-select'}, 'steamos-session-select'), (set(), None)]:
with patch.object(remote, 'SDDM_CONFS', ()), \
patch.object(remote, '_session_target', return_value='unknown'), \
patch.object(remote, '_running', return_value=False), \
patch.object(remote.shutil, 'which', side_effect=lambda n: '/usr/bin/' + n if n in available else None):
self.assertEqual(remote.session()['selector'], expected)
def test_script_probe_does_not_match_names_in_transmitted_python_source(self):
with patch.object(remote.subprocess, 'call', return_value=1) as call:
self.assertFalse(remote._running('steamos-nested-desktop', full=True))
pattern = call.call_args.args[0][-1]
for command in ('/usr/bin/steamos-nested-desktop',
'/bin/sh /usr/bin/steamos-nested-desktop',
'bash steamos-nested-desktop --flag'):
self.assertIsNotNone(re.search(pattern, command), command)
for command in ('python3 -c ' + backup.REMOTE.read_text(),
'/bin/sh -c pgrep -f steamos-nested-desktop',
'/usr/bin/steamos-nested-desktop-other'):
self.assertIsNone(re.search(pattern, command), command)
def fake_ssh(home, os_release):
@@ -393,9 +477,11 @@ class Ssh(unittest.TestCase):
class GameMode(unittest.TestCase):
def test_command_is_only_ever_the_session_switch(self):
self.assertEqual(backup.session_command('steamosctl'), 'steamosctl switch-to-game-mode')
self.assertEqual(backup.session_command('steamos-session-select'), 'steamos-session-select gamescope')
self.assertEqual(backup.session_command('holo-session-select'), 'holo-session-select gamescope')
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot'):
for bad in (None, '', 'rm -rf ~', '/tmp/steamos-session-select', 'steamos-session-select; reboot',
'steamosctl switch-to-desktop-mode', 'steamosctl; reboot'):
with self.assertRaises(backup.BackupError):
backup.session_command(bad)
+105
View File
@@ -0,0 +1,105 @@
"""Exercise the shipped desktop alert and its UI with a monotonic fake clock."""
import pathlib
import shutil
import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JavaScript')
class DesktopBatteryComfort(unittest.TestCase):
def test_alert_confirmation_and_lifecycle(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
code = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
code = code[code.index('class DesktopBatteryAlert'):]
js = r'''
const assert = require('node:assert/strict');
const elements = new Map();
const $ = id => {
if (!elements.has(id)) elements.set(id, {checked:false, textContent:''});
return elements.get(id);
};
let now = 0, calls = [], toasts = [], devGen = 1, comfortActive = false;
const performance = {now:()=>now};
const listeners = {};
const document = {hidden:false, addEventListener:(name,fn)=>listeners[name]=fn};
const HOST = {mobile:false};
const window = {frameApp:{notify:(message,request)=>{calls.push({message,request});return Promise.resolve();}}};
const toast = message => toasts.push(message);
''' + code + r'''
(async () => {
const low = {percent:15,status:'Discharging',watts:-4};
const high = {...low,percent:20};
const a = new DesktopBatteryAlert();
assert.equal(a.sample(low,0),false);
assert.equal(a.sample(low,1000),false); // rapid manual refreshes cannot confirm
assert.equal(a.sample(low,30000),true);
for (let t=60000;t<=900000;t+=30000) assert.equal(a.sample(low,t),false);
assert.equal(a.sample(high,930000),false);
assert.equal(a.sample(low,960000),false); // a recovery spike did not rearm
assert.equal(a.sample(low,990000),false);
a.sample(high,1020000); a.sample(high,1050000);
assert.equal(a.sample(low,1080000),false);
assert.equal(a.sample(low,1110000),true);
const c = new DesktopBatteryAlert();
c.sample(low,0); assert.equal(c.sample(low,30000),true);
c.sample(high,60000); c.sample(high,90000);
c.sample(low,120000); assert.equal(c.sample(low,150000),false); // ten minute cap
assert.equal(c.sample(low,631000),false); // long gap requires fresh confirmation
assert.equal(c.sample(low,661000),true);
for (const bad of [null,{}, {...low,percent:null}, {...low,percent:NaN}, {...low,percent:-1},
{...low,percent:101},{...low,percent:'10'}, {...low,status:'Unknown'},
{...low,status:'Charging'}, {...low,status:'Full'}, {...low,status:'Not charging'},
{...low,watts:0},{...low,watts:-0.5},{...low,watts:null},{...low,watts:NaN}]) {
const b = new DesktopBatteryAlert();
b.sample(low,0); assert.equal(b.sample(bad,30000),false);
assert.equal(b.sample(low,60000),false); // unknown interrupts confirmation
}
const b = new DesktopBatteryAlert();
b.sample(low,0); b.invalidate(); assert.equal(b.sample(low,30000),false);
assert.equal(b.sample(low,121000),false); // stale gap
assert.equal(b.sample(low,120000),false); // clock rollback
desktopBatterySample(low); now=30000; desktopBatterySample(low);
assert.equal(calls.length,0); // opt-in starts unchecked
$('desktopBatteryAlert').checked=true; $('desktopBatteryAlert').onchange();
now=60000;desktopBatterySample(low);now=90000;desktopBatterySample(low);
assert.equal(calls.length,1); assert.equal(calls[0].request,false);
assert.match(calls[0].message,/15%.*draining/);
$('desktopBatteryAlert').checked=false;$('desktopBatteryAlert').onchange();
$('desktopBatteryAlert').checked=true;$('desktopBatteryAlert').onchange();
now=120000;desktopBatterySample(low);now=150000;desktopBatterySample(low);
assert.equal(calls.length,1); // toggling does not clear episode latch
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
now=180000;desktopBatterySample(low);document.hidden=true;listeners.visibilitychange();
document.hidden=false;now=210000;desktopBatterySample(low);assert.equal(calls.length,1);
now=240000;desktopBatterySample(low);assert.equal(calls.length,2);
desktopBattery.latched=false;desktopBattery.lastAlert=-Infinity;desktopBattery.invalidate();
comfortActive=true;$('batteryAlert').checked=true;
now=270000;desktopBatterySample(low);now=300000;desktopBatterySample(low);
assert.equal(calls.length,2); // existing session owns battery alerts
comfortActive=false;HOST.mobile=true;
now=330000;desktopBatterySample(low);now=360000;desktopBatterySample(low);
assert.equal(calls.length,2);
HOST.mobile=false;
window.frameApp.notify=()=>Promise.reject(Error('denied'));
now=390000;desktopBatterySample(low);now=420000;desktopBatterySample(low);
await new Promise(setImmediate);
assert.match($('desktopBatteryStatus').textContent,/not confirmed/);
now=450000;desktopBatterySample(low);
assert.equal(toasts.length,3); // delivery failure does not retry on refresh
})().catch(e=>{console.error(e);process.exitCode=1;});
'''
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_existing_status_path_and_switch_reset(self):
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
self.assertIn('desktopBatterySample(s.battery);', page)
switch = page.split('if (link.device !== null && devId !== link.device)')[1].split('devGen++;')[0]
self.assertIn('$("desktopBatteryAlert").checked = false;', switch)
refresh = page.split('function refresh()')[1].split('async function refreshAfterAudio')[0]
self.assertIn('desktopBattery.invalidate();', refresh)
block = page.split('// ---- Desktop-only low-battery comfort:')[1].split('// ---- battery ----')[0]
self.assertNotIn('setTimeout', block)
self.assertNotIn('api(', block)
+87 -1
View File
@@ -4,6 +4,7 @@ import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch, Mock
@@ -25,7 +26,7 @@ class FrameDoctor(unittest.TestCase):
patch.object(doctor, 'run', side_effect=[config, *responses]) as run, \
patch.object(doctor, 'port_open', return_value=kwargs.get('devkit', False)), \
patch('sys.stdout', new_callable=io.StringIO) as output:
code = doctor.main([])
code = doctor.main(kwargs.get('argv', []))
return code, output.getvalue(), run
def test_unreachable_is_actionable_without_exposing_stderr(self):
@@ -105,6 +106,91 @@ class FrameDoctor(unittest.TestCase):
with patch('sys.stdout', new_callable=io.StringIO):
self.assertEqual(doctor.report(data), 1)
def stable(self, disk=None):
checks = [['UNKNOWN', 'Evidence unavailable; consult the runbook locally.'] for _ in doctor.SECTIONS]
checks[3] = disk or ['BROKEN', 'Less than 16 MiB free; review storage in Frame Control before installing.']
return {'guard': 'stable', 'checks': checks}
def test_plan_requires_exact_evidence_and_never_adds_remote_commands(self):
data = self.stable()
code, text, calls = self.main([result(json.dumps(data))], argv=['--plan'])
self.assertEqual(code, 1)
self.assertIn('READ-ONLY', text)
self.assertIn('use Steam storage management', text)
self.assertEqual(calls.call_count, 2) # config read and unchanged read-only probe
data['checks'][3] = ['BROKEN', 'Evidence unavailable; consult the runbook locally.']
_, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
self.assertNotIn('use Steam storage management', text)
self.assertIn('does not establish a safe automatic fix', text)
def test_plan_guard_blocks_all_section_actions(self):
for state in ('loop', 'risk', 'unknown'):
_, text, _ = self.main([result(json.dumps({'guard': state, 'checks': []}))], argv=['--plan'])
self.assertIn('BLOCKED', text)
self.assertNotIn('PLAN 4.', text)
def test_before_after_and_exclusive_private_baseline(self):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'before.json'
before = self.stable()
_, text, _ = self.main([result(json.dumps(before))], argv=['--save-report', str(path)])
saved = path.read_text()
self.assertNotIn('private-tailnet', saved)
self.assertNotIn('pinned-frame', saved)
if __import__('os').name != 'nt':
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
after = self.stable(['OK', 'Home and title storage have at least 256 MiB free.'])
_, text, _ = self.main([result(json.dumps(after))], argv=['--compare-before', str(path)])
self.assertIn('Steam and storage: BROKEN -> OK', text)
self.assertIn('do not prove repair', text)
self.assertIn('Before evidence: Less than 16 MiB free', text)
self.assertIn('After evidence: Home and title storage', text)
code, _, _ = self.main([result(json.dumps(after))], argv=['--save-report', str(path)])
self.assertEqual(code, 2)
self.assertEqual(path.read_text(), saved)
# Same alias with changed routing cannot masquerade as the same baseline.
code, text, calls = self.main([], argv=['--compare-before', str(path), '--usb', '192.0.2.9'])
self.assertEqual(code, 2)
self.assertIn('comparison refused', text)
self.assertEqual(calls.call_count, 1)
def test_report_refuses_symlinks(self):
with tempfile.TemporaryDirectory() as folder:
target = Path(folder) / 'target'
target.write_text('preserve')
link = Path(folder) / 'link'
try:
link.symlink_to(target)
except OSError:
self.skipTest('symlinks unavailable')
code, _, _ = self.main([result(json.dumps(self.stable()))], argv=['--save-report', str(link)])
self.assertEqual(code, 2)
self.assertEqual(target.read_text(), 'preserve')
def test_comparison_suppresses_improvement_if_guard_fails(self):
with patch('sys.stdout', new_callable=io.StringIO) as output:
doctor.compare(self.stable(), {'guard': 'risk', 'checks': []})
self.assertIn('Comparison blocked', output.getvalue())
self.assertNotIn('BROKEN -> OK', output.getvalue())
def test_untrusted_baselines_and_probe_are_rejected_without_echo(self):
for payload in ('secret', '[]', '{"version":1}', 'x' * 65537, '[' * 2000 + ']' * 2000):
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / 'baseline.json'
path.write_text(payload)
code, text, calls = self.main([], argv=['--compare-before', str(path)])
self.assertEqual(code, 2)
self.assertNotIn('secret', text)
calls.assert_not_called()
for data in ({'guard': 'secret', 'checks': []},
{'guard': 'risk', 'checks': [['WARN', 'secret']]},
dict(self.stable(), raw='secret'),
self.stable(['WARN', 'Never pairs'])):
code, text, _ = self.main([result(json.dumps(data))], argv=['--plan'])
self.assertEqual(code, 1)
self.assertNotIn('secret', text)
self.assertNotIn('Repair plan', text)
def test_help_shell_entry_point_is_offline(self):
# This Bash wrapper is for macOS/Linux; Windows uses the Python CLI.
import os, shutil
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
+256
View File
@@ -0,0 +1,256 @@
"""Desktop observation lifecycle, guarded HTTP API and packaged resource contract."""
import sandbox # noqa: F401
import contextlib
import io
import http.client
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_power
import server
STREAM = frame_power.sampler.stream_report
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test', 'readings': {
'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/test', 'state': 'available'}},
'unavailableGroups': ['fan']}
class DesktopPower(unittest.TestCase):
def setUp(self):
self.observation = frame_power.Observation()
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.lock = Path(self.temp.name) / 'frame-test.lock'
self.patches = [patch.object(frame_power.sampler, 'LOCK', self.lock)]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
self.addCleanup(self.observation.shutdown)
def wait(self):
self.observation.thread.join(4)
self.assertFalse(self.observation.thread.is_alive())
def stream(self, program, preset='minute'):
real = STREAM
def collect(command, payload, count, interval, stop, received, **kw):
return real([sys.executable, '-u', '-c', program], '', count, interval, stop, received, **kw)
mocked_stream = patch.object(frame_power.sampler, 'stream_report', side_effect=collect)
mocked_stream.start()
self.addCleanup(mocked_stream.stop)
return self.observation.start(preset, ('ssh', '-o', 'HostName=old', 'frame-old'),
{'alias': 'frame-old', 'deviceId': 'old'})
def test_complete_exports_match_and_no_background_collection_on_status(self):
def collect(command, payload, count, interval, stop, received, **kw):
self.assertEqual(count, 12)
self.assertEqual(interval, 5)
self.assertIn('HostName=old', command)
self.assertIn('StrictHostKeyChecking=yes', command)
self.assertIn('frame-old', command)
self.assertNotIn('pkill', ' '.join(command))
for _ in range(count):
received(SAMPLE.copy())
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': None, 'samples': [SAMPLE.copy() for _ in range(count)], 'summary': {}}
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect) as run:
self.observation.start('minute', ('ssh', '-o', 'HostName=old', 'frame-old'), {'alias': 'frame-old'})
self.wait()
state = self.observation.status()
self.assertEqual((state['received'], state['running'], state['stopped']), (12, False, None))
for _ in range(3):
self.observation.status()
self.assertEqual(run.call_count, 1)
report, _ = self.observation.export(state['id'], 'json')
self.assertEqual(json.loads(report)['summary']['battery.watts']['mean'], 0)
html, _ = self.observation.export(state['id'], 'html')
csv, _ = self.observation.export(state['id'], 'csv')
self.assertIn('Headset: frame-old', html)
self.assertIn('collection.state,Completed', csv)
self.assertIn('collection.receivedSamples,12', csv)
self.assertIn('battery.watts,0,W', csv)
def test_stop_keeps_received_sample_and_reaps_only_owned_child(self):
real_popen = subprocess.Popen
children = []
def spawn(*a, **kw):
proc = real_popen(*a, **kw)
children.append(proc)
return proc
event = json.dumps({'sample': SAMPLE})
with patch.object(frame_power.sampler.subprocess, 'Popen', side_effect=spawn):
state = self.stream(f'import sys,time; sys.stdin.read(); print({event!r}, flush=True); time.sleep(30)')
deadline = time.monotonic() + 3
while self.observation.status()['received'] < 1 and time.monotonic() < deadline:
time.sleep(.01)
self.assertEqual(self.observation.status()['received'], 1)
with self.assertRaisesRegex(ValueError, 'already running'):
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'other'})
active, _ = self.observation.export(state['id'], 'json')
self.assertIn('in progress', json.loads(active)['stopped'])
self.observation.stop()
self.wait()
self.assertIsNotNone(children[0].poll())
data, _ = self.observation.export(state['id'], 'json')
self.assertEqual(json.loads(data)['samples'], [SAMPLE])
self.assertIn('Stopped by user', json.loads(data)['stopped'])
csv, _ = self.observation.export(state['id'], 'csv')
self.assertIn('incomplete', csv)
def test_disconnect_and_oversize_keep_partial_and_incomplete(self):
for ending in ('sys.exit(255)', 'print("x"*262145, flush=True)'):
with self.subTest(ending=ending):
event = json.dumps({'sample': SAMPLE})
state = self.stream(f'import sys; sys.stdin.read(); print({event!r}, flush=True); {ending}')
self.wait()
data, _ = self.observation.export(state['id'], 'json')
report = json.loads(data)
self.assertEqual(report['samples'], [SAMPLE])
self.assertTrue(report['stopped'])
def test_local_lock_preserves_previous_report_and_invalid_inputs_do_not_spawn(self):
with patch.object(frame_power.sampler, 'stream_report') as run:
self.lock.mkdir()
with self.assertRaisesRegex(ValueError, 'Smoke session'):
self.observation.start('minute', ('ssh', 'frame'), {'alias': 'frame'})
for preset in ('hour', 1000, None):
with self.assertRaises(ValueError):
self.observation.start(preset, ('ssh', 'frame'), {'alias': 'frame'})
run.assert_not_called()
self.assertIsNone(self.observation.status()['id'])
def test_headset_switch_preserves_original_identity_and_requests_cancel(self):
done = threading.Event()
def collect(command, payload, count, interval, cancel, received, **kw):
received(SAMPLE)
done.set()
cancel.wait(3)
return {'requestedSamples': count, 'intervalSeconds': interval, 'samples': [SAMPLE], 'stopped': None}
with patch.object(frame_power.sampler, 'stream_report', side_effect=collect), \
patch.object(server, '_power', self.observation):
original = server.FRAME, list(server.HOST_OPTS)
try:
state = self.observation.start('five-minutes', ('ssh', 'old'), {'alias': 'old'})
self.assertTrue(done.wait(2))
server.route('different-headset', [])
self.wait()
current = self.observation.status()
self.assertEqual(current['headset']['alias'], 'old')
self.assertEqual(current['requested'], 60)
self.assertIn('Headset switched', current['stopped'])
with self.assertRaises(ValueError):
self.observation.export('stale-id', 'html')
with self.assertRaises(ValueError):
self.observation.export(state['id'], '../file')
finally:
server.route(*original)
def test_http_guards_stale_target_stop_id_and_download_headers(self):
httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
conn = http.client.HTTPConnection(*httpd.server_address)
def request(method, path, body=None, device=None, key=True):
headers = {'X-Frame-UI': server.UI_KEY} if key else {}
if device:
headers['X-Frame-Device'] = device
conn.request(method, path, json.dumps(body) if body is not None else None, headers)
r = conn.getresponse()
return r.status, dict(r.getheaders()), r.read()
try:
with patch.object(server, '_power', self.observation), \
patch.object(server, 'LINK', SimpleNamespace(active_device=lambda: {'id': 'new'})), \
patch.object(frame_power.sampler, 'stream_report') as run:
for path in ('/api/power', '/api/power/report?id=x&format=html'):
self.assertEqual(request('GET', path, key=False)[0], 403)
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, key=False)[0], 403)
for device, expected in ((None, 400), ('old', 409)):
self.assertEqual(request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, device)[0], expected)
run.assert_not_called()
self.observation.id = 'test-id'
self.observation.report = {'schemaVersion': 1, 'requestedSamples': 12, 'intervalSeconds': 5,
'headset': {'alias': 'old'}, 'stopped': 'Disconnected', 'samples': [SAMPLE]}
status, headers, data = request('GET', '/api/power/report?id=test-id&format=html')
self.assertEqual(status, 200)
self.assertIn('attachment;', headers['Content-Disposition'])
self.assertIn('no-store', headers['Cache-Control'])
self.assertIn(b'Disconnected', data)
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'stale'}, 'new')[0], 409)
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': 'test-id'}, 'new')[0], 200)
received = threading.Event()
def collect(command, payload, count, interval, cancel, emit, **kw):
emit(SAMPLE)
received.set()
cancel.wait(3)
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'samples': [SAMPLE], 'stopped': 'Stopped'}
run.side_effect = collect
code, _, data = request('POST', '/api/power', {'action': 'start', 'preset': 'minute'}, 'new')
self.assertEqual(code, 200)
new_id = json.loads(data)['id']
self.assertNotEqual(new_id, 'test-id')
self.assertTrue(received.wait(2))
self.assertEqual(request('GET', '/api/power/report?id=test-id&format=json')[0], 409)
self.assertEqual(request('POST', '/api/power', {'action': 'stop', 'id': new_id}, 'new')[0], 200)
self.wait()
self.assertEqual(self.observation.status()['received'], 1)
self.assertEqual(self.observation.status()['headset']['deviceId'], 'new')
finally:
conn.close()
httpd.shutdown()
httpd.server_close()
thread.join()
def test_early_remote_smoke_lock_has_structured_reason_without_sensor_reads(self):
self.lock.mkdir()
output = io.StringIO()
with patch.object(frame_power.sampler, 'snapshot') as sensor, contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(frame_power.sampler.main(['--probe', '--stream']), 75)
sensor.assert_not_called()
self.assertIn('Smoke session active', json.loads(output.getvalue())['finished']['stopped'])
def test_phone_can_import_without_desktop_sampler_resources(self):
with tempfile.TemporaryDirectory() as tmp:
import os
import shutil
shutil.copyfile(ROOT / 'ui/frame_power.py', Path(tmp) / 'frame_power.py')
program = 'import sys; sys.path.insert(0, sys.argv[1]); import frame_power; assert frame_power.Observation().status()["id"] is None'
result = subprocess.run([sys.executable, '-I', '-B', '-c', program, tmp],
env={**os.environ, 'FRAME_LOCAL': '1'}, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
def test_packaged_resources_under_isolated_python(self):
config = json.loads((ROOT / 'app/package.json').read_text())
# Materialize exactly the Python resources selected by electron-builder.
with tempfile.TemporaryDirectory() as tmp:
resources = Path(tmp)
import shutil
for spec in config['build']['extraResources']:
if spec.get('to') not in ('ui', 'scripts'):
continue
source = ROOT / 'app' / spec['from']
for pattern in spec['filter']:
for file in source.glob(pattern):
if file.is_file():
dest = resources / spec['to'] / file.name
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(file, dest)
program = ('import sys; sys.path.insert(0, sys.argv[1]); import frame_power; '
'p=frame_power.sampler.remote_payload(); '
'assert "def snapshot" in p and "def battery" in p; '
'o=frame_power.Observation(); assert not o.status()["running"]')
run = subprocess.run([sys.executable, '-I', '-B', '-c', program, str(resources / 'ui')], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stderr)
run = subprocess.run(['node', '--test', str(ROOT / 'tests/power_desktop.cjs')], capture_output=True, text=True)
self.assertEqual(run.returncode, 0, run.stdout + run.stderr)
+182
View File
@@ -0,0 +1,182 @@
"""Offline HTML exports and streamed transport, with real local child cleanup."""
import sandbox # noqa: F401
import base64
import contextlib
import csv
from html.parser import HTMLParser
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'scripts'))
import frame_power_sample as power
import frame_power_report as page
SAMPLE = {'time': 1, 'elapsedSeconds': 0, 'osBuild': 'test',
'readings': {'battery.watts': {'value': 0, 'unit': 'W', 'source': '/sys/battery', 'state': 'available'},
'thermal.<script>alert(1)</script>': {'value': None, 'unit': 'C', 'source': '/sys/temp', 'state': power.UNAVAILABLE}},
'unavailableGroups': ['proximity']}
class Links(HTMLParser):
def __init__(self):
super().__init__()
self.links = {}
self.scripts = 0
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
if tag == 'script':
self.scripts += 1
if tag == 'a':
self.links[attrs['download']] = base64.b64decode(attrs['href'].split(',', 1)[1]).decode()
class Report(unittest.TestCase):
def collect(self, program, count=1):
with contextlib.redirect_stderr(io.StringIO()):
return power.stream_report([sys.executable, '-c', program], '', count, 1)
def test_csv_metadata_headset_alias_is_plain_text_in_spreadsheets(self):
for prefix in ('=', '+', '-', '@', ' =', '\t='):
with self.subTest(prefix=prefix):
alias = prefix + 'HYPERLINK("https://example.invalid")'
report = {'headset': {'alias': alias}, 'requestedSamples': 12,
'stopped': 'Stopped', 'samples': []}
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
self.assertEqual(len(rows), 4)
self.assertEqual([row[5] for row in rows[1:]], ["'" + alias] * 3)
report['headset']['alias'] = 'living-room-frame'
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
self.assertEqual([row[5] for row in rows[1:]], ['living-room-frame'] * 3)
def test_completed_stream_preserves_zero_unknown_units_and_downloads(self):
event = json.dumps({'sample': SAMPLE})
program = f'import sys; sys.stdin.read(); print({event!r}); print(\'{{"finished": {{"stopped": null}}}}\')'
report = self.collect(program)
self.assertIsNone(report['stopped'])
self.assertEqual(report['samples'], [SAMPLE])
markup = page.render(report, power.csv_samples(report))
parser = Links()
parser.feed(markup)
self.assertEqual(parser.scripts, 0)
self.assertIn('&lt;script&gt;', markup)
self.assertIn('<td>0</td>', markup)
self.assertIn('<td>Unknown</td>', markup)
self.assertIn('<td>C</td>', markup)
self.assertIn('scope="row"', markup)
self.assertEqual(json.loads(parser.links['frame-power.json']), report)
self.assertEqual(parser.links['frame-power.csv'], power.csv_samples(report))
self.assertIn(power.UNAVAILABLE, parser.links['frame-power.csv'])
def test_transport_failure_retains_received_samples_as_incomplete(self):
event = json.dumps({'sample': SAMPLE})
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); sys.exit(255)', 2)
self.assertEqual(report['samples'], [SAMPLE])
self.assertIn('SSH exit 255', report['stopped'])
def test_lock_stop_retains_samples(self):
event = json.dumps({'sample': SAMPLE})
end = json.dumps({'finished': {'stopped': 'Smoke session active'}})
report = self.collect(f'import sys; sys.stdin.read(); print({event!r}); print({end!r}); sys.exit(75)', 2)
self.assertEqual(report['stopped'], 'Smoke session active')
self.assertEqual(len(report['samples']), 1)
def test_user_stop_terminates_only_local_child_and_retains_samples(self):
children = []
real_popen = subprocess.Popen
def spawn(*args, **kwargs):
proc = real_popen(*args, **kwargs)
children.append(proc)
return proc
with mock.patch.object(power.subprocess, 'Popen', side_effect=spawn), \
mock.patch.object(power.queue.Queue, 'get', side_effect=[json.dumps({'sample': SAMPLE}), KeyboardInterrupt]):
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)', 2)
self.assertEqual(report['samples'], [SAMPLE])
self.assertIn('Stopped by user', report['stopped'])
self.assertIsNotNone(children[0].poll())
def test_canceled_launch_does_not_open_ssh(self):
import threading
stop = threading.Event()
stop.set()
with mock.patch.object(power.subprocess, 'Popen') as spawn:
report = power.stream_report(['ssh', 'frame'], '', 12, 5, stop_event=stop)
spawn.assert_not_called()
self.assertIn('Stopped before collection', report['stopped'])
def test_stop_interrupts_child_that_never_reads_payload(self):
import threading
stop = threading.Event()
timer = threading.Timer(0.1, stop.set)
timer.start()
try:
report = power.stream_report([sys.executable, '-c', 'import time; time.sleep(30)'],
'x' * 1048576, 1, 1, stop_event=stop)
finally:
timer.cancel()
self.assertIn('Stopped', report['stopped'])
self.assertEqual(report['samples'], [])
def test_invalid_stream_is_not_completed(self):
report = self.collect('import sys; sys.stdin.read(); print("not json")')
self.assertIn('interrupted', report['stopped'])
self.assertEqual(report['samples'], [])
def test_deadline_terminates_child(self):
with mock.patch.object(power.time, 'monotonic', side_effect=[0, 40]):
report = self.collect('import sys,time; sys.stdin.read(); time.sleep(30)')
self.assertIn('Timed out', report['stopped'])
def test_export_bounds_and_existing_file_fail_before_ssh(self):
with tempfile.TemporaryDirectory() as temp:
existing = Path(temp) / 'report.html'
existing.write_text('keep')
for args in (['--html', str(existing)], ['--html', 'new.html', '--count', '11', '--interval', '60'],
['--html', 'new.html', '--csv']):
with mock.patch.object(power.subprocess, 'Popen') as spawn, contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit):
power.main(args)
spawn.assert_not_called()
self.assertEqual(existing.read_text(), 'keep')
def test_cli_writes_incomplete_report_and_returns_nonzero(self):
report = {'schemaVersion': 1, 'requestedSamples': 2, 'intervalSeconds': 1,
'stopped': 'Stopped by user', 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / 'report.html'
with mock.patch.object(power, 'stream_report', return_value=report), \
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(power.main(['--html', str(path)]), 75)
self.assertIn('Stopped by user', path.read_text())
def test_collect_emits_each_sample_before_completion(self):
status = mock.Mock()
with mock.patch.object(power, 'snapshot', return_value=(SAMPLE.copy(), None)):
events = []
report = power.collect(status, 1, 1, events.append)
self.assertEqual(events, [{'sample': report['samples'][0]}])
def test_failed_save_preserves_json_on_stdout(self):
report = {'schemaVersion': 1, 'requestedSamples': 1, 'intervalSeconds': 1,
'stopped': None, 'samples': [SAMPLE], 'summary': power.summarize([SAMPLE])}
with tempfile.TemporaryDirectory() as temp:
output = io.StringIO()
with mock.patch.object(power, 'stream_report', return_value=report), \
mock.patch.object(power, 'LOCK', Path(temp) / 'no-lock'), \
contextlib.redirect_stderr(io.StringIO()), contextlib.redirect_stdout(output):
code = power.main(['--html', str(Path(temp) / 'missing' / 'report.html')])
self.assertEqual(code, 1)
self.assertEqual(json.loads(output.getvalue()), report)
def test_premature_completion_is_incomplete_even_with_clean_ssh_exit(self):
report = self.collect('import sys; sys.stdin.read(); print(\'{"finished": {"stopped": null}}\')')
self.assertIn('Incomplete', report['stopped'])
self.assertEqual(report['samples'], [])
+66 -2
View File
@@ -21,12 +21,13 @@ ROOT = Path(__file__).resolve().parent.parent
WORKFLOW = ROOT / ".github" / "workflows" / "release.yml"
FAKEGH = ROOT / "tests" / "fakegh"
STEP = "- name: Create the draft release"
UPLOAD_STEP = "- name: Upload to the release"
def step_script():
def step_script(step=STEP):
"""The step's `run: |` block, dedented."""
lines = WORKFLOW.read_text().splitlines()
i = next(n for n, line in enumerate(lines) if line.strip() == STEP)
i = next(n for n, line in enumerate(lines) if line.strip() == step)
while lines[i].strip() != "run: |":
i += 1
indent = len(lines[i]) - len(lines[i].lstrip())
@@ -87,6 +88,19 @@ class DraftStep(unittest.TestCase):
[patch] = self.patches(log)
self.assertEqual(patch[3], "repos/saphid/frame-control/releases/42")
def test_a_rerun_reuses_the_same_untagged_draft(self):
p, log = self.run_step([release(42, "untagged-abc")])
self.assertEqual(p.returncode, 0, p.stderr + p.stdout)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log)[0][3], "repos/saphid/frame-control/releases/42")
def test_ambiguous_drafts_do_not_create_or_patch(self):
p, log = self.run_step([release(42, "untagged-abc"), release(43, "untagged-def")])
self.assertNotEqual(p.returncode, 0)
self.assertIn("expected one release", p.stderr)
self.assertEqual(self.posts(log), [])
self.assertEqual(self.patches(log), [])
def test_other_releases_alone_mean_a_new_draft(self):
p, log = self.run_step([release(7, "v9.8.6", "Frame Control 9.8.6", draft=False),
release(8, "v9.8.70", "Frame Control 9.8.70")])
@@ -102,5 +116,55 @@ class DraftStep(unittest.TestCase):
self.assertEqual(self.patches(log), [])
@unittest.skipUnless(os.name != "nt" and shutil.which("bash") and shutil.which("jq"),
"needs a POSIX bash and jq; Actions runs this step under bash")
class UploadStep(unittest.TestCase):
def run_step(self, releases, error=None):
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
script = step_script(UPLOAD_STEP).replace("${{ matrix.files }}", "artifact.exe artifact.zip")
(d / "step.sh").write_text(script)
(d / "releases.json").write_text(json.dumps(releases))
(d / "artifact.exe").write_bytes(b"installer")
(d / "artifact.zip").write_bytes(b"portable")
env = dict(os.environ, PATH="%s:%s" % (FAKEGH, os.environ["PATH"]),
FAKEGH_RELEASES=str(d / "releases.json"), FAKEGH_TAGS="v9.8.7",
FAKEGH_LOG=str(d / "log"), FAKEGH_ALLOW_RELEASE_UPLOAD="1",
GITHUB_REF_NAME="v9.8.7", GH_REPO="saphid/frame-control")
if error:
env["FAKEGH_UPLOAD_ERROR"] = error
p = subprocess.run(["bash", "--noprofile", "--norc", "-eo", "pipefail", str(d / "step.sh")],
cwd=d, env=env, capture_output=True, text=True, timeout=30)
log = [json.loads(line) for line in (d / "log").read_text().splitlines()]
return p, log
def test_tagged_draft_uses_normal_upload(self):
p, log = self.run_step([release(42, "v9.8.7")])
self.assertEqual(p.returncode, 0, p.stderr)
self.assertEqual(len([c for c in log if c[:2] == ["release", "upload"]]), 1)
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
def test_untagged_draft_is_reassociated_then_uploaded(self):
p, log = self.run_step([release(42, "untagged-abc")])
self.assertEqual(p.returncode, 0, p.stderr)
self.assertEqual(len([c for c in log if c[:2] == ["release", "upload"]]), 2)
patches = [c for c in log if c[:3] == ["api", "-X", "PATCH"]]
self.assertEqual(len(patches), 1)
self.assertIn("repos/saphid/frame-control/releases/42", patches[0])
self.assertIn("tag_name=v9.8.7", patches[0])
def test_ambiguous_untagged_drafts_are_not_changed(self):
p, log = self.run_step([release(42, "untagged-abc"), release(43, "untagged-def")])
self.assertNotEqual(p.returncode, 0)
self.assertIn("expected one matching draft", p.stderr)
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
def test_other_upload_failures_do_not_patch_draft(self):
p, log = self.run_step([release(42, "v9.8.7")], error="network unavailable")
self.assertNotEqual(p.returncode, 0)
self.assertIn("network unavailable", p.stderr)
self.assertFalse(any(c[:3] == ["api", "-X", "PATCH"] for c in log))
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -355,6 +355,8 @@ class FindingPanels(unittest.TestCase):
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, out, "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": [1, 0, 2, 3, 769, 4],
"GAMESCOPE_FOCUSED_WINDOW": [0]})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = \n", "")
self.assertEqual(self.real_read_props(":0"), {"GAMESCOPE_FOCUSABLE_WINDOWS": []})
t.subprocess.run = lambda *a, **k: subprocess.CompletedProcess(a, 0, "GAMESCOPE_FOCUSED_WINDOW: not found.\n", "")
self.assertEqual(self.real_read_props(":0"), {})
@@ -392,17 +394,52 @@ class FindingPanels(unittest.TestCase):
self.assertIn("Headset view", out["why"])
def test_desktop_running_but_not_listed(self):
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = []
self.t.desktop_running = lambda: True
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("desktop is running", out["why"])
def test_nothing_open(self):
self.props[(":0", "GAMESCOPE_FOCUSABLE_WINDOWS")] = []
out = self.t.panels()
self.assertEqual(out, {"focus": None, "focus_display": None, "panels": [],
"why": "No app panel is open in the headset. Open the desktop or an app in "
"the headset, then try again."})
def test_absent_list_is_unknown_even_when_desktop_is_running(self):
for desktop in (False, True):
with self.subTest(desktop=desktop):
self.t.desktop_running = lambda: desktop
self.t._ROOT.clear()
out = self.t.panels()
self.assertEqual(out["panels"], [])
self.assertIn("couldn't read gamescope's panel list", out["why"])
self.assertIn("can't tell whether", out["why"])
self.assertIn("Headset view", out["why"])
self.assertNotIn("No app panel is open", out["why"])
def test_focus_property_without_panel_list_is_unknown(self):
self.props[(":0", "GAMESCOPE_FOCUSED_WINDOW")] = [0]
self.assertIn("couldn't read gamescope's panel list", self.t.panels()["why"])
def test_failed_xprop_does_not_claim_no_panels_are_open(self):
t = self.t
t.read_props = self.real_read_props
saved = t.subprocess.run
self.addCleanup(setattr, t.subprocess, "run", saved)
for failure in ("refused", "timeout"):
with self.subTest(failure=failure):
def run(*args, **kwargs):
if failure == "timeout":
raise subprocess.TimeoutExpired(args[0], kwargs["timeout"])
return subprocess.CompletedProcess(args[0], 1, "", "Can't open display")
t.subprocess.run = run
t._ROOT.clear()
out = t.panels()
self.assertIn("couldn't read gamescope's panel list", out["why"])
self.assertNotIn("No app panel is open", out["why"])
def test_missing_x_tools_are_named(self):
self.plasma_on(":0")
self.t.shutil.which = lambda tool: None if tool == "xwininfo" else "/usr/bin/" + tool
+9
View File
@@ -10,6 +10,8 @@ function connectionFixture() {
sourceState:{jobs:new Map(),detail:null,detailRequest:0},touches:new Map(),gesture:null,padType:{blur(){},value:''},
log(){},renderPill(){},renderConnDlg(){},renderHome(){},resetShots(){},resetClips(){},renderBanner(){},loadDevices(){},renderDevices(){},renderTests(){},viaText(){return 'test';},netLabel(){return 'test';},refresh:()=>Promise.resolve(),reloadAll(){},checkContactPrompt(){},ctrlReset(){},padShow:s=>{c.pad.state=s.state;}});
c.window.connDevice='a';
c.document.addEventListener=()=>{};
f.run(pick(html,'class DesktopBatteryAlert {','// ---- battery ----'));
f.run('const UI_KEY="test";'+pick(html,'const HEADSET_FREE =','// What a panel shows'));
f.run(pick(html,'function onConnection(s) {','// Read the event stream'));
return f;
@@ -41,8 +43,15 @@ test('a device switch discards completed install results and the open detail',()
Object.assign(c,{pad:{state:'off',queue:[]},padReset(){},sourceState:{jobs:new Map([['test:one',{done:true,result:{package:'app.on.a',game_data:true}}]]),detail:{app:{}},detailRequest:0}});
f.el('appDetail').open=true;
f.el('storageResult').textContent='Old headset storage';
f.el('desktopBatteryAlert').checked=true;
f.run('desktopBattery.pending={condition:"low",at:0};desktopBattery.latched=true;desktopBattery.lastAlert=42;');
f.run('onConnection({phase:"connected",device:{id:"b",name:"B"},devices_rev:1,now:0})');
assert.match(f.el('storageResult').textContent,/Headset changed/);
assert.equal(f.el('desktopBatteryAlert').checked,false);
assert.equal(f.run('desktopBattery.pending'),null);
assert.equal(f.run('desktopBattery.latched'),false);
assert.equal(f.run('desktopBattery.lastAlert'),42);
assert.match(f.el('desktopBatteryStatus').textContent,/Off.*Headset changed/);
assert.equal(c.sourceState.jobs.size,0);assert.equal(c.sourceState.detail,null);assert.equal(f.el('appDetail').open,false);
});
test('only a preview matching current diagnostics consent can be sent or copied',async()=>{
+4 -5
View File
@@ -30,9 +30,8 @@ ARCHIVE = re.compile(r'^(saves|settings|android)/[A-Za-z0-9._-]{1,200}\.tar\.gz$
PART = re.compile(r'^(saves:(\d{1,10}|linux)|settings:(steam|system)|android:[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+)$')
KINDS = ('saves', 'settings', 'android')
FORMAT = 1
# The switch Valve's own devkit tools use (frame/devkit-utils/steamos-delete). Nothing else
# is ever run as a session command.
SELECTORS = ('holo-session-select', 'steamos-session-select')
# Fixed SteamOS switches only; never execute a command supplied by remote status.
SELECTORS = ('steamosctl', 'holo-session-select', 'steamos-session-select')
REASONS = {
'not_installed': 'Install the game first, then restore its saves',
'no_prefix': 'Start the game once so Proton sets up its folders, then restore',
@@ -344,10 +343,10 @@ def session_status():
def session_command(selector):
"""The switch to Game Mode, exactly as Valve's devkit tools run it."""
"""Allow only fixed Game Mode commands, including older devkit selectors."""
if selector not in SELECTORS:
raise BackupError('this SteamOS build has no session switch command; use the steps in the guide')
return shlex.join([selector, 'gamescope'])
return shlex.join([selector, 'switch-to-game-mode' if selector == 'steamosctl' else 'gamescope'])
def return_to_game_mode(confirmed=False):
+45 -9
View File
@@ -373,15 +373,40 @@ def restore(part, stamp, source):
# The Frame normally runs gamescope (Game Mode) with Plasma nested inside it as one panel
# (steamos-nested-desktop). A full Desktop Mode session is SDDM autologin into Plasma
# instead. Valve's own devkit tools (frame/devkit-utils: steamos-get-status, steamos-delete)
# read the session from these files and switch with holo-session-select or
# steamos-session-select, so that's what this reads and suggests.
# read the login preference from these files. It is not the running session.
SDDM_CONFS = ('/etc/sddm.conf.d/zz-steamos-autologin.conf', '/etc/sddm.conf.d/steamos.conf')
SESSION_TARGETS = ('gamescope-session.target', 'plasma-workspace-wayland.target')
def _session_target(unit):
"""Read the main user's manager, not a nested Plasma session bus over SSH."""
runtime = '/run/user/%d' % os.getuid()
env = dict(os.environ, XDG_RUNTIME_DIR=runtime,
DBUS_SESSION_BUS_ADDRESS='unix:path=%s/bus' % runtime)
try:
result = subprocess.run(['systemctl', '--user', 'show', unit,
'--property=LoadState', '--property=ActiveState'],
capture_output=True, text=True, timeout=5, env=env)
except (OSError, subprocess.TimeoutExpired):
return 'unknown'
if result.returncode:
return 'unknown'
properties = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
if properties.get('LoadState') == 'not-found':
return 'not-found'
if properties.get('LoadState') == 'loaded':
return properties.get('ActiveState', 'unknown')
return 'unknown'
def _running(name, full=False):
# This module is sent as `python3 -c <source>`: unanchored pgrep -f would
# match the script names inside our own source, even when no script runs.
pattern = (r'^([^ ]*/)?((ba|da|z)?sh +([^ ]*/)?)?%s( |$)' % re.escape(name)
if full else name)
try:
return subprocess.call(['pgrep', '-f' if full else '-x', name], stdout=subprocess.DEVNULL,
return subprocess.call(['pgrep', '-f' if full else '-x', pattern], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0
except OSError:
return False
@@ -397,21 +422,32 @@ def session():
m = re.search(r'^\s*Session\s*=\s*(\S+)', text, re.M)
conf, configured = path, (m.group(1) if m else None)
break
selector = next((s for s in ('holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
selector = next((s for s in ('steamosctl', 'holo-session-select', 'steamos-session-select') if shutil.which(s)), None)
# On the Frame gamescope's process is named gamescope-wl (seen on build 20261015.6141207),
# under the start-gamescope-session script.
gamescope = (_running('gamescope') or _running('gamescope-wl') or
_running('start-gamescope-session', full=True))
plasma = _running('plasmashell')
nested = _running('steamos-nested-desktop', full=True) # a script: its process name is the shell's
if gamescope:
targets = {unit: _session_target(unit) for unit in SESSION_TARGETS}
game, desktop = (targets[unit] for unit in SESSION_TARGETS)
mode = 'unknown'
source = 'session-targets'
if (game, desktop) == ('active', 'inactive'):
mode = 'game'
elif plasma or (configured and 'plasma' in configured):
elif (game, desktop) == ('inactive', 'active'):
mode = 'desktop'
else:
mode = 'unknown'
elif game == desktop == 'not-found':
# Older images have neither target. A compositor plus Plasma is ambiguous:
# Desktop Mode's steamvr-plasma.service also runs gamescope and nested scripts.
source = 'legacy-processes'
if gamescope and not plasma and not nested:
mode = 'game'
elif plasma and not gamescope and not nested:
mode = 'desktop'
return {'mode': mode, 'gamescope': gamescope, 'plasma': plasma, 'nested_desktop': nested,
'configured': configured, 'config_file': conf, 'selector': selector}
'configured': configured, 'config_file': conf, 'selector': selector,
'session_targets': targets, 'mode_source': source}
def main(argv):
+111
View File
@@ -0,0 +1,111 @@
"""One desktop observation using the read-only sampler; reports stay in memory."""
import json
from pathlib import Path
import sys
import threading
import time
import uuid
# Shared with the source-checkout CLI; shipped as explicit desktop resources.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
try:
import frame_power_sample as sampler
from frame_power_report import render
except ModuleNotFoundError as exc:
# Phone/headset bundles host the same UI without desktop-only resources.
if exc.name not in ('frame_power_sample', 'frame_power_report'):
raise
sampler = render = None
PRESETS = {'minute': (12, 5), 'five-minutes': (60, 5)}
class Observation:
def __init__(self):
self.lock = threading.RLock()
self.report = None
self.running = False
self.thread = None
self.cancel = None
self.reason = None
self.id = None
self.started = None
def status(self):
with self.lock:
return {'id': self.id, 'running': self.running, 'received': len(self.report['samples']) if self.report else 0,
'requested': self.report['requestedSamples'] if self.report else 0,
'headset': self.report['headset'] if self.report else None,
'started': self.started, 'stopped': self.report['stopped'] if self.report else None}
def start(self, preset, route, headset):
if sampler is None:
raise ValueError('Desktop power-report resources are missing; reinstall the desktop app')
if preset not in PRESETS:
raise ValueError('Choose a one-minute or five-minute observation')
with self.lock:
if self.running:
raise ValueError('An observation is already running')
if sampler.LOCK.exists():
raise ValueError('Smoke session active (/tmp/frame-test.lock); observation not started')
count, interval = PRESETS[preset]
self.id = uuid.uuid4().hex
self.cancel = threading.Event()
self.reason = None
self.started = time.time()
self.report = {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'headset': headset, 'stopped': 'Observation in progress; received samples only',
'samples': [], 'summary': {}}
self.running = True
# Pin the route before the caller releases the server's route lock.
command = [*route[:-1], '-T', '-o', 'StrictHostKeyChecking=yes',
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', route[-1],
'python3', '-', '--probe', '--count', str(count), '--interval', str(interval)]
self.thread = threading.Thread(target=self._collect, args=(command, count, interval), daemon=True)
self.thread.start()
return self.status()
def _collect(self, command, count, interval):
def received(sample):
with self.lock:
self.report['samples'].append(sample)
try:
result = sampler.stream_report(command, sampler.remote_payload(), count, interval,
self.cancel, received, discard_errors=True)
with self.lock:
result['headset'] = self.report['headset']
result['stopped'] = self.reason or result['stopped']
self.report = result
except Exception as exc:
with self.lock:
self.report['stopped'] = self.reason or f'Observation interrupted: {exc}'
finally:
with self.lock:
self.running = False
def stop(self, reason='Stopped by user; received samples retained'):
with self.lock:
if self.running:
self.reason = reason
self.report['stopped'] = reason
self.cancel.set()
return self.status()
def export(self, observation_id, format):
if format not in ('html', 'json', 'csv'):
raise ValueError('Unknown report format')
with self.lock:
if not self.report or observation_id != self.id:
raise ValueError('This report was replaced; refresh the observation')
# Independent snapshot, including samples already received while running.
report = json.loads(json.dumps(self.report, allow_nan=False))
report['summary'] = sampler.summarize(report['samples'])
if format == 'json':
return json.dumps(report, allow_nan=False, indent=2), 'application/json'
csv = sampler.csv_samples(report)
return (render(report, csv), 'text/html; charset=utf-8') if format == 'html' else (csv, 'text/csv; charset=utf-8')
def shutdown(self):
self.stop('App closed; received samples retained')
if self.thread:
self.thread.join(timeout=8)
+8 -3
View File
@@ -271,7 +271,7 @@ def panels(limit=None):
out["why"] = ("The headset took too long to list its panels (its X tools didn't answer in time). "
+ ("Some may be missing. " if found else "") + "Try again, or use the Headset view.")
elif not found:
out["why"] = why_none(unfound)
out["why"] = why_none(unfound, props)
return out
@@ -295,13 +295,18 @@ def desktop_running():
return False
def why_none(listed):
def why_none(listed, props):
"""Why panels() found nothing, in words the person at the computer can act on.
listed: how many of the windows gamescope lists aren't X11 windows on any display."""
listed: how many of the windows gamescope lists aren't X11 windows on any display.
props: the root properties actually read; an absent list is not an empty list."""
missing = [tool for tool in ("xprop", "xwininfo") if not shutil.which(tool)]
if missing:
return (f"This SteamOS build has no {' or '.join(missing)}, which Frame Control uses to find the "
"headset's panels. Use the Headset view instead, and please report your SteamOS version.")
if "GAMESCOPE_FOCUSABLE_WINDOWS" not in props:
return ("Frame Control couldn't read gamescope's panel list, so it can't tell whether an app panel is open. "
"Check that SteamVR is running in the headset, then try again or use the Headset view. "
"If this persists, report your SteamOS version and this exact message.")
if listed:
# gamescope lists windows, but none is an X11 window on any display: a Wayland
# window (a desktop drawn straight to gamescope, say) can't be found or captured this way.
+146 -5
View File
@@ -1430,8 +1430,22 @@
<div class="page" data-page="tools">
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="powerReport">
<h2>Power &amp; heat report</h2>
<p class="sub">Observe battery, CPU, temperatures and available sensors without changing settings. One observation at a time, every five seconds, for at most five minutes.</p>
<label for="powerPreset">Observation length</label>
<select id="powerPreset"><option value="minute">1 minute · 12 samples</option><option value="five-minutes">5 minutes · 60 samples</option></select>
<div class="row"><button class="action" id="powerStart">Start observation</button><button id="powerStop" disabled>Stop and keep samples</button></div>
<progress id="powerProgress" max="12" value="0" aria-label="Samples received" hidden></progress>
<p id="powerStatus" role="status" aria-live="polite">No observation yet.</p>
<div class="row"><button data-power-format="html" disabled>Download HTML report</button><button data-power-format="json" disabled>Download JSON</button><button data-power-format="csv" disabled>Download CSV</button></div>
<p id="powerSaveStatus" role="status" aria-live="polite"></p>
<p class="hint">Download before starting again or quitting: only the latest observation is kept in memory. Downloads during collection are incomplete snapshots. Stop, disconnect or switching headsets keeps received samples, labeled with the original headset. A smoke session blocks sensor reads.</p>
<p class="hint">Open the saved HTML in your browser; it works offline and includes JSON/CSV downloads. Unknown readings are never treated as zero. Rails overlap; do not add them. Sensor accuracy is unverified. Reports include sensor paths and OS builds: review before sharing.</p>
</section>
<section class="panel" id="vrTools">
<div class="shelf-head"><h2>VR comfort and performance</h2><span class="spacer"></span><span class="sub" id="vrUpdated">Waiting for the Frame</span></div>
<div class="stats" id="vrMetrics"></div>
@@ -1451,6 +1465,12 @@
</details>
</section><section class="panel" id="comfort" style="margin:20px 0;padding:16px">
<div class="shelf-head"><h2>Family and comfort</h2><span class="count">Session timer and reminders</span></div>
<div id="desktopBatteryComfort" hidden>
<h3>Desktop low-battery alert</h3>
<label><input id="desktopBatteryAlert" type="checkbox"> Alert on this computer at 15% or less</label>
<p class="hint">No session timer needed. Off when this page opens or you switch headsets. Uses normal status refreshes while the page is visible; no extra polling or headset changes. Two readings must confirm low charge and battery drain. One alert until charge recovers to 20%, with at least ten minutes between alerts.</p>
<p id="desktopBatteryStatus" class="hint" role="status">Off.</p>
</div>
<form id="comfortForm">
<div class="row" style="flex-wrap:wrap;gap:12px">
<label>Session limit (minutes) <input id="sessionMinutes" type="number" min="1" max="240" value="30" required style="width:75px"></label>
@@ -1718,7 +1738,7 @@
<button class="small" id="sessionCheck">Check</button></div>
<div class="sub" id="sessionState">Check which mode the headset is in.</div>
<div class="row" style="margin-top:12px"><button class="small" id="sessionGame" disabled>Return to Game Mode</button></div>
<div class="hint">Uses the same switch as Valve's developer tools (<code>steamos-session-select gamescope</code>).
<div class="hint">Uses Valve's supported Game Mode switch for this SteamOS build.
It closes the desktop and anything open in it. If the headset can't be reached, hold Power for 10 seconds to
restart it; <a href="https://github.com/saphid/frame-control/blob/main/docs/backup-and-recovery.md#stuck-in-desktop-mode" target="_blank">more ways back</a>.</div>
</section>
@@ -2116,6 +2136,7 @@ const HOST = { computer: "computer" };
function applyHostWording(h) {
Object.assign(HOST, h);
window.frameControlCenter?.host(h);
$("desktopBatteryComfort").hidden = !!h.mobile || !window.frameApp?.notify;
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
document.body.classList.toggle("mobile", !!h.mobile);
$("shotsFolder").hidden = !!h.mobile;
@@ -2158,7 +2179,7 @@ const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
// then are about the old one, so they're dropped rather than shown.
let devGen = 0;
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
const HEADSET_FREE = /^\/api\/(power|devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
async function api(path, body, device = window.connDevice) {
const gen = devGen;
const checkGeneration = () => {
@@ -2226,6 +2247,7 @@ function setOnline(ok, why) {
if (HOST.mobile) $('mobileConnectionStatus').textContent = ok ? 'Connected to the Frame serving this session.' : 'The Frame is disconnected. Retry or check its address in connection setup.';
$('offline').hidden = ok;
if (!ok) {
desktopBattery.invalidate();
$("offMsg").textContent = why || "Can't reach the Frame";
setConn(false, "Offline");
$("battChip").hidden = true; closeDevPop();
@@ -2262,6 +2284,7 @@ function refresh() {
setConn(true, state.hostname);
setOnline(true);
} catch (e) {
desktopBattery.invalidate();
renderPerformance(null);
if (!e.stale) stateRead = {valid: false, error: e};
renderHome();
@@ -2442,6 +2465,60 @@ $("castBtn").onclick = () => {
$("viewer").requestFullscreen?.().catch(() => {});
};
// ---- Desktop-only low-battery comfort: consumes existing status, never polls ----
class DesktopBatteryAlert {
constructor() {
this.pending = null;
this.latched = false;
this.lastAlert = -Infinity;
}
invalidate() { this.pending = null; }
sample(b, now) {
// Require actual drain as well as driver state: plugged-in idle can report Discharging.
const valid = Number.isFinite(b?.percent) && b.percent >= 0 && b.percent <= 100 &&
["Charging", "Discharging", "Full", "Not charging"].includes(b.status);
const low = valid && b.percent <= 15 && b.status === "Discharging" &&
Number.isFinite(b.watts) && b.watts < -0.5;
const recovered = valid && b.percent >= 20;
const condition = low ? "low" : recovered ? "recovered" : null;
if (!condition) { this.invalidate(); return false; }
if (!this.pending || this.pending.condition !== condition || now - this.pending.at > 90000 || now < this.pending.at) {
this.pending = {condition, at: now};
return false;
}
if (now - this.pending.at < 20000) return false;
if (recovered) this.latched = false;
if (!low || this.latched || now - this.lastAlert < 600000) return false;
this.latched = true;
this.lastAlert = now; // latch even when system delivery fails; never retry on each refresh
return true;
}
}
const desktopBattery = new DesktopBatteryAlert();
$("desktopBatteryAlert").onchange = () => {
desktopBattery.invalidate();
$("desktopBatteryStatus").textContent = $("desktopBatteryAlert").checked
? "On. Waiting for two fresh status readings confirming low charge and battery drain."
: "Off.";
};
function desktopBatterySample(b) {
if (!$("desktopBatteryAlert").checked || HOST.mobile || !window.frameApp?.notify || document.hidden ||
(comfortActive && $("batteryAlert").checked)) {
desktopBattery.invalidate();
return;
}
if (!desktopBattery.sample(b, performance.now())) return;
const message = `Frame battery is low (${b.percent}%) and draining. Consider charging or taking a break.`;
$("desktopBatteryStatus").textContent = message;
toast(message);
// Desktop bridge only: never prompt or send a phone/browser push from a status refresh.
const generation = devGen;
window.frameApp.notify(message, false).catch(() => {
if (generation !== devGen || !$("desktopBatteryAlert").checked) return;
$("desktopBatteryStatus").textContent = message + " System notification delivery was not confirmed. Check system settings or use Enable / test notifications below.";
});
}
document.addEventListener("visibilitychange", () => desktopBattery.invalidate());
// ---- battery ----
function battery(b, power) {
const pct = b?.percent;
@@ -2497,6 +2574,7 @@ function render(s) {
renderHome(s);
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
battery(s.battery, s.power);
desktopBatterySample(s.battery);
$("devPopTitle").textContent = $("devPopTitle").title = window.connName || "Headset";
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
@@ -5454,6 +5532,63 @@ $("panelList").onclick = e => {
act("Show panel", () => api("/api/panels", {action: "focus", key: b.dataset.panel}), b);
};
// ---- Read-only power observation ----
let powerState = null, powerTimer = null, powerSeq = 0;
function renderPower(s) {
powerState = s;
$('powerStart').disabled = !!s.running;
$('powerPreset').disabled = !!s.running;
$('powerStop').disabled = !s.running;
$('powerProgress').max = s.requested || 1;
$('powerProgress').value = s.received;
$('powerProgress').hidden = !s.id;
const identity = s.headset ? `Headset: ${s.headset.alias}. ` : '';
$('powerStatus').textContent = s.id ? identity + `${s.received} of ${s.requested} samples received. ` +
(s.running ? (s.stopped?.startsWith('Stopped') ? 'Stopping…' : 'Observing…') : s.stopped ? `Incomplete: ${s.stopped}` : 'Completed.') : 'No observation yet.';
document.querySelectorAll('[data-power-format]').forEach(b => b.disabled = !s.id);
}
async function loadPower() {
const seq = ++powerSeq;
clearTimeout(powerTimer);
try {
const s = await api('/api/power');
if (seq !== powerSeq) return;
renderPower(s);
if (s.running && location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 1000);
} catch (e) {
if (seq !== powerSeq) return;
$('powerStatus').textContent = `Observation status unavailable: ${e.message}. Received samples remain on the local server while it is running.`;
if (location.hash === '#powerReport') powerTimer = setTimeout(loadPower, 3000);
}
}
$('powerStart').onclick = async () => {
$('powerStart').disabled = true;
try {
renderPower(await api('/api/power', {action:'start', preset:$('powerPreset').value}));
loadPower();
} catch (e) { $('powerStatus').textContent = e.message; $('powerStart').disabled = !!powerState?.running; }
};
$('powerStop').onclick = async () => {
if (!powerState?.id) return;
$('powerStop').disabled = true;
try { renderPower(await api('/api/power', {action:'stop', id:powerState.id})); loadPower(); }
catch (e) { $('powerStatus').textContent = e.message; $('powerStop').disabled = false; }
};
document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async () => {
if (!powerState?.id) return;
const id = powerState.id, format = b.dataset.powerFormat;
b.disabled = true;
try {
const r = await fetch(`/api/power/report?id=${encodeURIComponent(id)}&format=${format}`, {headers:{'X-Frame-UI':UI_KEY}});
if (!r.ok) throw new Error((await r.json()).error || `HTTP ${r.status}`);
const blob = await r.blob(), a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = `frame-power-${id}.${format}`; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
$('powerSaveStatus').textContent = 'Download requested. Open the saved HTML in your browser to view it offline; it includes JSON and CSV downloads.';
} catch (e) { $('powerSaveStatus').textContent = `Download failed: ${e.message}`; }
finally { b.disabled = !powerState?.id; }
});
// ---- Read-only game storage ----
let storageSeq = 0;
const storageState = s => ({'permission-denied':'Permission denied', missing:'Missing or disconnected',
@@ -5761,10 +5896,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", backup:"tools"};
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", powerReport:"tools", backup:"tools"};
const TOOL_TITLES = {transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", backup:"Backup & recovery"};
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", powerReport:"Power & heat report", backup:"Backup & recovery"};
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
let page = "apps";
// Reuse the existing live lists and form handlers in their new destinations.
@@ -5872,6 +6007,7 @@ function showPage(e) {
if (tool === 'panels') loadPanelSwitcher();
if (tool === 'backup') loadBackups();
if (tool === 'storage') loadStorage();
if (tool === 'powerReport') loadPower();
if (tool === 'androidSettings') {
$('repNew').hidden = false; $('androidSettings').prepend($('repNew')); loadDisplays();
}
@@ -6826,6 +6962,11 @@ function onConnection(s) {
stateReceivedAt = null;
stateRead = {valid: false, error: null};
online = null;
$("desktopBatteryAlert").checked = false;
desktopBattery.invalidate();
// Keep the cooldown across switches, but a new headset can start its own charge episode.
desktopBattery.latched = false;
$("desktopBatteryStatus").textContent = "Off. Headset changed; enable for this headset if wanted.";
devGen++; // answers still on their way are about the other headset
padReset(); ctrlReset();
sourceState.jobs.clear(); sourceState.detail = null; sourceState.detailRequest++;
+39
View File
@@ -64,6 +64,7 @@ import frame_media # noqa: E402
import frame_panels # noqa: E402
import frame_quest # noqa: E402
import frame_meta # noqa: E402
import frame_power # noqa: E402
import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
@@ -109,6 +110,7 @@ HOST_OPTS = []
frame_android.SSH_OPTS = SSH[1:]
_power = frame_power.Observation()
_route_lock = threading.Lock()
@@ -120,6 +122,7 @@ def route(alias, host_opts):
with _route_lock:
moved = alias != FRAME
if moved:
_power.stop('Headset switched; received samples retained for the previous headset')
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
# Invalidate launches before publishing the new route. send/stop share
# a writer lock, so an old process cannot receive events after this.
@@ -3009,7 +3012,28 @@ def source_manage(body):
raise Failure(str(e), 400)
def power_action(body):
action = body.get('action')
if LOCAL:
raise Failure('Power observations are available on the desktop app', 400)
if action == 'stop':
# ID prevents an old page stopping a replacement observation.
with _power.lock:
if body.get('id') != _power.id:
raise Failure('This observation was replaced; refresh', 409)
return _power.stop()
if action != 'start':
raise Failure('Unknown observation action', 400)
with _route_lock:
headset = {'alias': FRAME, 'deviceId': LINK.active_device()['id'] if LINK else None}
try:
return _power.start(body.get('preset'), tuple([*SSH, FRAME]), headset)
except ValueError as exc:
raise Failure(str(exc), 409) from exc
POST = {
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
@@ -3193,6 +3217,18 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/power":
self.send_json(_power.status())
elif path == "/api/power/report":
args = parse_qs(url.query)
try:
format = args.get('format', ['html'])[0]
report_id = args.get('id', [''])[0]
data, mime = _power.export(report_id, format)
except ValueError as exc:
raise Failure(str(exc), 409) from exc
self.send_bytes(data.encode('utf-8'), mime,
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
@@ -3368,6 +3404,8 @@ class Handler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict):
raise Failure("request body must be a JSON object", 400)
if path == '/api/power' and body.get('action') == 'start' and LINK and not meant:
raise Failure('Observation start needs the intended X-Frame-Device headset', 400)
# Not headset work: switching headsets mustn't wait for (or refuse) these.
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
result = handler(body)
@@ -3641,6 +3679,7 @@ def main():
# mid-cleanup would abort it and leave the SSH master running.
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_power.shutdown()
_stop_sync.set()
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes