Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a6b13a9b2 | ||
|
|
42b0d729cb | ||
|
|
25fe57be10 | ||
|
|
ae63fa584c | ||
|
|
337ac99b3e | ||
|
|
aada257325 | ||
|
|
8bc3d63126 | ||
|
|
c690e691a3 | ||
|
|
e3fcd3eef8 | ||
|
|
0da1fe5077 | ||
|
|
1f7615b312 | ||
|
|
2ded4c5a0d | ||
|
|
5b47cd3330 | ||
|
|
aead570b5d | ||
|
|
de931bc56f | ||
|
|
71288d8980 | ||
|
|
64b9babe87 | ||
|
|
4e456480ae | ||
|
|
241f538f99 | ||
|
|
21dacfcbe7 | ||
|
|
eb181d8996 | ||
|
|
827ddcf14f | ||
|
|
7c3894e630 | ||
|
|
040581a4c7 | ||
|
|
17f6d52717 | ||
|
|
b4e80cab0a | ||
|
|
b98e3e496f | ||
|
|
a148e02d39 | ||
|
|
0fb9174c23 | ||
|
|
c7489a88eb | ||
|
|
be74850a8e | ||
|
|
dc21428f85 | ||
|
|
fa43c83b06 | ||
|
|
1d4c57d648 | ||
|
|
c6215712fe | ||
|
|
cd7ab2d9f7 | ||
|
|
4b68971383 | ||
|
|
54c001d537 | ||
|
|
5ce5c023b6 | ||
|
|
709fef0dbd | ||
|
|
49408dc171 | ||
|
|
c728e64fbd | ||
|
|
0626c52e12 | ||
|
|
b9b4a00485 | ||
|
|
eb125b59c7 | ||
|
|
7e53fe32cb | ||
|
|
ebae409632 | ||
|
|
08cb267ce4 |
No files matched your search
@@ -41,6 +41,10 @@ jobs:
|
||||
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
- name: Linux Wireless Adapter doctor (read-only PC smoke)
|
||||
run: |
|
||||
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
|
||||
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: App tests
|
||||
@@ -70,6 +74,21 @@ jobs:
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
# Fast, PC-only Windows query validation, without capture libraries or a headset.
|
||||
wireless-windows:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Wireless doctor fixtures, UI and live read-only Windows inventory
|
||||
run: python -m unittest discover -s tests -p 'test_wireless.py' -v
|
||||
|
||||
# The iPhone app: builds for the Simulator and runs its unit tests.
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
|
||||
@@ -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/') }}
|
||||
|
||||
@@ -2,6 +2,126 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
|
||||
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
|
||||
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
|
||||
visible; no adapter disable action is offered because an unused instance
|
||||
cannot be identified reliably. No headset access or settings changes.
|
||||
[Sources, verification and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add a Linux PC Wireless Adapter doctor in Settings and Devices, with a source
|
||||
CLI and local JSON report. Select observed hardware without assuming Valve
|
||||
IDs; inspect USB/interface presence, driver/module binding, udev/permissions,
|
||||
rfkill, channel/band and network manager evidence. Missing evidence stays
|
||||
unknown; all checks are read-only and never contact the Frame.
|
||||
[Usage and verification limits](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
- Add Linux Tools → Steam desktop sharing: request `steam -pipewire` only after
|
||||
checking Steam is not running, or open KDE Application Permissions with
|
||||
instructions to revoke a verified Steam screen-sharing session manually. No
|
||||
automatic permission reset, Steam restart, or headset access. Frame Control
|
||||
Linux capture remains disabled; live portal behavior is unverified.
|
||||
[Usage and limits](docs/steam-pipewire.md).
|
||||
|
||||
- Use gamescope's input for the keyboard and trackpad, following the focused
|
||||
headset panel without installing an input service. Start KDE Connect only for
|
||||
accents and emoji, keeping queued text ordered and cancelling it on headset
|
||||
switches. Retain intended-headset checks before starting either control and
|
||||
prevent Unicode helper startup from deadlocking with a headset switch.
|
||||
- Keep accepted keyboard text when the Unicode queue is full: reject new batches
|
||||
before sending their clicks or keys, and count in-flight text toward the limit.
|
||||
Retain unsent text after helper failures/timeouts, report the error and resume
|
||||
it with Try again. Clean up failed workers without touching a new headset's queue.
|
||||
[Current verification and device limits](docs/evidence/pad-gamescope-2026-10-24.md).
|
||||
|
||||
- Keep media playback running when SteamVR refuses the optional theatre dark
|
||||
surround, including a retry after standby. Log the refusal and play without
|
||||
the surround. Overlay teardown attempts every owned handle and always calls
|
||||
OpenVR shutdown even if a handle fails to close.
|
||||
|
||||
- Check the desktop server's single-instance lock before starting background
|
||||
downloads, so a second server exits cleanly with the existing "already
|
||||
running" message.
|
||||
|
||||
- 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
|
||||
compatibility or diagnose its reported failure (issue #102).
|
||||
|
||||
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
|
||||
battery, CPU, named temperatures, fan and available power rails (issue #52).
|
||||
JSON samples/summaries and CSV exports retain explicit unavailable states;
|
||||
active smoke-test locks stop measurement. No power settings are changed.
|
||||
|
||||
- Show saved Android compatibility reports on store search cards and app details.
|
||||
Reports refresh in the background, so a stalled database or credential lookup
|
||||
cannot delay search. Warnings allow installation; build compatibility blockers
|
||||
still apply. Public copies use this computer's reports, not private shared ratings.
|
||||
|
||||
- Fix two cases where optional diagnostic logs could retain part of a headset
|
||||
display name or an SSH host name, while preserving existing privacy redaction
|
||||
(issue #81).
|
||||
|
||||
- Keep the full host-key recovery explanation readable in Devices, including
|
||||
long addresses, and remove doubled punctuation from the connection tooltip
|
||||
(issue #87). Refresh stays within the header and switching headsets selects
|
||||
the new headset's details.
|
||||
|
||||
- Fix experimental PC screen capture on Windows builds whose streaming libraries
|
||||
lack Windows Graphics Capture. Whole screens use Desktop Duplication; individual
|
||||
windows are hidden with an explanation instead of failing when shown. A Windows
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://github.com/saphid/steam-frame/releases/latest)
|
||||
[](https://github.com/saphid/frame-control/releases/latest)
|
||||
[](#install)
|
||||
[](https://github.com/saphid/steam-frame/actions/workflows/checks.yml)
|
||||
[](https://github.com/saphid/frame-control/actions/workflows/checks.yml)
|
||||
[](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>
|
||||
|
||||
@@ -44,6 +44,12 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
|
||||
**🔋 Battery and status**<br>
|
||||
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
|
||||
|
||||
For PC-side USB, driver, radio and network manager evidence, use the
|
||||
[Linux Wireless Adapter doctor](docs/linux-wireless-adapter-doctor.md).
|
||||
|
||||
For repeatable read-only battery, power-rail, temperature and fan measurements,
|
||||
use the [power and heat sampler](docs/power-and-heat.md).
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -70,10 +76,10 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
<td valign="top">
|
||||
|
||||
**📸 Screenshots and recordings**<br>
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record SteamVR's mirrored headset view, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder. [Recording details and passthrough limits](docs/recording.md#does-record-include-passthrough).
|
||||
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one. Copy text to the headset's clipboard and back again. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -113,17 +119,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
|
||||
@@ -136,7 +155,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"
|
||||
@@ -165,26 +184,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
|
||||
@@ -220,12 +257,14 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control in detail](docs/frame-control.md) | Every feature, how it works, per-platform notes, building |
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [SteamOS October 2026 compatibility](docs/steamos-2026-10-compat.md) | Frame OS 0.5.6 connection changes and SteamOS 3.9.5 PC host notes |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games may not |
|
||||
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Steam desktop sharing (Linux)](docs/steam-pipewire.md) | External Steam helper: launch with `-pipewire` after exiting Steam, or open KDE permissions to re-pick a screen manually; no automatic permission reset |
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
@@ -233,6 +272,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 |
|
||||
|
||||
@@ -248,8 +288,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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.11",
|
||||
"version": "0.4.20",
|
||||
"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"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -190,6 +190,35 @@ IzzyOnDroid, curated GitHub releases, itch.io). **Library** shows the apps on th
|
||||
Frame as cards, with Play and a menu for Test, Report and Remove; the display
|
||||
settings and recent reports are below it.
|
||||
|
||||
Store search cards and app details also show **Reported not working** or
|
||||
**Reported issues** when saved compatibility reports say so. Details include
|
||||
the report date, version and notes. These are advisories: Install remains
|
||||
available unless the selected build has an existing compatibility blocker
|
||||
(for example, no arm64-v8a build or Android API above 30). Reports never clear
|
||||
those blockers. Apps reported not working are excluded from Home's featured
|
||||
app and recommendation shelves; search and category results still show them.
|
||||
An unrated install failure caused by the wrong APK architecture or Android API
|
||||
is ignored, using the same rules as the catalogue. A person's rating takes
|
||||
priority over automated results, with the latest rating winning.
|
||||
|
||||
Reports warm in a background worker at server start. Search and details read
|
||||
only the last complete in-memory snapshot and schedule a single background
|
||||
refresh after 60 seconds; they never wait for disk, Keychain, a subprocess or
|
||||
the report database. During initial loading they show the usual build verdict;
|
||||
after a refresh, the next search or detail request sees the reports. Failed
|
||||
refreshes keep the previous snapshot and retry after another 60 seconds.
|
||||
|
||||
**Availability:** public copies benefit from their own saved reports, including
|
||||
reports for apps outside the bundled catalogue, across every source with the
|
||||
same package name. This does not give public users the private shared ratings.
|
||||
The checked-in `apk-catalog/site/apps.js` contains predictions and no tested
|
||||
report verdicts, so it cannot supply a useful public report snapshot. No private
|
||||
reports, free-text notes or credentials are exported or published. The existing
|
||||
maintainer loader still uses its configured database access in the background;
|
||||
the search feature adds no sharing, endpoint or network destination. Tests leave
|
||||
the hook off by default (`FRAME_COMPAT_SEARCH_REPORTS=0` also disables it in
|
||||
server subprocesses) and use synthetic reports to exercise refresh behavior.
|
||||
|
||||
The store groups apps by what a headset is good for, not by F-Droid's list:
|
||||
VR, Games, Emulators, Watch & listen, Read & learn, Create, Work, Web & chat
|
||||
and Tools (`ui/apk_sources/_categories.py`). A VR app is always in VR. Otherwise
|
||||
@@ -402,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
|
||||
[SideQuest features and limits](sidequest.md) for commands, archive scope and
|
||||
verification status. These paths have offline coverage; real Frame storage and
|
||||
permissions remain unverified. They do not change APK install or launch behavior.
|
||||
|
||||
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
Confirming authorizes installing the app, starting it to prepare Android,
|
||||
checking the running package/version, copying OBBs through the existing
|
||||
verified data helper, and closing the game. Open it from Steam afterwards.
|
||||
If setup fails, read the error and retry the same backup; do not uninstall
|
||||
first, since that deletes saves. An app that cannot start may also prevent
|
||||
data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
and runtime compatibility remain unknown. Record those details, Frame Control
|
||||
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
|
||||
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
|
||||
Successful installation or fixture tests do not establish RE4 compatibility;
|
||||
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
|
||||
@@ -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`.
|
||||
@@ -97,6 +97,12 @@ The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
When a connection fails, the header pill keeps its compact offline state and
|
||||
retry countdown; its tooltip and connection dialog contain the full reason.
|
||||
Devices shows a short reason in the headset row (with the full reason in its
|
||||
tooltip) and a wrapping recovery explanation in the selected headset's details.
|
||||
**Use this headset** also selects that headset's details.
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
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,142 @@
|
||||
# Media surround hardening: 2026-10-24
|
||||
|
||||
PR [#51](https://github.com/saphid/frame-control/pull/51), branch
|
||||
`theatre-media-surround-hardening`, merged main `2ded4c5` without rebasing.
|
||||
Both fixes remain necessary on that main: an optional-surround error escapes
|
||||
`play()`, and a non-`RuntimeError` during overlay teardown skips the remaining
|
||||
handles and OpenVR shutdown.
|
||||
|
||||
## Offline checks
|
||||
|
||||
- Focused media suite: **23 tests passed** on Linux / Python 3.12.3.
|
||||
- Final full suite: **1,381 tests, OK (15 skipped)** in 145.384 s with
|
||||
`PYTHONFAULTHANDLER=1 python3 -m unittest discover -s tests -v`.
|
||||
The service-regression module passed **33 tests**. Player/server compilation
|
||||
and `git diff --check` passed. Current macOS/Windows validation is left to CI.
|
||||
- The startup/upload, creation/setup, deferred-surround refusal and teardown
|
||||
regression tests were also run with main's player implementation: they
|
||||
failed as expected (six errors across subtests). The screen-refusal test
|
||||
passed, confirming that actual screen failures still propagate.
|
||||
- The historical macOS failure was job
|
||||
[109239733717](https://github.com/saphid/frame-control/actions/runs/36516462623/job/109239733717),
|
||||
on `dac9dbc`, Python 3.12: 619 tests, three failures in artwork resolver
|
||||
tests (`test_resolver_slot_released_when_thread_cannot_start`,
|
||||
`test_timed_out_lookups_are_capped`,
|
||||
`test_url_fetch_refuses_private_hosts_and_honours_deadline`). Media tests
|
||||
passed. Late DNS workers retained semaphore slots; the first failed
|
||||
assertion could also leak acquired slots into later tests.
|
||||
- Main already contains `8051687`'s wait-for-idle and unconditional slot
|
||||
cleanup, preserved by `d546568`. The current artwork module passed all
|
||||
**40 tests** locally. No additional artwork changes were needed.
|
||||
- The first full run completed **1,380 tests, 15 skipped**, with one failure:
|
||||
`test_a_second_server_is_refused_until_the_first_exits` observed SIGSEGV
|
||||
(`-11`) instead of exit 1. All 41 server tests passed on a focused rerun.
|
||||
Main started daemon index/TLS workers before refusing a second server;
|
||||
its existing shutdown notes document a race with OpenSSL interpreter exit.
|
||||
This crash mechanism is inferred; the first run did not retain a native
|
||||
fault trace. The subprocess assertion now retains stderr for diagnosis.
|
||||
A deterministic regression now checks that rejection starts no listener,
|
||||
cleanup sweep or background workers. It failed before moving the ownership
|
||||
check ahead of startup work and passed afterwards.
|
||||
|
||||
The surround retry now logs a refusal before discarding that cosmetic work.
|
||||
Creation, setup and first-upload failures also allow the screen to play;
|
||||
standby remains retryable, and screen errors remain fatal. Teardown tests
|
||||
exercise OpenVR errors, unexpected exceptions and a late `InterruptedError`,
|
||||
checking that all handles are attempted and shutdown runs.
|
||||
|
||||
## Earlier blocked real Frame attempt
|
||||
|
||||
Read-only SSH preflight: SteamOS **0.5.6**, BUILD_ID **20261015.6141207**;
|
||||
SteamVR server PID **297506**, compositor PID **297620**; existing media
|
||||
service `inactive/dead`, MainPID 0; battery **99%**, fuel gauge `Not charging`,
|
||||
charger `Full`. The shared lock was absent at preflight.
|
||||
|
||||
The subsequent atomic `mkdir /tmp/frame-test.lock` failed with
|
||||
`FileExistsError(17, 'File exists')`: another session acquired it in between.
|
||||
The probe stopped before uploading files, launching media, creating overlays,
|
||||
or changing any state. The foreign lock was neither modified nor released.
|
||||
All generated fixtures remained on the computer and were then removed; no
|
||||
headset cleanup was necessary. Steam/SteamVR and the prior player state were
|
||||
left alone.
|
||||
|
||||
## Follow-up real Frame smoke at the exact PR head
|
||||
|
||||
**Passed, 2026-10-24 10:35:02–10:35:23 UTC**, with byte-identical media modules
|
||||
from **`5d551a81658d9ff14f49285c28609d506cbc20aa`**. This later run resolves
|
||||
the earlier lack of fresh playback evidence. No production code changed.
|
||||
The headset was unworn. SteamOS **0.5.6**, BUILD_ID **20261015.6141207**,
|
||||
SteamVR **2.18.2**; battery **100%, Full** before and after.
|
||||
|
||||
Read-only preflight found Game Mode (`XDG_CURRENT_DESKTOP=gamescope`,
|
||||
`start-gamescope` running, no `plasmashell`), Steam/SteamVR running, no media
|
||||
overlays, and the installed media service inactive/not loaded. The test then
|
||||
acquired `/tmp/frame-test.lock` with atomic `mkdir`; it did not take over a
|
||||
foreign lock. Under the lock, those safety conditions were checked again.
|
||||
|
||||
Only generated media and the PR's four unchanged modules were uploaded, under
|
||||
the test-owned, uid-1000 directory
|
||||
`/tmp/pr51-5d551a8-4c0976a52564-utfbk_m0/`. The clip was a **12 s, 30 fps,
|
||||
960×540 half-SBS H.264** MP4 with solid red/cyan eye images and **silent AAC**.
|
||||
It was generated with FFmpeg's `color`, `hstack` and `anullsrc` sources; no
|
||||
personal or external media was used.
|
||||
|
||||
The exact `frame_media_remote.run()` Play/status/Stop path was exercised by
|
||||
importing its unmodified module and changing only its in-memory `ROOT`,
|
||||
`RUNTIME`, `STATUS` and `UNIT` constants to the isolated test locations and
|
||||
`frame-control-media-pr51-5d551a8-4c0976a52564.service`. The real
|
||||
`frame_media_player.py` ran unchanged with `--layout auto --theatre`, using
|
||||
`h264_v4l2m2m` and Pulse output. This checks the player and its systemd lifecycle;
|
||||
it does **not** reverify the HTTP/UI upload or runtime installer, nor the
|
||||
literal production unit name. The installed media runtime, log, status and
|
||||
library were preserved.
|
||||
|
||||
- First Play: `starting` → `playing` → **`ended`**, **360 frames**, **160 dropped**,
|
||||
**12.0397 s**, `running=false`. The drops were the normal unworn/standby
|
||||
`SetOverlayRaw` retry path; no fault was forced. At 60 frames, dropped was 0.
|
||||
`vrcmd --overlays` listed `framecontrol.media.surround` as **1×1 visible**
|
||||
and `framecontrol.media.screen` as **visible**. These are SteamVR metadata,
|
||||
not a wearer or eye-capture verification.
|
||||
- After EOF: unit **inactive/dead, not-found, MainPID 0**, no test player or
|
||||
decoder, and neither owned overlay remained. Stop after EOF succeeded and
|
||||
retained `ended`.
|
||||
- Second Play reached `playing`, 60 frames, 0 dropped. Explicit Stop returned
|
||||
**`stopped`, 72 frames, 0 dropped, `running=false`**. Again the unit was
|
||||
inactive/dead/not-found with MainPID 0, no player/decoder and no owned overlays.
|
||||
- The test unit's `ExecStart` and cgroup identified the unique temporary
|
||||
runtime. First-run player/decoder PIDs were **309650/309655**; second-run
|
||||
PIDs were **309805/309810**. Stop controlled only this dedicated test unit.
|
||||
Its player log was empty; no surround refusal or teardown error was logged.
|
||||
|
||||
### Uploaded SHA-256 values
|
||||
|
||||
```text
|
||||
generated_SBS.mp4 1b39cac1d3b092b89657250c817019b68e87a474976ccd44aeb38e8eee44ffca
|
||||
frame_media.py 001e3158f4c3931794846b21f1e4cb553349d9326b4db1454a01976d446ad97a
|
||||
frame_media_player.py 86b0a140ebff857c2819c74d3ac45d1897735c585d44c7360ad6f18c2ecf2645
|
||||
frame_media_remote.py b60386eb192d32594358246a4e665474c543600f2c564c2efd554ef38d7a631d
|
||||
frame_splat.py f35750926f1149c41b95617a302012406d355c0dfdd4530438b0c42ee6d55daa
|
||||
```
|
||||
|
||||
### Cleanup and limits
|
||||
|
||||
The temporary directory, including its fixture, code, bytecode, status and log,
|
||||
was removed only after verifying that its player and decoder had exited. The
|
||||
transient unit was collected. The test released only its own lock after cleanup.
|
||||
All installed media runtime file hashes, including `player.log` and
|
||||
`status.json`, were unchanged. The installed `frame-control-media.service`
|
||||
remained inactive/not loaded. Steam **297826**, vrserver **297506**,
|
||||
vrcompositor **297620**, vrdashboard **297721**, and start-gamescope **296997**
|
||||
kept the same PIDs. The pre-existing overlay key/dimension/visibility/type
|
||||
inventory matched the final inventory, and all dashboard main panels remained
|
||||
`not_visible`. No dashboard command, wake request, global setting, reboot or
|
||||
Steam/SteamVR stop was issued.
|
||||
|
||||
Cosmetic surround refusal and exceptional teardown remain **fake-tested**;
|
||||
no real refusal or teardown fault was induced. This short unworn-headset
|
||||
smoke does not verify appearance, eye isolation, comfort, audible sound/lip
|
||||
sync (the AAC was silent), sustained display performance, or the issue #28
|
||||
report of laggy 1080p video. It does not cover new H.265, photo or splat cases.
|
||||
Historical checks remain in the earlier PR #51 description and
|
||||
[media playback notes](../vr-video.md). The PR remains draft and unmerged
|
||||
for parent review and CI.
|
||||
@@ -0,0 +1,178 @@
|
||||
# Keyboard and trackpad through gamescope, 2026-10-24
|
||||
|
||||
PR: [#66](https://github.com/saphid/frame-control/pull/66), `pad-gamescope`.
|
||||
Parent/main: v0.4.18 commit `0da1fe5`. Merge commit: `1737366`, with parents
|
||||
`cba23fb` (the existing PR) and `0da1fe5` (main). No history was rewritten.
|
||||
|
||||
## Merge and implementation
|
||||
|
||||
Both `/api/input` (keyboard/trackpad) and `/api/touch` (live-view Control)
|
||||
start and poll the gamescope helper. KDE Connect starts only when Unicode
|
||||
typing needs it. The existing ordered, bounded queue and switch cancellation
|
||||
remain in place; cross-service typing order is an estimate, not an application
|
||||
acknowledgement.
|
||||
|
||||
The merge keeps main's intended-device validation and work reservation before
|
||||
a GET starts an input helper, immutable SSH routes, launch generations and
|
||||
serialized writes. Switching headsets also stops the power report and resets
|
||||
both input agents and queued typing before publishing the new route. The page
|
||||
keeps main's Tools sections, clipboard controls and single instances of the
|
||||
keyboard/trackpad and VR tools, with the PR's gamescope help text.
|
||||
|
||||
The start/status regression tests now exercise the gamescope helper for both
|
||||
endpoints. An explicit `/api/input` test checks that start and poll use that
|
||||
helper without starting KDE Connect. Existing stale/missing-headset and
|
||||
route-reservation tests remain active.
|
||||
|
||||
## Real Frame: focused click and ASCII keys received
|
||||
|
||||
Host: lxso2, `ssh frame`. SteamOS **0.5.6**, BUILD_ID **20261015.6141207**,
|
||||
SteamVR **2.18.2**. At **11:05 UTC**, Game Mode was active, full Plasma was
|
||||
inactive, Steam and SteamVR were running, battery was **99%**, and gamescope's
|
||||
focusable/focused window lists were empty. The shared test lock was absent.
|
||||
Existing X11 tools and libX11 were available; Tk could not load its library.
|
||||
|
||||
At **11:09:23 UTC**, an initial read-only preflight saw no focused window
|
||||
but activity level **0** (idle), rather than the required **3** (standby), and
|
||||
aborted before lock acquisition or input. At **11:09:57 UTC**, another task
|
||||
held the shared lock with a focused panel; that lock and panel were left alone.
|
||||
|
||||
At **11:20:22 UTC**, read-only checks found the lock absent, no focused or
|
||||
focusable windows, activity **3** and battery **98%**. The bounded probe ran at
|
||||
**11:20:37 UTC** against implementation commit `5641d3c`:
|
||||
|
||||
1. Rechecked empty focus and standby, acquired `/tmp/frame-test.lock` with
|
||||
atomic `mkdir`, wrote a unique owner marker and rechecked both conditions.
|
||||
2. Created only a disposable 640×400 X11 event-recorder window on `:0`, tagged
|
||||
with test app ID `2000999066`. It had a 45-second alarm and closed on stdin
|
||||
EOF. No existing application, full desktop or dashboard was started.
|
||||
3. Waited for gamescope to report that exact window (`62914561`) focused.
|
||||
Started the production server's `TouchAgent` over SSH; it reported ready.
|
||||
4. Called production `server.remote_touch` for positioning, then
|
||||
`server.remote_input` with a left click, `Frame66` and the Enter key button.
|
||||
Both responses reported `sent: true`. More importantly, the **window itself
|
||||
recorded ButtonPress/Release (button 1), the ASCII key events in order and
|
||||
Return**. `XLookupString` decoded exactly `Frame66\r`. The events were
|
||||
delivered through gamescope's EIS socket, not fabricated with XSendEvent.
|
||||
5. Closed the helper by EOF and waited for exit, closed the recorder, checked
|
||||
empty focus and standby, then removed only the owner-matching test lock.
|
||||
|
||||
[State and received events](pad-gamescope-2026-10-24/readback.json) and the
|
||||
[probe](pad-gamescope-2026-10-24/probe.py)/[X11 recorder](pad-gamescope-2026-10-24/recorder.py)
|
||||
are retained. The archived scripts use checkout-relative source paths; the
|
||||
recorded run used the same harness from `/tmp`. Motion records omit an unused
|
||||
field that only has meaning for key/button events.
|
||||
|
||||
Delayed readback at **11:21:46 UTC** confirmed no test lock or recorder process,
|
||||
empty focused/focusable lists, Game Mode active, full Plasma inactive, activity
|
||||
3 and battery 98%. The original Steam and vrserver PIDs were unchanged. No
|
||||
install, security/global setting change, dashboard/session switch, reset or
|
||||
reboot was performed. No headset helper or owned lock remained.
|
||||
|
||||
This establishes **focused-panel click and ASCII key delivery on `:0`**.
|
||||
It does not establish wearer-visible presentation, Unicode delivery or GUI
|
||||
interaction. The absolute-position setup reached the recorder's edge
|
||||
(639,399), rather than its centre; pixel-accurate absolute pointing is **not**
|
||||
proved by this small-window probe. The trackpad click and typed keys still
|
||||
arrived in the owned focused window. The original PR's earlier acceptance-only
|
||||
observations remain historical; this observation adds actual window events.
|
||||
|
||||
## Offline checks and remaining limits
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_input.py -v`: **58 passed**.
|
||||
- `python3 -m unittest discover -s tests -p test_touch.py -v`: **43 passed**.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
**34 passed**.
|
||||
- `python3 -m unittest discover -s tests -v`: **1,403 ran, OK, 15 skipped**
|
||||
(Linux x86_64, Python 3.12; 150.825 seconds).
|
||||
- Full fake-Frame e2e in task-specific Docker project `frame-control-pr66`:
|
||||
**47 passed** after the lock fix (192.270 seconds). Both task containers,
|
||||
their network and all three volumes were removed afterward. The run emitted
|
||||
an unclosed test-socket ResourceWarning; the suite returned OK.
|
||||
- Node page suites: `ctrl_gestures.mjs`, `pad_queue.mjs`, `desk_panels.mjs`
|
||||
passed, using the production page functions.
|
||||
- `node --test app/test/*.test.js`: **3 test files passed**.
|
||||
- `node --test site/test/*.test.mjs`: **2 test files passed**.
|
||||
- Python compilation, desktop/site JavaScript syntax and `git diff --check`
|
||||
passed. Fake-Frame e2e and exact-head CI results are recorded in the PR after completion.
|
||||
|
||||
The first full regression run executed 1,401 tests and found two tests still
|
||||
expecting `/api/input` to start the old KDE Connect helper. They were updated
|
||||
to the intended gamescope route. The final full run includes the lazy-KDE
|
||||
regression above and the lock-order regression below. The initial restricted-sandbox input run could not open
|
||||
loopback sockets; the focused rerun with managed network access passed.
|
||||
|
||||
Review of the merged code then found a lock inversion: the Unicode worker
|
||||
held the pad-send lock while `InputAgent.start()` acquired the route lock,
|
||||
whereas a headset switch held the route lock before resetting the pad. A
|
||||
bounded subprocess regression reproduced the deadlock with the real
|
||||
`InputAgent.send/start` methods and an offline launch stub. Unicode delivery
|
||||
and resets now take the route lock before the pad lock; the route lock is
|
||||
reentrant so an agent can capture its route inside that reservation. The new
|
||||
regression passed after the fix, as did all **58 focused input tests** and
|
||||
the **1,403-test full suite** (15 skipped). The local fake-Frame suite also
|
||||
passed **47 tests after this fix**. Exact-head CI results are recorded in the PR.
|
||||
|
||||
Remaining device work requires an idle, available Frame and the shared lock:
|
||||
verify Unicode in a real text field, ordering under real typing load and
|
||||
precise absolute pointing for small panels. Worn-headset presentation,
|
||||
packaged Electron pointer lock and Windows GUI execution remain separate
|
||||
checks. Automated Windows/macOS CI does not establish those interactions.
|
||||
|
||||
|
||||
## Review round 2: queue saturation and failure recovery
|
||||
|
||||
The coordinator identified silent eviction in both `_queue_for_kde()` and the
|
||||
worker's retry path. Both sliced the oldest accepted events off at 200 entries,
|
||||
even though the page had already received `sent: true` and discarded its copy.
|
||||
|
||||
Admission now serializes concurrent requests, checks capacity before any pointer
|
||||
or keyboard send, and returns `sent: false, state: waiting` if the whole batch
|
||||
will not fit. Text stays in the bounded queue while in flight, and is removed
|
||||
only after the helper accepts it. Rejected mixed batches send no clicks; retrying
|
||||
them cannot duplicate clicks already sent by this server. The page polls until
|
||||
it can retry. Pointer input stays usable while accepted text is pending.
|
||||
|
||||
The old worker also left its worker marker and one-hour timing sentinel behind
|
||||
if `send()` raised. Failure/timeout handling now retains unsent text, clears the
|
||||
worker/timing state and exposes an error through input status. The page polls
|
||||
accepted text without requiring another keypress. **Try again** explicitly
|
||||
restarts the retained queue. Thread-start failures follow the same retention
|
||||
rule. A stale worker's exception cannot change the new route's state.
|
||||
|
||||
The proposed send-to-enqueue headset-switch race was not reachable through the
|
||||
normal HTTP switch path: `Handler.do_POST()` reserves `working(meant)` across
|
||||
both operations; `devices_post()` shares `_work_lock` and refuses a moving
|
||||
operation while work is active. Connector configuration/reconnect paths defer
|
||||
changed headsets while work runs. A production-handler regression attempts a
|
||||
switch at both send and enqueue and passes even on the previous implementation.
|
||||
No separate connector change was needed. Atomic queue admission now uses the
|
||||
existing route-before-pad lock order as well, because starting either input
|
||||
agent can capture its route synchronously.
|
||||
|
||||
Three deterministic saturation regressions fail on `215db7b` and pass with the
|
||||
fix: capacity during the typing delay, capacity during an unsuccessful-send
|
||||
retry, and two concurrent producers competing for the last slots. They check
|
||||
exact accepted event sequences, reject a mixed click/text batch before its
|
||||
pointer effects, and verify delivery exactly once after retry. Additional tests
|
||||
cover explicit recovery after timeout, helper errors, send exceptions and
|
||||
thread-start failure, stale-worker isolation, HTTP status/start behavior, and
|
||||
the production page's polling/error behavior.
|
||||
|
||||
No new active headset test was performed: this change affects host queue
|
||||
admission/recovery and status handling, not Frame event translation or injection.
|
||||
The earlier real click/ASCII evidence remains scoped to its recorded commit.
|
||||
Real Unicode, packaged Electron pointer lock, Windows GUI, wearer-visible
|
||||
presentation and precise small-window absolute positioning remain unverified.
|
||||
`sent: true` means accepted by this server, not application delivery. The queue
|
||||
is in memory; switching headsets cancels it and server exit loses it. A transport
|
||||
failure after a partial write can still make delivery ambiguous; this is not an
|
||||
end-to-end exactly-once protocol.
|
||||
|
||||
Round 2 local verification: **141 focused input/touch/service tests passed**
|
||||
(62 input, 43 touch, 36 service; 20.061 seconds), and **1,409 server tests ran, OK, 15 skipped**
|
||||
(Linux x86_64, Python 3.12, 153.198 seconds). All three production-page Node
|
||||
suites passed; app tests passed (3 files), site tests passed (2 files), Python
|
||||
compilation and `git diff --check` passed. The existing skipped tests require
|
||||
zsh, macOS/Swift or built native PC libraries. Current-head CI is tracked in
|
||||
PR #66; the previous 47-test local fake-Frame run above is historical.
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Active real-Frame evidence probe: aborts unless idle, then owns the shared lock.
|
||||
|
||||
Run manually on an authorised host: python3 docs/evidence/pad-gamescope-2026-10-24/probe.py
|
||||
The recorded run used /tmp paths; archived paths are relative to this checkout.
|
||||
"""
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
data = tempfile.TemporaryDirectory(prefix='pr66-live-')
|
||||
os.environ.update(FRAME_CONTROL_DATA_DIR=data.name, FRAME_CONTROL_TELEMETRY='0', FRAME_PRIVATE_SSH='1')
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server
|
||||
|
||||
SSH = ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', 'frame']
|
||||
owner = 'pr66-' + uuid.uuid4().hex
|
||||
result = {'time': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'owner': owner,
|
||||
'base': '0da1fe5', 'route': 'production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS',
|
||||
'events': []}
|
||||
recorder = None
|
||||
locked = False
|
||||
rows = queue.Queue()
|
||||
|
||||
def ssh(cmd):
|
||||
return subprocess.run([*SSH, cmd], capture_output=True, text=True, timeout=15, check=True).stdout
|
||||
|
||||
def focus():
|
||||
return json.loads(server.ssh('python3 - focus', stdin=(ROOT/'ui/frame_touch.py').read_text(), timeout=15))
|
||||
|
||||
def activity():
|
||||
return json.loads(ssh('/opt/steamvr/bin/linuxarm64/vrcmd --stats'))[0]['activity_level']
|
||||
|
||||
def read_rows():
|
||||
for raw in recorder.stdout:
|
||||
rows.put(json.loads(raw))
|
||||
|
||||
def collect(seconds):
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
try:
|
||||
result['events'].append(rows.get(timeout=max(0.01, end-time.monotonic())))
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
try:
|
||||
result['initial_focus'] = focus()
|
||||
result['initial_activity'] = activity()
|
||||
assert not result['initial_focus'].get('window'), 'existing focused panel; do not interrupt'
|
||||
assert result['initial_activity'] == 3, 'headset is not in standby'
|
||||
ssh('mkdir /tmp/frame-test.lock && printf %s ' + shlex.quote(owner) + ' > /tmp/frame-test.lock/owner')
|
||||
locked = True
|
||||
result['lock_acquired'] = True
|
||||
assert not focus().get('window') and activity() == 3, 'session changed during lock acquisition'
|
||||
code = base64.b64encode(Path(__file__).with_name('recorder.py').read_bytes()).decode()
|
||||
command = 'python3 -u -c ' + shlex.quote("import base64;exec(compile(base64.b64decode('" + code + "'),'pr66-recorder','exec'))")
|
||||
recorder = subprocess.Popen([*SSH, command], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
threading.Thread(target=read_rows, daemon=True).start()
|
||||
initial = rows.get(timeout=8)
|
||||
result['recorder'] = initial
|
||||
assert initial.get('ready'), initial
|
||||
window = initial['window']
|
||||
for _ in range(20):
|
||||
current = focus()
|
||||
if current.get('window') == window:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
result['focused_panel'] = current
|
||||
assert current.get('window') == window and current.get('display') == ':0', 'owned panel did not obtain focus; no input sent'
|
||||
server.remote_input({})
|
||||
for _ in range(50):
|
||||
if server._touch.status.get('state') in ('ready', 'error'):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
result['agent_status'] = dict(server._touch.status)
|
||||
assert result['agent_status'].get('state') == 'ready', result['agent_status']
|
||||
target = {'window': window, 'display': ':0'}
|
||||
assert focus().get('window') == window and activity() == 3
|
||||
result['position_response'] = server.remote_touch({'events': [dict(target, fx=0.5, fy=0.5)]})
|
||||
time.sleep(0.2)
|
||||
assert focus().get('window') == window and activity() == 3
|
||||
result['pad_response'] = server.remote_input({'events': [{'singleclick': True}, {'key': 'Frame66'}, {'specialKey': 12}]})
|
||||
collect(2)
|
||||
received = result['events']
|
||||
result['received_text'] = ''.join(e.get('text', '') for e in received if e.get('type') == 2)
|
||||
result['click_received'] = any(e.get('type') == 4 and e.get('code') == 1 and e.get('window') == window for e in received)
|
||||
result['release_received'] = any(e.get('type') == 5 and e.get('code') == 1 and e.get('window') == window for e in received)
|
||||
result['passed'] = result['click_received'] and result['release_received'] and result['received_text'] == 'Frame66\r'
|
||||
assert result['passed'], 'event recorder did not observe expected click and keys'
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
finally:
|
||||
proc = server._touch.proc
|
||||
server.reset_pad_route()
|
||||
if proc:
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
result['input_helper_exited'] = True
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=5)
|
||||
result['input_helper_terminated'] = True
|
||||
if recorder:
|
||||
recorder.stdin.close()
|
||||
recorder.wait(timeout=8)
|
||||
collect(0.2)
|
||||
result['recorder_exit'] = recorder.returncode
|
||||
result['recorder_stderr'] = recorder.stderr.read()[-1000:]
|
||||
if locked:
|
||||
result['final_focus'] = focus()
|
||||
result['final_activity'] = activity()
|
||||
ssh('test "$(cat /tmp/frame-test.lock/owner)" = ' + shlex.quote(owner)
|
||||
+ ' && rm /tmp/frame-test.lock/owner && rmdir /tmp/frame-test.lock')
|
||||
result['owned_lock_released'] = True
|
||||
Path('/tmp/pr66-live.json').write_text(json.dumps(result, indent=2) + '\n')
|
||||
print(json.dumps(result, indent=2))
|
||||
data.cleanup()
|
||||
sys.exit(0 if result.get('passed') else 1)
|
||||
@@ -0,0 +1,280 @@
|
||||
{
|
||||
"time": "2026-10-24T11:20:37.392607+00:00",
|
||||
"owner": "pr66-7c2a8e8851f449bcb0bb6a040be94d99",
|
||||
"base": "0da1fe5",
|
||||
"route": "production server.remote_input -> TouchAgent SSH -> frame_touch.py -> gamescope EIS",
|
||||
"events": [
|
||||
{
|
||||
"type": 6,
|
||||
"window": 62914561,
|
||||
"x": 320,
|
||||
"y": 200,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 6,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 4,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 1,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 5,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 1,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 50,
|
||||
"synthetic": false,
|
||||
"text": "",
|
||||
"keysym": 65505
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 41,
|
||||
"synthetic": false,
|
||||
"text": "F",
|
||||
"keysym": 70
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 41,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 50,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 27,
|
||||
"synthetic": false,
|
||||
"text": "r",
|
||||
"keysym": 114
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 27,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 38,
|
||||
"synthetic": false,
|
||||
"text": "a",
|
||||
"keysym": 97
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 38,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 58,
|
||||
"synthetic": false,
|
||||
"text": "m",
|
||||
"keysym": 109
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 58,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 26,
|
||||
"synthetic": false,
|
||||
"text": "e",
|
||||
"keysym": 101
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 26,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 15,
|
||||
"synthetic": false,
|
||||
"text": "6",
|
||||
"keysym": 54
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 15,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 15,
|
||||
"synthetic": false,
|
||||
"text": "6",
|
||||
"keysym": 54
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 15,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"type": 2,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 36,
|
||||
"synthetic": false,
|
||||
"text": "\r",
|
||||
"keysym": 65293
|
||||
},
|
||||
{
|
||||
"type": 3,
|
||||
"window": 62914561,
|
||||
"x": 639,
|
||||
"y": 399,
|
||||
"code": 36,
|
||||
"synthetic": false
|
||||
},
|
||||
{
|
||||
"cleaned": true
|
||||
}
|
||||
],
|
||||
"initial_focus": {
|
||||
"window": null
|
||||
},
|
||||
"initial_activity": 3,
|
||||
"lock_acquired": true,
|
||||
"recorder": {
|
||||
"ready": true,
|
||||
"window": 62914561,
|
||||
"pid": 317210,
|
||||
"display": ":0",
|
||||
"appid": 2000999066
|
||||
},
|
||||
"focused_panel": {
|
||||
"window": 62914561,
|
||||
"display": ":0",
|
||||
"name": "Frame Control PR66 disposable input probe",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 640,
|
||||
"height": 400,
|
||||
"root": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"app": 2000999066
|
||||
},
|
||||
"agent_status": {
|
||||
"state": "ready",
|
||||
"focus": 62914561
|
||||
},
|
||||
"position_response": {
|
||||
"state": "ready",
|
||||
"focus": 62914561,
|
||||
"sent": true
|
||||
},
|
||||
"pad_response": {
|
||||
"state": "ready",
|
||||
"focus": 62914561,
|
||||
"sent": true
|
||||
},
|
||||
"received_text": "Frame66\r",
|
||||
"click_received": true,
|
||||
"release_received": true,
|
||||
"passed": true,
|
||||
"input_helper_exited": true,
|
||||
"recorder_exit": 0,
|
||||
"recorder_stderr": "",
|
||||
"final_focus": {
|
||||
"window": null
|
||||
},
|
||||
"final_activity": 3,
|
||||
"owned_lock_released": true,
|
||||
"implementation_commit": "5641d3c316dfe225321f7233840cc83ff8c3f527",
|
||||
"device": {
|
||||
"steamos": "0.5.6",
|
||||
"build_id": "20261015.6141207",
|
||||
"steamvr": "2.18.2",
|
||||
"battery_before": 98,
|
||||
"battery_after": 98
|
||||
},
|
||||
"delayed_cleanup": {
|
||||
"time": "2026-10-24T11:21:46Z",
|
||||
"shared_lock_absent": true,
|
||||
"owned_recorder_absent": true,
|
||||
"game_mode": "active",
|
||||
"full_plasma": "inactive",
|
||||
"steam_pid": 297826,
|
||||
"vrserver_pid": 297506,
|
||||
"focusable_windows": [],
|
||||
"focused_windows": [],
|
||||
"activity_level": 3
|
||||
},
|
||||
"limits": [
|
||||
"No wearer or packaged Electron/GUI interaction",
|
||||
"ASCII only; Unicode and cross-service ordering not tested on device",
|
||||
"The positioning setup reached the recorder edge (639,399); pixel-accurate absolute targeting is not established by this probe"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Disposable X11 event recorder used for the PR #66 focused-input observation."""
|
||||
import ctypes as C
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import time
|
||||
|
||||
X = C.CDLL('libX11.so.6')
|
||||
P, U = C.c_void_p, C.c_ulong
|
||||
for name, restype, args in [
|
||||
('XOpenDisplay', P, [C.c_char_p]), ('XDefaultRootWindow', U, [P]),
|
||||
('XCreateSimpleWindow', U, [P, U, C.c_int, C.c_int, C.c_uint, C.c_uint, C.c_uint, U, U]),
|
||||
('XInternAtom', U, [P, C.c_char_p, C.c_int]),
|
||||
('XChangeProperty', C.c_int, [P, U, U, U, C.c_int, C.c_int, P, C.c_int]),
|
||||
('XStoreName', C.c_int, [P, U, C.c_char_p]),
|
||||
('XSelectInput', C.c_int, [P, U, C.c_long]), ('XMapWindow', C.c_int, [P, U]),
|
||||
('XFlush', C.c_int, [P]), ('XPending', C.c_int, [P]),
|
||||
('XConnectionNumber', C.c_int, [P]), ('XNextEvent', C.c_int, [P, P]),
|
||||
('XLookupString', C.c_int, [P, P, C.c_int, P, P]),
|
||||
('XDestroyWindow', C.c_int, [P, U]), ('XCloseDisplay', C.c_int, [P]),
|
||||
]:
|
||||
fn = getattr(X, name)
|
||||
fn.restype, fn.argtypes = restype, args
|
||||
|
||||
class InputEvent(C.Structure):
|
||||
_fields_ = [('type', C.c_int), ('serial', U), ('send_event', C.c_int),
|
||||
('display', P), ('window', U), ('root', U), ('subwindow', U),
|
||||
('time', U), ('x', C.c_int), ('y', C.c_int), ('x_root', C.c_int),
|
||||
('y_root', C.c_int), ('state', C.c_uint), ('code', C.c_uint),
|
||||
('same_screen', C.c_int)]
|
||||
|
||||
class Event(C.Union):
|
||||
_fields_ = [('type', C.c_int), ('input', InputEvent), ('pad', C.c_long * 24)]
|
||||
|
||||
def say(**row):
|
||||
print(json.dumps(row), flush=True)
|
||||
|
||||
def end(*_):
|
||||
raise TimeoutError('probe ended')
|
||||
|
||||
signal.signal(signal.SIGTERM, end)
|
||||
signal.signal(signal.SIGALRM, end)
|
||||
signal.alarm(45)
|
||||
d = X.XOpenDisplay(b':0')
|
||||
assert d, 'X display unavailable'
|
||||
w = 0
|
||||
try:
|
||||
w = X.XCreateSimpleWindow(d, X.XDefaultRootWindow(d), 0, 0, 640, 400, 0, 0, 0x224466)
|
||||
for name, value in [(b'STEAM_GAME', 2000999066), (b'_NET_WM_PID', os.getpid())]:
|
||||
val = U(value)
|
||||
X.XChangeProperty(d, w, X.XInternAtom(d, name, 0), 6, 32, 0, C.byref(val), 1)
|
||||
X.XStoreName(d, w, b'Frame Control PR66 disposable input probe')
|
||||
X.XSelectInput(d, w, 1 | 2 | 4 | 8 | 64 | 32768 | 2097152)
|
||||
X.XMapWindow(d, w)
|
||||
X.XFlush(d)
|
||||
say(ready=True, window=w, pid=os.getpid(), display=':0', appid=2000999066)
|
||||
while True:
|
||||
while X.XPending(d):
|
||||
event = Event()
|
||||
X.XNextEvent(d, C.byref(event))
|
||||
if event.type in (2, 3, 4, 5, 6):
|
||||
e = event.input
|
||||
row = dict(type=event.type, window=e.window, x=e.x, y=e.y,
|
||||
synthetic=bool(e.send_event))
|
||||
if event.type != 6:
|
||||
row['code'] = e.code
|
||||
if event.type == 2:
|
||||
buf, keysym = C.create_string_buffer(32), U()
|
||||
n = X.XLookupString(C.byref(e), buf, 32, C.byref(keysym), None)
|
||||
row.update(text=buf.raw[:n].decode('utf-8', 'replace'), keysym=keysym.value)
|
||||
say(**row)
|
||||
ready, _, _ = select.select([0, X.XConnectionNumber(d)], [], [], 0.2)
|
||||
if 0 in ready and not os.read(0, 1024):
|
||||
break
|
||||
except TimeoutError:
|
||||
pass
|
||||
finally:
|
||||
if w:
|
||||
X.XDestroyWindow(d, w)
|
||||
X.XCloseDisplay(d)
|
||||
say(cleaned=True)
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"date": "2026-10-24",
|
||||
"osVersion": "0.5.6",
|
||||
"osBuild": "20261015.6141207",
|
||||
"commands": [
|
||||
"scripts/frame-power-sample.sh --count 3 --interval 1",
|
||||
"scripts/frame-power-sample.sh --count 1 --csv"
|
||||
],
|
||||
"jsonSampleCount": 3,
|
||||
"elapsedSeconds": [
|
||||
0.078011,
|
||||
1.088542,
|
||||
2.107291
|
||||
],
|
||||
"stopped": null,
|
||||
"unavailableGroups": [
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
],
|
||||
[
|
||||
"proximity"
|
||||
]
|
||||
],
|
||||
"selectedSummary": {
|
||||
"battery.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 99,
|
||||
"max": 99,
|
||||
"mean": 99.0,
|
||||
"first": 99,
|
||||
"last": 99
|
||||
},
|
||||
"battery.status": {
|
||||
"unit": "state",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"states": [
|
||||
"Not charging"
|
||||
]
|
||||
},
|
||||
"battery.tempC": {
|
||||
"unit": "C",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 26.400000000000002,
|
||||
"max": 26.400000000000002,
|
||||
"mean": 26.4,
|
||||
"first": 26.400000000000002,
|
||||
"last": 26.400000000000002
|
||||
},
|
||||
"battery.watts": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/power_supply/max1720x_bat_7-36"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": -0.12,
|
||||
"max": -0.02,
|
||||
"mean": -0.07,
|
||||
"first": -0.02,
|
||||
"last": -0.07
|
||||
},
|
||||
"cpu.percent": {
|
||||
"unit": "%",
|
||||
"sources": [
|
||||
"/proc/stat"
|
||||
],
|
||||
"available": 2,
|
||||
"unavailable": 1,
|
||||
"min": 8.7,
|
||||
"max": 11.0,
|
||||
"mean": 9.85,
|
||||
"first": 11.0,
|
||||
"last": 8.7
|
||||
},
|
||||
"fan.pwmRaw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/pwm1"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 41,
|
||||
"max": 41,
|
||||
"mean": 41.0,
|
||||
"first": 41,
|
||||
"last": 41
|
||||
},
|
||||
"fan.slg4ax46073v.fan1_input": {
|
||||
"unit": "RPM",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon50/fan1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 8624,
|
||||
"max": 8638,
|
||||
"mean": 8628.666667,
|
||||
"first": 8638,
|
||||
"last": 8624
|
||||
},
|
||||
"proximity.raw": {
|
||||
"unit": "raw",
|
||||
"sources": [
|
||||
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
|
||||
],
|
||||
"available": 0,
|
||||
"unavailable": 3,
|
||||
"states": []
|
||||
},
|
||||
"rail.max34417_9_10.vph.power1": {
|
||||
"unit": "W",
|
||||
"sources": [
|
||||
"/sys/class/hwmon/hwmon53/power1_input"
|
||||
],
|
||||
"available": 3,
|
||||
"unavailable": 0,
|
||||
"min": 4.656299,
|
||||
"max": 4.804066,
|
||||
"mean": 4.705555,
|
||||
"first": 4.656299,
|
||||
"last": 4.804066
|
||||
}
|
||||
},
|
||||
"csvRows": 92,
|
||||
"csvSummaryMetrics": 91,
|
||||
"csvHasExplicitUnavailable": true,
|
||||
"limits": [
|
||||
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
|
||||
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
|
||||
"No settings, services, device files or application state changed; no remote installation.",
|
||||
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,22 @@
|
||||
# SteamOS 0.5.6 compatibility check, 25 October 2026
|
||||
|
||||
The real Frame was accessed **after** the 00:00 AEDT hold, under a separately
|
||||
acquired `/tmp/frame-test.lock`. The coordinator had finished its v0.4.19 smoke
|
||||
test and released its lock first. This check took place approximately 00:04–
|
||||
00:08 AEDT (13:04–13:08 UTC). A device clock reading in the first snapshot was
|
||||
**00:04:58 AEDT**. The one temporary capture was removed and our lock was
|
||||
released. Steam and SteamVR were left running.
|
||||
|
||||
| Check | Observation |
|
||||
|---|---|
|
||||
| OS | `VERSION_ID=0.5.6`, `VARIANT_ID=vr`, `BUILD_ID=20261022.6153909` |
|
||||
| Update channel | `atomupd-manager tracked-branch` returned `beta`; tracked variant `vr`; update status `idle`. `atomupd-manager status` and `check` returned D-Bus `AccessDenied` to the normal user. No root, update, or channel change was attempted. |
|
||||
| Discovery | `frame.local` and a sample `frame-0000.local` name were unresolved from the Linux host. Avahi was active on the Frame, but `/etc/avahi/avahi-daemon.conf` has `publish-addresses=no`. The configured `ssh frame` alias worked. The host reached the Frame over Tailscale, so a local-subnet scan and same-LAN cached-IP reconnection were not exercised. |
|
||||
| Script fallback | `steamos-session-select` was present and `holo-session-select` absent. The installed devkit utilities contained `steamos-prepare-upload` and `steamos-delete`; their `holo-*` replacements were absent. No session switch, install, or delete command was run. |
|
||||
| Battery and memory | Frame Control's `ui/frame_status.py` returned a system battery at 98%, Charging, with its source under `/sys/class/power_supply/max1720x_bat_7-36`; memory was 16,422,952,960 bytes total and 11,256,848,384 bytes available. The battery was 97% at the initial raw sysfs check and 98% at the final check while charging. |
|
||||
| Recording source | The user `steamvr-v4l2cam.service` and `/dev/video99` were present. A single frame from that device was black while the unworn headset was in standby. Frame Control's MP4 path reads that same SteamVR `system.HeadsetView` mirror and never opens a passthrough camera. Active passthrough inclusion in the mirror remains unverified. The sample image was not retained or published. |
|
||||
| Dominant eye | Neither standard `steamvr.vrsettings` path checked had a readable dominant-eye setting. No source or effect on the mirror was verified; Frame Control does not attach dominant-eye metadata. |
|
||||
|
||||
This was a bounded compatibility check, not a rerun of the coordinator's
|
||||
13-step smoke test. The coordinator reported that smoke passed on this same
|
||||
build, with battery 95% to 96% while charging and cleanup complete.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Windows Wireless Adapter doctor verification, 24 October 2026
|
||||
|
||||
PC-only implementation and verification. No Frame was contacted, before or
|
||||
after 13:00 UTC. No adapters or security settings were changed. This evidence
|
||||
does not establish a physical Steam wireless adapter fix or RF interference.
|
||||
|
||||
## Windows VM
|
||||
|
||||
An existing local Windows 11 VM (build 26200), Python 3.12.14 from the installed
|
||||
Frame Control bundle, ran the isolated suite from a task-specific Downloads
|
||||
folder:
|
||||
|
||||
```powershell
|
||||
& 'C:\Users\frame\AppData\Local\Programs\Frame Control\resources\python\python.exe' -m unittest discover -s tests -p test_wireless.py -v
|
||||
```
|
||||
|
||||
Result: **15 tests, OK, one skipped** (Node absent). This includes the real
|
||||
read-only PowerShell inventory and authenticated HTTP handler tests. The app's
|
||||
connector was never started. Existing test SSH credentials were used only for
|
||||
the Windows VM; no credentials were copied into the repository.
|
||||
|
||||
- [Exact test output](windows-wireless-tests-2026-10-24.txt).
|
||||
- [Sanitized live inventory](windows-wireless-summary-2026-10-24.json): two
|
||||
virtual-machine network devices, with PnP/presence/adapter/route sources
|
||||
available. WLAN unavailable; channels/bands remained null. All PnP IDs,
|
||||
device names and interface names were removed from this artifact.
|
||||
- The guest clock was approximately seven hours ahead of the host. `observedAt`
|
||||
is retained as returned by Windows, not used to establish test timing.
|
||||
|
||||
The first probe used a list of requested default prefixes. A missing IPv6
|
||||
route caused PowerShell to throw away available IPv4 data. The final query
|
||||
filters `Get-NetRoute` results instead, and the rerun above confirms routes
|
||||
are available. No network configuration was changed to make the query work.
|
||||
|
||||
## Browser UI and local tests
|
||||
|
||||
Production `ui/index.html` ran against a loopback-only synthetic API fixture,
|
||||
with no connector and no headset transport. Opening/revisiting the tool caused
|
||||
no wireless query; an explicit click rendered two distinct Wi-Fi Direct PnP
|
||||
instances and a 2.4 GHz PC interface while the headset state was offline.
|
||||
Unknown virtual-adapter channels, non-present/disabled status, and unconfirmed
|
||||
interference remained visible. Checked at 1280 and 640 CSS pixels without
|
||||
horizontal overflow.
|
||||
|
||||

|
||||
|
||||
The screenshot uses synthetic readings, not the VM's two Ethernet interfaces.
|
||||
The yellow synthetic-data label was added by the browser fixture for this
|
||||
capture; it is not product UI.
|
||||
|
||||
Local commands:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
|
||||
node tests/wireless.cjs
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 -m py_compile ui/*.py
|
||||
node --check /tmp/wireless-index-scripts.js
|
||||
```
|
||||
|
||||
The targeted suite passed 15 tests with the live Windows-only test skipped;
|
||||
the direct Node run passed all four UI tests. The complete server suite passed
|
||||
1,423 tests with 16 skips before the additional missing-metadata test; a final
|
||||
complete run and Windows CI results are recorded in the PR. The syntax check
|
||||
used inline scripts extracted from the production HTML.
|
||||
|
||||
## What remains unverified
|
||||
|
||||
No physical Steam dongle, Razer headset, live Wi-Fi Direct session, channel
|
||||
reading from real wireless hardware, dropout reproduction or recovery action.
|
||||
The English netsh parser is fixture-tested; localised or denied readings remain
|
||||
unknown. Windows CI validates query execution on hosted virtual hardware.
|
||||
There is no adapter disable action because the available evidence cannot
|
||||
reliably identify a safe extra instance.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"platform": "Windows-11-10.0.26200-SP0",
|
||||
"python": "3.12.14",
|
||||
"state": "partial",
|
||||
"sources": {
|
||||
"pnp": "ok",
|
||||
"present": "ok",
|
||||
"adapters": "ok",
|
||||
"routes": "ok",
|
||||
"wlan": "unavailable"
|
||||
},
|
||||
"observedAt": "2026-10-24T19:15:45.946191+00:00",
|
||||
"deviceCount": 2,
|
||||
"devicesWithoutIdentifiers": [
|
||||
{
|
||||
"present": true,
|
||||
"pnpStatus": "OK",
|
||||
"problemCode": 0,
|
||||
"operational": 6,
|
||||
"media": 0,
|
||||
"defaultRoute": false,
|
||||
"wifi": false,
|
||||
"hardware": false,
|
||||
"channel": null,
|
||||
"band": null
|
||||
},
|
||||
{
|
||||
"present": true,
|
||||
"pnpStatus": "OK",
|
||||
"problemCode": 0,
|
||||
"operational": 1,
|
||||
"media": 1,
|
||||
"defaultRoute": true,
|
||||
"wifi": false,
|
||||
"hardware": true,
|
||||
"channel": null,
|
||||
"band": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 214 KiB |
@@ -0,0 +1,24 @@
|
||||
test_ambiguous_adapter_join_never_selects_the_first_match (test_wireless.Inventory.test_ambiguous_adapter_join_never_selects_the_first_match) ... ok
|
||||
test_channels_require_exact_guid_and_band_is_never_guessed (test_wireless.Inventory.test_channels_require_exact_guid_and_band_is_never_guessed) ... ok
|
||||
test_counts_unique_pnp_ids_not_rows_or_suffix_order (test_wireless.Inventory.test_counts_unique_pnp_ids_not_rows_or_suffix_order) ... ok
|
||||
test_distinct_instances_presence_disabled_and_activity_are_separate (test_wireless.Inventory.test_distinct_instances_presence_disabled_and_activity_are_separate) ... ok
|
||||
test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct (test_wireless.Inventory.test_friendly_name_is_only_a_candidate_and_other_duplicate_names_are_not_direct) ... ok
|
||||
test_missing_interface_metadata_and_invalid_guids_stay_unknown (test_wireless.Inventory.test_missing_interface_metadata_and_invalid_guids_stay_unknown) ... ok
|
||||
test_missing_sources_are_unknown_and_adapter_fallback_is_retained (test_wireless.Inventory.test_missing_sources_are_unknown_and_adapter_fallback_is_retained) ... ok
|
||||
test_raw_ssid_mac_and_errors_are_not_returned (test_wireless.Inventory.test_raw_ssid_mac_and_errors_are_not_returned) ... ok
|
||||
test_concurrent_requests_do_not_spawn_more_probes (test_wireless.Probe.test_concurrent_requests_do_not_spawn_more_probes) ... ok
|
||||
test_failure_timeout_and_invalid_output_are_explicit_and_release_lock (test_wireless.Probe.test_failure_timeout_and_invalid_output_are_explicit_and_release_lock) ... ok
|
||||
test_http_is_authenticated_pc_only_and_never_calls_ssh (test_wireless.Probe.test_http_is_authenticated_pc_only_and_never_calls_ssh) ... GET "GET /api/host/wireless HTTP/1.1" 403 -
|
||||
GET "GET /api/host/wireless HTTP/1.1" 400 -
|
||||
GET "GET /api/host/wireless HTTP/1.1" 200 -
|
||||
ok
|
||||
test_live_windows_inventory (test_wireless.Probe.test_live_windows_inventory) ... ok
|
||||
test_non_windows_starts_nothing (test_wireless.Probe.test_non_windows_starts_nothing) ... ok
|
||||
test_static_noninteractive_local_command_and_timeout (test_wireless.Probe.test_static_noninteractive_local_command_and_timeout) ... ok
|
||||
test_ui_behavior (test_wireless.Probe.test_ui_behavior) ... skipped 'Node required'
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 15 tests in 8.139s
|
||||
|
||||
OK (skipped=1)
|
||||
Windows wireless smoke: partial {'pnp': 'ok', 'present': 'ok', 'adapters': 'ok', 'routes': 'ok', 'wlan': 'unavailable'} devices= 2
|
||||
@@ -6,6 +6,45 @@ This is Frame Control code using Python, Steam and SteamVR already on the Frame.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
@@ -58,6 +58,11 @@ counts them while they run.
|
||||
restore chosen parts after a factory reset, and **Return to Game Mode** when
|
||||
the headset is stuck in Desktop Mode. Sign-in files and keys are never copied.
|
||||
Not tested on a headset yet. See [backup-and-recovery.md](backup-and-recovery.md).
|
||||
- **Wireless Adapter doctor** (Windows, Tools → This PC): explicitly read local
|
||||
PnP instances, presence/link states and available channel/band readings;
|
||||
explain possible Wi-Fi Direct duplicates and 2.4 GHz audio overlap. No headset
|
||||
access, automatic changes or disable button. See
|
||||
[Windows wireless diagnosis](windows-wireless-doctor.md).
|
||||
- **Battery** with charging state: charge rate in watts, time to full or empty,
|
||||
charger type and wattage (for example USB-C PD 20 W), and battery temperature.
|
||||
- **Status**: storage, memory, temperature, Wi-Fi, uptime, and whether SteamVR,
|
||||
|
||||
@@ -8,13 +8,15 @@ installed; the desktop app does not bundle this command.
|
||||
|
||||
```sh
|
||||
scripts/frame-doctor.sh
|
||||
scripts/frame-doctor.sh --alias bedroom --lan frame.local
|
||||
scripts/frame-doctor.sh --alias bedroom --lan 192.168.1.40
|
||||
scripts/frame-doctor.sh --alias frame --lan 192.168.1.40
|
||||
```
|
||||
|
||||
Use only addresses belonging to your intended headset. The command tries the
|
||||
existing `frame` SSH alias first (Tailscale if setup configured it that way),
|
||||
then `frame.local`, then USB-C at `10.86.200.233`. `--alias`, `--lan` and `--usb`
|
||||
then an optional `--lan` address, then USB-C at `10.86.200.233`. SteamOS 0.5.6
|
||||
stopped publishing mDNS names, so use a known IP for `--lan` if `.local` no longer
|
||||
resolves. `--alias`, `--lan` and `--usb`
|
||||
override those defaults. It reports the successful path without printing its
|
||||
address. It neither discovers other devices nor enables Tailscale.
|
||||
|
||||
@@ -58,6 +60,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 +129,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 +148,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 +162,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.
|
||||
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,118 @@
|
||||
# Linux Wireless Adapter doctor
|
||||
|
||||

|
||||
|
||||
On the **Linux PC**, open **Settings → Linux Wireless Adapter doctor**, or
|
||||
**Devices & connection → Linux Wireless Adapter doctor**. It works without a
|
||||
connected headset. Choose **Read PC device inventory**, select the intended
|
||||
USB device or wireless interface, then **Check selected device**. Nothing runs
|
||||
until requested. Re-read the inventory after reconnecting a device.
|
||||
|
||||
From a source checkout, the same collector is available without starting the
|
||||
app or its headset connection workers:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_wireless_doctor.py
|
||||
python3 ui/frame_wireless_doctor.py --device net:wlan0
|
||||
python3 ui/frame_wireless_doctor.py --device usb:1-2 --json > adapter-report.json
|
||||
```
|
||||
|
||||
Use a selector actually listed on your PC. No device is selected automatically.
|
||||
All USB devices are included, including hubs and devices without a network
|
||||
interface. A product label, observed VID/PID, or a single available interface
|
||||
**does not identify Valve's adapter**. There is no trusted adapter ID table in
|
||||
this feature. Compare inventory before and after reconnecting the intended
|
||||
adapter when safe. For a USB device exposing multiple wireless interfaces,
|
||||
select the specific interface for radio/manager evidence.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- **Presence and binding:** `/sys/bus/usb/devices` and `/sys/class/net` correlate
|
||||
USB ancestry, wireless interfaces, driver links and module links. An absent
|
||||
interface does not establish which driver should be installed. A missing
|
||||
module link may mean a built-in driver. A bound driver does not prove Steam
|
||||
streaming support.
|
||||
- **Permissions and udev:** `udevadm info --query=property` supplies a small
|
||||
allowlist of driver, ID and tag fields. If available, the report includes USB
|
||||
node mode, owner/group IDs and the current user's read/write access. Direct
|
||||
USB access is not necessarily required by kernel networking; a restrictive
|
||||
node alone is not diagnosed as the cause. No udev rules are installed or run.
|
||||
- **Radio blocks:** read-only WLAN rfkill sysfs entries are matched to the
|
||||
selected interface's PHY name. An unrelated block cannot establish that
|
||||
this adapter is blocked. Unavailable or unmatched entries remain unknown.
|
||||
- **Channel/band:** `iw dev IFACE info` provides the current channel and
|
||||
frequency where reported; `iw phy PHY info` supplies enabled frequencies.
|
||||
An idle interface may have no current channel. These observations do not
|
||||
establish the channel used by a Steam streaming session, supported regions,
|
||||
or the correct regulatory setting.
|
||||
- **Network managers:** visible `/proc/*/comm` process names show
|
||||
NetworkManager, iwd and wpa_supplicant. `nmcli` reads only the selected
|
||||
interface's general state, managed flag and driver. NetworkManager can use
|
||||
iwd or wpa_supplicant as its backend, so coexistence is not itself a conflict.
|
||||
If both backends are visible, the report suggests checking who owns this
|
||||
exact interface. It does not claim a confirmed conflict or stop services.
|
||||
Restricted process visibility and unverified backend ownership are explicit.
|
||||
|
||||
Every command uses fixed read-only arguments, no shell, closed stdin and a
|
||||
three-second timeout. Optional tools are not installed automatically. Missing
|
||||
tools, denied reads, disappearing devices, and restricted VM/container hardware
|
||||
visibility remain unknown. A successful report is a snapshot, not a hardware
|
||||
health certificate. CLI exit 0 means collection is supported, not that the
|
||||
adapter works; unsupported platforms/headset-local mode return 2, as does an
|
||||
invalid/stale selector. JSON has schema version 1.
|
||||
|
||||
The doctor never uses SSH, scans nearby Wi-Fi networks, opens a connection to
|
||||
the Frame, escalates privileges, loads drivers, changes security settings,
|
||||
changes channels, unblocks radios, changes network settings, or restarts
|
||||
services. Run it as your ordinary user, never with sudo. The app hides the
|
||||
entry on macOS, Windows and phone sessions; its API refuses collection in
|
||||
headset-local (`FRAME_LOCAL=1`) mode. The source CLI is intended only for the PC.
|
||||
|
||||
Reports stay local unless you explicitly save, copy or share them. Exported
|
||||
fields include observed USB IDs/product labels, interface names, driver names
|
||||
and permission metadata. USB serials, Wi-Fi names, MAC/IP address fields,
|
||||
credentials, process arguments and raw command output are not exported. Review
|
||||
the report before posting it: hardware labels and local paths can identify
|
||||
parts of your setup.
|
||||
|
||||
## Motivation and shipped comparison
|
||||
|
||||
[The 23 October 2026 complaint](https://x.com/ExpressGinga81/status/2113457683143741720)
|
||||
says the Wi-Fi dongle does not work on Ubuntu/Kubuntu 26.04 while SteamVR works
|
||||
under Wayland. It supplies **no adapter VID/PID, driver, logs or established
|
||||
cause**. This feature helps gather that missing evidence; it does not claim to
|
||||
fix that complaint or certify Ubuntu/Kubuntu 26.04 support.
|
||||
|
||||
Shipped **v0.4.18** has local network/gateway discovery in `frame_network.py`
|
||||
and headset connection management, but no local USB/driver/rfkill adapter
|
||||
report or adapter diagnostic UI. The existing `frame-doctor.sh` diagnoses the
|
||||
headset through SSH and is a different tool. This doctor never calls it.
|
||||
General SteamOS 3.9.5 / Frame OS 0.5.6 compatibility and OS backend transition
|
||||
notes are covered separately in
|
||||
[`docs/steamos-2026-10-compat.md`](https://github.com/saphid/frame-control/blob/9c7110c1f48d160b0fb3c3b9f658d4937bd2032d/docs/steamos-2026-10-compat.md)
|
||||
([PR #164](https://github.com/saphid/frame-control/pull/164)). Existing PC
|
||||
route/SSID diagnostics use NetworkManager's `nmcli` with an `iwgetid` SSID
|
||||
fallback, independently of whether NetworkManager uses wpa_supplicant (SteamOS
|
||||
3.9.5 new installs) or iwd (upgrades). This doctor reads actual PC evidence and
|
||||
does not infer a conflict, hardware identity or fix from those OS defaults.
|
||||
There is no adapter-specific device observation in that compatibility work.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_wireless_doctor.py -v
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
Tests use an explicitly synthetic USB `1234:5678`, sysfs trees, command replies
|
||||
and loopback HTTP. They cover an unbound USB device, module binding, multiple
|
||||
interfaces, PHY-matched and unrelated rfkill entries, channel parsing, manager
|
||||
coexistence, missing/denied commands, stale/injected selectors, export field
|
||||
filtering, authentication and refusal to probe in headset-local mode. The
|
||||
HTTP tests instantiate the handler without starting headset workers.
|
||||
|
||||
Linux host collection was exercised on kernel `6.8.0-139-generic`: an existing
|
||||
PC interface was visible with its `iwlwifi` binding and unblocked PHY; `iw` and
|
||||
`nmcli` were absent and reported as unavailable. This is **not a physical Steam
|
||||
Frame Wireless Adapter test**, an Ubuntu/Kubuntu 26.04 test, or a headset test.
|
||||
No Frame access is part of this work.
|
||||
@@ -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`:
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# PC in the headset (Experimental)
|
||||
|
||||
For Steam's own Linux Wayland desktop-sharing flow, see the separate
|
||||
[Steam desktop sharing helper](steam-pipewire.md). It does not enable this
|
||||
feature on Linux.
|
||||
|
||||
**Tools → PC in the headset**, on Windows only. It's the Windows version of
|
||||
[Mac in the headset](mac-in-headset.md): a whole screen on your PC
|
||||
becomes its own SteamVR panel in the Frame. Individual windows are available only
|
||||
@@ -107,6 +111,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:
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# Read-only power and heat measurements
|
||||
|
||||
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
|
||||
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
|
||||
over the existing `ssh frame` alias. It needs Python 3 on both computers and
|
||||
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
|
||||
and batch mode; it never pairs, prompts for a password or changes SSH security.
|
||||
|
||||
```sh
|
||||
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
|
||||
scripts/frame-power-sample.sh > power.json
|
||||
|
||||
# Five-minute observation: samples at 0, 5, ... 300 seconds:
|
||||
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
|
||||
|
||||
# Long-form CSV samples; JSON summary goes separately to stderr:
|
||||
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
|
||||
```
|
||||
|
||||
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
|
||||
are target start-to-start intervals, not guarantees: read latency can delay a
|
||||
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
|
||||
The first sample has no CPU utilization: two valid `/proc/stat` observations
|
||||
are required. CPU utilization then covers the interval between observations,
|
||||
averaged across the machine, rather than the utilization of one process.
|
||||
|
||||
## 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
|
||||
temperature, using the same parser as Frame Control's battery details.
|
||||
Positive battery watts mean charging on the Frame fuel gauge; negative means
|
||||
discharge. `Not charging` alone does not prove a charger is connected.
|
||||
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
|
||||
- Named thermal zones in °C, with zone names retained. A thermal zone is not
|
||||
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
|
||||
temperature threshold.
|
||||
- hwmon power channels, labels and driver names in watts. `vph` is the measured
|
||||
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
|
||||
readings; do not sum all channels as if they were independent power consumers.
|
||||
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
|
||||
is assumed. The historical Frame driver reported a percent-like value of 41.
|
||||
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
|
||||
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
|
||||
worn/unworn threshold.
|
||||
|
||||
Each metric includes its unit, source path and availability state. Missing,
|
||||
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
|
||||
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
|
||||
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
|
||||
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
|
||||
lists, logs, screenshots and application inventories are not collected.
|
||||
|
||||
The JSON report contains raw samples and a summary per metric: available and
|
||||
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
|
||||
observed charging states. Means exclude unavailable readings. They are sample
|
||||
means, not time-weighted energy or battery-runtime estimates. Different sample
|
||||
availability and intervals must be considered when comparing sessions.
|
||||
|
||||
Numeric validity does not establish sensor accuracy. Historical readings
|
||||
already showed an apparently absent/broken right-display thermistor near
|
||||
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
|
||||
Those raw numbers remain visible for diagnosis; neither should be used to
|
||||
infer cooling performance or safety. This tool does not diagnose heat, explain
|
||||
a spike, predict battery health, or promise power savings.
|
||||
|
||||
## Safety and shared-device use
|
||||
|
||||
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
|
||||
The Python sources are sent on SSH stdin and not installed on the headset.
|
||||
There is no sudo, OpenVR initialization, service call, settings write, process
|
||||
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
|
||||
automatic measurement is added. Redirects above write only to your computer.
|
||||
The observer itself uses a little CPU and SSH traffic, so label baseline
|
||||
conditions and avoid interpreting very small changes as savings.
|
||||
|
||||
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
|
||||
the Frame before any probe and before each sample. If a smoke session is active,
|
||||
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
|
||||
samples, sets `stopped`, and exits 75. It never removes or acquires another
|
||||
session's lock. This is an advisory check, not an atomic lease: a smoke session
|
||||
can start during a file read. Coordinate sessions and defer measurement when
|
||||
timing overlaps. It does not block someone else from starting a smoke test.
|
||||
|
||||
SSH failures/timeouts return a nonzero exit and an explanation, without
|
||||
presenting a zero or an old report as a successful measurement. Fully missing
|
||||
sensors can still produce a successful report with explicit unavailable states.
|
||||
On a connection failure, partial samples buffered on the headset can be lost.
|
||||
|
||||
## Evidence and remaining work
|
||||
|
||||
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
|
||||
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
|
||||
fan, GPU clock, board temperatures and charger current through the final
|
||||
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
|
||||
see [the recorded results](evidence/power-sampler-2026-10-24.json).
|
||||
No smoke lock was present at the preflight or sampler checks. No settings,
|
||||
services, application state or device files were changed. This short run is
|
||||
a parser/transport check, not a controlled baseline or a worn-headset test.
|
||||
|
||||
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
|
||||
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
|
||||
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
|
||||
differ from the current check; comparisons do not establish a regression.
|
||||
|
||||
This change completes only the sampler portion of “Plan: leave it on, but
|
||||
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
|
||||
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
|
||||
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
|
||||
are separate work. No controls are offered here. Follow the shared-device
|
||||
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
|
||||
|
||||
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*.py' -v
|
||||
bash -n scripts/frame-power-sample.sh
|
||||
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).
|
||||
@@ -203,9 +203,11 @@ and display name of the headset being tried (whole, whatever their case), and
|
||||
then any name ssh gives after "hostname", "host" or "to", become `<host>`; a Windows home folder's
|
||||
whole name (spaces and apostrophes included) becomes `<user>`, and ssh's
|
||||
whole `user@host:` field (spaces, `DOMAIN\user` and full domain names
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. A host name ssh mentions
|
||||
in some other wording can still get through, so check the log lines in **Show
|
||||
exactly what's included** before sending. A failure that repeats on every
|
||||
included) becomes `<user>@<host>:`; then the scrubbing below. Whole display names
|
||||
are hidden even when they contain a scrubber token such as `<host>` or overlap
|
||||
ssh's wording. Those overlaps also preserve redaction of URLs, home folders,
|
||||
and keys. A host name ssh mentions in some other wording can still get through,
|
||||
so check the log lines in **Show exactly what's included** before sending. A failure that repeats on every
|
||||
retry is written at most once every 5 minutes.
|
||||
|
||||
Everything is scrubbed like error details and limited to what fits in the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
Both are on **Tools → Capture & Live**. Everything is saved to
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,21 @@ the Frame, converted to `.mp4` there (without re-encoding) and copied to
|
||||
|
||||
Recording and **Live** can run together; both read the same device.
|
||||
|
||||
### Does Record include passthrough?
|
||||
|
||||
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
|
||||
Frame Control does not open the headset's passthrough cameras or add a separate
|
||||
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
|
||||
appear in the recording; Frame Control cannot force or promise that result.
|
||||
Steam's own **Steam clips** use a separate game recording path and may differ.
|
||||
|
||||
On 25 October 2026, the Frame was in standby. A frame captured from
|
||||
`/dev/video99` was black, as expected in standby, so this check **did not
|
||||
establish whether active passthrough appears in the mirror**. A wearer-visible
|
||||
passthrough session and its simultaneous mirror frame are still needed for a
|
||||
yes-or-no answer about passthrough pixels. See the
|
||||
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
## Steam clips
|
||||
|
||||
**Steam clips** lists what Steam's game recording has kept on the Frame: saved
|
||||
@@ -147,19 +162,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.
|
||||
@@ -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.
|
||||
|
||||
@@ -46,14 +46,14 @@ unless your router's DNS registers DHCP client names.
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_frame -N '' -C "mac->steam-frame"
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@frame.local
|
||||
ssh-copy-id -i ~/.ssh/id_ed25519_frame.pub steamos@192.168.1.40
|
||||
```
|
||||
|
||||
`~/.ssh/config` block (managed between marker lines by the script):
|
||||
|
||||
```
|
||||
Host frame
|
||||
HostName frame.local
|
||||
HostName 192.168.1.40
|
||||
User steamos
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||
@@ -62,6 +62,10 @@ Host frame
|
||||
```
|
||||
|
||||
The script only asks for the password if the pairing below doesn't work.
|
||||
Replace the example IP with the one in the Frame's Quick Settings. SteamOS
|
||||
0.5.6 stopped publishing `.local` names; an existing SSH alias or the headset's
|
||||
current IP is more reliable. Frame Control remembers authenticated IPs per
|
||||
network and checks the saved SSH identity on reconnect.
|
||||
|
||||
## Password-free pairing (SteamOS devkit service)
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Steam desktop sharing on a Linux PC
|
||||
|
||||
**Tools → Steam desktop sharing** helps with Steam's own `steam -pipewire`
|
||||
desktop-in-headset flow on a local Wayland PC. It needs no Frame connection.
|
||||
It is separate from Frame Control's [PC in the headset](pc-in-headset.md),
|
||||
which remains disabled on Linux. Steam and the desktop portal own the capture,
|
||||
screen picker and headset flow; this helper does not provide a stream.
|
||||
|
||||
## Launch Steam with PipeWire
|
||||
|
||||
Save your game and use **Steam → Exit** first. Then click **Launch Steam with
|
||||
PipeWire**. Frame Control checks this user's local processes, finds `steam` on
|
||||
PATH, and requests `steam -pipewire`. This flag applies to that launch only.
|
||||
No Steam settings, login files or startup entries are changed. An existing
|
||||
Steam session is never stopped, signalled or silently switched.
|
||||
|
||||
If Steam or a Steam launcher/helper is running, the button explains that Steam
|
||||
must be exited first. If the process inventory cannot be read, it refuses to
|
||||
launch. It also refuses root, SSH, headless, X11 and Flatpak-sandboxed Frame
|
||||
Control sessions. Run Frame Control as your normal user in the local Wayland
|
||||
desktop with its session bus.
|
||||
|
||||
For a Flatpak-only Steam install without a `steam` command on PATH, the helper
|
||||
provides a manual command instead of choosing an installation for you. After
|
||||
exiting Steam, run `flatpak run com.valvesoftware.Steam -pipewire` in a local
|
||||
terminal. Flatpak Steam processes are included in the existing-session check.
|
||||
|
||||
A successful request is **not confirmation of a picker or stream**. Steam may
|
||||
exit or fail after the initial check, and another launcher can race the local
|
||||
process check. Do not start Steam elsewhere at the same time. If nothing opens,
|
||||
run the command yourself in a terminal to see Steam's error.
|
||||
|
||||
## Reset screen-share choice
|
||||
|
||||
Click **Reset screen-share choice…** to request KDE's **Application Permissions**
|
||||
page. This button does **not** revoke anything or report a completed reset.
|
||||
Frame Control cannot reliably map a Steam runtime display name to a
|
||||
Steam-specific portal grant or restore token across backends. It never reads,
|
||||
edits or clears the portal permission store or Steam tokens.
|
||||
|
||||
On KDE:
|
||||
|
||||
1. Open **System Settings → Applications → Application Permissions** if the
|
||||
button cannot open it.
|
||||
2. Find the entry for Steam. The Ubuntu 26.04 KDE report showed **Steam Linux
|
||||
Runtime 3.0**. That display name alone is not proof that every session there
|
||||
is safe to remove; verify the session belongs to your Steam desktop share.
|
||||
3. Under **Screen sharing**, choose **Manage 1 Session** (the count may differ).
|
||||
4. Choose **Revoke** for that verified session only. **Do not use Revoke All**
|
||||
or change another application's grants. If ownership is ambiguous, stop.
|
||||
5. Retry Steam's desktop sharing flow. The reporter saw the portal picker again
|
||||
after revoking that session. Results can differ by Steam and portal version.
|
||||
|
||||
The reported entry sometimes appeared intermittently. If it is missing, do not
|
||||
clear other entries or the whole portal store. Check again when Steam's sharing
|
||||
session is present. If **Application Permissions** itself is missing on
|
||||
Ubuntu/Kubuntu, check your distribution's `kde-config-flatpak` package: the
|
||||
reporter needed that package and a reboot before the page appeared. Frame
|
||||
Control does not install packages, reboot, request root or change security
|
||||
settings. Older KDE modules may lack this page; other desktops receive guidance
|
||||
only, with no automatic reset or guessed permission target.
|
||||
|
||||
The launcher uses `kcmshell6 --list` to verify the exact module, then
|
||||
`kcmshell6 kcm_app-permissions`. It does not substitute the older Flatpak-only
|
||||
page. A request to open the page does not prove that a window became visible.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
- User-supplied report and screenshot description:
|
||||
[ExpressGinga81's thread and replies](https://x.com/ExpressGinga81/status/2113242533530911058).
|
||||
The report describes Ubuntu 26.04 KDE, the initial picker appearing once,
|
||||
the Steam Linux Runtime 3.0 entry, and Revoke making the picker reappear.
|
||||
This is reported behavior, not a live test performed for this feature.
|
||||
- KDE upstream [`flatpak-kcm`](https://github.com/KDE/flatpak-kcm):
|
||||
`CMakeLists.txt` and `kcm_app-permissions.json` identify Application
|
||||
Permissions; `ui/SessionList.qml` distinguishes Revoke from Revoke All.
|
||||
- Automated tests mock Steam, processes and KDE commands. HTTP tests verify
|
||||
request guards, no actions on GET, and no headset work. Existing CI discovers
|
||||
these tests on Linux, macOS and Windows; they do not need a desktop portal.
|
||||
- lxso2 has no interactive Wayland/PipeWire portal for a chooser test.
|
||||
**No live chooser, revoke, Steam launch or headset display is verified here.**
|
||||
No Frame access was performed for this task, including during the hold before
|
||||
2026-10-24 13:00 UTC. The helper never invokes SSH or Frame controls.
|
||||
|
||||
## Announcement
|
||||
|
||||
Linux Wayland users can now find **Steam desktop sharing** under Tools to
|
||||
request Steam with `-pipewire` after exiting Steam, or open KDE's Application
|
||||
Permissions to revoke only a verified Steam screen-sharing session and try the
|
||||
picker again. Frame Control does not clear permissions automatically. This is
|
||||
help for Steam's own desktop-sharing flow; Frame Control's Linux capture
|
||||
feature remains disabled. Live picker behavior still needs desktop validation.
|
||||
@@ -0,0 +1,67 @@
|
||||
# SteamOS October 2026 compatibility
|
||||
|
||||
SteamOS 3.9.5 is the **PC host / Steam Machine** update. It is not the Frame
|
||||
headset OS. The headset release discussed here is **SteamOS 0.5.6 beta**.
|
||||
|
||||
## Finding and connecting to the headset
|
||||
|
||||
SteamOS 0.5.6 stopped publishing mDNS names, including `frame.local`. Existing
|
||||
addresses in Frame Control still work if they resolve, and the app tries all
|
||||
saved addresses. After an authenticated connection through a `.local` name,
|
||||
Frame Control also remembers the numeric IP on that network. If the name stops
|
||||
resolving, it tries that IP and checks the same pinned SSH identity before using
|
||||
it. The IP is never reused automatically on a different network.
|
||||
|
||||
In **Devices**, **Find on this network** checks the SteamOS devkit advertisement,
|
||||
known IPs and the local IPv4 `/24`. It offers scan results only when their SSH
|
||||
key matches the headset's saved key. The scan is bounded to a private subnet;
|
||||
it cannot find a headset across a router or on another subnet. Enter the IP
|
||||
shown in the headset's Quick Settings in the address field in those cases.
|
||||
For initial pairing, **Set Up Connection** accepts a manual IP and reuses an
|
||||
existing `~/.ssh/config` `HostName` before trying mDNS. A configured SSH alias
|
||||
continues to work. Frame doctor no longer assumes `frame.local`: supply
|
||||
`--lan <IP>` when a separate LAN route is needed.
|
||||
|
||||
## SteamOS script names
|
||||
|
||||
Session switching already prefers `holo-session-select` over
|
||||
`steamos-session-select`, after the Frame's `steamosctl` control. Desktop
|
||||
process detection accepts `holo-nested-desktop` and its old name. The sideload
|
||||
helper prefers `holo-prepare-upload` and `holo-delete` if a future devkit
|
||||
provides them, falling back to the vendored `steamos-*` scripts. The devkit
|
||||
service name `_steamos-devkit._tcp` is a protocol identifier, so it is not
|
||||
renamed.
|
||||
|
||||
## SteamOS 3.9.5 Linux PC hosts
|
||||
|
||||
NetworkManager's `nmcli` supplies the active SSID and route diagnostics; the
|
||||
SSID reader falls back to `iwgetid`. Neither depends on whether NetworkManager
|
||||
uses wpa_supplicant (new installs) or iwd (upgraded installs). The current code
|
||||
does not manage either backend or the Steam Frame Wireless Adapter. An adapter
|
||||
doctor belongs in its own PC host feature.
|
||||
|
||||
## Android APKs and telemetry
|
||||
|
||||
The 0.5.6 Desktop Mode **Add to Steam** action for `.apk` files is another way
|
||||
to make a Steam library entry. Frame Control already creates a non-Steam
|
||||
shortcut for every installed APK through the running Steam client's API, with
|
||||
its own persistent Lepton instance and artwork. It does not edit
|
||||
`shortcuts.vdf` or require a Steam restart, so no extra shortcut option is
|
||||
needed in its install flow.
|
||||
|
||||
The battery reader selects a system battery by power-supply `type` and `scope`
|
||||
and reads standard sysfs fields. The memory reader uses `/proc/meminfo`.
|
||||
Neither relies on the performance overlay's labels or hard-coded SteamOS
|
||||
paths. SteamOS 0.5.6's improved overlay reporting does not establish a sysfs
|
||||
path change. A live check on build `20261022.6153909` confirmed that Frame
|
||||
Control's existing readers return battery and memory values. The dominant-eye
|
||||
setting is not read yet; recording metadata must not claim an eye until its
|
||||
source and effect on `/dev/video99` are verified. The same check found the
|
||||
`beta` update branch, but the normal user could not query update availability
|
||||
through Atomupd; no update was applied. See the
|
||||
[live evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
Sources: [SteamOS 3.9 news](https://store.steampowered.com/news/app/1675200),
|
||||
[Frame OS news](https://steamcommunity.com/app/4165890/allnews/), and the
|
||||
Frame Control implementation in `ui/frame_network.py`, `ui/frame_status.py`,
|
||||
`ui/frame_android.py`, `ui/frame_link.py` and `frame/android/steam_shortcuts.py`.
|
||||
@@ -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.
|
||||
|
||||
@@ -151,10 +154,31 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
types on the Frame. On a computer, clicking the pad passes your mouse and
|
||||
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
|
||||
|
||||
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
|
||||
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
|
||||
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
||||
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
|
||||
Since Control (below), the pad goes through **gamescope's own input socket**
|
||||
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
|
||||
focus, Steam's own menus included, and needs nothing installed on the Frame.
|
||||
Only characters that aren't on a US keyboard (accents, emoji) go through
|
||||
**KDE Connect** as below; it starts the first time you type one. (Before
|
||||
Control, everything went through KDE Connect, which is why the notes below
|
||||
describe it in detail.)
|
||||
|
||||
The server keyboard queue holds at most 200 events, including text being sent.
|
||||
A batch with an accent sends its whole keyboard part through KDE Connect, and
|
||||
later keys follow it there while typing is pending. If the batch will not fit,
|
||||
the server rejects it before sending any clicks or keys; the page keeps it and
|
||||
retries after polling. Accepted events are never evicted to make room. A timeout
|
||||
or helper failure retains unsent text and shows **Try again**; that button resumes
|
||||
the retained queue. The queue lives in memory and is lost if the server exits.
|
||||
Switching headsets cancels queued text and stops both input helpers before the
|
||||
new route is published. Both controls validate the intended headset before they
|
||||
start. Ordering between the two input services uses a typing-time estimate;
|
||||
neither service acknowledges when characters reach the application.
|
||||
|
||||
[PR #66 verification on 2026-10-24](evidence/pad-gamescope-2026-10-24.md)
|
||||
records an owned focused X11 panel receiving a click and `Frame66` plus Enter
|
||||
on a real Frame with SteamOS 0.5.6, after the shared headset became available.
|
||||
The helper, window and owner-marked lock were cleaned up. This is ASCII input
|
||||
proof, not wearer-visible, Unicode or packaged Electron GUI verification.
|
||||
|
||||
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
@@ -285,6 +309,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).
|
||||
|
||||
@@ -12,6 +12,22 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
The gamescope keyboard/trackpad route has focused suites in `test_input.py`,
|
||||
`test_touch.py` and `test_service_regressions.py`. They cover pad translation,
|
||||
ASCII/modifier keys, Unicode queuing/retries, stale panel targets, release
|
||||
handling, route cancellation, and intended-headset start/status guards.
|
||||
`tests/page/pad_queue.mjs` and `tests/page/ctrl_gestures.mjs` execute the page's
|
||||
real queue and gesture functions in Node. These are offline tests; their
|
||||
acknowledgements do not prove that a real application received input.
|
||||
See [the 2026-10-24 PR #66 evidence](evidence/pad-gamescope-2026-10-24.md)
|
||||
for the real focused-panel click/key observation, cleanup and remaining limits.
|
||||
|
||||
The read-only power/heat sampler has fixture and transport tests in
|
||||
`tests/test_power_sample.py`. Run
|
||||
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
|
||||
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
|
||||
real-device verification limits.
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
@@ -54,8 +70,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
|
||||
@@ -156,6 +175,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
|
||||
@@ -365,7 +392,11 @@ trees on the fake Frame. None of it has run on a headset.
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
hardware-decoder command construction, malformed splats, stereo parallax and
|
||||
fake-Frame library/process ownership. `tests/e2e/test_media_transfer.py` checks the real
|
||||
fake-Frame library/process ownership. It also covers optional-surround refusal
|
||||
at creation, setup, first upload and retry after standby, fatal screen refusal,
|
||||
and best-effort overlay teardown with shutdown.
|
||||
Run `python3 -m unittest discover -s tests -p test_media.py -v`.
|
||||
`tests/e2e/test_media_transfer.py` checks the real
|
||||
HTTP/SSH upload and library listing without pretending the fake renders VR.
|
||||
Real decode timings and captured stereo output are recorded in
|
||||
[vr-video.md](vr-video.md). Generated media only; no external player required.
|
||||
@@ -437,3 +468,34 @@ files, source preference, missing/invalid sensors and permission errors.
|
||||
`tests/control_center.cjs` covers charging states and optional health fields,
|
||||
alongside existing stale/offline handling. No headset is used. See
|
||||
[battery monitoring](battery-health.md) for sources, units and limitations.
|
||||
|
||||
## Host-key recovery presentation
|
||||
|
||||
`python3 -m unittest discover -s tests -p test_link_ui.py -v` runs the page's
|
||||
connection/banner/selection functions in Node and checks the recovery CSS guard.
|
||||
`test_link.py` covers connection failures and short probe reasons with fake SSH
|
||||
and local sockets. Neither suite uses a real headset.
|
||||
|
||||
Browser verification on 2026-10-24 used the current page's markup, styles and
|
||||
recovery renderers with synthetic changed-key state and two headsets at
|
||||
1280×800. It caught a later `.item .s` rule overriding the earlier wrapping
|
||||
rule, which the original source-only assertion missed. Check computed wrapping,
|
||||
the complete explanation, tooltip punctuation, Refresh bounds and selection
|
||||
after **Use this headset** when changing these styles. This fixture does not
|
||||
exercise Electron integration or a real SSH identity change.
|
||||
|
||||
|
||||
## PC-only Windows wireless diagnosis
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p 'test_wireless.py' -v
|
||||
node --test tests/wireless.cjs
|
||||
```
|
||||
|
||||
The checks workflow runs this targeted suite on Windows, including the real
|
||||
read-only PowerShell query, and the normal server suite includes it on all
|
||||
platforms. HTTP tests instantiate only a handler, never the app connector.
|
||||
Fixtures cover duplicate instance identity, unknown states, GUID matching,
|
||||
localised/denied channel data and 6 GHz ambiguity. No tests contact a real Frame.
|
||||
A Windows CI runner/VM cannot prove physical Steam dongle behaviour or audio RF
|
||||
interference. See [sources and limits](windows-wireless-doctor.md).
|
||||
@@ -101,6 +101,14 @@ Stills and the theatre surround are re-sent after waking. Five minutes
|
||||
without an accepted frame is reported as an error. Headset-view captures
|
||||
taken during standby show a flat dark frame, not our screen.
|
||||
|
||||
**Optional surround:** if SteamVR refuses the theatre dark surround at startup
|
||||
or on a retry after standby, playback continues without it and the player log
|
||||
records `Theatre surround unavailable`. Screen errors still fail playback.
|
||||
On exit, the player attempts to destroy every owned overlay and calls OpenVR
|
||||
shutdown even if an overlay's teardown raises an exception. Refusal and
|
||||
teardown errors are covered by fake-OpenVR tests; a real surround refusal has
|
||||
not been induced safely. See the [current hardening checks](evidence/media-surround-2026-10-24.md).
|
||||
|
||||

|
||||
|
||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Windows Wireless Adapter doctor
|
||||
|
||||
Open **Tools → This PC → Wireless Adapter doctor**, then choose **Read PC
|
||||
adapters**. It works without a headset connection. It only queries this Windows
|
||||
PC, with no automatic polling, changes, elevation or UAC prompt. It never
|
||||
contacts the Frame. A direct `#wireless` link on another platform explains that
|
||||
Windows is required.
|
||||
|
||||
The snapshot shows:
|
||||
|
||||
- Exact network PnP instance IDs, including non-present devices, with separate
|
||||
presence, PnP status/problem code, operational state and media connection state.
|
||||
- Microsoft Wi-Fi Direct Virtual Adapter candidates and their distinct instance
|
||||
count. `SWD\WIFIDIRECT\` is enumerator evidence; an English device-name match
|
||||
is explicitly labelled **Name only**. A repeated row for one ID is counted once.
|
||||
- PC Wi-Fi interfaces, hardware-interface status and whether Windows has a
|
||||
default route configured on an interface. Neither a route nor **Up** proves
|
||||
that Steam uses that adapter. Multiple configured defaults are possible.
|
||||
- Reported channel and band, when `netsh wlan show interfaces` supplies recognised
|
||||
English fields and an exact matching interface GUID. Missing, localised,
|
||||
disconnected or denied readings remain **Unknown**. There is no name-based
|
||||
channel association. A PC's router channel is not the Steam dongle's channel.
|
||||
- Possible spectrum overlap when an interface reports **2.4 GHz**, without
|
||||
claiming interference. With no such reading, overlap stays **Unknown**:
|
||||
unreported virtual/proprietary radios and audio dongles may still use 2.4 GHz.
|
||||
Channel 1–14 alone does not identify a band: 6 GHz reuses low channel numbers.
|
||||
|
||||
## Why there is no disable button
|
||||
|
||||
[夕凪's report](https://x.com/Oo_yuunagi_oO/status/2113904070352343135)
|
||||
(24 October 2026) says extra Microsoft Wi-Fi Direct Virtual Adapter instances
|
||||
appear when their wireless adapter becomes unavailable, and disabling the newly
|
||||
appeared one *seems* to stop recurrence. This is useful owner evidence, but does
|
||||
not establish which instance would be safe to disable on another PC.
|
||||
|
||||
Windows can legitimately create several Wi-Fi Direct virtual interfaces.
|
||||
A `#2` suffix is not a creation timestamp; non-present, down, or disconnected
|
||||
status is not proof of an unused instance. The inventory does not reliably map
|
||||
Steam's active radio/session to an expendable PnP instance. Disabling the wrong
|
||||
one could interrupt streaming, PC Wi-Fi, Mobile Hotspot or wireless displays.
|
||||
Frame Control therefore supplies evidence and guidance, with no disable,
|
||||
re-enable, driver removal or other write endpoint. It never changes security or
|
||||
location settings, requests administrator access, or bypasses execution policy.
|
||||
|
||||
Before any manual changes, retain the exact instance IDs and compare snapshots
|
||||
before/after a drop with Steam Support. Do not disable a device based only on
|
||||
this report. A future disable feature needs a verified Steam adapter/session
|
||||
mapping, fresh protection for primary/active instances, explicit confirmation
|
||||
for one exact instance, and a tested recovery path that re-enables that same ID.
|
||||
|
||||
## Checking a 2.4 GHz audio conflict
|
||||
|
||||
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
|
||||
(22 October 2026) describes Razer wireless-headset interference and temporarily
|
||||
disabling the Steam Frame dongle. It also mentions a PC/Wi-Fi shutdown during
|
||||
Windows Update and asks whether the dongle caused it. The post does not prove
|
||||
that connection or specify a measured audio radio channel. Treat the suggested
|
||||
2.4 GHz conflict as a hypothesis, not a diagnosis of the shutdown.
|
||||
|
||||
Try wired audio briefly, separate radio dongles with a USB extension lead, or
|
||||
compare PC Ethernet / an existing 5 or 6 GHz network. Change one thing at a time
|
||||
and record whether symptoms recur. The doctor cannot measure RF congestion,
|
||||
read an audio dongle's channel, identify a faulty driver, or prove a dropout fix.
|
||||
|
||||
## Sources, privacy and limits
|
||||
|
||||
The single local PowerShell process uses `Get-PnpDevice -Class Net`, its
|
||||
`-PresentOnly` variant, `Get-NetAdapter -IncludeHidden`, `Get-NetRoute` for
|
||||
IPv4/IPv6 defaults, and `netsh wlan show interfaces`. Each source reports
|
||||
availability separately. Access denial, unavailable cmdlets/services and
|
||||
unsupported features produce partial/unknown results; there is no permission
|
||||
workaround. Queries run sequentially, so even one snapshot can span a state
|
||||
change. One probe runs at a time, with a 20-second process timeout.
|
||||
|
||||
SSIDs, MAC addresses and raw command/error output are omitted from the API/UI.
|
||||
PnP IDs and adapter/interface names can identify this PC; review before sharing.
|
||||
The tool does not send or save reports, and does not add these readings to
|
||||
telemetry or problem-report diagnostics. Copy relevant text manually if needed.
|
||||
|
||||
The shipped basic network-identity probe (`frame_network.py`) finds a gateway,
|
||||
network name and Tailscale state. It does not inventory virtual adapters or
|
||||
check channel overlap. The new doctor is a separate, explicit PC query.
|
||||
|
||||
## Verification
|
||||
|
||||
`tests/test_wireless.py` checks duplicate identity, presence/disabled/link
|
||||
separation, ambiguous joins, partial sources, privacy filtering, 6 GHz/channel
|
||||
ambiguity, localisation, missing band and denied output. HTTP tests require the
|
||||
UI key, reject headset-local mode, and assert no SSH call. `tests/wireless.cjs`
|
||||
executes the production UI with boundary fixtures: navigation starts no query,
|
||||
clicks query once, unknown values and failure/retry remain visible, and PnP IDs
|
||||
and names are escaped as text.
|
||||
|
||||
Windows CI also executes the real read-only PowerShell probe against the
|
||||
runner's network devices. That proves Windows query execution, not a physical
|
||||
Steam dongle, Wi-Fi Direct session or Razer headset. See the PR for exact CI
|
||||
results. An existing local Windows 11 VM passed 15 targeted tests (one UI test skipped
|
||||
because Node is absent), including the live query. PnP, presence, adapters and
|
||||
routes were available; WLAN was unavailable in this VM. See
|
||||
[VM evidence](evidence/windows-wireless-2026-10-24.md). No Frame was accessed; no physical
|
||||
adapter/dropout fix or RF measurements are claimed.
|
||||
@@ -73,7 +73,11 @@ discover_devkit() {
|
||||
pick_host() {
|
||||
local candidates=()
|
||||
[[ -n "$host_arg" ]] && candidates+=("$host_arg")
|
||||
[[ -z "$host_arg" ]] && candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
if [[ -z "$host_arg" ]]; then
|
||||
local saved=$(ssh -G "$FRAME_ALIAS" 2>/dev/null | awk '$1 == "hostname" {print $2; exit}')
|
||||
[[ -n "$saved" && "$saved" != "$FRAME_ALIAS" && "$saved" =~ $HOST_RE ]] && candidates+=("$saved")
|
||||
candidates+=("$FRAME_ALIAS.local" "$FRAME_ALIAS")
|
||||
fi
|
||||
local h
|
||||
for h in "${candidates[@]}"; do
|
||||
if reachable "$h"; then
|
||||
@@ -224,6 +228,7 @@ pair_with_devkit() {
|
||||
print "==> Looking for the Steam Frame"
|
||||
if ! HOST=$(pick_host); then
|
||||
print -u2 "Could not reach the Frame on port 22 or $DEVKIT_PORT."
|
||||
print -u2 "SteamOS 0.5.6 stopped publishing .local names; use its IP from Quick Settings."
|
||||
print -u2 "Check: Developer Mode on + user password set; same Wi-Fi; no client isolation."
|
||||
print -u2 "Then re-run with the IP from Quick Settings: scripts/connect.sh 192.168.x.y"
|
||||
exit 1
|
||||
|
||||
@@ -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" "$@"
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Read-only, bounded power/heat sampler. See docs/power-and-heat.md.
|
||||
set -euo pipefail
|
||||
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
exec python3 "$root/scripts/frame_power_sample.py" "$@"
|
||||
@@ -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,102 @@ 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('--lan', help='Optional LAN IP or host name; SteamOS 0.5.6 no longer publishes .local names')
|
||||
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 value is None:
|
||||
continue
|
||||
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 +321,46 @@ 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)):
|
||||
routes = [('configured alias (Tailscale if configured)', None)]
|
||||
if args.lan:
|
||||
routes.append(('LAN', args.lan))
|
||||
routes.append(('USB-C', args.usb))
|
||||
for label, host in routes:
|
||||
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 +382,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__':
|
||||
|
||||
@@ -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>'''
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Bounded read-only Frame measurements; no device files are written.
|
||||
|
||||
The host sends this and frame_status.py over one trusted SSH connection. Only
|
||||
battery() and CPU tick helpers are reused: no OpenVR, services or analytics.
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
LOCK = Path('/tmp/frame-test.lock')
|
||||
UNAVAILABLE = 'unavailable: not reported, unreadable or invalid'
|
||||
|
||||
|
||||
def read(path):
|
||||
try:
|
||||
return Path(path).read_text().strip()
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def number(path, scale=1, minimum=None, maximum=None):
|
||||
try:
|
||||
value = int(read(path)) * scale
|
||||
if not math.isfinite(value):
|
||||
return None
|
||||
if minimum is not None and value < minimum or maximum is not None and value > maximum:
|
||||
return None
|
||||
return round(value, 6)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def snapshot(status, before):
|
||||
if LOCK.exists():
|
||||
raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.')
|
||||
ticks = status.cpu_ticks()
|
||||
readings = {}
|
||||
|
||||
def add(key, value, unit, source):
|
||||
readings[key] = {'value': value, 'unit': unit, 'source': source,
|
||||
'state': 'available' if value is not None else UNAVAILABLE}
|
||||
|
||||
battery = status.battery() or {}
|
||||
for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'),
|
||||
('tempC', 'C')):
|
||||
value = battery.get(field)
|
||||
if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'):
|
||||
value = None
|
||||
add('battery.' + field, value, unit, battery.get('source'))
|
||||
add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat')
|
||||
if before is None:
|
||||
readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples'
|
||||
add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0),
|
||||
'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq')
|
||||
|
||||
# Discover by driver name/label, never assume a hwmon index. Keep source
|
||||
# paths and channel numbers to distinguish duplicate labels.
|
||||
for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')):
|
||||
name = read(node + '/name') or Path(node).name
|
||||
for path in sorted(glob.glob(node + '/power*_input')):
|
||||
channel = Path(path).name[:-6]
|
||||
label = read(node + '/' + channel + '_label') or channel
|
||||
add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path)
|
||||
for path in sorted(glob.glob(node + '/fan*_input')):
|
||||
add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path)
|
||||
# pwm1 on Frame's driver is percent; leave raw to avoid assuming the
|
||||
# generic hwmon 0..255 scale on other drivers or later firmware.
|
||||
if name == 'slg4ax46073v':
|
||||
add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1')
|
||||
for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')):
|
||||
name = read(node + '/type') or Path(node).name
|
||||
add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200),
|
||||
'C', node + '/temp')
|
||||
for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')):
|
||||
for path in sorted(glob.glob(node + '/in_temp_*_input')):
|
||||
add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200),
|
||||
'C', path)
|
||||
for field in ('iin', 'ichg'):
|
||||
path = node + '/in_current_pm8550b_' + field + '_fb_input'
|
||||
# Known names, but no fixed IIO index; absent channels are covered
|
||||
# by group availability below.
|
||||
if Path(path).exists():
|
||||
add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path)
|
||||
if read(node + '/name') == 'vcnl4000':
|
||||
add('proximity.raw', number(node + '/in_proximity_raw', minimum=0),
|
||||
'raw', node + '/in_proximity_raw')
|
||||
unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity')
|
||||
if not any(k.startswith(group + '.') and v['value'] is not None
|
||||
for k, v in readings.items())]
|
||||
os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines()
|
||||
if '=' in line)
|
||||
return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None,
|
||||
'readings': readings, 'unavailableGroups': unavailable}, ticks
|
||||
|
||||
|
||||
def summarize(samples):
|
||||
result = {}
|
||||
keys = sorted({key for sample in samples for key in sample['readings']})
|
||||
for key in keys:
|
||||
rows = [s['readings'][key] for s in samples if key in s['readings']]
|
||||
values = [r['value'] for r in rows if r['value'] is not None]
|
||||
item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}),
|
||||
'available': len(values), 'unavailable': len(samples) - len(values)}
|
||||
if values and all(isinstance(v, (int, float)) for v in values):
|
||||
item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6),
|
||||
first=values[0], last=values[-1])
|
||||
else:
|
||||
item['states'] = sorted(set(values))
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
|
||||
def collect(status, count, interval, emit=None):
|
||||
samples, before = [], None
|
||||
started = time.monotonic()
|
||||
stopped = None
|
||||
for index in range(count):
|
||||
if index:
|
||||
time.sleep(max(0, started + index * interval - time.monotonic()))
|
||||
try:
|
||||
sample, before = snapshot(status, before)
|
||||
except RuntimeError as exc:
|
||||
stopped = str(exc)
|
||||
break
|
||||
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
|
||||
samples.append(sample)
|
||||
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():
|
||||
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([cell(v) for v in (sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE)])
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def parser():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
|
||||
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
|
||||
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
|
||||
ap.add_argument('--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
|
||||
|
||||
|
||||
def remote_payload():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
source = (root / 'ui/frame_status.py').read_text()
|
||||
# Executing as a module prevents frame_status.main() from probing unrelated
|
||||
# status. Nothing is installed, cached or logged on the headset.
|
||||
return ('import types, sys\n'
|
||||
'status = types.ModuleType("frame_status")\n'
|
||||
f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n'
|
||||
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
|
||||
|
||||
|
||||
def 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
|
||||
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:
|
||||
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.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:
|
||||
print(json.dumps(report, allow_nan=False))
|
||||
return 75 if report['stopped'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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.
|
||||
@@ -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; } }
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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});
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -31,7 +31,8 @@ function page(api) {
|
||||
|
||||
// A request that fails, or reaches a link that had dropped, keeps its keys and releases.
|
||||
for (const [label, api] of [["request failed", async () => { throw new Error("offline"); }],
|
||||
["not sent", async () => ({ state: "starting", sent: false })]]) {
|
||||
["not sent", async () => ({ state: "starting", sent: false })],
|
||||
["queue full", async () => ({ state: "waiting", sent: false })]]) {
|
||||
const { pad, padSend, shown } = page(api);
|
||||
padSend({ dx: 5 }); // sent and lost: a move, fine to drop
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
@@ -45,4 +46,29 @@ for (const [label, api] of [["request failed", async () => { throw new Error("of
|
||||
fail(label + ": " + JSON.stringify(q));
|
||||
if (!shown.length || shown.at(-1).state === "ready") fail(label + ": state not shown");
|
||||
}
|
||||
// Acknowledged text is removed locally, but its pending status must reach padShow
|
||||
// so failures reported after acceptance are polled without another keypress.
|
||||
{
|
||||
const { pad, padSend, shown } = page(async () => ({ state: "ready", sent: true, queued: 1 }));
|
||||
padSend({ key: "é" });
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
if (pad.queue.length || shown.at(-1)?.queued !== 1) fail("accepted text is not polled");
|
||||
}
|
||||
|
||||
// Exercise the real status/poll function: queued text leaves pointer controls
|
||||
// usable, still polls, shows an eventual error, and does not silently retry it.
|
||||
{
|
||||
const pad = { state: "ready", queue: [] }, elements = {};
|
||||
let poll = null;
|
||||
const show = new Function("pad", "$", "PAD_STATES", "TOUCH", "padFlush", "devGen", "api",
|
||||
"setTimeout", "clearTimeout", grab("padShow") + "; return padShow;")(
|
||||
pad, id => elements[id] ||= { classList: { toggle() {} } }, {}, false, () => {}, 0,
|
||||
async () => ({ state: "error", message: "Text is retained; try again." }),
|
||||
fn => { poll = fn; return 1; }, () => { poll = null; });
|
||||
show({ state: "ready", queued: 1 });
|
||||
if (pad.state !== "ready" || !poll) fail("pending text disabled pointer or skipped poll");
|
||||
await poll();
|
||||
if (pad.state !== "error" || poll || !elements.padHint.textContent.includes("retained"))
|
||||
fail("asynchronous error not surfaced");
|
||||
}
|
||||
console.log("queue rules ok");
|
||||
@@ -13,13 +13,13 @@ function grab(name) {
|
||||
if (start < 0) fail(`no top-level ${name} in index.html`);
|
||||
let depth = 0;
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
|
||||
for (const c of lines[i]) depth += c === "{" || c === "[" ? 1 : c === "}" || c === "]" ? -1 : 0;
|
||||
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
|
||||
}
|
||||
fail(`unbalanced ${name}`);
|
||||
}
|
||||
const names = ["esc", "storeName", "storeHue", "storeInitial", "storeText", "storeOpenSource", "sourcePage", "storeImage",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"GLYPH_VR", "GLYPH_APP", "RUNS", "storeRuns", "storeReportDetails", "storePickEligible", "STORE_CATS", "storeCat", "storeCard", "storeRow", "storeKind", "storeArt", "storeIcon", "sourceMark", "currentOffer", "detailBadges", "renderAppDetail", "renderAppDetailBody",
|
||||
"updateInstall", "openStoreApp", "loadOfferDetails", "installStoreApp"];
|
||||
const code = names.map(grab).join("");
|
||||
|
||||
@@ -34,7 +34,7 @@ function page(api = async () => ({}), androidApps = []) {
|
||||
const document = { activeElement: null, querySelector: () => null };
|
||||
const console = { error() {} };
|
||||
const calls = [];
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp };")(
|
||||
const fns = new Function("$", "sourceState", "document", "console", "api", "androidApps", "runJob", "loadAndroid", "act", "window", "devGen", code + "; return { renderAppDetail, openStoreApp, storeCard, storeRow, storePickEligible };")(
|
||||
$, sourceState, document, console, (...a) => { calls.push(a); return api(...a); }, androidApps,
|
||||
async (title, key, work) => work(), () => {}, async (title, work) => work(),
|
||||
{ open() { fail("an in-app installation opened an external page"); } }, 0);
|
||||
@@ -114,4 +114,31 @@ for (const [job, expected] of [[null, 1], [{ done: false }, 0],
|
||||
if (installs.some(([, body]) => body.source !== offer.source || body.id !== offer.id)) fail("installed a different offer");
|
||||
if (p.calls.some(([path]) => path === "/api/android")) fail("hero Install launched an already installed app");
|
||||
}
|
||||
|
||||
// Report warnings appear on real cards, shelves and details; they are advisory.
|
||||
for (const [kind, tone, label] of [['no', 'warning', 'Reported not working'], ['maybe', 'issues', 'Reported issues']]) {
|
||||
const p = page();
|
||||
const o = {...offer, verdict: {tone, label: label+' on the Frame'},
|
||||
compat_report: {verdict: kind, lines: ['Reported <unsafe> notes', '2 reports, 0 working']}};
|
||||
const app = {name: o.name, offers: [o]};
|
||||
p.sourceState.apps = [app];
|
||||
p.sourceState.detail = {app, choice: 0};
|
||||
const card = p.storeCard(app), shelf = p.storeRow('VR picks', 'Synthetic fixtures', [app]);
|
||||
if (!card.includes('>'+label+'</span>') || !shelf.includes('>'+label+'</span>')) fail('warning missing on card or shelf');
|
||||
if (!p.renderAppDetail()) fail('report detail did not render');
|
||||
const html = p.els.detailBody.innerHTML;
|
||||
if (!html.includes('Saved compatibility reports') || !html.includes('Reported <unsafe> notes')) fail('report evidence missing or unescaped');
|
||||
if (html.includes('Reported <unsafe>')) fail('report notes injected HTML');
|
||||
if (p.els.detailInstall.disabled || p.els.detailInstall.textContent !== 'Install on the Frame') fail('report disabled installation');
|
||||
if (kind==='no' && p.storePickEligible(o)) fail('broken report is eligible for featured/VR picks');
|
||||
// A positive report cannot remove a hard blocker. Negative evidence stays visible too.
|
||||
for (const reportKind of [kind, 'works']) {
|
||||
const blocked = {...o, verdict: {tone: 'blocked', label: 'No arm64 build'}, compat_report: {...o.compat_report, verdict: reportKind}};
|
||||
app.offers = [blocked];
|
||||
if (!p.renderAppDetail() || !p.els.detailInstall.disabled) fail('report overrode hard blocker');
|
||||
if (p.storePickEligible(blocked)) fail('blocked app eligible for picks');
|
||||
if (reportKind!=='works' && !p.storeCard(app).includes('>'+label+'</span>')) fail('hard blocker hid report warning');
|
||||
}
|
||||
}
|
||||
|
||||
console.log("ok");
|
||||
@@ -0,0 +1,70 @@
|
||||
// Run the real doctor page script with a minimal DOM; no browser or hardware I/O.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
class Element {
|
||||
constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.value = ''; this.hidden = false; this.disabled = false; this.textContent = ''; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
prepend(...nodes) { this.children.unshift(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
get firstChild() { return this.children[0]; }
|
||||
get options() { return this.children; }
|
||||
}
|
||||
const ids = ['back','inventory','diagnose','device','message','results','json','reportSection','selection','download','copy'];
|
||||
const elements = Object.fromEntries(ids.map(id => [id,new Element(id === 'device' ? 'select' : 'div')]));
|
||||
const requests = [];
|
||||
let pending;
|
||||
const context = vm.createContext({
|
||||
document:{getElementById:id=>elements[id],createElement:tag=>new Element(tag)},
|
||||
URLSearchParams,location:{search:'?key=test-key'},
|
||||
fetch:(path,options)=>{ requests.push({path,options}); return new Promise(resolve=>{pending=resolve;}); },
|
||||
navigator:{clipboard:{writeText:async()=>{throw Error('clipboard denied');}}},
|
||||
});
|
||||
const source = readFileSync(new URL('../../ui/wireless-doctor.html',import.meta.url),'utf8');
|
||||
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1],context);
|
||||
assert.equal(requests.length,0,'opening the page must not probe');
|
||||
assert.equal(elements.back.href,'/?key=test-key#devices');
|
||||
const fixture = {
|
||||
schema:1,supported:true,selected:null,
|
||||
usb:[{id:'usb:1-2',vid:'1234',pid:'5678',product:'<img src=x onerror=alert(1)>'}],
|
||||
interfaces:[{id:'net:wlan0',name:'wlan0',usb:'usb:1-2',driver:'synthetic'}],
|
||||
checks:[{id:'presence',title:'Presence',status:'ok',suggestion:'Select hardware',evidence:['Synthetic evidence']}]
|
||||
};
|
||||
let promise=elements.inventory.onclick();
|
||||
assert.equal(elements.device.disabled,true,'selection must stay fixed during a read');
|
||||
assert.equal(requests[0].path,'/api/wireless-doctor');
|
||||
assert.equal(requests[0].options.headers['X-Frame-UI'],'test-key');
|
||||
pending({ok:true,json:async()=>fixture}); await promise;
|
||||
assert.equal(elements.device.value,'','no automatic hardware choice');
|
||||
assert.equal(elements.diagnose.disabled,true);
|
||||
assert.equal(elements.device.options.length,3);
|
||||
assert.match(elements.device.options[2].textContent,/<img src=x/,'hardware labels must stay plain text');
|
||||
assert.equal(elements.device.options[2].children.length,0);
|
||||
assert.equal(elements.reportSection.hidden,false);
|
||||
assert.equal(elements.results.children.length,1);
|
||||
elements.device.value='net:wlan0'; elements.device.onchange();
|
||||
assert.equal(elements.diagnose.disabled,false);
|
||||
assert.equal(elements.results.children.length,0,'changing hardware clears the previous result');
|
||||
assert.equal(elements.reportSection.hidden,true,'cannot export an old report for a new selection');
|
||||
promise=elements.diagnose.onclick();
|
||||
assert.equal(requests[1].path,'/api/wireless-doctor?device=net%3Awlan0');
|
||||
assert.equal(elements.device.disabled,true);
|
||||
pending({ok:true,json:async()=>({...fixture,selected:'net:wlan0'})}); await promise;
|
||||
assert.equal(elements.device.value,'net:wlan0');
|
||||
assert.equal(elements.device.disabled,false);
|
||||
await elements.copy.onclick();
|
||||
assert.match(elements.message.textContent,/Clipboard unavailable/);
|
||||
promise=elements.diagnose.onclick();
|
||||
assert.equal(elements.results.children.length,0,'clear a healthy snapshot before retrying');
|
||||
assert.equal(elements.reportSection.hidden,true);
|
||||
pending({ok:false,json:async()=>({error:'Device is no longer present. Refresh and select an observed device.'})}); await promise;
|
||||
assert.match(elements.message.textContent,/no longer present/);
|
||||
assert.equal(elements.results.children.length,0);
|
||||
assert.equal(elements.reportSection.hidden,true,'failed requests cannot export stale success');
|
||||
assert.equal(elements.inventory.disabled,false,'can refresh after failure');
|
||||
promise=elements.inventory.onclick();
|
||||
pending({ok:true,json:async()=>({...fixture,supported:false,usb:[],interfaces:[]})}); await promise;
|
||||
assert.equal(elements.selection.hidden,true);
|
||||
assert.equal(elements.diagnose.disabled,true);
|
||||
assert.match(elements.message.textContent,/only on a Linux PC/);
|
||||
console.log('doctor UI: on-demand reads, explicit selection, safe labels, stale report clearing and failure recovery passed');
|
||||
@@ -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});}
|
||||
});
|
||||
@@ -22,6 +22,7 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
os.environ["FRAME_COMPAT_SEARCH_REPORTS"] = "0" # server subprocesses never start real report readers
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
|
||||
@@ -230,8 +230,9 @@ def cleanup(smoke):
|
||||
# It logs a failed sync and exits 0, so read its log; a second run lists
|
||||
# what Steam still has registered, which must not include ours.
|
||||
try:
|
||||
first = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
second = ssh('python3 ~/devkit-utils/steamos-delete 2>&1', timeout=120)
|
||||
command = frame_titles.devkit_script('delete') + ' 2>&1'
|
||||
first = ssh(command, timeout=120)
|
||||
second = ssh(command, timeout=120)
|
||||
for out in (first, second):
|
||||
if 'sync of devkit games failed' in out:
|
||||
raise AssertionError(out.strip().splitlines()[-1])
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const {test} = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const html = fs.readFileSync(path.join(__dirname, '../ui/index.html'), 'utf8');
|
||||
const code = html.split('// Local Steam helper: never start')[1].split('// ---- shared destinations')[0];
|
||||
|
||||
function fixture() {
|
||||
const elements = new Map(), requests = [];
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {disabled:true, textContent:''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const ctx = {$, api:(url, body) => new Promise((resolve, reject) => requests.push({url, body, resolve, reject}))};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext('// never start' + code, ctx);
|
||||
return {ctx, $, requests};
|
||||
}
|
||||
|
||||
test('navigation reads status; only clicks request launch or permission UI', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
assert.equal(requests.length, 0);
|
||||
const load = ctx.loadSteamPipewire();
|
||||
assert.equal(requests[0].url, '/api/steam-pipewire');
|
||||
assert.equal(requests[0].body, undefined);
|
||||
requests[0].resolve({available:true, guidance:'Verify the Steam session'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, false);
|
||||
assert.equal($('steamPipewireGuidance').textContent, 'Verify the Steam session');
|
||||
const launch = $('steamPipewireLaunch').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[1].body)), {action:'launch', confirmed:true});
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
await $('steamPipewireReset').onclick(); // repeated action suppressed
|
||||
assert.equal(requests.length, 2);
|
||||
requests[1].reject(new Error('Exit Steam first'));
|
||||
await launch;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Exit Steam first');
|
||||
const reset = $('steamPipewireReset').onclick();
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(requests[2].body)), {action:'permissions', confirmed:true});
|
||||
requests[2].resolve({reset:false, opened:true, message:'No permission was changed'});
|
||||
await reset;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
test('unsupported status and read failure keep actions disabled', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
requests[0].resolve({available:false, reason:'Local Wayland required'});
|
||||
await load;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
assert.equal($('steamPipewireReset').disabled, true);
|
||||
assert.equal($('steamPipewireStatus').textContent, 'Local Wayland required');
|
||||
const retry = ctx.loadSteamPipewire();
|
||||
requests[1].reject(new Error('Unavailable'));
|
||||
await retry;
|
||||
assert.equal($('steamPipewireLaunch').disabled, true);
|
||||
});
|
||||
|
||||
test('stale status cannot overwrite an action result', async () => {
|
||||
const {ctx, $, requests} = fixture();
|
||||
const load = ctx.loadSteamPipewire();
|
||||
const reset = ctx.steamPipewireAction('permissions');
|
||||
requests[1].resolve({message:'No permission was changed'});
|
||||
await reset;
|
||||
requests[0].resolve({available:true});
|
||||
await load;
|
||||
assert.equal($('steamPipewireStatus').textContent, 'No permission was changed');
|
||||
});
|
||||
|
||||
|
||||
test('Tools navigation opens the separate Steam panel without starting PC capture', () => {
|
||||
const {workspaceFixture} = require('./workspace_fixture.cjs');
|
||||
const f = workspaceFixture();
|
||||
f.navigate('#steamPipewire');
|
||||
assert.equal(f.activePage, 'tools');
|
||||
assert.equal(f.el('steamPipewire').hidden, false);
|
||||
assert.equal(f.el('macview').hidden, true);
|
||||
assert.ok(f.calls.some(call => call[0] === 'steam-pipewire'));
|
||||
assert.ok(!f.calls.some(call => call[0] === 'macview'));
|
||||
assert.match(html, /\$\("steamPipewireLink"\)\.hidden = h.mobile \|\| h.os !== "Linux"/);
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
|
||||
import sandbox # noqa: F401
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -173,5 +178,152 @@ class StagingTests(unittest.TestCase):
|
||||
{'android': False})
|
||||
|
||||
|
||||
class BundleTests(unittest.TestCase):
|
||||
setUp = BackupTests.setUp
|
||||
tearDown = StagingTests.tearDown
|
||||
def bundle(self, entries=None, suffix='.xapk'):
|
||||
path = self.root / ('backup' + suffix)
|
||||
path.write_bytes(apk(entries if entries is not None else {
|
||||
'backup/game.apk': self.apk.read_bytes(),
|
||||
f'backup/Android/obb/{PKG}/{self.obb[0].name}': b'game data',
|
||||
'backup/manifest.json': b'{"package_name":"untrusted.metadata"}',
|
||||
}))
|
||||
return path
|
||||
|
||||
def test_all_explicit_extensions_use_current_preview_and_cancel_cleanup(self):
|
||||
for suffix in ('.xapk', '.apks', '.apkm'):
|
||||
with self.subTest(suffix=suffix):
|
||||
source = self.bundle(suffix=suffix)
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
ssh.assert_not_called()
|
||||
self.assertEqual(read['plan']['package'], PKG)
|
||||
self.assertEqual(read['plan']['obb'][0]['name'], self.obb[0].name)
|
||||
folders = list(server._android_backups[read['token']]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': read['token']})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
|
||||
source = self.bundle()
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
class Request:
|
||||
headers = {'X-Filename': source.name, 'X-Mode': 'android-backup',
|
||||
'X-Android-Backup': token, 'Content-Length': str(source.stat().st_size)}
|
||||
rfile = io.BytesIO(source.read_bytes())
|
||||
server.Handler.upload(Request())
|
||||
read = server.android_backup({'action': 'inspect', 'token': token})
|
||||
self.assertEqual(read['plan']['package'], PKG)
|
||||
self.assertEqual(len(server._android_backups[token]['dirs']), 2)
|
||||
folders = list(server._android_backups[token]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': token})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
|
||||
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
|
||||
cases = [
|
||||
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
|
||||
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
|
||||
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
|
||||
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), '/outside.obb': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
|
||||
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
|
||||
({'manifest.json': b'{}'}, 'standalone APK'),
|
||||
]
|
||||
for entries, message in cases:
|
||||
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
|
||||
source = self.bundle(entries)
|
||||
with self.assertRaisesRegex(server.Failure, message):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
self.assertFalse(server._android_backups)
|
||||
ssh.assert_not_called()
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_links_limits_bad_zip_and_mixed_selection_are_refused(self):
|
||||
source = self.bundle({'base.apk': self.apk.read_bytes()})
|
||||
link = zipfile.ZipInfo('link.apk')
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
with zipfile.ZipFile(source, 'a') as archive:
|
||||
archive.writestr(link, 'somewhere')
|
||||
destination = self.root / 'extract'
|
||||
destination.mkdir()
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'links'):
|
||||
backup.unpack_bundle(source, destination)
|
||||
self.assertEqual(list(destination.iterdir()), [])
|
||||
source = self.bundle()
|
||||
for limit, value, message in [('MAX_FILES', 1, 'too many'), ('MAX_BYTES', 1, 'size limit')]:
|
||||
with patch.object(backup, limit, value):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.unpack_bundle(source, destination)
|
||||
with patch.object(backup.shutil, 'disk_usage', return_value=type('Disk', (), {'free': 0})()):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'staging space'):
|
||||
backup.unpack_bundle(source, destination)
|
||||
with self.assertRaisesRegex(server.Failure, 'one complete'):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source), str(self.apk)]})
|
||||
source.write_bytes(b'not a zip')
|
||||
with self.assertRaisesRegex(server.Failure, 'Could not read'):
|
||||
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
self.assertFalse(server._android_backups)
|
||||
|
||||
def test_manifest_split_and_required_split_are_refused(self):
|
||||
from test_quest import axml
|
||||
for attrs in ({'split': 'config.arm64'}, {'split_required': True},
|
||||
{'split_required': True, 'resmap': True}):
|
||||
data = axml(PKG, **attrs)
|
||||
if attrs.get('resmap'):
|
||||
# Strip the attribute name, retaining the Android resource ID.
|
||||
data = data.replace('isSplitRequired'.encode('utf-16-le'),
|
||||
('x' * len('isSplitRequired')).encode('utf-16-le'))
|
||||
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
|
||||
with self.subTest(attrs=attrs):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
|
||||
backup.inspect([str(self.apk)])
|
||||
|
||||
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
|
||||
source = self.bundle()
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
entry = server._android_backups[read['token']]
|
||||
folders = list(entry['dirs'])
|
||||
def job(label, work, **kwargs):
|
||||
return work(lambda *args: None)
|
||||
with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', side_effect=job), \
|
||||
patch.object(backup, 'install', side_effect=backup.android.FrameError('fixture install failure')) as install:
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'fixture install failure'):
|
||||
server.android_backup({'action': 'install', 'token': read['token']})
|
||||
install.assert_called_once_with(entry['plan'], 'auto', unittest.mock.ANY)
|
||||
self.assertFalse(server._android_backups)
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
@unittest.skipUnless(shutil.which('node'), 'needs node')
|
||||
def test_actual_send_handler_routes_bundle_to_importer_and_pc_zip_to_titles(self):
|
||||
page = (Path(__file__).resolve().parents[1] / 'ui/index.html').read_text(encoding='utf-8')
|
||||
function = page[page.index('async function sendFilesOne('):page.index('$("androidBackupChoose").onclick')]
|
||||
script = r"""
|
||||
const calls = [];
|
||||
global.window = {connDevice:'fixture'};
|
||||
global.$ = () => ({});
|
||||
global.refresh = () => {};
|
||||
global.toast = msg => {throw new Error(msg)};
|
||||
global.TITLE_EXT = /\.(zip|exe)$/i;
|
||||
global.importAndroidFiles = async files => calls.push(['android', files.map(f => f.name)]);
|
||||
global.sideload = async file => calls.push(['title', file.name]);
|
||||
global.act = () => {throw new Error('unexpected copy')};
|
||||
(async () => {
|
||||
for (const name of ['game.xapk', 'game.apks', 'game.apkm', 'pc.zip']) {
|
||||
await sendFilesOne([{name, size:123}], new Set(), 'fixture');
|
||||
}
|
||||
await sendFilesOne([{name:'base.apk',size:123},{name:'split.apk',size:123}], new Set(), 'fixture');
|
||||
process.stdout.write(JSON.stringify(calls));
|
||||
})().catch(e => {console.error(e); process.exitCode = 1});
|
||||
"""
|
||||
result = subprocess.run(['node', '-e', function + script], capture_output=True, text=True, check=True)
|
||||
self.assertEqual(json.loads(result.stdout), [
|
||||
['android', ['game.xapk']], ['android', ['game.apks']],
|
||||
['android', ['game.apkm']], ['title', 'pc.zip'],
|
||||
['android', ['base.apk', 'split.apk']]])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -17,6 +17,9 @@ PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').rea
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
with _images._lock:
|
||||
_images._urls.clear()
|
||||
_images._tokens.clear()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Search report snapshots: synthetic evidence only, no credentials or services."""
|
||||
import sandbox # noqa: F401
|
||||
import sys
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_catalog
|
||||
import frame_compat_db as db
|
||||
from apk_sources import search
|
||||
from test_apk_search import SettingsTest, fake, ENTRIES
|
||||
|
||||
|
||||
class ReportTests(SettingsTest):
|
||||
def hook(self, rows):
|
||||
by = frame_catalog.reports.by_package(rows)
|
||||
snapshot = {pkg: v for pkg, rs in by.items() if (v := frame_catalog.reports.verdict(rs))}
|
||||
p = patch.object(search, 'compat_reports', lambda: snapshot)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_search_and_details_share_catalogue_rules(self):
|
||||
pkg = ENTRIES[0]['package']
|
||||
self.hook([dict(package=pkg, date='2026-10-01', result='runs'),
|
||||
dict(package=pkg, date='2026-10-02', rating='broken', notes='Crashes <here>'),
|
||||
dict(package=pkg, date='2026-10-03', result='runs')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])):
|
||||
offer = search.search(timeout=.01)['apps'][0]['offers'][0]
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(detail['compat_report']['verdict'], 'no')
|
||||
self.assertEqual(offer['compat_report'], detail['compat_report'])
|
||||
self.assertEqual(detail['verdict']['tone'], 'warning')
|
||||
self.assertIn('Reported not working', detail['verdict']['label'])
|
||||
|
||||
def test_warnings_never_change_installability_or_hard_blockers(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=['arm64-v8a'])
|
||||
for kind, tone in [('works', 'works'), ('issues', 'issues'), ('broken', 'warning')]:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', rating=kind)])
|
||||
decorated = search.decorate(entry)
|
||||
self.assertEqual(decorated['verdict']['tone'], tone)
|
||||
self.assertTrue(decorated['fit']['installable'])
|
||||
for blocker in [dict(min_sdk=31), dict(abis=['x86_64']),
|
||||
dict(requires_meta_services=True), dict(engine='VrApi')]:
|
||||
blocked = search.decorate(dict(entry, **blocker))
|
||||
self.assertEqual(blocked['verdict']['tone'], 'blocked')
|
||||
self.assertIsNotNone(blocked['compat_report'])
|
||||
|
||||
def test_wrong_file_failures_do_not_warn_but_real_failures_do(self):
|
||||
entry = dict(ENTRIES[0], min_sdk=23, abis=[])
|
||||
for notes in ['no arm64-v8a build', 'needs Android API 32']:
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes=notes)])
|
||||
self.assertIsNone(search.decorate(entry)['compat_report'])
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='crashes')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
self.hook([dict(package=entry['package'], date='2026-10-01', result='install_failed', notes='installer crashed')])
|
||||
self.assertEqual(search.decorate(entry)['verdict']['tone'], 'warning')
|
||||
|
||||
def test_report_warning_does_not_prevent_install(self):
|
||||
self.hook([dict(package=ENTRIES[0]['package'], date='2026-10-01', rating='broken')])
|
||||
mod = fake()
|
||||
with patch.object(search, 'modules', return_value=([mod], [])), \
|
||||
patch.object(frame_catalog.frame_android, 'install', return_value={'package': ENTRIES[0]['package']}) as install:
|
||||
result = search.install('one', ENTRIES[0]['id'])
|
||||
self.assertEqual(result['package'], ENTRIES[0]['package'])
|
||||
install.assert_called_once()
|
||||
|
||||
def test_stalled_loader_does_not_block_search_or_details(self):
|
||||
entered, release, finished = threading.Event(), threading.Event(), threading.Event()
|
||||
main = threading.get_ident()
|
||||
|
||||
def loader():
|
||||
self.assertNotEqual(threading.get_ident(), main)
|
||||
entered.set()
|
||||
try:
|
||||
if not release.wait(2):
|
||||
raise TimeoutError('test stalled')
|
||||
return []
|
||||
finally:
|
||||
finished.set()
|
||||
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict)
|
||||
from apk_sources import _web
|
||||
with patch.object(search, 'compat_reports', cache.read), \
|
||||
patch.object(search, 'modules', return_value=([fake()], [])), \
|
||||
patch.object(_web, 'prune'), \
|
||||
patch.object(db, 'key', side_effect=AssertionError('credentials on request')), \
|
||||
patch.object(db._opener, 'open', side_effect=AssertionError('network on request')), \
|
||||
patch.object(db.subprocess, 'run', side_effect=AssertionError('subprocess on request')):
|
||||
try:
|
||||
search.warm() # production server startup, before the first request
|
||||
self.assertTrue(entered.wait(1))
|
||||
start = time.monotonic()
|
||||
result = search.search(timeout=.01)
|
||||
detail = search.details('one', ENTRIES[0]['id'])
|
||||
self.assertLess(time.monotonic() - start, .2)
|
||||
self.assertTrue(result['apps'])
|
||||
self.assertIsNone(detail['compat_report'])
|
||||
finally:
|
||||
release.set()
|
||||
self.assertTrue(finished.wait(1))
|
||||
|
||||
|
||||
class CacheTests(unittest.TestCase):
|
||||
def wait_idle(self, cache):
|
||||
deadline = time.monotonic() + 1
|
||||
while time.monotonic() < deadline:
|
||||
with cache._lock:
|
||||
if not cache._running:
|
||||
return
|
||||
time.sleep(.001)
|
||||
self.fail('report worker did not finish')
|
||||
|
||||
def test_public_copy_reads_its_local_mirror_and_outbox(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
mirror, outbox = Path(tmp) / 'mirror.json', Path(tmp) / 'outbox.jsonl'
|
||||
mirror.write_text(json.dumps({'reports': [dict(package='org.example.app', id='old',
|
||||
date='2026-10-01', result='runs')]}))
|
||||
outbox.write_text(json.dumps(dict(package='org.example.app', id='new',
|
||||
date='2026-10-02', rating='broken')) + '\n')
|
||||
with patch.object(db, 'MIRROR', str(mirror)), patch.object(db, 'OUTBOX', str(outbox)), \
|
||||
patch.object(db, '_mem', dict(at=0, reports=None, source=None)), \
|
||||
patch.object(db, 'shared', return_value=False), \
|
||||
patch.object(db, 'fetch_all', side_effect=AssertionError('private database read')):
|
||||
cache = search.ReportCache(db.load, frame_catalog.reports.verdict)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'no')
|
||||
|
||||
def test_ttl_refresh_is_single_flight_and_keeps_last_good_snapshot(self):
|
||||
loader = Mock(return_value=[dict(package='org.example.app', date='2026-10-01', rating='issues')])
|
||||
cache = search.ReportCache(loader, frame_catalog.reports.verdict, ttl=60)
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
first = cache.read()
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe')
|
||||
self.assertEqual(loader.call_count, 1)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
|
||||
def stalled():
|
||||
entered.set()
|
||||
release.wait(2)
|
||||
raise OSError('offline')
|
||||
|
||||
loader.side_effect = stalled
|
||||
cache._at -= 61
|
||||
try:
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertTrue(entered.wait(1))
|
||||
for _ in range(10):
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2)
|
||||
finally:
|
||||
release.set()
|
||||
self.wait_idle(cache)
|
||||
self.assertIs(cache.read(), first)
|
||||
self.assertEqual(loader.call_count, 2) # failure also has a retry TTL
|
||||
loader.side_effect = None
|
||||
loader.return_value = [dict(package='org.example.app', date='2026-10-02', rating='works')]
|
||||
cache._at -= 61
|
||||
cache.read()
|
||||
self.wait_idle(cache)
|
||||
self.assertEqual(cache.read()['org.example.app'][0], 'works')
|
||||
self.assertEqual(first['org.example.app'][0], 'maybe') # published snapshots never mutated
|
||||
@@ -27,6 +27,9 @@ def fake(source_id='one', fn=None):
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
p = patch.object(search, 'compat_reports', None)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
|
||||
|
||||
@@ -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,113 @@ 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_holo_nested_desktop_is_recognized(self):
|
||||
with patch.object(remote, 'SDDM_CONFS', ()), \
|
||||
patch.object(remote, '_session_target', return_value='not-found'), \
|
||||
patch.object(remote, '_running', side_effect=lambda name, full=False: name == 'holo-nested-desktop'), \
|
||||
patch.object(remote.shutil, 'which', return_value=None):
|
||||
self.assertTrue(remote.session()['nested_desktop'])
|
||||
|
||||
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 +484,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)
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ class ConfigBlock(unittest.TestCase):
|
||||
fc.write_config("10.0.0.5", 22, "deck")
|
||||
text = fc.CONFIG.read_text(encoding="utf-8")
|
||||
self.assertEqual(fc.configured_user(), "deck") # not "me" from Host other
|
||||
self.assertEqual(fc.configured_host(), "10.0.0.5")
|
||||
finally:
|
||||
fc.SSH_DIR, fc.CONFIG = saved
|
||||
self.assertEqual(text.count(fc.BEGIN), 1)
|
||||
|
||||
@@ -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)
|
||||
@@ -323,6 +323,16 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_mdns_result_remembers_numeric_ip_only_on_its_network(self):
|
||||
d = self.reg.add_device("frame-cache", hosts=["frame-cache.local"])
|
||||
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="192.168.1.40")
|
||||
a = fd.Registry(self.reg.path).get(d["id"])["addresses"][0]
|
||||
self.assertEqual((a["last_ip"], a["last_ip_network"]), ("192.168.1.40", "n-home"))
|
||||
self.reg.record_success(d["id"], "frame-cache.local", "n-home", 2.0, ip="host;bad")
|
||||
self.assertEqual(self.reg.get(d["id"])["addresses"][0]["last_ip"], "192.168.1.40")
|
||||
self.reg.update_address(d["id"], "frame-cache.local", new_host="frame-next.local")
|
||||
self.assertNotIn("last_ip", self.reg.get(d["id"])["addresses"][0])
|
||||
|
||||
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
|
||||
d = self.reg.add_device('frame', name='First')
|
||||
before = self.reg.path.stat()
|
||||
|
||||
@@ -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):
|
||||
@@ -45,7 +46,8 @@ class FrameDoctor(unittest.TestCase):
|
||||
self.assertFalse(any('/register' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_fallback_preserves_identity_and_disables_mutating_ssh_options(self):
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))])
|
||||
_, text, run = self.main([result(code=255), result(json.dumps({'guard': 'loop', 'checks': []}))],
|
||||
argv=['--lan', 'frame.local'])
|
||||
args = run.call_args_list[2].args[0]
|
||||
for flag in ('HostKeyAlias=pinned-frame', 'HostName=frame.local', 'BatchMode=yes',
|
||||
'StrictHostKeyChecking=yes', 'UpdateHostKeys=no', 'PermitLocalCommand=no',
|
||||
@@ -54,6 +56,10 @@ class FrameDoctor(unittest.TestCase):
|
||||
self.assertIn('OK Connection: LAN', text)
|
||||
self.assertIn('Counters are rising', text)
|
||||
|
||||
def test_no_implicit_mdns_fallback(self):
|
||||
_, _, run = self.main([result(code=255), result(code=255)])
|
||||
self.assertFalse(any('HostName=frame.local' in str(c.args[0]) for c in run.call_args_list))
|
||||
|
||||
def test_hostkey_failure_does_not_suggest_deleting_identity(self):
|
||||
_, text, _ = self.main([result(code=255, err='REMOTE HOST IDENTIFICATION HAS CHANGED')] * 3)
|
||||
self.assertIn('Do not delete known_hosts', text)
|
||||
@@ -105,6 +111,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
|
||||
|
||||
@@ -14,6 +14,15 @@ import frame_titles # noqa: E402
|
||||
from frame_titles import FrameError # noqa: E402
|
||||
|
||||
|
||||
class DevkitNames(unittest.TestCase):
|
||||
def test_holo_script_is_preferred_with_vendored_steamos_fallback(self):
|
||||
command = frame_titles.devkit_script('prepare-upload', '--gameid MyGame')
|
||||
self.assertLess(command.index('holo-prepare-upload'), command.index('steamos-prepare-upload'))
|
||||
self.assertIn('[ -f ~/devkit-utils/holo-prepare-upload ]', command)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_titles.devkit_script('unknown')
|
||||
|
||||
|
||||
def elf(machine, e_type=3, interp=True, pad=0):
|
||||
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
|
||||
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
|
||||
|
||||
@@ -18,6 +18,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -631,3 +632,428 @@ class PageQueue(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class PadEvents(unittest.TestCase):
|
||||
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.pad = staticmethod(server.pad_events)
|
||||
|
||||
def test_moves_clicks_and_scroll(self):
|
||||
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
|
||||
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
|
||||
self.assertEqual(out[0], {"dx": 3, "dy": -2})
|
||||
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
|
||||
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
|
||||
self.assertEqual(extra, [])
|
||||
|
||||
def test_keys_and_modifiers(self):
|
||||
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
|
||||
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
|
||||
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
|
||||
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
|
||||
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
|
||||
|
||||
def test_text_stays_ascii_through_gamescope(self):
|
||||
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
|
||||
self.assertEqual(out, [{"text": "Hi there"}])
|
||||
self.assertEqual(slow, [])
|
||||
|
||||
def test_keyboard_goes_to_kde_whole_and_in_order(self):
|
||||
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
|
||||
out, slow = self.pad(events, kde=True)
|
||||
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
|
||||
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
|
||||
|
||||
def test_shifted_key_with_modifier(self):
|
||||
out, _ = self.pad([{"key": "A", "ctrl": True}])
|
||||
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
|
||||
|
||||
|
||||
class PadDelivery(unittest.TestCase):
|
||||
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
|
||||
|
||||
class Agent:
|
||||
def __init__(self, sent=True, state="ready"):
|
||||
self.sent, self.state, self.got = sent, state, []
|
||||
|
||||
def send(self, events):
|
||||
if events and self.sent:
|
||||
self.got.append(events)
|
||||
return {"state": self.state, "sent": self.sent and bool(events)}
|
||||
|
||||
def setUp(self):
|
||||
import server
|
||||
self.s = server
|
||||
self.saved = (server._touch, server._input, server.KDE_WAIT)
|
||||
server._touch, server._input = self.Agent(), self.Agent()
|
||||
self.reset()
|
||||
|
||||
def tearDown(self):
|
||||
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
|
||||
self.settle()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.s._kde_queue.clear()
|
||||
self.s._kde_error = None
|
||||
self.s._kde_until = self.s._typed_until = 0.0
|
||||
self.s._kde_worker[0] = None
|
||||
|
||||
def settle(self):
|
||||
worker = self.s._kde_worker[0]
|
||||
if worker:
|
||||
worker.join(5)
|
||||
|
||||
def typed(self):
|
||||
return [e for batch in self.s._input.got for e in batch]
|
||||
|
||||
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
|
||||
status = self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.assertTrue(status["sent"]) # so the page doesn't send it again
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
|
||||
def test_mixed_text_stays_in_one_ordered_stream(self):
|
||||
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
|
||||
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
|
||||
|
||||
def test_batch_gamescope_didnt_take_is_not_queued(self):
|
||||
self.s._touch = self.Agent(sent=False, state="starting")
|
||||
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
|
||||
self.assertFalse(status["sent"]) # the page tries the whole batch again...
|
||||
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
|
||||
|
||||
def test_waits_for_kde_connect_then_sends_without_more_input(self):
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
|
||||
self.s._input.sent = True
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
|
||||
def test_later_ascii_waits_behind_a_pending_accent(self):
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.s.remote_input({"events": [{"key": "x"}]})
|
||||
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
|
||||
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
|
||||
self.s._input.sent = True
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||
|
||||
def test_errors_and_timeout_retain_accepted_text_for_explicit_retry(self):
|
||||
events = [{"key": "é"}] * 150
|
||||
for state in ("error", "starting"):
|
||||
with self.subTest(state=state):
|
||||
self.reset()
|
||||
self.s._input = self.Agent(sent=False, state=state)
|
||||
self.s.KDE_WAIT = 0
|
||||
self.assertTrue(self.s.remote_input({"events": events})["sent"])
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, events)
|
||||
self.assertIsNone(self.s._kde_worker[0])
|
||||
self.assertEqual(self.s._kde_until, 0)
|
||||
status = self.s.pad_status({"state": "ready"})
|
||||
self.assertEqual(status["state"], "error")
|
||||
self.assertIn("retained", status["message"])
|
||||
self.assertFalse(self.s.remote_input({"events": [{"key": "x"}]})["sent"])
|
||||
self.s._input = self.Agent()
|
||||
self.s.start_pad()
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), events)
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertIsNone(self.s._kde_error)
|
||||
|
||||
def test_worker_exception_and_start_failure_are_visible_and_retryable(self):
|
||||
for start_failure in (False, True):
|
||||
with self.subTest(start_failure=start_failure):
|
||||
self.reset()
|
||||
events = [{"key": "é"}]
|
||||
if start_failure:
|
||||
with mock.patch.object(threading.Thread, "start", side_effect=RuntimeError("no thread")):
|
||||
self.assertTrue(self.s.remote_input({"events": events})["sent"])
|
||||
else:
|
||||
with mock.patch.object(self.s._input, "send", side_effect=OSError("send failed")):
|
||||
self.assertTrue(self.s.remote_input({"events": events})["sent"])
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, events)
|
||||
self.assertIsNone(self.s._kde_worker[0])
|
||||
self.assertEqual(self.s._kde_until, 0)
|
||||
self.assertEqual(self.s.pad_status({"state": "ready"})["state"], "error")
|
||||
self.s._input = self.Agent()
|
||||
self.s.start_pad()
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), events)
|
||||
|
||||
def test_ascii_just_after_an_accent_stays_behind_it(self):
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
|
||||
self.settle()
|
||||
self.assertEqual(self.s._touch.got, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||
|
||||
def test_accent_just_after_gamescope_text_waits_for_it(self):
|
||||
self.s.remote_input({"events": [{"key": "x" * 50}]})
|
||||
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
|
||||
start = time.time()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
|
||||
def test_typing_time_counts_every_key_transition(self):
|
||||
t = self.s.typing_seconds
|
||||
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
|
||||
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
|
||||
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
|
||||
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
|
||||
|
||||
def test_the_margin_is_added_once_not_per_request(self):
|
||||
for _ in range(10):
|
||||
self.s.remote_input({"events": [{"key": "ab"}]})
|
||||
# ten requests of 4 transitions each: the work queues up, with no margin added per request
|
||||
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
|
||||
|
||||
def hold_worker(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
class Gate:
|
||||
def wait(self, timeout):
|
||||
entered.set()
|
||||
if not release.wait(3):
|
||||
raise RuntimeError("test did not release worker")
|
||||
return False
|
||||
patch = mock.patch.object(self.s, "_kde_cancel", Gate())
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.addCleanup(release.set)
|
||||
return entered, release
|
||||
|
||||
def test_saturation_rejects_whole_batch_and_retry_delivers_every_accepted_event_once(self):
|
||||
entered, release = self.hold_worker()
|
||||
self.s._typed_until = time.time() + 60
|
||||
first = [{"key": "é"}] * 150
|
||||
remainder = [{"key": "ü"}] * 50
|
||||
overflow = [{"singleclick": True}, {"key": "ß"}] * 75
|
||||
self.assertTrue(self.s.remote_input({"events": first})["sent"])
|
||||
self.assertTrue(entered.wait(1)) # batch is in flight, waiting for prior ASCII
|
||||
try:
|
||||
self.assertTrue(self.s.remote_input({"events": remainder})["sent"])
|
||||
status = self.s.remote_input({"events": overflow})
|
||||
self.assertFalse(status["sent"])
|
||||
self.assertEqual(status["state"], "waiting")
|
||||
self.assertEqual(self.s._kde_queue, first + remainder)
|
||||
self.assertEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
|
||||
self.assertEqual(self.s._touch.got, []) # rejected clicks cannot be duplicated
|
||||
finally:
|
||||
self.s._typed_until = 0
|
||||
release.set()
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), first + remainder)
|
||||
self.assertTrue(self.s.remote_input({"events": overflow})["sent"])
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), first + remainder + [{"key": "ß"}] * 75)
|
||||
self.assertEqual(self.s._touch.got, [[e for _ in range(75) for e in (
|
||||
{"button": "left", "down": True}, {"button": "left", "down": False})]])
|
||||
|
||||
def test_retrying_batch_keeps_its_capacity_and_order(self):
|
||||
entered, release = self.hold_worker()
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
first, second = [{"key": "é"}] * 150, [{"key": "ü"}] * 150
|
||||
self.assertTrue(self.s.remote_input({"events": first})["sent"])
|
||||
self.assertTrue(entered.wait(1)) # first send failed; worker is in retry delay
|
||||
try:
|
||||
self.assertFalse(self.s.remote_input({"events": second})["sent"])
|
||||
self.assertEqual(self.s._kde_queue, first)
|
||||
finally:
|
||||
self.s._input.sent = True
|
||||
release.set()
|
||||
self.settle()
|
||||
self.assertTrue(self.s.remote_input({"events": second})["sent"])
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), first + second)
|
||||
|
||||
def test_concurrent_requests_cannot_both_claim_the_last_capacity(self):
|
||||
entered, release = self.hold_worker()
|
||||
self.s._typed_until = time.time() + 60
|
||||
first = [{"key": "é"}]
|
||||
self.s.remote_input({"events": first})
|
||||
self.assertTrue(entered.wait(1))
|
||||
barrier, results = threading.Barrier(3), []
|
||||
def request(key):
|
||||
barrier.wait(2)
|
||||
batch = [{"key": key}] * 150
|
||||
results.append((batch, self.s.remote_input({"events": batch})["sent"]))
|
||||
threads = [threading.Thread(target=request, args=(key,)) for key in ("ü", "ß")]
|
||||
try:
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
barrier.wait(2)
|
||||
for thread in threads:
|
||||
thread.join(2)
|
||||
self.assertFalse(thread.is_alive())
|
||||
self.assertEqual(sorted(sent for _, sent in results), [False, True])
|
||||
finally:
|
||||
self.s._typed_until = 0
|
||||
release.set()
|
||||
self.settle()
|
||||
accepted = next(batch for batch, sent in results if sent)
|
||||
rejected = next(batch for batch, sent in results if not sent)
|
||||
self.assertEqual(self.typed(), first + accepted)
|
||||
self.assertTrue(self.s.remote_input({"events": rejected})["sent"])
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), first + accepted + rejected)
|
||||
|
||||
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
|
||||
for _ in range(40):
|
||||
self.reset()
|
||||
self.s._input.got.clear()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
time.sleep(0.002)
|
||||
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
|
||||
|
||||
|
||||
class PadRoute(unittest.TestCase):
|
||||
def test_unicode_start_and_headset_switch_do_not_deadlock(self):
|
||||
# Real InputAgent.send/start takes the route lock. Race it with route(),
|
||||
# which cancels the pad worker; mocks of send() hide this lock inversion.
|
||||
# A child keeps a regression's blocked daemon threads out of this suite.
|
||||
code = r'''
|
||||
import sandbox
|
||||
import server as s
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
entered, release, switching = threading.Event(), threading.Event(), threading.Event()
|
||||
s._input = s.InputAgent(packages=[])
|
||||
s._input._launch = lambda *args: None # exercise real start, without any SSH
|
||||
s._touch = mock.Mock()
|
||||
send = s._input.send
|
||||
def held(events):
|
||||
entered.set()
|
||||
assert release.wait(2)
|
||||
return send(events)
|
||||
s._input.send = held
|
||||
s._power.stop = lambda *args: switching.set()
|
||||
s._queue_for_kde([{'key': 'é'}])
|
||||
worker = s._kde_worker[0]
|
||||
assert entered.wait(2), 'Unicode worker never reached send'
|
||||
switch = threading.Thread(target=s.route, args=('frame-pr66-new', []), daemon=True)
|
||||
switch.start()
|
||||
switching.wait(0.2)
|
||||
release.set()
|
||||
worker.join(2)
|
||||
switch.join(2)
|
||||
assert not worker.is_alive(), 'Unicode worker deadlocked starting input during headset switch'
|
||||
assert not switch.is_alive(), 'headset switch deadlocked cancelling Unicode input'
|
||||
assert not s._kde_queue
|
||||
'''
|
||||
env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(ROOT / 'tests'), str(ROOT / 'ui')])}
|
||||
result = subprocess.run([sys.executable, '-c', code], env=env, capture_output=True,
|
||||
text=True, timeout=8)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def setUp(self):
|
||||
import server
|
||||
self.s = server
|
||||
self.agents = mock.patch.multiple(server, _touch=mock.Mock(), _input=mock.Mock())
|
||||
self.agents.start()
|
||||
self.addCleanup(self.agents.stop)
|
||||
self.s._touch.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s._input.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s.reset_pad_route()
|
||||
self.s._touch.reset_mock(); self.s._input.reset_mock()
|
||||
self.addCleanup(self.s.reset_pad_route)
|
||||
|
||||
def switch(self):
|
||||
with mock.patch.multiple(self.s, FRAME="frame-a", HOST_OPTS=[], MUX=[], SSH=[], macview=None), \
|
||||
mock.patch.multiple(self.s.frame_android, FRAME="frame-a", SSH_OPTS=[]):
|
||||
self.s.route("frame-b", [])
|
||||
|
||||
def test_stale_worker_exception_cannot_damage_new_route(self):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
class BrokenWait:
|
||||
def wait(self, timeout):
|
||||
entered.set()
|
||||
if not release.wait(2):
|
||||
raise RuntimeError("test did not release old worker")
|
||||
raise OSError("old route wait failed")
|
||||
def set(self):
|
||||
pass # hold its exception until the new route has been published
|
||||
self.s._kde_cancel = BrokenWait()
|
||||
self.s._typed_until = time.time() + 60
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
worker = self.s._kde_worker[0]
|
||||
try:
|
||||
self.assertTrue(entered.wait(1))
|
||||
self.switch()
|
||||
marker = object()
|
||||
with self.s._kde_lock:
|
||||
self.s._kde_queue.append({"key": "new"})
|
||||
self.s._kde_worker[0] = marker
|
||||
self.s._kde_until = 42
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(2)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(self.s._kde_queue, [{"key": "new"}])
|
||||
self.assertIs(self.s._kde_worker[0], marker)
|
||||
self.assertEqual(self.s._kde_until, 42)
|
||||
self.assertIsNone(self.s._kde_error)
|
||||
|
||||
def test_switch_stops_both_input_agents_and_discards_old_queue(self):
|
||||
self.s._kde_queue.append({"key": "old"})
|
||||
self.s._kde_until = self.s._typed_until = time.time() + 60
|
||||
cancel = self.s._kde_cancel
|
||||
self.switch()
|
||||
self.s._input.stop.assert_called_once()
|
||||
self.s._touch.stop.assert_called_once()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertEqual((self.s._kde_until, self.s._typed_until), (0, 0))
|
||||
self.assertTrue(cancel.is_set())
|
||||
|
||||
def test_switch_cancels_delayed_and_retrying_text_before_new_input(self):
|
||||
for delaying in (True, False):
|
||||
with self.subTest(delaying=delaying):
|
||||
waiting, cancel = threading.Event(), threading.Event()
|
||||
class ObservedCancel:
|
||||
def wait(self, timeout):
|
||||
waiting.set()
|
||||
return cancel.wait(timeout)
|
||||
def set(self):
|
||||
cancel.set()
|
||||
self.s.reset_pad_route()
|
||||
self.s._kde_cancel = ObservedCancel()
|
||||
self.s._typed_until = time.time() + 60 if delaying else 0
|
||||
self.s._input.reset_mock()
|
||||
self.s._input.send.return_value = {"state": "starting", "sent": False}
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
worker = self.s._kde_worker[0]
|
||||
self.assertTrue(waiting.wait(1), "worker never reached its cancellable delay")
|
||||
self.switch()
|
||||
worker.join(1)
|
||||
self.assertFalse(worker.is_alive())
|
||||
attempts = self.s._input.send.call_count
|
||||
self.assertEqual(attempts, 0 if delaying else 1)
|
||||
self.s._input.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||
next_worker = self.s._kde_worker[0]
|
||||
if next_worker:
|
||||
next_worker.join(1)
|
||||
self.assertFalse(next_worker.is_alive())
|
||||
self.assertEqual(self.s._input.send.call_count, attempts + 1)
|
||||
self.assertEqual(self.s._input.send.call_args.args[0], [{"key": "ü"}])
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
@@ -58,6 +58,50 @@ class FailureDetail(unittest.TestCase):
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_mdns_falls_back_only_to_ip_authenticated_on_this_network(self):
|
||||
address = {"host": "frame.local", "kind": "mdns", "last_ip": "192.168.1.40",
|
||||
"last_ip_network": "n-home"}
|
||||
def result(host, port, **kwargs):
|
||||
return {"state": "answered", "ip": host, "rtt_ms": 1} if host == "192.168.1.40" else \
|
||||
{"state": "unresolved", "detail": "Can't find name"}
|
||||
with mock.patch.object(fl, "probe", side_effect=result) as probe:
|
||||
self.assertEqual(fl.probe_address(address, 22, "n-home")["ip"], "192.168.1.40")
|
||||
self.assertEqual([c.args[0] for c in probe.call_args_list], ["frame.local", "192.168.1.40"])
|
||||
probe.reset_mock()
|
||||
self.assertEqual(fl.probe_address(address, 22, "n-cafe")["state"], "unresolved")
|
||||
probe.assert_called_once()
|
||||
|
||||
def test_find_uses_cached_ip_and_neighbours_without_trusting_them(self):
|
||||
device = {"id": "d1", "alias": "frame", "port": 22, "addresses": [{"host": "frame.local", "kind": "mdns",
|
||||
"last_ip": "192.168.1.40", "last_ip_network": "n-test"}]}
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = device
|
||||
link.state = {"network": NET}
|
||||
def answer(host, port, timeout=3):
|
||||
return {"state": "answered" if host in ("192.168.1.40", "192.168.1.41") else "unresolved",
|
||||
"detail": "test", "ip": host if host.startswith("192.") else None, "rtt_ms": 1}
|
||||
with mock.patch.object(fl, "probe", side_effect=answer), \
|
||||
mock.patch.object(fl.frame_network, "local_scan_candidates", return_value=["192.168.1.40", "192.168.1.41"]), \
|
||||
mock.patch.object(fl.frame_network, "neighbour_cache", return_value=""), \
|
||||
mock.patch.object(fl.frame_devices, "pinned_keys", return_value=[("ssh-ed25519", "SHA256:test")]), \
|
||||
mock.patch.object(fl, "matches_pinned_key", return_value=True), \
|
||||
mock.patch.object(fl.frame_host, "which", return_value=None), \
|
||||
mock.patch("frame_connect.discover_devkit", return_value=[]):
|
||||
found = fl.mdns_find(link)
|
||||
self.assertEqual(found["scanned"], 1) # cached IP is tried once
|
||||
self.assertEqual({h["host"] for h in found["hosts"]}, {"192.168.1.40", "192.168.1.41"})
|
||||
self.assertTrue(found["hosts"][0]["added"] is False)
|
||||
|
||||
def test_scan_requires_saved_matching_ssh_key(self):
|
||||
key = ("ssh-ed25519", fd.fingerprint("a2V5"))
|
||||
response = subprocess.CompletedProcess([], 0, "192.168.1.41 ssh-ed25519 a2V5\n", "")
|
||||
with mock.patch.object(fl.frame_host, "which", return_value="ssh-keyscan"), \
|
||||
mock.patch.object(fl.frame_host, "run_ssh", return_value=response):
|
||||
self.assertTrue(fl.matches_pinned_key("192.168.1.41", 22, [key]))
|
||||
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, [("ssh-ed25519", "SHA256:other")]))
|
||||
self.assertFalse(fl.matches_pinned_key("name;bad", 22, [key]))
|
||||
self.assertFalse(fl.matches_pinned_key("192.168.1.41", 22, []))
|
||||
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
|
||||
@@ -64,6 +64,7 @@ class LinkUI(unittest.TestCase):
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>Offline<span class="via"> · retrying in 12 s<\/span>/);
|
||||
assert.ok($('conn').title.includes(changed.error.message));
|
||||
assert.ok(!$('conn').title.includes('..'), $('conn').title);
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
@@ -120,7 +121,9 @@ class LinkUI(unittest.TestCase):
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
# More specific than the later .item .s rule: the old equal-specificity
|
||||
# rule passed this test while the browser still clipped the explanation.
|
||||
self.assertIn(".item.dev-problem .s { white-space: normal; overflow-wrap: anywhere; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Issue #81: synthetic, deterministic log scrubbing; no sockets or private data."""
|
||||
import sandbox # noqa: F401
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_link as fl
|
||||
import frame_telemetry as tm
|
||||
|
||||
|
||||
def baseline(text, hosts):
|
||||
"""Frozen v0.4.11 connection pipeline, before issue #81's extra spans.
|
||||
|
||||
Keep these operations independent of frame_link's implementations so a change
|
||||
to its ordering or matching cannot silently weaken the differential oracle.
|
||||
The shared scrubber's default behavior is deliberately unchanged by this fix.
|
||||
"""
|
||||
keep = {'host', 'hostname', 'to', 'port', 'answer', 'the', 'a', 'an', 'this',
|
||||
'it', 'its', 'be', 'connect', 'find', 'work', 'try'}
|
||||
names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True)
|
||||
parts = re.split(r'(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)', text)
|
||||
for i in range(0, len(parts), 2):
|
||||
for name in names:
|
||||
parts[i] = re.sub(r'(?<![\w.:-])%s(?![\w-]|[:.%%]\w)' % re.escape(name),
|
||||
'<host>', parts[i], flags=re.I)
|
||||
plain = keep - {'host', 'hostname', 'to', 'port'}
|
||||
|
||||
def operand(match):
|
||||
word = match.group(2).rstrip('.')
|
||||
return (match.group(0) if word.lower() in plain else
|
||||
f'{match.group(1)} <host>{match.group(2)[len(word):]}')
|
||||
|
||||
text = re.sub(r'''(?i)\b(hostname|host|to(?:\s+host)?)\s+(?!<)([^\s:,;'"()|]+)''',
|
||||
operand, ''.join(parts))
|
||||
return tm.scrub(text, 600)
|
||||
|
||||
|
||||
class LogScrubbing(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patch = mock.patch.object(tm, '_user_names', return_value={'SyntheticLogin'})
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
def test_display_name_crosses_a_scrubber_token(self):
|
||||
name = '<host> Jane Doe headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' stopped answering', [name]), '<host> stopped answering')
|
||||
self.assertEqual(fl.scrub_failure('<user>@<host>: <ip>', ['user', 'host', 'ip']),
|
||||
'<user>@<host>: <ip>')
|
||||
self.assertEqual(fl.scrub_failure('<host>', ['<host>']), '<host>')
|
||||
|
||||
def test_all_standalone_tokens_survive_and_whole_names_cross_them(self):
|
||||
for token in ('host', 'user', 'email', 'ip', 'mac', 'steamid', 'hex',
|
||||
'token', 'ssh-key', 'pem', 'url', 'redacted'):
|
||||
token = '<' + token + '>'
|
||||
name = token + ' Synthetic Fixture headset'
|
||||
with self.subTest(token=token):
|
||||
self.assertEqual(fl.scrub_failure(token, [token, token[1:-1]]), token)
|
||||
self.assertEqual(fl.scrub_failure(name.upper() + ' failed', [name]), '<host> failed')
|
||||
|
||||
def test_a_name_crossing_the_output_limit_leaves_no_fragment(self):
|
||||
name = '<host> ' + 'Synthetic ' * 100 + 'headset'
|
||||
self.assertEqual(fl.scrub_failure(name + ' trailing detail', [name]), '<host>')
|
||||
|
||||
def test_display_name_cannot_swallow_ssh_operand_marker(self):
|
||||
text = 'ssh: connect to host bastion-jane port 22: Connection refused'
|
||||
self.assertEqual(fl.scrub_failure(text, ['to host']),
|
||||
'ssh: connect <host> <host> port 22: Connection refused')
|
||||
|
||||
def test_reverted_fix_counterexamples(self):
|
||||
cases = [
|
||||
('Could not connect to hostname bastion-jane', ['connect to'], ['bastion-jane']),
|
||||
('Jane Smith@<host>: Permission denied', ['Smith@<host>'], ['Jane', 'Smith']),
|
||||
('frame.local Jane Doe headset stopped answering', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('x ' * 295 + 'frame.local Jane Doe headset', ['frame.local Jane Doe headset'], ['Jane', 'Doe']),
|
||||
('https://jane@example.com/private(confidential)', ['jane@example.com'], ['private', 'confidential']),
|
||||
(r'C:\Users\Jane@workpc (Smith)\.ssh\config', ['Jane@workpc'], ['Jane', 'Smith']),
|
||||
('-----BEGIN PRIVATE KEY-----\nSYNTHETICBODY\n-----END PRIVATE KEY-----@frame',
|
||||
['KEY-----@frame'], ['SYNTHETICBODY']),
|
||||
('Could not connect to hostname /home/Jane Doe/.ssh/config', ['connect to'], ['Jane', 'Doe']),
|
||||
]
|
||||
for text, hosts, private in cases:
|
||||
with self.subTest(text=text):
|
||||
out = fl.scrub_failure(text, hosts)
|
||||
for value in private:
|
||||
self.assertNotIn(value, out)
|
||||
self.assertLessEqual(len(out), 600)
|
||||
|
||||
def test_overlapping_names_and_operands_and_literal_tokens(self):
|
||||
cases = [
|
||||
('Alpha Beta Gamma failed', ['Alpha Beta', 'Beta Gamma'], '<host> failed'),
|
||||
('<host> Jane Doe headset failed', ['<host> Jane Doe headset', 'Jane Doe'], '<host> failed'),
|
||||
('to hostname bastion-jane', [], 'to <host> <host>'),
|
||||
('<host failed', ['<host'], '<host> <host>'),
|
||||
]
|
||||
for text, hosts, expected in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(fl.scrub_failure(text, hosts), expected)
|
||||
|
||||
def test_randomized_differential_against_v0411(self):
|
||||
rng = random.Random(81)
|
||||
templates = [
|
||||
'https://{u}@example.invalid/{p}(confidential)?key={p}',
|
||||
r'C:\Users\{u}@workpc ({p})\.ssh\config',
|
||||
'/home/{u} {p}/.ssh/config',
|
||||
'-----BEGIN PRIVATE KEY-----\n{p}\n-----END PRIVATE KEY-----@frame',
|
||||
'{u}.{p}@example.invalid',
|
||||
'fe80::abcd%{u}',
|
||||
'{u} {p}@frame.local: Permission denied',
|
||||
'ssh-ed25519 {p}',
|
||||
'password={p}',
|
||||
'<host> {u} {p} headset stopped answering',
|
||||
'frame.local {u} {p} headset stopped answering',
|
||||
]
|
||||
for iteration in range(1200):
|
||||
user = 'Synthetic' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=14))
|
||||
private = 'Fixture' + ''.join(rng.choices('abcdefghijklmnopqrstuvxyz', k=18))
|
||||
field = rng.choice(templates).format(u=user, p=private)
|
||||
prefix = rng.choice(['', 'Could not connect to hostname ', 'ssh: connect to host ',
|
||||
'Timed out talking to ', 'debug | ', 'x ' * 295])
|
||||
text = prefix + field + rng.choice(['', ' port 22: refused', '\nnext line', "'s password: "])
|
||||
# Names intentionally cut through the syntax later scrubber rules need.
|
||||
options = [user, user + '@workpc', private, 'connect to', 'to host',
|
||||
'<host> ' + user + ' ' + private + ' headset',
|
||||
'frame.local ' + user + ' ' + private + ' headset',
|
||||
'KEY-----@frame', 'host', '<host>', 'SyntheticLogin']
|
||||
hosts = rng.sample(options, rng.randrange(5))
|
||||
before, after = baseline(text, hosts), fl.scrub_failure(text, hosts)
|
||||
with self.subTest(iteration=iteration, text=text, hosts=hosts):
|
||||
trace = fl._ScrubTrace(text)
|
||||
traced = tm.scrub(fl.hide_operands(fl.hide_hosts(text, hosts, _trace=trace),
|
||||
_sub=trace.sub), 600, _sub=trace.sub)
|
||||
self.assertEqual(traced, before)
|
||||
# Compare every original three-character window, not just whole
|
||||
# canaries: even a partially exposed user/path/secret is a regression.
|
||||
# Mask placeholders with a separator so removing one cannot join
|
||||
# unrelated text into a misleadingly new three-character window.
|
||||
old_visible = fl.PLACEHOLDER.sub('\0', before)
|
||||
new_visible = fl.PLACEHOLDER.sub('\0', after)
|
||||
for start in range(len(text) - 2):
|
||||
fragment = text[start:start + 3]
|
||||
if fragment not in old_visible:
|
||||
self.assertNotIn(fragment, new_visible)
|
||||
self.assertLessEqual(len(after), 600)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -68,7 +68,7 @@ class Media(unittest.TestCase):
|
||||
self.assertIn('-frames:v', cmd)
|
||||
|
||||
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
|
||||
fail=None, layout='auto'):
|
||||
fail=None, layout='auto', on_create=None, on_call=None):
|
||||
"""Run the player against a fake OpenVR; returns (status, pixels calls).
|
||||
|
||||
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
|
||||
@@ -82,11 +82,18 @@ class Media(unittest.TestCase):
|
||||
write_status(path, **values)
|
||||
|
||||
class FakeOverlay:
|
||||
created = 0
|
||||
|
||||
def create(self, *a, **k):
|
||||
return len(calls)
|
||||
# Handles in creation order: surround 0, then screen 1 (theatre).
|
||||
FakeOverlay.created += 1
|
||||
if on_create:
|
||||
on_create(*a, **k)
|
||||
return FakeOverlay.created - 1
|
||||
|
||||
def call(self, *a):
|
||||
pass
|
||||
if on_call:
|
||||
on_call(*a)
|
||||
|
||||
def pixels(self, handle, data, w, h):
|
||||
calls.append((handle, w, h))
|
||||
@@ -132,6 +139,58 @@ class Media(unittest.TestCase):
|
||||
self.assertEqual(result['dropped'], 2)
|
||||
self.assertIn((0, 1, 1), calls[3:])
|
||||
|
||||
def test_theatre_surround_failure_does_not_stop_playback(self):
|
||||
def fail_surround(n):
|
||||
if n == 1: # the surround's upload is the first pixels call
|
||||
raise RuntimeError('OpenVR SetOverlayRaw failed: 11')
|
||||
result, _ = self.play_with('clip_SBS.mp4', on_pixels=fail_surround)
|
||||
self.assertEqual((result['state'], result['frames']), ('ended', 4))
|
||||
|
||||
def test_theatre_surround_creation_and_setup_failures_are_cosmetic(self):
|
||||
def fail_create(key, *a, **k):
|
||||
if key.endswith('.surround'):
|
||||
raise RuntimeError('OpenVR CreateOverlay failed: 11')
|
||||
def fail_alpha(name, *a):
|
||||
if name == 'SetOverlayAlpha':
|
||||
raise RuntimeError('OpenVR SetOverlayAlpha failed: 11')
|
||||
for hooks in ({'on_create': fail_create}, {'on_call': fail_alpha}):
|
||||
with self.subTest(hooks=hooks), patch('builtins.print') as log:
|
||||
result, calls = self.play_with('clip_SBS.mp4', **hooks)
|
||||
self.assertEqual((result['state'], result['frames']), ('ended', 4))
|
||||
self.assertEqual(sum(h == 1 for h, w, hgt in calls), 4)
|
||||
self.assertIn('Theatre surround unavailable:', log.call_args.args[0])
|
||||
|
||||
def test_theatre_surround_refusal_after_standby_is_not_retried(self):
|
||||
def refuse_retry(n):
|
||||
if n == 3: # surround busy, screen accepted, surround retry refused
|
||||
raise RuntimeError('OpenVR SetOverlayRaw failed: 11')
|
||||
with patch('builtins.print') as log:
|
||||
result, calls = self.play_with('clip_SBS.mp4', busy=1, on_pixels=refuse_retry)
|
||||
self.assertEqual((result['state'], result['frames'], result['dropped']), ('ended', 4, 0))
|
||||
self.assertEqual(sum(h == 0 for h, w, hgt in calls), 2)
|
||||
self.assertIn('Theatre surround unavailable:', log.call_args.args[0])
|
||||
|
||||
def test_screen_refusal_still_fails_playback(self):
|
||||
def refuse_screen(n):
|
||||
if n == 2:
|
||||
raise RuntimeError('OpenVR SetOverlayRaw failed: 11')
|
||||
with self.assertRaisesRegex(RuntimeError, 'SetOverlayRaw failed: 11'):
|
||||
self.play_with('clip_SBS.mp4', on_pixels=refuse_screen)
|
||||
|
||||
def test_close_attempts_every_overlay_and_shuts_down_after_teardown_errors(self):
|
||||
for error in (RuntimeError('OpenVR error'), ValueError('bad handle'),
|
||||
InterruptedError('late Stop')):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
overlay = player.Overlay.__new__(player.Overlay)
|
||||
overlay.handles = [10, 20]
|
||||
overlay.vr = unittest.mock.Mock()
|
||||
overlay.call = unittest.mock.Mock(side_effect=[error, None])
|
||||
overlay.close()
|
||||
self.assertEqual(overlay.call.call_args_list, [
|
||||
unittest.mock.call('DestroyOverlay', 20),
|
||||
unittest.mock.call('DestroyOverlay', 10)])
|
||||
overlay.vr.VR_ShutdownInternal.assert_called_once_with()
|
||||
|
||||
def test_stop_mid_video_reports_stopped(self):
|
||||
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
|
||||
self.assertEqual(result['state'], 'stopped')
|
||||
|
||||
@@ -102,6 +102,17 @@ class Parsers(unittest.TestCase):
|
||||
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
|
||||
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
|
||||
|
||||
def test_neighbour_scan_stays_on_local_subnet(self):
|
||||
nearby = ("192.168.50.31 dev wlan0 lladdr 00:11:22:33:44:55 REACHABLE\n"
|
||||
"192.168.51.31 dev wlan0 lladdr 00:11:22:33:44:66 STALE\n"
|
||||
"192.168.50.1 dev wlan0 lladdr 00:11:22:33:44:77 STALE\n")
|
||||
self.assertEqual(fn.parse_neighbours(nearby, "192.168.50.9", "192.168.50.1"), ["192.168.50.31"])
|
||||
candidates = fn.local_scan_candidates("192.168.50.9", "192.168.50.1", nearby)
|
||||
self.assertEqual(candidates[0], "192.168.50.31")
|
||||
self.assertEqual(len(candidates), 252)
|
||||
self.assertNotIn("192.168.51.31", candidates)
|
||||
self.assertEqual(fn.local_scan_candidates("100.101.1.4", "100.101.1.1"), [])
|
||||
|
||||
def test_windows(self):
|
||||
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
|
||||
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
|
||||
|
||||
@@ -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', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -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)
|
||||
@@ -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('<script>', 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'], [])
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Power sampler fixtures and SSH transport contract; no real device contact."""
|
||||
import sandbox # noqa: F401
|
||||
import contextlib
|
||||
import csv
|
||||
import importlib.util
|
||||
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]
|
||||
spec = importlib.util.spec_from_file_location('power_sample', ROOT / 'scripts/frame_power_sample.py')
|
||||
power = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(power)
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import frame_status as status
|
||||
|
||||
|
||||
class Sampler(unittest.TestCase):
|
||||
def patch(self, obj, name, **kwargs):
|
||||
p = mock.patch.object(obj, name, **kwargs)
|
||||
result = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
return result
|
||||
|
||||
def fixture(self, files):
|
||||
# Windows cannot create a directory named iio:device8. Keep the
|
||||
# production sysfs spelling in returned paths while using a portable
|
||||
# name inside this temporary fixture tree.
|
||||
def local_path(path):
|
||||
return self.root / str(path).replace('\\', '/').lstrip('/').replace(':', '__COLON__')
|
||||
|
||||
for path, value in files.items():
|
||||
node = local_path(path)
|
||||
node.parent.mkdir(parents=True, exist_ok=True)
|
||||
node.write_text(str(value))
|
||||
actual_glob = power.glob.glob
|
||||
def paths(pattern):
|
||||
return ['/' + Path(p).relative_to(self.root).as_posix().replace('__COLON__', ':')
|
||||
for p in actual_glob(str(local_path(pattern)))]
|
||||
actual_read = power.read
|
||||
self.patch(power.glob, 'glob', side_effect=paths)
|
||||
self.patch(power, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
|
||||
self.patch(status, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
|
||||
return local_path
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name)
|
||||
p = mock.patch.object(power, 'LOCK', self.root / 'lock')
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def test_real_file_units_zero_signed_battery_and_named_sensors(self):
|
||||
h = '/sys/class/hwmon/hwmon88'
|
||||
b = '/sys/class/power_supply/max1720x_bat_7-36'
|
||||
self.fixture({h + '/name': 'max34417_9_10', h + '/power1_label': 'vph',
|
||||
h + '/power1_input': '4710000',
|
||||
'/sys/class/hwmon/hwmon3/name': 'slg4ax46073v',
|
||||
'/sys/class/hwmon/hwmon3/fan1_input': '0',
|
||||
'/sys/class/hwmon/hwmon3/pwm1': '41',
|
||||
'/sys/class/thermal/thermal_zone9/type': 'cpu3_top_thermal',
|
||||
'/sys/class/thermal/thermal_zone9/temp': '45321',
|
||||
b + '/type': 'Battery', b + '/capacity': '0', b + '/status': 'Discharging',
|
||||
b + '/voltage_now': '3800000', b + '/current_now': '-1000000',
|
||||
'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
|
||||
sample, ticks = power.snapshot(status, (80, 80))
|
||||
r = sample['readings']
|
||||
self.assertEqual(r['rail.max34417_9_10.vph.power1']['value'], 4.71)
|
||||
self.assertEqual(r['battery.percent']['value'], 0)
|
||||
self.assertEqual(r['battery.watts']['value'], -3.8)
|
||||
self.assertEqual(r['battery.status']['value'], 'Discharging')
|
||||
self.assertEqual(r['fan.slg4ax46073v.fan1_input']['value'], 0)
|
||||
self.assertEqual(r['fan.pwmRaw']['unit'], 'raw')
|
||||
self.assertEqual(r['thermal.cpu3_top_thermal.thermal_zone9']['value'], 45.321)
|
||||
self.assertEqual(r['cpu.percent']['value'], 50)
|
||||
self.assertEqual(ticks, (100, 90))
|
||||
self.assertEqual(sample['unavailableGroups'], ['board', 'charger', 'proximity'])
|
||||
|
||||
def test_missing_and_invalid_values_remain_unavailable(self):
|
||||
self.fixture({'/sys/class/thermal/thermal_zone0/temp': '999999999',
|
||||
'/sys/class/hwmon/hwmon2/name': 'fan',
|
||||
'/sys/class/hwmon/hwmon2/fan1_input': '-1'})
|
||||
sample, ticks = power.snapshot(status, None)
|
||||
self.assertIsNone(ticks)
|
||||
self.assertTrue(all(r['value'] is None for r in sample['readings'].values()))
|
||||
self.assertEqual(len(sample['unavailableGroups']), 6)
|
||||
self.assertIn('two valid CPU', sample['readings']['cpu.percent']['state'])
|
||||
|
||||
def test_permission_error_is_unavailable(self):
|
||||
with mock.patch.object(Path, 'read_text', side_effect=PermissionError):
|
||||
self.assertIsNone(power.number('/unreadable'))
|
||||
for value in ('NaN', 'Infinity', '1.5', '', '9' * 5000):
|
||||
with mock.patch.object(power, 'read', return_value=value):
|
||||
self.assertIsNone(power.number('/invalid'))
|
||||
|
||||
def test_iio_units_and_permission_denied_proximity(self):
|
||||
node = '/sys/bus/iio/devices/iio:device8'
|
||||
local_path = self.fixture({node + '/name': 'vcnl4000',
|
||||
node + '/in_temp_pcba_at_wifi_input': '37500',
|
||||
node + '/in_current_pm8550b_iin_fb_input': '241671',
|
||||
'/etc/os-release': 'BUILD_ID=20261015.6141207\n'})
|
||||
# exists() is used only for optional known charger channels and lock.
|
||||
exists = Path.exists
|
||||
with mock.patch.object(Path, 'exists', lambda p: exists(p) if p == power.LOCK
|
||||
else exists(local_path(str(p)))):
|
||||
sample, _ = power.snapshot(status, None)
|
||||
self.assertEqual(sample['osBuild'], '20261015.6141207')
|
||||
self.assertEqual(sample['readings']['board.iio:device8.in_temp_pcba_at_wifi_input']['value'], 37.5)
|
||||
self.assertEqual(sample['readings']['charger.iio:device8.iin']['value'], 0.241671)
|
||||
self.assertIsNone(sample['readings']['proximity.raw']['value'])
|
||||
self.assertIn('proximity', sample['unavailableGroups'])
|
||||
|
||||
def test_unknown_charging_state_is_not_claimed(self):
|
||||
self.fixture({})
|
||||
with mock.patch.object(status, 'battery', return_value={'status': 'Unknown', 'percent': 50}):
|
||||
r = power.snapshot(status, None)[0]['readings']
|
||||
self.assertIsNone(r['battery.status']['value'])
|
||||
self.assertEqual(r['battery.percent']['value'], 50)
|
||||
|
||||
def test_smoke_lock_prevents_any_sensor_reads(self):
|
||||
power.LOCK.mkdir()
|
||||
with mock.patch.object(status, 'cpu_ticks') as ticks:
|
||||
with self.assertRaisesRegex(RuntimeError, 'Smoke session'):
|
||||
power.snapshot(status, None)
|
||||
ticks.assert_not_called()
|
||||
|
||||
def test_smoke_lock_appearing_between_samples_stops_and_preserves_partial_report(self):
|
||||
self.fixture({'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
|
||||
with mock.patch.object(power.time, 'sleep', side_effect=lambda _: power.LOCK.mkdir()):
|
||||
report = power.collect(status, 3, 1)
|
||||
self.assertEqual(len(report['samples']), 1)
|
||||
self.assertIn('Smoke session', report['stopped'])
|
||||
self.assertEqual(report['requestedSamples'], 3)
|
||||
|
||||
def test_summaries_ignore_missing_values_and_keep_state_changes(self):
|
||||
def row(value, unit='W'):
|
||||
return {'value': value, 'unit': unit, 'source': '/sensor', 'state': 'available'}
|
||||
samples = [{'readings': {'rail': row(0), 'state': row('Charging', 'state')}},
|
||||
{'readings': {'rail': row(None), 'state': row('Full', 'state')}},
|
||||
{'readings': {'rail': row(6)}}]
|
||||
summary = power.summarize(samples)
|
||||
self.assertEqual(summary['rail'], dict(unit='W', sources=['/sensor'], available=2,
|
||||
unavailable=1, min=0, max=6, mean=3, first=0, last=6))
|
||||
self.assertEqual(summary['state']['states'], ['Charging', 'Full'])
|
||||
self.assertEqual(summary['state']['unavailable'], 1)
|
||||
|
||||
def test_csv_quoting_and_unavailable_groups(self):
|
||||
report = {'samples': [{'time': 1, 'elapsedSeconds': 0,
|
||||
'readings': {'fan,test': {'value': -3.8, 'unit': 'W',
|
||||
'source': '=formula', 'state': 'available'}},
|
||||
'unavailableGroups': ['thermal']}]}
|
||||
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
|
||||
self.assertEqual(rows[1][2:6], ['fan,test', '-3.8', 'W', "'=formula"])
|
||||
self.assertEqual(rows[2][-1], power.UNAVAILABLE)
|
||||
|
||||
def test_bounded_arguments_rejected_before_ssh(self):
|
||||
for args in (['--count', '0'], ['--count', '721'], ['--interval', 'nan'],
|
||||
['--interval', 'inf'], ['--interval', '0.5'], ['--interval', '61']):
|
||||
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
power.main(args)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_local_lock_prevents_ssh(self):
|
||||
power.LOCK.mkdir()
|
||||
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertEqual(power.main([]), 75)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_ssh_payload_runs_as_module_and_outputs_report(self):
|
||||
report = dict(samples=[], summary={}, stopped=None)
|
||||
def ssh(command, **kwargs):
|
||||
self.assertIn('BatchMode=yes', command)
|
||||
self.assertIn('StrictHostKeyChecking=yes', command)
|
||||
self.assertIn('frame', command)
|
||||
self.assertEqual(command[-4:], ['--count', '2', '--interval', '1.0'])
|
||||
# Execute the exact transmitted source, with main suppressed. Its
|
||||
# frame_status module must be importable without running status().
|
||||
namespace = {'__name__': 'payload_test'}
|
||||
with mock.patch.dict(sys.modules):
|
||||
exec(compile(kwargs['input'], '<ssh-stdin>', 'exec'), namespace)
|
||||
remote = sys.modules['frame_status']
|
||||
self.assertTrue(callable(remote.battery))
|
||||
with mock.patch.object(remote, 'status', side_effect=AssertionError('full status forbidden')), \
|
||||
mock.patch.object(remote, 'battery', return_value=None), \
|
||||
mock.patch.object(remote, 'cpu_ticks', return_value=(100, 90)), \
|
||||
mock.patch.object(namespace['glob'], 'glob', return_value=[]), \
|
||||
mock.patch.object(namespace['Path'], 'exists', return_value=False):
|
||||
report.update(namespace['collect'](remote, 1, 1))
|
||||
return subprocess.CompletedProcess(command, 0, json.dumps(report), '')
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power.subprocess, 'run', side_effect=ssh), contextlib.redirect_stdout(output):
|
||||
self.assertEqual(power.main(['--count', '2', '--interval', '1']), 0)
|
||||
self.assertEqual(len(json.loads(output.getvalue())['samples']), 1)
|
||||
|
||||
def test_ssh_failures_are_not_measurements(self):
|
||||
for result in (subprocess.CompletedProcess([], 255, '', 'offline'),
|
||||
subprocess.CompletedProcess([], 0, 'bad json', '')):
|
||||
output = io.StringIO()
|
||||
with mock.patch.object(power.subprocess, 'run', return_value=result), \
|
||||
contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
|
||||
self.assertNotEqual(power.main([]), 0)
|
||||
self.assertEqual(output.getvalue(), '')
|
||||