Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2bb178d5 | ||
|
|
b1f1880736 | ||
|
|
da34d94009 | ||
|
|
338726d581 | ||
|
|
234b0a1814 | ||
|
|
7a8e919546 | ||
|
|
32727fc6e5 | ||
|
|
d79efb4730 | ||
|
|
95643b7ea0 | ||
|
|
9dab523af9 | ||
|
|
a29fac2594 | ||
|
|
6d5cb20847 | ||
|
|
987c36c5f6 | ||
|
|
649185237f | ||
|
|
5b43170fda | ||
|
|
0cb085dc50 | ||
|
|
61db62edcf | ||
|
|
b0a65129f1 | ||
|
|
958743c79d | ||
|
|
05308932e8 | ||
|
|
4e4853e82b | ||
|
|
d44b6121ae | ||
|
|
af6f97ca5a | ||
|
|
3438c248d9 | ||
|
|
509ddbdc95 | ||
|
|
f2d293bd1a | ||
|
|
a5a554d0c2 | ||
|
|
af2dca8365 | ||
|
|
6a8e0093de | ||
|
|
53107fd91b | ||
|
|
548f5d8496 | ||
|
|
a03c94ae12 | ||
|
|
dd3df85fd6 | ||
|
|
e7bfa80e09 | ||
|
|
b26e34ce09 | ||
|
|
ab4b58b1c7 | ||
|
|
74089fb2f1 | ||
|
|
7f828cbcf8 | ||
|
|
42665d14db | ||
|
|
8dd6eb3fe9 | ||
|
|
ef309c9931 | ||
|
|
de0f0eb0e3 | ||
|
|
bdef125f68 | ||
|
|
f64cbe5bac | ||
|
|
d4714eadf7 | ||
|
|
bcdd69f6d8 | ||
|
|
f5becdd2c0 | ||
|
|
e185cb3b29 | ||
|
|
2320dc4d0e | ||
|
|
affe3eda65 | ||
|
|
9e9b25e9f4 | ||
|
|
f7153b3ab1 | ||
|
|
5c987ae8b8 | ||
|
|
42b0d729cb | ||
|
|
ae63fa584c | ||
|
|
337ac99b3e | ||
|
|
aada257325 | ||
|
|
8bc3d63126 | ||
|
|
c690e691a3 | ||
|
|
e3fcd3eef8 | ||
|
|
0da1fe5077 | ||
|
|
1f7615b312 | ||
|
|
2ded4c5a0d | ||
|
|
5b47cd3330 | ||
|
|
aead570b5d | ||
|
|
de931bc56f | ||
|
|
71288d8980 | ||
|
|
64b9babe87 |
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
|
||||
|
||||
@@ -173,3 +173,24 @@ jobs:
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
|
||||
checksums:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Attach SHA256SUMS after all installers upload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
|
||||
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
|
||||
# Find the same draft using its exact tag, or its version title if a
|
||||
# draft edit made GitHub temporarily show an untagged-* tag name.
|
||||
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
|
||||
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
|
||||
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
|
||||
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
|
||||
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
|
||||
@@ -2,6 +2,130 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Skip backup saves and settings reached through a symbolic link anywhere below
|
||||
the home folder, and recheck paths before packing each file. Local synthetic
|
||||
tests cover redirected Proton prefixes and links changed after discovery;
|
||||
concurrent filesystem changes can still race the checks.
|
||||
[Backup layout and limits](docs/backup-and-recovery.md).
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
This route has local fixture coverage, not a real split-game headset test.
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
No adapter setting changes or report uploads are made.
|
||||
|
||||
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
|
||||
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
|
||||
and test one connection condition at a time. In-app disable/re-enable remains
|
||||
deferred until an active Steam adapter instance can be identified safely.
|
||||
[Guide and limits](docs/windows-wireless-doctor.md).
|
||||
|
||||
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
|
||||
on Windows and Command-Tab on macOS. Single-window panels keep their own
|
||||
source, so the button is hidden there.
|
||||
|
||||
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
|
||||
lets the Python relay close its SSH child on Stop, app quit or parent exit;
|
||||
add a short [Discord guide](docs/streaming-to-discord.md).
|
||||
|
||||
- Add a read-only wired connection doctor for a PC using the Frame's observed
|
||||
USB-C network: show the local route, SSH port and pinned-key login result
|
||||
without changing IP, host keys or headset settings.
|
||||
[Usage and limits](docs/wired-connection.md).
|
||||
|
||||
- Start the Android app catalogue without waiting for the headset's installed-app
|
||||
list. Browsing remains available if that status request is slow or fails;
|
||||
installation still waits for a confirmed list to avoid duplicate installs.
|
||||
|
||||
- Verify the packaged OBS Start and Stop control with a real Frame, and record
|
||||
37.600 seconds of headset mirror video and non-silent output audio in OBS.
|
||||
Listening, sync and active passthrough remain unverified.
|
||||
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
|
||||
|
||||
- Bundle the OBS source helper in Linux and macOS installers and add a
|
||||
**Capture & Live** Start/Stop control. The app selects the active Frame and
|
||||
closes its SSH stream on a headset switch or app exit. A real Frame stream
|
||||
reached OBS with video and non-silent audio; listening and sync remain
|
||||
unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
|
||||
default-output audio to a local OBS Media Source. A real Frame supplied a
|
||||
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
|
||||
remain unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Link emulator setup guidance from Store → Emulators. Existing Steam library
|
||||
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
|
||||
transfer still needs a verified per-instance workflow.
|
||||
[Guide](docs/emulators.md).
|
||||
|
||||
- Record the current Frame OS mirror and charge-limit checks. Active
|
||||
passthrough in the mirror is still unknown; this build has no supported
|
||||
user-writable 80% charge-limit control.
|
||||
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- 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
|
||||
@@ -95,3 +219,8 @@
|
||||
shared Wine services are left alone. Verified with local Linux process
|
||||
regressions and a 13/13 real Frame smoke run that stopped five test-owned
|
||||
Proton process PIDs before removing the Windows test title.
|
||||
|
||||
- Keep failed automatic screenshot and clip copies visible after three attempts.
|
||||
**Resume** and **Copy everything now** allow another try without restarting;
|
||||
headset switches cannot use another headset's list or saved-file history.
|
||||
[Copying guide](docs/recording.md).
|
||||
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -25,6 +25,8 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -44,6 +46,9 @@ 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).
|
||||
|
||||
@@ -73,10 +78,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>
|
||||
@@ -127,6 +132,24 @@ Choose the operating system and processor of the computer you will use.
|
||||
| **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`) |
|
||||
|
||||
### Is it safe? Check your download
|
||||
|
||||
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
```sh
|
||||
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
|
||||
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
|
||||
|
||||
日本語: [5 ステップの接続ガイド](docs/connection-faq.md#日本語-5-ステップで接続)を参照してください。v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
|
||||
**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
|
||||
@@ -183,8 +206,10 @@ or run it with `--appimage-extract-and-run`.
|
||||
|
||||
## Open the app
|
||||
|
||||
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the unsigned-app warning, use the macOS steps
|
||||
open it from Applications. For the Gatekeeper 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
|
||||
@@ -220,6 +245,14 @@ network. Guest Wi-Fi/client isolation can prevent discovery and connections.
|
||||
check Developer Mode, the address and network isolation, then run setup again.
|
||||
For connections away from home, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
|
||||
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
|
||||
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
|
||||
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
|
||||
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
|
||||
cable has not been tested for the Frame's USB network. See the
|
||||
[connection FAQ and Japanese quick start](docs/connection-faq.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
games in `~/devkit-game`), and nothing
|
||||
@@ -254,15 +287,18 @@ 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 |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.md) · [Frame plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [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 |
|
||||
|
||||
@@ -17,6 +17,7 @@ const REQUIRED = [
|
||||
"ui/frame_status.py",
|
||||
"scripts/frame_power_sample.py",
|
||||
"scripts/frame_power_report.py",
|
||||
"scripts/obs-headset-source.py",
|
||||
"ui/frame_android.py",
|
||||
"frame/android/lepton-app.sh",
|
||||
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
|
||||
// custom entry is never overwritten or removed.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
|
||||
const FILE = "com.saphid.frame-control.desktop";
|
||||
|
||||
function quoteExec(value) {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
|
||||
}
|
||||
|
||||
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
|
||||
executable = process.execPath, filesystem = fs,
|
||||
home = os.homedir() }) {
|
||||
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
||||
? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
||||
const file = path.join(config, "autostart", FILE);
|
||||
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
|
||||
? env.APPIMAGE : executable;
|
||||
function owned() {
|
||||
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
|
||||
catch (err) { if (err.code === "ENOENT") return false; throw err; }
|
||||
}
|
||||
function enabled() {
|
||||
if (platform === "linux") return owned();
|
||||
return !!electronApp.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
function set(enabledNow) {
|
||||
if (platform !== "linux") {
|
||||
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
|
||||
args: enabledNow ? ["--hidden"] : [] });
|
||||
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
|
||||
return enabled();
|
||||
}
|
||||
if (enabledNow) {
|
||||
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
|
||||
filesystem.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
|
||||
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
|
||||
const tmp = file + `.tmp-${process.pid}`;
|
||||
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
|
||||
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
|
||||
} else if (owned()) {
|
||||
filesystem.unlinkSync(file);
|
||||
}
|
||||
return enabled();
|
||||
}
|
||||
return { enabled, set, file };
|
||||
}
|
||||
|
||||
module.exports = { createLoginItem, quoteExec };
|
||||
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -12,6 +12,7 @@ const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createLoginItem } = require("./login-item");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -30,10 +31,12 @@ const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
const loginItem = createLoginItem({ electronApp: app });
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
@@ -304,6 +307,62 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
|
||||
// The OBS source is local to this computer. Closing the Python relay's control
|
||||
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
|
||||
// terminates Python without running its cleanup block.
|
||||
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
|
||||
let obsChild = null;
|
||||
let obsState = { state: "idle", alias: "", error: "" };
|
||||
function publicObs() { return { ...obsState, supported: true }; }
|
||||
function setObs(state, alias = "", error = "") {
|
||||
obsState = { state, alias, error };
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
|
||||
return publicObs();
|
||||
}
|
||||
function stopObs() {
|
||||
if (!obsChild) return publicObs();
|
||||
setObs("stopping", obsState.alias);
|
||||
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
|
||||
return publicObs();
|
||||
}
|
||||
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", async (e) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (obsChild) return publicObs();
|
||||
const alias = activeAlias();
|
||||
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
|
||||
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const executable = python || await findPython(env);
|
||||
if (!executable) throw new Error(PYTHON_HELP);
|
||||
// A concurrent click or a headset switch may have happened while finding Python.
|
||||
if (obsChild) return publicObs();
|
||||
if (alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
|
||||
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias, "--control-stdin"],
|
||||
{ env, stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
|
||||
obsChild = child;
|
||||
setObs("starting", alias);
|
||||
let output = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
output = (output + chunk.toString("utf8")).slice(-2048);
|
||||
if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias);
|
||||
});
|
||||
child.once("error", (err) => {
|
||||
if (obsChild !== child) return;
|
||||
obsChild = null;
|
||||
setObs("failed", alias, `Could not start OBS source: ${err.message}`);
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (obsChild !== child) return;
|
||||
const stopping = obsState.state === "stopping" || quitting;
|
||||
obsChild = null;
|
||||
setObs(stopping ? "idle" : "failed", alias,
|
||||
stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`);
|
||||
});
|
||||
return publicObs();
|
||||
});
|
||||
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
|
||||
|
||||
// The page reports the headsets it knows (the server's Devices tab), so the Frame
|
||||
// menu can switch between them. Only plain names and ids go into the menu.
|
||||
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
@@ -314,6 +373,7 @@ ipcMain.on("devices:changed", (e, list) => {
|
||||
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||
devices = next;
|
||||
if (obsChild && obsState.alias !== activeAlias()) stopObs();
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
@@ -516,7 +576,60 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function showWindow() {
|
||||
if (!win) return createWindow(true);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function ensureTray() {
|
||||
if (tray) return true;
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
|
||||
if (icon.isEmpty()) return false;
|
||||
tray = new Tray(icon);
|
||||
// Some Linux desktops accept the Tray object but expose no status icon.
|
||||
// Keep a visible login window rather than hiding the only way back in.
|
||||
if (process.platform === "linux") {
|
||||
const bounds = tray.getBounds();
|
||||
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
|
||||
}
|
||||
tray.setToolTip("Frame Control");
|
||||
tray.on("click", showWindow);
|
||||
updateTrayMenu();
|
||||
return true;
|
||||
} catch {
|
||||
if (tray) { try { tray.destroy(); } catch {} tray = null; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Open Frame Control", click: showWindow },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Frame Control", click: () => app.quit() },
|
||||
]));
|
||||
}
|
||||
|
||||
function toggleLoginItem() {
|
||||
try {
|
||||
const on = loginItem.set(!loginItem.enabled());
|
||||
if (on) ensureTray();
|
||||
else if (tray) { tray.destroy(); tray = null; }
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Open at Login", String(err.message || err));
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
@@ -525,7 +638,10 @@ function createWindow() {
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
@@ -579,6 +695,8 @@ function buildMenu() {
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
|
||||
enabled: app.isPackaged, click: toggleLoginItem },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
@@ -627,7 +745,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
if (!argv.includes("--hidden")) showWindow();
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -635,12 +753,41 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("activate", showWindow);
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
stopServer();
|
||||
if (!obsChild || obsQuitReady) return;
|
||||
event.preventDefault();
|
||||
if (obsQuitWaiting) return;
|
||||
obsQuitWaiting = true;
|
||||
const child = obsChild;
|
||||
let timer;
|
||||
child.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
});
|
||||
stopObs();
|
||||
timer = setTimeout(() => {
|
||||
if (child.exitCode === null) {
|
||||
if (IS_WIN) {
|
||||
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
|
||||
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
|
||||
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
|
||||
} else child.kill("SIGKILL");
|
||||
}
|
||||
obsQuitReady = true;
|
||||
app.quit();
|
||||
}, 8000);
|
||||
});
|
||||
process.on("exit", () => { stopObs(); stopServer(); });
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.17",
|
||||
"version": "0.4.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.17",
|
||||
"version": "0.4.30",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.17",
|
||||
"version": "0.4.30",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -40,6 +40,7 @@
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -69,6 +70,7 @@
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
|
||||
@@ -21,6 +21,15 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
obs: {
|
||||
status: () => ipcRenderer.invoke("obs:status"),
|
||||
start: () => ipcRenderer.invoke("obs:start"),
|
||||
stop: () => ipcRenderer.invoke("obs:stop"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("obs:state");
|
||||
ipcRenderer.on("obs:state", (_e, state) => cb(state));
|
||||
},
|
||||
},
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createLoginItem } = require("../login-item");
|
||||
|
||||
test("Linux login toggle creates and removes only its own desktop entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
|
||||
executable: "/opt/Frame Control/frame-control" });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
const body = fs.readFileSync(login.file, "utf8");
|
||||
assert.match(body, /^# Managed by Frame Control;/);
|
||||
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
|
||||
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.equal(fs.existsSync(login.file), false);
|
||||
fs.mkdirSync(path.dirname(login.file), { recursive: true });
|
||||
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
|
||||
assert.throws(() => login.set(true), /different autostart entry/);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
|
||||
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
|
||||
executable: "/tmp/.mount_extracted/AppRun" });
|
||||
login.set(true);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
|
||||
login.set(false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const platform of ["win32", "darwin"]) {
|
||||
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
|
||||
let openAtLogin = false;
|
||||
const calls = [];
|
||||
const electronApp = {
|
||||
getLoginItemSettings: () => ({ openAtLogin }),
|
||||
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
|
||||
};
|
||||
const login = createLoginItem({ platform, electronApp });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
|
||||
assert.equal(calls.at(-1).openAsHidden, true);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.deepEqual(calls.at(-1).args, []);
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
@@ -37,9 +37,13 @@ are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
This flow handles one base APK, supported native-library or asset splits, and
|
||||
standard OBB files for one game at a time. It checks the package and version in
|
||||
each split manifest before touching the headset. The existing Quest merger
|
||||
rejects resource, code and feature splits and sets with multiple device
|
||||
variants. A merged APK is re-signed; that can affect a game's licence or login.
|
||||
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
|
||||
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
@@ -55,3 +59,5 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
The supported-split backup route was checked with generated local fixtures and
|
||||
existing merger tests, not a real split game on the Frame.
|
||||
@@ -436,13 +436,13 @@ 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
|
||||
base APK, any supported splits, 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
|
||||
one base APK, optional supported splits and 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
|
||||
@@ -450,7 +450,7 @@ 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;
|
||||
100-file/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**.
|
||||
@@ -463,12 +463,14 @@ 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.
|
||||
**Limits:** this backup importer requires exactly one base APK. For split sets,
|
||||
each split must identify the same package and version, and pass the existing
|
||||
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
|
||||
feature splits and does not choose among device variants. Merging re-signs the
|
||||
APK, which can affect licences. It does not restore `Android/data`, private app
|
||||
data, saves or entitlements. These split imports have local fixture and merger
|
||||
tests, but no real split game has been installed with this backup route on the
|
||||
Frame.
|
||||
|
||||
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,
|
||||
|
||||
@@ -25,8 +25,28 @@ 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. The literal desktop button
|
||||
was not clicked in this test, and older SteamOS builds remain fixture-tested.
|
||||
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
|
||||
|
||||
@@ -80,7 +100,21 @@ copy one by hand if you need it.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
- Symbolic links, including any ancestor below the home folder of a selected
|
||||
save or settings path, and single files over 2 GiB. Discovery checks each
|
||||
component, and packing checks it again before opening each file.
|
||||
|
||||
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
|
||||
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
|
||||
Wine save folder redirected by a symlink below the home folder is skipped,
|
||||
even if its target is inside home; it is not resolved and backed up elsewhere.
|
||||
The home folder itself is trusted and may use the host's normal path aliases.
|
||||
|
||||
These are path checks, not a filesystem snapshot: a concurrent rename or link
|
||||
replacement between a check and a read can still race it. Close games and
|
||||
avoid moving or relinking these folders during backup. This fix rejects links
|
||||
already present when checked, including ones introduced after discovery; it
|
||||
does not provide atomic protection against concurrent filesystem changes.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
@@ -172,11 +206,18 @@ If the button can't help, or the headset isn't reachable:
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
folders), links in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
Synthetic symlink fixtures cover every component of a Proton save root below
|
||||
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
|
||||
and file links introduced between discovery and packing. Ordinary Proton and
|
||||
non-Steam saves, Linux saves, controller layouts, reference settings and home
|
||||
aliases still pack with their original bytes. These are local fixture checks;
|
||||
this path fix has not been tested on a headset.
|
||||
`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.
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ only control the Frame has is a vendor charger attribute that Valve's own
|
||||
charging daemon also manages. Research as of 2026-10-23, confirmed with the
|
||||
read-only checks below on a Frame running SteamOS 0.5.6 (build 20261015.6141207):
|
||||
|
||||
Rechecked read-only on build `20261022.6153909` on 2026-10-25: the
|
||||
`BatteryChargeLimit1` interface still returned `UnknownInterface`, no
|
||||
`charge_control_*` sysfs attribute existed, and the vendor charging switches
|
||||
were root-owned mode `644`. See [Round 3 headset evidence](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- `steamosctl get-max-charge-level` fails with `UnknownInterface: ...BatteryChargeLimit1`.
|
||||
- `/usr/share/steamos-manager/devices/` has `[battery_charge_limit]` only for
|
||||
the Steam Deck, ROG Ally, ROG Xbox Ally and MSI Claw, not the Frame.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Connection and streaming lag FAQ
|
||||
|
||||
## Does Frame Control lower my streaming lag?
|
||||
|
||||
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
|
||||
game stream. Frame Control does not carry the game stream. It cannot lower your
|
||||
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
|
||||
game is not a Frame Control measurement.
|
||||
|
||||
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
|
||||
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
|
||||
route to the Frame. Use the route that lets the app connect. To improve game
|
||||
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
|
||||
The [wired connection doctor](wired-connection.md) checks only Frame Control's
|
||||
SSH route. A successful doctor check does not prove that the game stream uses
|
||||
the cable or has low lag.
|
||||
|
||||
## Can I use a USB-A-to-C cable?
|
||||
|
||||
A Mac reached the Frame over a USB-C connection on the
|
||||
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
|
||||
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
|
||||
cable might show a USB network interface on your PC, but this is unknown on
|
||||
the tested Frame OS and PC hosts. Check whether your PC shows that interface
|
||||
and whether Frame Control's wired doctor can reach the Frame at
|
||||
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
|
||||
for Frame Control if the USB route does not work.
|
||||
|
||||
## 日本語: 5 ステップで接続
|
||||
|
||||
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
|
||||
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
|
||||
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
|
||||
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
|
||||
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
|
||||
|
||||
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -0,0 +1,27 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; ProtonDB Badges loaded in Decky backend and Steam shared UI registry | No | Decky and plugin UI in the VR shell, wearer-visible badges, controller input and other plugins remain unverified | 25 Oct, v0.4.29: [PR #195](https://github.com/saphid/frame-control/pull/195), [PR #202](https://github.com/saphid/frame-control/pull/202), [headset findings](decky-frame.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
|
||||
VR user interface has not been seen or used.
|
||||
|
||||
## Official store plugin audit, 2026-10-25
|
||||
|
||||
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
|
||||
reported `activity_level` 3 (standby). The battery was 99% before and after.
|
||||
The test downloaded the official store's current archives, checked each SHA-256
|
||||
against store metadata, and inspected their manifests and binaries. For seven
|
||||
non-root candidates, the removable user-service loader started each plugin in
|
||||
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
|
||||
The service and test folders were removed after the run; the owned shared lock
|
||||
was released. **A load message is not proof that a plugin's page, settings, or
|
||||
action works in VR.** No plugin was tried by a wearer.
|
||||
|
||||
| Official store plugin (tested version) | Result | Evidence and limit |
|
||||
| --- | --- | --- |
|
||||
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
|
||||
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
|
||||
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
|
||||
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
|
||||
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
|
||||
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
|
||||
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
|
||||
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
|
||||
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
|
||||
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
|
||||
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
|
||||
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
|
||||
|
||||
The table reflects the tested versions only. Other releases can change their
|
||||
files or behavior. The official store's digest checks the archive in transit;
|
||||
it does not certify plugin code. Plugins run with the headset user's access.
|
||||
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
managed store ID and version while the plugin was installed. The service
|
||||
remained active after each store change. The battery was 99% before and after.
|
||||
The test removed its plugin folder, loader, and owned lock. This validates the
|
||||
store file and service path over SSH, not a visible VR plugin page.
|
||||
|
||||
Frame Control's desktop app and opt-in headset panel now show official store
|
||||
entries. Install, update, and remove need an explicit confirmation. They only
|
||||
change Frame Control owned plugin folders under
|
||||
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. The current VR shell does not
|
||||
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
|
||||
shows installed plugins and store actions, but plugin pages, settings, routes,
|
||||
and Decky toasts are **not** available there yet. The panel itself still needs
|
||||
a worn-headset visual and controller check.
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
|
||||
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
|
||||
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
|
||||
needed. `systemctl --user` was running. The Steam client launched with
|
||||
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
|
||||
CEF remote debugging on `127.0.0.1:8080`. The usual
|
||||
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
|
||||
|
||||
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
|
||||
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
|
||||
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
|
||||
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
|
||||
`window.deckyHasLoaded === true` and one registered Decky tab in
|
||||
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
|
||||
its current body text did not contain Decky. The Steam web helper had
|
||||
`--disable-quick-menu`. This is evidence of backend and shared-context
|
||||
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
|
||||
interaction in the headset and a plugin UI test remain necessary.
|
||||
|
||||
Frame Control's per-user manager was then exercised on the real Frame:
|
||||
**install → status → update → status → remove → status**. The service became
|
||||
active, the binary matched Decky's published SHA-256, and removal left no
|
||||
service, binary or listening port 1337. Decky's already injected JavaScript
|
||||
remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## ProtonDB Badges compatibility test
|
||||
|
||||
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
|
||||
was tested on the same Frame OS build. It is a popular, passive Decky plugin
|
||||
(133 GitHub stars at test time) that adds badges to game pages. The published
|
||||
archive SHA-256 was
|
||||
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
|
||||
Its six archive entries had no links or path traversal, and its manifest declared
|
||||
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
|
||||
no backend `main.py`; its source backend only manages its own settings.
|
||||
|
||||
The test copied the archive into Frame Control's managed Decky plugin folder,
|
||||
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
|
||||
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
|
||||
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
|
||||
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
|
||||
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
|
||||
overlay. The headset was in standby, so no in-headset plugin page or badge
|
||||
interaction was verified. The backend's unauthenticated root URL returned
|
||||
HTTP 403, as expected.
|
||||
|
||||
The test removed the managed Decky service and binary, the test plugin folder,
|
||||
and its shared-device lock. The service was no longer running, the plugin list
|
||||
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
|
||||
already injected frontend may remain in the running Steam web helper until
|
||||
Steam normally restarts; the test did not restart Steam or SteamVR.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
|
||||
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
|
||||
`sudo systemctl` to enable a **root** service. Its
|
||||
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
|
||||
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
|
||||
under `/etc`; their root service has broad control over the headset. The
|
||||
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
|
||||
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
|
||||
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
|
||||
the CEF target, while visible VR shell integration is unresolved.
|
||||
|
||||
Frame Control downloads only Decky's official latest ARM64 release, requires
|
||||
its published SHA-256 digest, and checks the ELF architecture. It installs a
|
||||
**user** service named `frame-control-decky.service` in
|
||||
`~/.config/systemd/user`, with the binary and plugin folder under
|
||||
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
|
||||
`/etc`, or changes CEF flags. Decky warns that running without root is not
|
||||
officially supported. Plugins that require root, assume `~/homebrew`, or rely
|
||||
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder. The opt-in store manager installs compatible archives only into its
|
||||
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
`PluginLoader-arm64` SHA-256
|
||||
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
|
||||
This digest comes from that release's GitHub API asset metadata; it detects a
|
||||
download mismatch, but is not an independent signature.
|
||||
|
||||
## Opt in and remove
|
||||
|
||||
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
|
||||
notice, check the acknowledgement box, and choose **Install**. **Update**
|
||||
downloads and verifies the latest ARM64 release again. **Remove managed
|
||||
Decky** disables and removes only Frame Control's user service and binary.
|
||||
It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Open **Official plugin store** to review a plugin, then confirm each install,
|
||||
update, or remove. Root and x86-only plugins are blocked. The manager only
|
||||
removes plugin folders that it installed; it leaves other Decky folders alone.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
Manual removal if Frame Control is unavailable:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
|
||||
```
|
||||
|
||||
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
|
||||
and `settings`. Check those folders before deleting them yourself. An upstream
|
||||
Decky installation is separate; use its own installer to remove it. Frame
|
||||
Control refuses to install alongside an upstream system service.
|
||||
|
||||
## Next compatibility test
|
||||
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Confirm the plugin's visible
|
||||
UI, controller interaction, and persistence across a normal Steam restart.
|
||||
Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as usable in VR yet. A worn
|
||||
headset check is needed for both the Decky menu and the Frame Control SteamVR
|
||||
panel; only then can their controller interaction and plugin presentation be
|
||||
confirmed.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Emulators and your own games
|
||||
|
||||
Frame Control's **Store → Emulators** lists Android emulators from its open
|
||||
app sources. Pick an app, read its compatibility result, and install it. An
|
||||
installed Android app gets its own Steam library shortcut and artwork; **Add
|
||||
artwork** in its library menu can fill missing images later. The artwork
|
||||
workflow also covers native Linux and Windows titles added through **Send to
|
||||
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
|
||||
can supply exact-title community images; source images and generated art are
|
||||
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
|
||||
for the limits and tested behavior.
|
||||
|
||||
## Set up an emulator
|
||||
|
||||
1. In **Store → Emulators**, choose an emulator that supports the console and
|
||||
file format you want. Check its compatibility result before installing;
|
||||
an app listed in the store is not automatically verified on the Frame.
|
||||
2. Launch the new shortcut from Steam. Open the emulator's own settings to
|
||||
choose a gamepad layout, core or system, and game folder. These controls
|
||||
differ by emulator; Frame Control does not change them.
|
||||
3. Use game files you own and any firmware that emulator's instructions
|
||||
require. Frame Control does not provide ROMs or firmware.
|
||||
|
||||
**Android apps run in separate Lepton instances.** Copying a ROM to the
|
||||
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
|
||||
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
|
||||
deferred until an emulator and its file picker can be tested end to end on a
|
||||
real Frame. An app's own import or sync feature may be enough. Advanced users
|
||||
can use [Lepton's per-instance ADB port](apks.md)
|
||||
to copy into that instance, after confirming the correct container and port.
|
||||
Do not assume that port 5555 is the emulator: it is Lepton Development, while
|
||||
installed apps receive their own ports.
|
||||
|
||||
For a native Linux emulator that can read the Frame's home directory, use
|
||||
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
|
||||
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
|
||||
build, correct controller mapping and readable storage must be checked for
|
||||
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
|
||||
native game archive can instead be added through
|
||||
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
|
||||
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
|
||||
not create one Steam shortcut per ROM.
|
||||
|
||||
## If something does not appear
|
||||
|
||||
- Check that the emulator is running and is the same app instance to which
|
||||
you sent the file. Android instances do not share `/sdcard` storage.
|
||||
- Check the emulator's supported extension and whether it expects an archive
|
||||
or an extracted file. Some systems need firmware before games appear.
|
||||
- For an empty Steam tile, use **Add artwork** in the installed app or
|
||||
sideloaded title menu. This changes artwork, not emulator data or ROMs.
|
||||
|
||||
The Frame's microSD/USB path into Android apps remains unverified without an
|
||||
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Auto-sync retry and headset-selection reliability
|
||||
|
||||
Date: 25 October 2026. Base: `d79efb4`, Frame Control v0.4.30.
|
||||
Issue: [#214](https://github.com/saphid/frame-control/issues/214).
|
||||
Environment: Linux x86_64, Python 3.12.3, Node 24.15.0.
|
||||
|
||||
## Reproduced problems
|
||||
|
||||
The existing 19 auto-sync tests passed on the base revision. Added regressions
|
||||
failed before the implementation: an exhausted clip lost its error, explicit
|
||||
retry did not recover failed screenshots or clips, and switches around listing
|
||||
or copying continued the old pass. The original callback interface also had no
|
||||
way to carry the tick's captured device into the server list reservation.
|
||||
|
||||
The final fake-source tests exercise these boundaries:
|
||||
|
||||
- Fail screenshots or clips three times, keep checking without further copy
|
||||
attempts, and retain the source error plus an explicit retry instruction.
|
||||
- Recover the source and use either Resume or Copy everything now without
|
||||
restarting. Successful copies stay deduplicated; Resume retains the ledger
|
||||
even after a local file is deleted. Copy everything now retains its documented
|
||||
ability to restore deleted files and include older media.
|
||||
- Reset an exhausted budget while the source still fails: another three
|
||||
attempts are allowed, rather than unlimited background attempts.
|
||||
- Save an exhausted item by hand and clear its stale failure on the next list.
|
||||
- Switch after tick captures A, during a list, after screenshot bookkeeping,
|
||||
between media kinds, before a transfer, or before transfer bookkeeping.
|
||||
Interrupted passes do not mark the other headset's IDs or continue copying.
|
||||
- Switch to B exactly at callback entry through the production `_sync_list`
|
||||
adapter. `working(A)` refuses before calling the source list, leaving both
|
||||
ledgers and copy callbacks untouched. The same production reservation blocks
|
||||
a device switch during a list and rejects stale screenshot/clip transfers.
|
||||
- Execute the production Settings renderer in Node: Resume is available for
|
||||
copy errors and disk-full pauses, and hidden for a healthy state.
|
||||
|
||||
## Implementation boundary
|
||||
|
||||
Each list callback now receives the captured device, like the copy callbacks.
|
||||
The existing server `working(meant)` mechanism checks that intended headset
|
||||
under `_work_lock` while reserving work; `devices_post` uses the same lock to
|
||||
refuse switches during that work. The worker also checks for interruption
|
||||
around lists and transfers. No new device lock or clip destination logic is
|
||||
introduced. The server diff is confined to `_sync_list`.
|
||||
|
||||
The failed-attempt and error maps are in-memory and reset on explicit retry or
|
||||
restart. The successful-copy ledger and settings persistence retain their
|
||||
existing format. Disk-full pauses, offline backoff, clip settling and the
|
||||
three-attempt limit remain covered by the original tests.
|
||||
|
||||
## Checks
|
||||
|
||||
- `python3 -m unittest discover -s tests -p test_autosync.py -v`: 33 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_shot_copy.py -v`: 2 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_clips.py -v`: 34 passed,
|
||||
including local ffmpeg coverage.
|
||||
- `python3 -m unittest discover -s tests -p test_service_regressions.py -v`:
|
||||
36 passed.
|
||||
- `python3 -m unittest discover -s tests -p test_page_scripts.py -v`: 5 passed.
|
||||
- `node --test site/test/*.test.mjs`: 2 test files passed.
|
||||
- `python3 -m py_compile ui/frame_autosync.py ui/server.py tests/test_autosync.py`
|
||||
and `git diff --check`: passed.
|
||||
|
||||
- `python3 -m unittest discover -s tests -v`: 1,525 tests run in 184.876
|
||||
seconds, passed with 16 skips. Ran with sandbox escalation for loopback
|
||||
sockets; test isolation still used throwaway app/SSH data and mocked headset
|
||||
transport. Final output:
|
||||
|
||||
```text
|
||||
Ran 1525 tests in 184.876s
|
||||
OK (skipped=16)
|
||||
```
|
||||
|
||||
## Limits and handoff
|
||||
|
||||
All new regressions use fake sources and mocked transport; they do not prove
|
||||
real-headset transfer or UI rendering. No headset was accessed: its shared test
|
||||
lock was held by another task. The Docker fake-Frame end-to-end suite and
|
||||
Windows/macOS runs were not performed locally. The coordinator owns review and
|
||||
merge; this task does not release, change PR #113, or publish announcements.
|
||||
|
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,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,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,58 @@
|
||||
# OBS relay and MP4 follow-up, 25 October 2026 UTC
|
||||
|
||||
This check used a Linux x86-64 host (`lxso2`) and the real Frame running
|
||||
SteamOS 0.5.6, build `20261022.6153909`. The host reached `ssh frame`. Before
|
||||
device work, the task atomically acquired `/tmp/frame-test.lock`, wrote its
|
||||
owner marker, and after cleanup removed only that marker and its lock. Battery
|
||||
was 99–100%; SteamVR 2.18.2 reported `activity_level: 3` (standby). Steam and
|
||||
SteamVR were left running. No passthrough session or game was started.
|
||||
|
||||
The production `scripts/obs-headset-source.py` relay reached its first local
|
||||
UDP packet in 0.65–1.15 seconds across several runs. A 12-second wall-clock
|
||||
capture of its MPEG-TS packets contained 325 H.264 frames at 1280×720,
|
||||
spanning 10.83 seconds of video timestamps: 30.0 fps. All 324 adjacent frame
|
||||
intervals were 33.333–33.334 ms, with none over 50 ms. The 826 captured TS
|
||||
packets had zero sync-byte, transport-error-indicator, or per-PID continuity
|
||||
errors. Six final TS packets arrived after the receiver's 12-second cutoff and
|
||||
were not included in those counts. Static standby pixels cannot establish
|
||||
whether a moving game would drop frames.
|
||||
|
||||
After the fix, the `--height 1080 --fps 60 --no-audio` fallback reached its
|
||||
first packet in 0.597 seconds. An eight-second wall-clock capture contained
|
||||
447 H.264 frames at 1920×1080 over 7.45 seconds of video timestamps: 60.0 fps.
|
||||
All frame intervals were 16.666–16.667 ms, none over 25 ms, and its 756 TS
|
||||
packets had zero continuity or transport-error-indicator errors. It had no
|
||||
audio stream, as requested. The helper exited 0 on Stop, and the owned lock
|
||||
was released.
|
||||
|
||||
The stream also contained 48 kHz stereo AAC. A 440 Hz synthetic tone played
|
||||
through the Frame's default speaker output with real-time pacing appeared in
|
||||
the capture for about 0.89 seconds; decoded audio had a -21.0 dB peak. This
|
||||
proves an audio signal reached the output monitor and OBS transport. It does
|
||||
not prove audible monitoring, real game audio, or audio/video sync: there was
|
||||
no simultaneous visible marker to compare with the tone.
|
||||
|
||||
The production MP4 recording commands were run separately for six seconds,
|
||||
then Stop, remux, and copy to the host completed. Stop returned after 7.27
|
||||
seconds from Start, including six seconds of recording; the remote ffmpeg
|
||||
exited 0. The copied 36,929-byte MP4 contained 188 H.264 frames at 1920×1080
|
||||
and 30 fps (6.266 seconds), plus 281 AAC packets (5.994 seconds). One decoded
|
||||
frame at two seconds had a single color, RGB `(22, 27, 53)`, across all
|
||||
2,073,600 pixels. The test's remote files were removed; the host copy stayed
|
||||
under `/tmp` for inspection.
|
||||
|
||||
The relay previously returned an error when the app deliberately closed its
|
||||
control pipe, because its SSH child commonly exits 255 after termination.
|
||||
The same test reproduced this (`helper_exit: 1`) on the real Frame. The follow-up
|
||||
fix recognizes deliberate Stop and adds bounded startup and stall timeouts,
|
||||
with fake-SSH regression tests. A new locked real-Frame check after the fix
|
||||
received its first 1,316-byte UDP datagram in 0.697 seconds and returned
|
||||
`helper_exit_after_stop: 0` after closing the app control pipe. The longer
|
||||
transport and MP4 measurements above were taken before the code change; the
|
||||
fix does not alter codecs or capture commands.
|
||||
|
||||
Prior [OBS 30 Media Source evidence](obs-media-source-real-2026-10-25.md)
|
||||
records a real Frame stream in OBS on Linux. This follow-up measured the relay
|
||||
with an FFmpeg receiver; OBS itself was not relaunched. Windows and macOS
|
||||
application paths, wearer-visible image latency, real game sound, audible
|
||||
playback, perceptual A/V sync, and active passthrough pixels remain unverified.
|
||||
@@ -0,0 +1,48 @@
|
||||
# OBS Media Source on a real Frame, 2026-10-25 AEDT
|
||||
|
||||
The Linux x86_64 AppImage from PR #174's exact head `69fff152` had SHA-256
|
||||
`4902ac80b77fbf98ec918db29df9cf9260b8abd6763a5b5055572335869f038a`.
|
||||
Its extracted resources passed `app/build/check-resources.js`, including the
|
||||
OBS helper, and the app launched under Xvfb with an isolated profile and a
|
||||
dummy SSH alias. Its bundled server served the page with HTTP 200.
|
||||
|
||||
For the device test, the PC was lxso2. The real Frame was reached as `ssh frame`
|
||||
under an atomically acquired `/tmp/frame-test.lock`. Battery read 100% Full
|
||||
before and after. SteamVR 2.18.2 reported `activity_level: 3` (standby). The
|
||||
mirror service and `/dev/video99` were active. The test cleaned its temporary
|
||||
remote WAV and released only its own lock.
|
||||
|
||||
OBS 30.0.2 ran in a disposable Ubuntu 24.04 container on lxso2, with Xvfb and
|
||||
host loopback networking. A Media Source opened `udp://127.0.0.1:15099` with
|
||||
input format `mpegts`. A synthetic pattern and tone first verified the OBS
|
||||
scene: its saved 43.733-second MKV had H.264 video and AAC audio with a
|
||||
`-17.9 dB` audio peak. The real Frame test then ran the helper from the
|
||||
extracted AppImage resources **before** OBS opened the source, so OBS joined
|
||||
an ongoing stream. OBS's saved 37.600-second MKV had H.264 video and AAC audio.
|
||||
The standby mirror appeared in decoded frames by five seconds. A 440 Hz tone
|
||||
played briefly to the Frame's default PulseAudio output appeared in the OBS
|
||||
audio recording at seconds 20–21; decoded audio peaked at `-34.4 dB`. A
|
||||
separate direct recording of `@DEFAULT_MONITOR@` during the same type of tone
|
||||
peaked at `-34.5 dB`. These measurements verify non-silent headset output
|
||||
audio passed through the helper and OBS into the saved recording.
|
||||
|
||||
The first OBS attempt opened its Media Source well before the Frame sent
|
||||
packets. Its output had a silent AAC track until the mirror appeared late.
|
||||
Starting the helper before OBS fixed this in the second run. The setup guide
|
||||
now recommends starting the source in Frame Control before adding or activating
|
||||
the OBS Media Source.
|
||||
|
||||
The exact-head AppImage was then launched on an isolated Xvfb display and
|
||||
profile with the real `frame` alias. In **Tools → Capture & Live**, clicking
|
||||
**Start OBS source** changed the UI to **Stop OBS source** and showed
|
||||
`udp://127.0.0.1:15099` after the first stream packet. The packaged Python
|
||||
helper was running with `--frame frame`. Clicking **Stop OBS source** returned
|
||||
the UI to **Start OBS source**; the helper and its remote `/dev/video99` ffmpeg
|
||||
process were gone. The app exited through **File → Quit**. Battery again read
|
||||
100% Full, and the owned lock was released.
|
||||
|
||||
**Limits:** Nobody listened to OBS audio monitoring or the Frame speakers.
|
||||
Audio/video sync was not measured. The headset was unworn and in standby, so
|
||||
this test does not answer whether active passthrough pixels appear in the
|
||||
mirror. The OBS MKVs were temporary local test artifacts and are not included
|
||||
in the repository.
|
||||
@@ -0,0 +1,32 @@
|
||||
# SteamOS PC OBS host feasibility check, 2026-10-25 UTC
|
||||
|
||||
The available x86_64 host, lxso2, runs Ubuntu 24.04.3. It has no `flatpak`
|
||||
command, OBS installation, SteamOS 3.9.5 VM or physical Steam Machine. This
|
||||
check therefore does **not** verify that Frame Control or Flatpak OBS runs on
|
||||
a SteamOS desktop or a read-only-root PC host.
|
||||
|
||||
The downloaded v0.4.27 Linux x86_64 AppImage matched GitHub's SHA-256 digest
|
||||
`42d774653c69b193c3d577743c32b8fe8dfeee7ea1f8cdf7369902962d647b83`.
|
||||
With `APPIMAGE_EXTRACT_AND_RUN=1`, it launched under Xvfb using an isolated
|
||||
profile and its bundled server returned HTTP 200. FUSE mounting, physical
|
||||
display rendering and SteamOS-specific dependencies were not tested.
|
||||
|
||||
The v0.4.27 tagged OBS helper then ran against the real Frame under the shared
|
||||
`/tmp/frame-test.lock`. Frame OS was 0.5.6 and SteamVR activity level was 3
|
||||
(standby). A receiver bound to `127.0.0.1:15099` on lxso2 accepted 497
|
||||
MPEG-TS packets, 93,436 bytes. Every datagram had complete 188-byte packets
|
||||
with sync bytes. `ffprobe` found H.264 video and AAC audio. Closing the
|
||||
helper's control pipe made it exit 0; no owned remote stream process remained.
|
||||
Battery read 100% Full before and after, and the owned lock was released. The
|
||||
temporary `.ts` file was removed. This proves the local UDP sender path on an
|
||||
Ubuntu host; it does not prove Flatpak OBS could receive it on SteamOS.
|
||||
|
||||
The public
|
||||
[OBS Flatpak manifest](https://github.com/flathub/com.obsproject.Studio/blob/master/com.obsproject.Studio.json)
|
||||
lists `--share=network`, which permits use of the host network. We read that
|
||||
manifest but did not install or run the Flatpak. A future check needs a real
|
||||
SteamOS 3.9.5 desktop host or VM with Flatpak OBS: launch the AppImage without
|
||||
root changes, start the source, record from the Media Source at
|
||||
`udp://127.0.0.1:15099`, verify video and audible game audio, then stop and
|
||||
confirm cleanup. The standby test cannot establish real game audio, A/V sync
|
||||
or active passthrough in the mirror.
|
||||
@@ -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,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
|
||||
}
|
||||
|
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# Round 3 headset checks, 25 October 2026
|
||||
|
||||
These checks used the real Frame after the device hold ended. SteamOS was
|
||||
0.5.6, build `20261022.6153909`. Each device session acquired
|
||||
`/tmp/frame-test.lock` atomically and released only its own lock. The battery
|
||||
read 100% and Full in the first check and after the OBS session; Steam and
|
||||
SteamVR were left running.
|
||||
|
||||
| Question | Observation | Conclusion |
|
||||
|---|---|---|
|
||||
| Does active passthrough appear in SteamVR's `/dev/video99` mirror? | The mirror service was active. One 1920×1080 RGB frame read from `/dev/video99` had a single pixel value, `(24, 28, 53)`, throughout. The headset was unworn; no active passthrough session or wearer-visible room view was established. Frame Control has no verified remote passthrough toggle. The temporary frame was removed. | **Still unknown.** A uniform standby frame cannot answer whether SteamVR composites active passthrough into its mirror. No headset setting was changed. |
|
||||
| Can Frame Control prompt when a headset text box opens? | No desktop `plasmashell` process was running during this check. There is no verified cross-app focus event from SteamVR, Lepton and the nested desktop; KDE Connect's `keyboardstate` packet describes keyboard support, not a focused text field. | **Not built.** A prompt triggered by an unverified signal would be misleading. The existing **Type on the headset** control remains available. |
|
||||
| Does reverse clipboard exist? | Frame Control already has **Copy the headset's clipboard here**. Its earlier real Frame test used a temporary nested desktop and confirmed that text copied through Klipper reached a Linux PC clipboard. No desktop panel was open during this session, so it was not rerun here. | **Shipped and previously verified**, text only. Windows/macOS backends and arbitrary headset apps remain unverified on a real Frame. |
|
||||
| Is an 80% charge limit available and writable? | `steamosctl get-max-charge-level` returned `UnknownInterface: ...BatteryChargeLimit1`. No `charge_control_*` attribute was found under `/sys/class/power_supply`. Only `pm8550b-charger/charging_pause` and `charging_enable` were present, each mode `644`, owner `root:root`. | **No supported user-writable control.** No battery attribute was written. |
|
||||
|
||||
The separate OBS transport check on the same build sent the mirror and the
|
||||
default-output monitor to a Linux PC. A 4.021-second temporary capture had
|
||||
H.264 video and AAC audio according to `ffprobe`. It was removed; OBS itself
|
||||
and audible playback were not tested. See the OBS source PR and guide.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Screenshot Save on a real Frame, 25 October 2026
|
||||
|
||||
After PR #192 merged, Frame Control's production `list_shots()` and
|
||||
`save_shots()` paths were run from lxso2 against the real Frame. The test held
|
||||
the shared `/tmp/frame-test.lock` with an owner marker and removed only its own
|
||||
lock. A separate locked check earlier in this round found SteamVR at activity
|
||||
level 3 (standby). No new screenshot was created.
|
||||
|
||||
- The Frame listed seven existing Steam screenshots.
|
||||
- One existing screenshot was copied to a temporary folder on the PC. Its
|
||||
local size matched the reported remote size: **294,885 bytes**. The new
|
||||
destination had four components below the temporary root: headset, Steam
|
||||
account, game ID and filename.
|
||||
- Saving the same screenshot a second time returned zero new copies. A fresh
|
||||
list then marked that screenshot as saved.
|
||||
- The temporary PC folder and its private image were removed at the end. The
|
||||
source screenshot on the Frame was only read. Battery was **100% Full**
|
||||
before and after.
|
||||
|
||||
This verifies one real screenshot transfer and the repeat-save behavior. It
|
||||
does **not** reproduce two different real screenshots with the same filename;
|
||||
the account/game/headset collision case is covered by the fake-source test in
|
||||
`tests/test_shot_copy.py`. Windows and macOS physical-PC saves were not tested.
|
||||
Active passthrough in the recording mirror remains unknown because the Frame
|
||||
was in standby.
|
||||
|
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
|
||||
@@ -0,0 +1,81 @@
|
||||
# Frame Control extensions
|
||||
|
||||
Extensions add a local panel to Frame Control's Tools page. They are plain HTML,
|
||||
CSS and JavaScript. Frame Control discovers them from its per-user data folder;
|
||||
there is no build step, server process, or headset installation. Open an extension
|
||||
to run it. Closing it unloads its page.
|
||||
|
||||
## Install the example
|
||||
|
||||
Copy [`examples/extensions/headset-glance`](../examples/extensions/headset-glance)
|
||||
to the `extensions` folder shown on Tools → Extensions, then select **Refresh**
|
||||
and **Open Headset Glance**. The folder is normally:
|
||||
|
||||
- Linux: `~/.local/share/frame-control/extensions`
|
||||
- macOS: `~/Library/Application Support/Frame Control/extensions`
|
||||
- Windows: `%APPDATA%\Frame Control\extensions`
|
||||
|
||||
To remove it, close the panel and delete only its `headset-glance` folder. The
|
||||
desktop app reads the folder on its computer. The iPhone app and the opt-in
|
||||
[SteamVR headset panel](headset-app.md) read the folder on the Frame. To use an
|
||||
extension in VR, copy its folder to
|
||||
`steamos@frame:~/.local/share/frame-control/extensions/`, then select
|
||||
**Extensions → Refresh extensions → Open** in the headset panel. The iframe,
|
||||
read-only bridge and asset rules are the same there.
|
||||
|
||||
## Make an extension
|
||||
|
||||
Create `extensions/your-id/manifest.json` and `index.html`. IDs use lowercase
|
||||
letters, digits and hyphens, start with a letter, and are at most 40 characters.
|
||||
The manifest ID must match the folder name:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "your-id",
|
||||
"name": "Your feature",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
`apiVersion` selects the Frame Control bridge contract, independently of your
|
||||
extension's own `version`. This app supports integer `1`; a missing field means
|
||||
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
|
||||
and their assets are not served. Declare it in new extensions so a future API
|
||||
change can fail clearly instead of running incompatible code. The runtime
|
||||
`capabilities` reply below reports the bridge version too.
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
|
||||
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
|
||||
top-level window. Content Security Policy restricts scripts, images, and
|
||||
`fetch` requests, but an extension can still navigate its own iframe to an
|
||||
external site. Treat third-party extensions as untrusted code and review them
|
||||
before installation.
|
||||
|
||||
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
|
||||
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
|
||||
shows a complete client:
|
||||
|
||||
```js
|
||||
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
|
||||
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
|
||||
```
|
||||
|
||||
Supported actions:
|
||||
|
||||
| Action | Value |
|
||||
| --- | --- |
|
||||
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
|
||||
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
|
||||
|
||||
`status` is the app's latest successful status snapshot. When that reading fails,
|
||||
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
|
||||
should use explicit, narrow actions and keep the iframe isolated. A third-party
|
||||
extension is code you choose to run locally: inspect its files before copying
|
||||
them into the folder. A Decky plugin is a separate Steam client plugin and does
|
||||
not use this API.
|
||||
@@ -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,
|
||||
@@ -79,7 +84,11 @@ counts them while they run.
|
||||
- **Volume** and mute (`wpctl`).
|
||||
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
|
||||
one with a click as its own Lepton instance (it keeps its data and shows in the
|
||||
Steam library), then launch, stop, test or remove it. **Report an APK** records
|
||||
Steam library), then launch, stop, test or remove it.
|
||||
The catalogue can load while the installed-app list is still being read. If
|
||||
that read fails, browsing remains available, but installation waits for a
|
||||
confirmed list so Frame Control does not make a duplicate app instance.
|
||||
**Report an APK** records
|
||||
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
|
||||
installed app). Your reports are saved on your computer and change the verdicts
|
||||
you see. With **Share compatibility results** on (Privacy & updates), they also
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Write a plugin for Frame
|
||||
|
||||
There are two paths. Choose the one that matches your feature.
|
||||
|
||||
## Frame Control extension
|
||||
|
||||
Use a Frame Control extension for a panel, status view, or guide that does not
|
||||
need to change the headset. Put `extension.json` and `index.html` in one folder
|
||||
under `~/.local/share/frame-control/extensions`. The opt-in headset panel lists
|
||||
the folder and opens it in a sandboxed iframe. The version 1 bridge has only
|
||||
`capabilities` and read-only `status`. It cannot call Steam, change settings,
|
||||
or run privileged code. See [the extension API](extensions.md) and its example.
|
||||
|
||||
## Decky plugin
|
||||
|
||||
Use a Decky plugin only if you need Decky's backend or Gamepad UI integration.
|
||||
Build an official-format ZIP with one top-level folder, `plugin.json`, and a
|
||||
frontend bundle in `dist/index.js`. A Python `main.py` backend is optional.
|
||||
Test on ARM64. If you ship an ELF helper, build it for AArch64 (ELF machine
|
||||
183). Avoid root, `sudo`, Steam Deck TDP controls, fixed x86 binaries, and
|
||||
paths under `/etc`. Frame Control's managed loader is a normal user service;
|
||||
it blocks root plugins and x86 archives. Keep data under your plugin folder or
|
||||
the user's home directory, and provide a removal path.
|
||||
|
||||
The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access
|
||||
Menu. A backend `Loaded` log only proves backend startup. Test any frontend
|
||||
page, setting, notification, route, and controller input while wearing a Frame
|
||||
before describing it as working in VR. Until a QAM bridge is verified, a Frame
|
||||
Control extension is the practical route for a visible read-only VR panel.
|
||||
|
||||
To test safely, use a separate Frame Control managed Decky install, verify the
|
||||
ZIP digest from the official store, install one plugin at a time, and inspect
|
||||
`journalctl --user -u frame-control-decky.service`. Remove only your own
|
||||
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
|
||||
@@ -0,0 +1,92 @@
|
||||
# Frame Control in the headset
|
||||
|
||||
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
|
||||
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
|
||||
the headset, independent of Decky's Quick Access Menu. The panel starts only
|
||||
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
|
||||
service. No `sudo`, root filesystem change, or Steam client patch is needed.
|
||||
|
||||
The companion copies its bundled UI over the existing trusted SSH connection.
|
||||
On the Frame, `frame-control-headset.service` runs a loopback server and one
|
||||
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
|
||||
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
|
||||
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
|
||||
it again from SteamVR's dashboard. Closing the panel stops its browser and
|
||||
server. The service is not enabled at boot.
|
||||
|
||||
The page shows battery, free home storage and SteamVR status. It can request
|
||||
SteamVR's main Game Mode dashboard, list and force quit a running game after an
|
||||
unsaved-progress confirmation, save an OpenVR screenshot to
|
||||
`~/Pictures/FrameControl`, and record the SteamVR mirror to
|
||||
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
|
||||
work folder from desktop Frame Control's recorder; they may have no audio if
|
||||
the monitor source is unavailable. Captures made in standby can be dark.
|
||||
|
||||
The page also discovers extensions from
|
||||
`~/.local/share/frame-control/extensions` and opens them in sandboxed iframes.
|
||||
It offers the version 1 read-only `capabilities` and `status` bridge from
|
||||
[the extension guide](extensions.md). Installing an extension still means
|
||||
copying its folder to the Frame; the panel does not download third-party code.
|
||||
|
||||
The panel also lists Decky plugins and can install, update, or remove compatible
|
||||
plugins from the official store when the opt-in managed loader is running.
|
||||
Each change asks for confirmation. The Frame VR shell does not show Decky's
|
||||
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
|
||||
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
|
||||
separates backend loading from VR use. Authors can use the
|
||||
[Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
The per-run UI key is passed in the URL fragment, removed from the address bar after load,
|
||||
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
|
||||
line and server log. The usual host/origin and anti-framing checks still apply.
|
||||
|
||||
## Real Frame test, 2026-10-25
|
||||
|
||||
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
|
||||
user service, served `/headset`, read live `/api/status` from the browser, and
|
||||
registered a mapped Chromium window with SteamVR under the dedicated overlay
|
||||
key. The server rejected a request with the wrong UI key. A read-only status
|
||||
probe reported the user service running and the overlay registered.
|
||||
|
||||
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
|
||||
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
|
||||
screenshot was a uniform dark frame. The documented temporary wake attempt
|
||||
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
|
||||
still left the overlay `not_visible`; the setting was restored to 5 and the
|
||||
dashboard hidden. The X window's `xwd` image was black too. These observations
|
||||
prove panel creation and page/API execution, **not wearer-visible pixels or
|
||||
laser/controller input**. A worn-headset check is still required before
|
||||
describing the panel as visually verified.
|
||||
|
||||
The test then removed the managed service and app bundle. Its unit became
|
||||
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
|
||||
battery was 100%. Steam and SteamVR were left running. The browser profile and
|
||||
extensions folder were deliberately kept.
|
||||
|
||||
A second test on the same build installed the expanded panel with the
|
||||
`headset-glance` example extension. The headset's authenticated loopback API
|
||||
returned live status (99% battery), discovered and served the extension, and
|
||||
listed no running games. The Game Mode dashboard request succeeded; an invalid
|
||||
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
|
||||
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
|
||||
reported an audio source. The per-run UI key was absent from the server log.
|
||||
After another wake and dashboard request, SteamVR still reported the panel
|
||||
`not_visible`. No wearer-visible screenshot or controller interaction was
|
||||
obtained. The test removed its example extension and captured media, then
|
||||
uninstalled the service. The unit, port, bundle and overlay were absent after
|
||||
removal; Steam and SteamVR remained running.
|
||||
|
||||
## Remove
|
||||
|
||||
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
|
||||
run:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
|
||||
```
|
||||
|
||||
That command removes only Frame Control's managed panel code. The Chromium
|
||||
profile remains in `~/.local/share/frame-control/headset/profile` so local
|
||||
extension data is not discarded. Inspect and delete that folder separately if
|
||||
you no longer want it. Third-party extensions are in
|
||||
`~/.local/share/frame-control/extensions` and are never removed by this tool.
|
||||
|
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.
|
||||
@@ -7,6 +7,12 @@ the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
|
||||
only). On Windows there's an experimental port,
|
||||
[PC in the headset](pc-in-headset.md).
|
||||
|
||||
On a **whole Mac display** panel, click **Switch Mac app (⌘Tab)** to send one
|
||||
Command-Tab chord to the Mac. The button is absent from single-window panels;
|
||||
those already keep their own source. It requires the same Mac Accessibility
|
||||
permission as other viewer keyboard input. The chord and button have automated
|
||||
checks, but a worn-headset click has not been verified.
|
||||
|
||||
The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
|
||||
## Why this design
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Headset view in OBS
|
||||
|
||||
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
|
||||
OBS on a Windows, Linux or macOS computer. The helper uses the same `/dev/video99` video
|
||||
source and default-output monitor as **Capture & Live → Record**. It sends a
|
||||
local MPEG-TS stream; no port is opened to the LAN or internet.
|
||||
|
||||
1. Connect to the Frame and start SteamVR. OBS runs on the computer; the Frame
|
||||
already has `ffmpeg`.
|
||||
2. In the **Frame Control desktop app**, open **Capture & Live** and click
|
||||
**Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching
|
||||
headsets, or quitting the app closes its SSH stream. It uses the selected
|
||||
headset and the app's bundled helper.
|
||||
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
|
||||
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
|
||||
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
|
||||
recording levels.
|
||||
|
||||
From a source checkout, you can instead install `python3` on the computer and
|
||||
run:
|
||||
|
||||
```sh
|
||||
python3 scripts/obs-headset-source.py
|
||||
```
|
||||
|
||||
Ctrl+C stops the checkout helper. The app closes a control pipe when you click
|
||||
Stop or quit; the helper then closes its SSH child, including on Windows. On
|
||||
Windows, install Python 3 and OpenSSH Client if Frame Control cannot find them.
|
||||
The Windows app Start/Stop path still needs a physical Windows/Frame test.
|
||||
If no first packet arrives within 20 seconds, or an active stream sends no
|
||||
packet for 10 seconds, the helper closes SSH and shows an error instead of
|
||||
leaving **Start OBS source** waiting or a frozen source running indefinitely.
|
||||
|
||||
To share the OBS view in a call, see the short
|
||||
[Discord setup guide](streaming-to-discord.md).
|
||||
|
||||
`--height 1080 --fps 60` increases quality and Frame CPU use. The default is
|
||||
720p30. `--port 15100` selects another loopback port; use the same port in
|
||||
OBS. If the Frame's PulseAudio monitor cannot be read, the helper exits with
|
||||
an error. Use `--no-audio` only when a silent view is useful. To inspect the
|
||||
stream without OBS, run `ffprobe -v error -show_streams
|
||||
udp://127.0.0.1:15099` in a second terminal while the helper is running.
|
||||
|
||||
This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
|
||||
passthrough camera feed. The mirror may be blank when the headset is idle or
|
||||
unworn. Whether active passthrough pixels appear in it is still unverified.
|
||||
The helper does not send a microphone. It is designed for one local OBS
|
||||
receiver. **Verified 2026-10-25:** the helper bundled in the Linux AppImage
|
||||
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
|
||||
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
|
||||
default output was present in the saved audio. The headset was in standby, so
|
||||
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
|
||||
and audio/video sync and active passthrough remain unverified. See the
|
||||
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
|
||||
The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
|
||||
measured packet continuity and a short MP4 recording on the current SteamOS
|
||||
build. The headset was still in standby.
|
||||
|
||||
## SteamOS / Steam Machine PC host
|
||||
|
||||
Use **Desktop Mode** on the SteamOS PC. Download the Linux x86_64 AppImage to
|
||||
your home folder. Make it executable and open it there; SteamOS has a
|
||||
read-only system root, so these steps do not use `apt` or change `/usr`:
|
||||
|
||||
```sh
|
||||
chmod +x ~/Downloads/Frame-Control-linux-x86_64.AppImage
|
||||
~/Downloads/Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
If AppImage mounting reports a FUSE error, try
|
||||
`APPIMAGE_EXTRACT_AND_RUN=1 ~/Downloads/Frame-Control-linux-x86_64.AppImage`.
|
||||
This extracts to writable temporary space and needs enough free disk space.
|
||||
Set up the Frame's SSH connection in Frame Control, then use **Start OBS
|
||||
source** before you add or activate OBS's Media Source. Install OBS as the
|
||||
`com.obsproject.Studio` Flatpak through Discover or your normal user Flatpak
|
||||
setup. In that OBS Media Source, clear **Local File**, set **Input** to
|
||||
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. Make a short
|
||||
recording and check both picture and sound before a stream. Use **Stop OBS
|
||||
source** in Frame Control when done.
|
||||
|
||||
The OBS Flatpak's published manifest includes `--share=network`, which is
|
||||
needed for its local UDP receiver. Check your installed permissions with
|
||||
`flatpak info --show-permissions com.obsproject.Studio` if OBS receives no
|
||||
packets. Frame Control's helper sends to loopback on this PC; it does not
|
||||
require an open LAN port or an OBS plugin. The PC does not need a system
|
||||
`ffmpeg` package for the helper; encoding runs on the Frame.
|
||||
|
||||
**Test limit:** The v0.4.27 AppImage launched on an Ubuntu 24.04 x86_64 host
|
||||
under Xvfb. Its helper sent H.264 and AAC from a real Frame to a local UDP
|
||||
receiver on that host. No SteamOS 3.9.5 PC, Steam Machine, Flatpak OBS runtime,
|
||||
read-only-root host, or physical display was available for this check. The
|
||||
SteamOS steps and Flatpak receiver therefore remain **untested on SteamOS**.
|
||||
See the [test record](evidence/obs-steamos-host-2026-10-25.md).
|
||||
|
||||
## Known issue: game audio in OBS
|
||||
|
||||
A Frame owner [reported](https://x.com/AzizahStellazio/status/2114147449468293171)
|
||||
that OBS **Game Capture / Application Audio Capture** can lose game audio when
|
||||
the headset turns on. OBS support attributed this to Valve/SteamVR. Frame
|
||||
Control cannot change that game-audio routing. If it happens, use Frame
|
||||
Control's **Start OBS source** and OBS **Media Source** steps above to capture
|
||||
the headset view and its output audio. You can also try OBS **Desktop Audio
|
||||
Capture** for the computer's output, then check its meter and a short test
|
||||
recording before you stream. Do not leave two active sources for the same audio
|
||||
unless you want duplicate sound.
|
||||
|
||||
The real Frame/OBS check above proved that a synthetic tone from the Frame's
|
||||
output reached the Media Source recording. It did **not** use a running game,
|
||||
listen to the audio, reproduce the reported Game Capture failure, or measure
|
||||
audio/video sync. Real game audio while the headset is awake remains unverified.
|
||||
@@ -1,10 +1,29 @@
|
||||
# Open questions and on-device checks
|
||||
|
||||
Research as of 2026-09-25, eight days after the Frame's retail release
|
||||
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
|
||||
pages. Searches of Reddit and the Steam forums turned up **almost no
|
||||
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
|
||||
"not documented yet", not "doesn't work".
|
||||
The September sections below record early checks after the Frame's retail
|
||||
release (2026-09-18). They are a historical test log, not a current setup
|
||||
guide. For current connection steps, see [SteamOS October 2026
|
||||
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
|
||||
[charge-limit.md](charge-limit.md).
|
||||
|
||||
## Current Frame OS 0.5.6 status (2026-10-25)
|
||||
|
||||
- `frame.local` no longer resolves on the checked 0.5.6 beta build
|
||||
(`20261022.6153909`). Avahi was active but did not publish addresses. The
|
||||
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
|
||||
the same network after verifying the pinned SSH key, or a manual IP/SSH
|
||||
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
|
||||
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
|
||||
- Read-only checks on that build found no SteamOS Manager
|
||||
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
|
||||
charging switches are root-owned and managed by Valve's charger service.
|
||||
No charging control was written. An 80% limit is **not supported** by Frame
|
||||
Control; see [charge-limit.md](charge-limit.md) and the
|
||||
[headset check](evidence/round3-headset-2026-10-25.md).
|
||||
- Active passthrough in the recording mirror remains **unknown**: the only
|
||||
captured mirror frame was uniform while the headset was unworn in standby.
|
||||
A worn, active passthrough check is still needed; see the
|
||||
[headset check](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
## Verified on device (2026-09-25)
|
||||
|
||||
@@ -14,7 +33,9 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
|
||||
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
|
||||
the hostname is `frame`.
|
||||
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
|
||||
- **3.** `frame.local` resolved from the Mac on the September build;
|
||||
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
|
||||
beta build; see the current status above.
|
||||
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
|
||||
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
|
||||
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
|
||||
@@ -148,9 +169,9 @@ check in the headset:
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
|
||||
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
|
||||
See [charge-limit.md](charge-limit.md).
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -9,6 +13,12 @@ whole screens. Place the panel with the dashboard's Float
|
||||
in World, Move and Size, point and click with the laser, scroll with the
|
||||
thumbstick, and type on the PC's keyboard.
|
||||
|
||||
On a whole-screen panel, click **Switch PC window (Alt+Tab)** to switch the
|
||||
foreground Windows window. It sends one explicit Alt-Tab chord through the
|
||||
viewer's existing input channel. The button is absent from single-window panels
|
||||
when a WGC-enabled build supports them. A real Windows/Frame button click has
|
||||
not been verified; use the PC keyboard if focus does not move as expected.
|
||||
|
||||
**It's experimental.** A Windows 11 VM has captured its real desktop with
|
||||
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
|
||||
hardware encoding and physical laser input remain unverified. See
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
Frame Control can record what the headset shows, with its sound, and save it as
|
||||
For a live OBS Media Source with headset output audio, see
|
||||
[Headset view in OBS](obs-headset-source.md).
|
||||
|
||||
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
|
||||
@@ -47,6 +50,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
|
||||
@@ -89,6 +107,18 @@ about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
Each newly saved screenshot lives under
|
||||
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
|
||||
The headset folder uses a short hash of Frame Control's device identity.
|
||||
Steam can use the same screenshot filename for two games or accounts; these
|
||||
folders keep both originals. Existing screenshots in the old flat
|
||||
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
|
||||
matched reliably to one headset and game, so **Copy everything now** can create
|
||||
a new, correctly identified copy. It never overwrites the old file.
|
||||
One real Frame screenshot was copied through this path on 25 October 2026;
|
||||
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
|
||||
case was tested with separate fake sources, not two real same-name screenshots.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
@@ -107,10 +137,19 @@ about once a minute and copies what's new with the same code as **Save** and
|
||||
- **When this computer is full** (under 512 MB free, or a copy runs out of
|
||||
space) it pauses and says so in Settings and above the lists. Free some space
|
||||
and press **Resume**.
|
||||
- **With several headsets** it copies from the one you're connected to.
|
||||
- **When one file keeps failing** it tries at most three times, then leaves
|
||||
the problem visible in Settings. Fix the source problem and press **Resume**
|
||||
to allow another three attempts without restarting Frame Control. **Copy
|
||||
everything now** also resets failed attempts and includes older files.
|
||||
Resume keeps the history of successful copies, so it does not recopy them.
|
||||
- **With several headsets** each check and copy belongs to the headset selected
|
||||
when the check began. Switching between lists or copies stops that pass;
|
||||
another headset's saved files cannot become part of its copy history.
|
||||
|
||||
Settings shows when it last checked, how many files it last copied and when,
|
||||
and the last problem it had. The screenshot and clip lists say when it's on.
|
||||
Retry and switch handling have offline regression coverage with fake sources;
|
||||
see the [test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
## For developers
|
||||
|
||||
@@ -178,17 +217,33 @@ 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).
|
||||
This did not click **Export** in the packaged desktop app or verify visible
|
||||
gameplay quality or perceptual audio/video sync.
|
||||
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, trims starting partway through a Steam
|
||||
recording, and the packaged desktop app's **Export** button. Two real saved
|
||||
clips' directory layouts were checked; the parser remains deliberately loose
|
||||
- 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
|
||||
|
||||
@@ -13,7 +13,9 @@ 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
|
||||
attaches everything to a **draft** release for that tag. After all eight
|
||||
installers upload, the workflow also attaches `SHA256SUMS` built from
|
||||
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
|
||||
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
|
||||
@@ -32,7 +34,9 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
```
|
||||
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||
are attached, each with the SHA-256 digest GitHub records. It also checks
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Share the Frame view in Discord
|
||||
|
||||
1. Start the **OBS source** in Frame Control and add it to OBS as described in
|
||||
[Headset view in OBS](obs-headset-source.md). Confirm that OBS shows the
|
||||
headset mirror and that its audio meter moves before joining a call.
|
||||
2. In OBS, click **Start Virtual Camera**. In Discord's **Voice & Video**
|
||||
settings, select **OBS Virtual Camera** as the camera, then turn it on in the
|
||||
call. This sends the OBS scene as video.
|
||||
3. If the call needs the Frame's game sound, route OBS's monitored Media Source
|
||||
audio to a virtual audio input installed on your computer, then select that
|
||||
input as Discord's microphone. Keep headphones on to avoid feedback. Test
|
||||
this in a private call first; a virtual camera alone carries no audio.
|
||||
4. Stop the OBS source in Frame Control when you finish. It closes the SSH
|
||||
stream; OBS may keep showing its last frame until the source is hidden.
|
||||
|
||||
Frame Control sends the Frame's **output audio**, not its microphone. Discord
|
||||
audio routing differs by computer and requires a separate virtual audio device
|
||||
if you want game sound in the call. This guide has not been verified in a real
|
||||
Discord call. The SteamVR mirror can be blank while the headset is idle, and
|
||||
active passthrough in that mirror remains unverified.
|
||||
@@ -154,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):
|
||||
|
||||
@@ -295,6 +316,10 @@ How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
|
||||
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).
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p><strong>This is a September 2026 archive.</strong> For current coverage and hardware limits, read the <a href="current-support-matrix.md">v0.4.25 support matrix</a>.</p>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
|
||||
@@ -12,6 +12,31 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
|
||||
## Unit tests
|
||||
|
||||
Automatic screenshot and clip copying has offline coverage in
|
||||
`tests/test_autosync.py`:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_autosync.py -v
|
||||
```
|
||||
|
||||
Fake sources exercise the three-attempt limit, visible exhausted errors,
|
||||
Resume/Copy everything now recovery, successful-copy deduplication, disk-full
|
||||
pauses and headset changes around lists and transfers. Server tests use the
|
||||
production `working(meant)` reservation and listing/copy adapters; a Node test
|
||||
executes the Settings renderer to check that Resume is available after an error.
|
||||
These tests use no headset. See the
|
||||
[auto-sync reliability test record](evidence/autosync-reliability-2026-10-25.md).
|
||||
|
||||
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`.
|
||||
@@ -165,6 +190,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
|
||||
@@ -374,7 +407,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.
|
||||
@@ -461,3 +498,19 @@ 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,141 @@
|
||||
# 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.
|
||||
|
||||
## When a stream drops immediately or the adapter will not reconnect
|
||||
|
||||
Read PC adapters **before** starting a stream, then refresh immediately after a
|
||||
drop. Compare the *exact* PnP instance IDs, presence and problem codes. Note
|
||||
whether the PC's own Wi-Fi drops too. A new `#2` name is not proof that this
|
||||
instance caused the failure. Keep the time of the drop and the two snapshots
|
||||
for Steam Support; the doctor does not save or upload them.
|
||||
|
||||
The app shows **Changes since previous read** after the second click. It
|
||||
compares exact PnP IDs, known status fields and reported channels/bands; an
|
||||
unknown value is never treated as a state change. If the PnP source was
|
||||
unavailable in either read, it declines to compare devices. Each successful
|
||||
read becomes the baseline for the next one. **Forget previous read** clears the
|
||||
baseline and comparison; closing the app clears them too. The before/after
|
||||
comparison stays in local memory and is not uploaded or added to telemetry.
|
||||
Reported changes can reflect sequential query timing or a service recovering,
|
||||
not a fault or permission to disable any instance.
|
||||
|
||||
To narrow a drop at stream start, try one condition at a time: wired PC
|
||||
networking instead of PC Wi-Fi, wired headphones instead of 2.4 GHz audio, and
|
||||
spacing the USB radio dongles apart. Recheck the stream after each change. The
|
||||
PC Wi-Fi channel in this report is not the Steam dongle's channel, so a matching
|
||||
or different number does not establish interference.
|
||||
|
||||
If the adapter will not reconnect, stop the failed stream and close Steam's
|
||||
streaming session before touching its USB connection. Refresh the inventory. If
|
||||
the dongle is absent, reconnect it once at the PC, understanding that this
|
||||
interrupts any connection using it, then refresh again. If the **same exact**
|
||||
PnP instance now reports problem code 22 (disabled), review that instance in
|
||||
Windows Device Manager and its normal enable action. Do not enable or disable a
|
||||
different numbered instance by guesswork. For a missing device or another
|
||||
problem code, record the before/after ID and code for Steam Support; do not
|
||||
guess a driver or change security settings.
|
||||
|
||||
These are user-controlled checks, not an automatic repair. We have no physical
|
||||
Steam Wireless Adapter dropout to reproduce and no verified mapping from a
|
||||
Wi-Fi Direct virtual instance to Steam's active session. An in-app disable and
|
||||
re-enable control remains deferred until that mapping and a recovery path are
|
||||
tested on real hardware.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Wired connection doctor
|
||||
|
||||
If you use a USB-C cable because the Wireless Adapter is unreliable, open
|
||||
**Devices & connection → Wired connection doctor** in the desktop app. Enter
|
||||
the headset's configured SSH alias and click **Check cable connection**. From
|
||||
a source checkout, run `python3 scripts/frame-wired-doctor.py --alias frame`.
|
||||
|
||||
The on-demand check reads the computer's selected route to the Frame USB
|
||||
address, checks whether port 22 answers, then runs remote `true` over SSH with
|
||||
the existing pinned host key. It does not change network settings or the
|
||||
headset, accept a new key, or attempt a password. A failure is shown as unknown
|
||||
or warning; the doctor never claims a cable works from an IP address alone.
|
||||
|
||||
The known address is `10.86.200.233` on the Frame's `10.86.200.232/29` USB
|
||||
network. A Mac with the Frame attached used `10.86.200.234/29` and completed a
|
||||
strict SSH login on 2026-09-28; see [the observed link](how-the-frame-works.md).
|
||||
If the route check says unknown, first confirm a data-capable USB-C cable, a
|
||||
visible PC network interface and a current Frame OS build. Other OS builds may
|
||||
use another subnet. If the port answers but SSH identity fails, connect using
|
||||
the normal pinned headset alias and resolve the pairing or key warning there;
|
||||
do not disable host-key checking.
|
||||
|
||||
This checks **Frame Control's SSH route**, not whether SteamVR's game stream
|
||||
uses the cable. The Wireless Adapter and Steam streaming can follow a different
|
||||
path. No cable is attached to the Linux test host, so this exact UI has only
|
||||
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
|
||||
fresh wired readback on the current Frame OS, remain unverified.
|
||||
|
||||
## If SSH works but the game stream stops
|
||||
|
||||
If the stream fails on USB, Wi-Fi and the Wireless Adapter, but this doctor
|
||||
reports a working SSH route, check SteamVR on the **PC**. Frame Control's
|
||||
`frame.local` reconnect only restores its headset management connection. It
|
||||
does not restart PC SteamVR or repair the game stream. On a Windows or Linux
|
||||
PC, save your work and close the VR game. Exit SteamVR from Steam on that PC,
|
||||
then start it again. This can show whether a PC SteamVR session is stuck; it
|
||||
does not fix the cause of repeated drops. SteamVR on macOS is not a supported
|
||||
PC streaming host.
|
||||
|
||||
Frame Control has no verified cross-platform command that safely stops only
|
||||
the intended PC SteamVR session. A forced restart could close a running game
|
||||
and lose unsaved progress. For that reason the app gives manual steps and does
|
||||
not add a one-click restart button.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Headset Glance</title>
|
||||
<main>
|
||||
<h1>Headset Glance</h1>
|
||||
<p id="summary">Reading the headset status…</p>
|
||||
<button id="refresh">Refresh</button>
|
||||
</main>
|
||||
<script src="main.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== parent || event.data?.type !== 'frame-control:response') return;
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (resolve) {
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data.value);
|
||||
}
|
||||
});
|
||||
|
||||
function request(action) {
|
||||
return new Promise(resolve => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, resolve);
|
||||
parent.postMessage({type: 'frame-control:request', id, action}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const status = await request('status');
|
||||
document.getElementById('summary').textContent = status.connected
|
||||
? `Frame OS ${status.os.version || 'unknown'} · Battery ${status.battery.percent ?? '?'}% · SteamVR ${status.steamvr ? 'running' : 'stopped'}`
|
||||
: 'No headset status is available yet.';
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', refresh);
|
||||
refresh();
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
body { margin: 0; background: #101820; color: #dcdedf; font: 16px system-ui, sans-serif; }
|
||||
main { padding: 24px; }
|
||||
h1 { font-size: 20px; }
|
||||
button { border: 0; border-radius: 4px; padding: 8px 14px; background: #1a9fff; color: #fff; cursor: pointer; }
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-checkout entry point for the PC's read-only USB-C route checks."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
from frame_wired_doctor import main # noqa: E402
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -293,7 +293,7 @@ def load_baseline(path):
|
||||
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)')
|
||||
@@ -307,6 +307,8 @@ def main(argv=None):
|
||||
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. Reports are saved only with --save-report.')
|
||||
@@ -327,7 +329,11 @@ def main(argv=None):
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send the Frame's SteamVR mirror and output audio to a local OBS Media Source.
|
||||
|
||||
OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
|
||||
the network: this process sends MPEG-TS packets to the computer's loopback.
|
||||
"""
|
||||
import argparse
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
PACKET = 188
|
||||
DATAGRAM = PACKET * 7
|
||||
STARTUP_TIMEOUT = 20
|
||||
STREAM_STALL = 10
|
||||
|
||||
|
||||
def remote_command(height=720, fps=30, audio=True):
|
||||
if height not in (720, 1080) or fps not in (30, 60):
|
||||
raise ValueError('height must be 720 or 1080; fps must be 30 or 60')
|
||||
rate = 3 if height == 720 else 6
|
||||
clock = '-thread_queue_size 1024 -use_wallclock_as_timestamps 1'
|
||||
video = f'{clock} -f v4l2 -video_size 1920x1080 -i /dev/video99'
|
||||
sound = f' {clock} -f pulse -i @DEFAULT_MONITOR@' if audio else ''
|
||||
maps = '-map 0:v:0 -map 1:a:0' if audio else '-map 0:v:0'
|
||||
encode = (f'-vf fps={fps},scale=-2:{height},format=yuv420p '
|
||||
f'-c:v libx264 -preset ultrafast -tune zerolatency -g {fps * 2} -bf 0 '
|
||||
f'-b:v {rate}M -maxrate {rate}M -bufsize {rate}M '
|
||||
'-x264-params repeat-headers=1')
|
||||
if audio:
|
||||
encode += ' -af aresample=async=1 -c:a aac -b:a 160k'
|
||||
return ("export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}; "
|
||||
"test -e /dev/video99 || { echo 'SteamVR mirror /dev/video99 is missing' >&2; exit 3; }; "
|
||||
f'ffmpeg -hide_banner -loglevel warning -nostdin {video}{sound} '
|
||||
f'{maps} {encode} -f mpegts -flush_packets 1 -')
|
||||
|
||||
|
||||
def relay(source, destination, on_packet=None):
|
||||
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
|
||||
pending = bytearray()
|
||||
total = 0
|
||||
while data := source.read(DATAGRAM):
|
||||
pending.extend(data)
|
||||
size = min(len(pending) // PACKET * PACKET, DATAGRAM)
|
||||
if size:
|
||||
destination.send(bytes(pending[:size]))
|
||||
if on_packet:
|
||||
on_packet()
|
||||
del pending[:size]
|
||||
total += size
|
||||
if pending:
|
||||
raise RuntimeError('Frame stream ended with an incomplete MPEG-TS packet')
|
||||
return total
|
||||
|
||||
|
||||
def watch_control(stream, proc, stopped):
|
||||
"""A desktop app closes its pipe to stop SSH, including on Windows.
|
||||
|
||||
Windows TerminateProcess does not run Python's finally block. The pipe also
|
||||
closes if the app crashes, so the SSH connection is closed in either case.
|
||||
"""
|
||||
try:
|
||||
stream.read(1)
|
||||
except OSError:
|
||||
pass
|
||||
stopped.set()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
class StreamWatchdog:
|
||||
"""Close SSH if the first packet or a later packet takes too long."""
|
||||
|
||||
def __init__(self, proc, startup=None, stall=None):
|
||||
self.proc = proc
|
||||
self.startup = STARTUP_TIMEOUT if startup is None else startup
|
||||
self.stall = STREAM_STALL if stall is None else stall
|
||||
self.condition = threading.Condition()
|
||||
self.last_packet = time.monotonic()
|
||||
self.ready = False
|
||||
self.closed = False
|
||||
self.reason = ''
|
||||
self.thread = threading.Thread(target=self._watch, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def packet(self):
|
||||
with self.condition:
|
||||
self.ready = True
|
||||
self.last_packet = time.monotonic()
|
||||
self.condition.notify()
|
||||
|
||||
def close(self):
|
||||
with self.condition:
|
||||
self.closed = True
|
||||
self.condition.notify()
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
def _watch(self):
|
||||
with self.condition:
|
||||
while not self.closed:
|
||||
left = self.last_packet + (self.stall if self.ready else self.startup) - time.monotonic()
|
||||
if left <= 0:
|
||||
self.reason = (f'The Frame sent no OBS video for {self.startup:g} seconds' if not self.ready
|
||||
else f'The Frame OBS video stopped for {self.stall:g} seconds')
|
||||
break
|
||||
self.condition.wait(left)
|
||||
else:
|
||||
return
|
||||
if self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
|
||||
parser.add_argument('--port', type=int, default=15099, help='local OBS UDP port (default: 15099)')
|
||||
parser.add_argument('--height', type=int, choices=(720, 1080), default=720)
|
||||
parser.add_argument('--fps', type=int, choices=(30, 60), default=30)
|
||||
parser.add_argument('--no-audio', action='store_true', help='video only when the Frame audio monitor is unavailable')
|
||||
parser.add_argument('--control-stdin', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args(argv)
|
||||
if not 1024 <= args.port <= 65535:
|
||||
parser.error('port must be between 1024 and 65535')
|
||||
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
|
||||
parser.error('frame must be one SSH host or alias')
|
||||
command = remote_command(args.height, args.fps, not args.no_audio)
|
||||
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
|
||||
target = ('127.0.0.1', args.port)
|
||||
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
|
||||
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
||||
stopped = threading.Event()
|
||||
watchdog = StreamWatchdog(proc)
|
||||
if args.control_stdin:
|
||||
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc, stopped), daemon=True).start()
|
||||
try:
|
||||
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
|
||||
code = proc.wait(timeout=10)
|
||||
if stopped.is_set():
|
||||
return 0
|
||||
if watchdog.reason:
|
||||
raise RuntimeError(watchdog.reason)
|
||||
if code or not amount:
|
||||
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
|
||||
'Check SteamVR and the audio monitor; try --no-audio if needed.')
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
watchdog.close()
|
||||
proc.stdout.close()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class _Sender:
|
||||
def __init__(self, sock, target):
|
||||
self.sock, self.target = sock, target
|
||||
self.ready = False
|
||||
|
||||
def send(self, data):
|
||||
self.sock.sendto(data, self.target)
|
||||
if not self.ready:
|
||||
self.ready = True
|
||||
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -70,6 +70,38 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
|
||||
done
|
||||
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
|
||||
|
||||
# The workflow uploads this only after all eight platform builds complete.
|
||||
# Regenerate it from GitHub's installer digests and compare its own GitHub
|
||||
# digest, so a stale or partial checksum file cannot pass the release gate.
|
||||
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
|
||||
expected_sum=$(sha256sum "$tmp/SHA256SUMS" | cut -d ' ' -f 1)
|
||||
checksum_digest=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
|
||||
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
|
||||
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
|
||||
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
|
||||
}
|
||||
echo "ok SHA256SUMS"
|
||||
|
||||
# Every published release carries the same concrete verification instructions.
|
||||
# Keep the release's own notes, and append this section only once. The tag URL
|
||||
# is stable even while a draft temporarily has an untagged-* API name.
|
||||
python3 - "$tmp/release.json" "$(dirname "$0")/release-safety-note.md" "$tag" > "$tmp/release-with-note.json" <<'EOF'
|
||||
import json, pathlib, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
body = (d.get("body") or "").rstrip()
|
||||
if "## Is it safe?" not in body:
|
||||
note = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").replace("{tag}", sys.argv[3]).strip()
|
||||
body = (body + "\n\n" if body else "") + note
|
||||
d["body"] = body
|
||||
json.dump(d, sys.stdout)
|
||||
EOF
|
||||
mv "$tmp/release-with-note.json" "$tmp/release.json"
|
||||
python3 - "$tmp/release.json" > "$tmp/release-body.txt" <<'EOF'
|
||||
import json, sys
|
||||
sys.stdout.buffer.write(json.load(open(sys.argv[1]))["body"].encode("utf-8"))
|
||||
EOF
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||
@@ -87,6 +119,7 @@ print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))'
|
||||
if [ -n "$dry" ]; then
|
||||
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
|
||||
cat "$tmp/update.json"
|
||||
echo "dry run: would include the Is it safe? section in the release body"
|
||||
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
|
||||
exit 0
|
||||
fi
|
||||
@@ -98,6 +131,6 @@ gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||
echo "ok update.json"
|
||||
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
|
||||
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||
echo "running copies will offer $tag at their next check"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
|
||||
|
||||
GitHub computes each asset digest from its uploaded bytes. The workflow runs
|
||||
this only after all platform builds finish; publish-release.sh checks that the
|
||||
uploaded SHA256SUMS bytes still match these digests before publishing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
INSTALLERS = (
|
||||
"Frame-Control-mac-arm64.dmg",
|
||||
"Frame-Control-mac-arm64.zip",
|
||||
"Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip",
|
||||
"Frame-Control-linux-x86_64.AppImage",
|
||||
"Frame-Control-linux-arm64.AppImage",
|
||||
"Frame-Control-linux-amd64.deb",
|
||||
"Frame-Control-linux-arm64.deb",
|
||||
)
|
||||
|
||||
|
||||
def find_release(lines, tag):
|
||||
releases = [json.loads(line) for line in lines if line.strip()]
|
||||
hits = [r for r in releases if r.get("tag_name") == tag]
|
||||
if not hits:
|
||||
title = re.compile(r"Frame Control " + re.escape(tag.removeprefix("v")) + r"(: .*)?")
|
||||
hits = [r for r in releases if r.get("draft")
|
||||
and str(r.get("tag_name") or "").startswith("untagged-")
|
||||
and title.fullmatch(r.get("name") or "")]
|
||||
if len(hits) != 1:
|
||||
raise ValueError(f"expected one release for {tag}; found {len(hits)}")
|
||||
return hits[0]
|
||||
|
||||
|
||||
def checksums(release):
|
||||
assets = release.get("assets") or []
|
||||
lines = []
|
||||
for name in INSTALLERS:
|
||||
matches = [a for a in assets if a.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected one asset named {name}; found {len(matches)}")
|
||||
digest = matches[0].get("digest") or ""
|
||||
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise ValueError(f"missing SHA-256 digest for {name}")
|
||||
lines.append(f"{digest[7:].lower()} {name}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tag", help="find exact release in JSON-lines input")
|
||||
parser.add_argument("--id", action="store_true", help="print the selected release id")
|
||||
args = parser.parse_args()
|
||||
data = sys.stdin.read()
|
||||
release = find_release(data.splitlines(), args.tag) if args.tag else json.loads(data)
|
||||
if args.id:
|
||||
print(int(release["id"]))
|
||||
else:
|
||||
# Windows text stdout changes LF to CRLF. SHA256SUMS must have the same
|
||||
# bytes on every runner, including publish-release.sh's verification.
|
||||
sys.stdout.buffer.write(checksums(release).encode("ascii"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
@@ -0,0 +1,11 @@
|
||||
## Is it safe?
|
||||
|
||||
Download only from this GitHub release. Check your installer against [SHA256SUMS](https://github.com/saphid/frame-control/releases/download/{tag}/SHA256SUMS). A matching hash proves that your file matches the file in this release. It does not prove that any program is free of malware.
|
||||
|
||||
- Windows PowerShell: `Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256`
|
||||
- macOS Terminal: `shasum -a 256 Frame-Control-mac-arm64.dmg`
|
||||
- Linux terminal: `sha256sum Frame-Control-linux-x86_64.AppImage`
|
||||
|
||||
Find the same filename in `SHA256SUMS` and compare all 64 hex characters. [Read the source](https://github.com/saphid/frame-control) and [see the build workflow](https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml). The Windows installer is **not code-signed**; SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**. Gatekeeper may warn or block it. Follow the opening steps in the README only if you trust this download.
|
||||
|
||||
日本語: 公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<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>
|
||||
<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> · <a href="https://github.com/saphid/frame-control/blob/main/docs/current-support-matrix.md">What we tested</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" id="download-mac" data-os="mac">
|
||||
@@ -257,6 +257,13 @@
|
||||
<h2>Good questions</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details id="verify-download">
|
||||
<summary>Is it safe? How do I check my download?</summary>
|
||||
<div><p>Starting with <b>v0.4.26</b>, download Frame Control and <a href="https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS">SHA256SUMS</a> from the same GitHub release. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Compare all 64 characters with the line for your filename.</p>
|
||||
<p>Windows PowerShell: <code>Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256</code><br>macOS Terminal: <code>shasum -a 256 Frame-Control-mac-arm64.dmg</code><br>Linux terminal: <code>sha256sum Frame-Control-linux-x86_64.AppImage</code></p>
|
||||
<p>The <a href="https://github.com/saphid/frame-control">source</a> and <a href="https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml">build workflow</a> are public. The Windows installer is not code-signed, so SmartScreen may warn. The macOS app has only an ad hoc signature, not an Apple Developer ID signature; the DMG is unsigned and not notarized, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.</p>
|
||||
<p lang="ja">日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。</p></div>
|
||||
</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. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:</p>
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const page = readFileSync(join(root, 'ui', 'mac-view.html'), 'utf8');
|
||||
const match = page.match(/function windowSwitchEvents\(hostName\) \{[\s\S]*?\n\}/);
|
||||
assert.ok(match, 'viewer exposes a bounded host-specific switch sequence');
|
||||
const events = vm.runInNewContext(`(${match[0]})`);
|
||||
for (const [host, modifier, mod] of [
|
||||
['mac', 'MetaLeft', 'meta'], ['windows', 'AltLeft', 'alt'],
|
||||
]) {
|
||||
const sequence = JSON.parse(JSON.stringify(events(host)));
|
||||
assert.deepEqual(sequence.map(x => [x.e, x.code]),
|
||||
[['down', modifier], ['down', 'Tab'], ['up', 'Tab'], ['up', modifier]]);
|
||||
assert.deepEqual(sequence[1].mods, [mod]);
|
||||
assert.deepEqual(sequence[2].mods, [mod]);
|
||||
}
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(events('linux'))), []);
|
||||
assert.match(page, /switchButton\.hidden = !src\.startsWith\("display:"\)/);
|
||||
console.log('window switch sequences passed');
|
||||
@@ -0,0 +1,41 @@
|
||||
// Run the actual wired-doctor page script without browser or device I/O.
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
class Element {
|
||||
constructor() { this.value = ''; this.disabled = false; this.textContent = ''; this.children = []; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
prepend(...nodes) { this.children.unshift(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
}
|
||||
const elements = Object.fromEntries(['back','alias','check','message','results'].map(id => [id, new Element()]));
|
||||
elements.alias.value = 'frame';
|
||||
const requests = [];
|
||||
let respond;
|
||||
const context = vm.createContext({
|
||||
document: {getElementById: id => elements[id], createElement: () => new Element()},
|
||||
URLSearchParams, location: {search: '?key=test-key'},
|
||||
fetch: (url, options) => { requests.push({url, options}); return new Promise(resolve => {respond = resolve;}); },
|
||||
});
|
||||
const source = readFileSync(new URL('../../ui/wired-doctor.html', import.meta.url), 'utf8');
|
||||
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1], context);
|
||||
assert.equal(requests.length, 0, 'page load must not probe the PC or Frame');
|
||||
assert.equal(elements.back.href, '/?key=test-key#devices');
|
||||
let promise = elements.check.onclick();
|
||||
assert.equal(elements.check.disabled, true);
|
||||
assert.equal(requests[0].url, '/api/host/wired?alias=frame');
|
||||
assert.equal(requests[0].options.headers['X-Frame-UI'], 'test-key');
|
||||
respond({ok: true, json: async () => ({state: 'unknown', checks: [{title: '<script>bad</script>',status: 'unknown',detail: 'No known route'}]})});
|
||||
await promise;
|
||||
assert.equal(elements.results.children.length, 1);
|
||||
assert.equal(elements.results.children[0].children[0].textContent, '<script>bad</script>', 'device text must stay plain text');
|
||||
assert.equal(elements.check.disabled, false);
|
||||
promise = elements.check.onclick();
|
||||
assert.equal(elements.results.children.length, 0, 'stale result cleared before retry');
|
||||
respond({ok: false, json: async () => ({error: 'PC route unavailable'})});
|
||||
await promise;
|
||||
assert.match(elements.message.textContent, /route unavailable/);
|
||||
assert.equal(elements.results.children.length, 0);
|
||||
assert.equal(elements.check.disabled, false);
|
||||
console.log('wired doctor UI: on-demand, escaped data, stale-result clearing and retry passed');
|
||||
@@ -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');
|
||||
@@ -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])
|
||||
|
||||
@@ -95,8 +95,20 @@ test('Assistant Back encodes the injected key for the same-origin workspace only
|
||||
assert.match(html, /referrerpolicy="no-referrer"/);
|
||||
});
|
||||
|
||||
test('catalogue browsing starts while installed-app discovery is still pending', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
assert.deepEqual(f.requests.map(r => r.url.split('?')[0]), ['/api/android', '/api/search']);
|
||||
await f.answer(1, {apps: [{name: 'Example Game', category: 'games', package: 'example.game',
|
||||
offers: [{source: 'test', id: 'example'}]}], sources: []});
|
||||
assert.equal(f.run('st.installedState'), 'loading');
|
||||
assert.match(f.element('content').innerHTML, /Example Game/);
|
||||
await f.answer(0, {apps: [{package: 'example.game', label: 'Example Game'}]});
|
||||
assert.match(f.element('content').innerHTML, /Installed/);
|
||||
});
|
||||
|
||||
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
await f.answer(1, {apps: [], sources: []});
|
||||
f.run('st.view = "installed"; render()');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
@@ -105,16 +117,16 @@ test('installed read failure is persistent, retry recovers, and cached entries a
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const retry = f.element('content').querySelector('[data-retry-installed]');
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.requests[1].url, '/api/android');
|
||||
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
|
||||
assert.equal(f.requests[2].url, '/api/android');
|
||||
await f.answer(2, {apps: [{package: 'cached.app', label: 'Cached app'}]});
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
const failed = f.run('loadInstalled()');
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
f.requests[2].reject(Error('refresh failed')); await failed;
|
||||
f.requests[3].reject(Error('refresh failed')); await failed;
|
||||
assert.match(f.element('content').innerHTML, /Cached app/);
|
||||
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
|
||||
const empty = f.run('loadInstalled()'); await f.answer(4, {apps: []}); await empty;
|
||||
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
@@ -122,12 +134,12 @@ test('installed read failure is persistent, retry recovers, and cached entries a
|
||||
test('newest installed read wins over older success and failure without pruning cached data', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
|
||||
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
|
||||
const newer = f.run('loadInstalled()'); await f.answer(2, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installed.has("new.app")'), true);
|
||||
assert.equal(f.run('st.needsData.has("new.app")'), true);
|
||||
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
|
||||
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
|
||||
await f.answer(4, {apps: []}); await newest; // request 1 is the independent bootstrap catalogue search
|
||||
f.requests[3].reject(Error('late failure')); await old;
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
assert.equal(f.run('st.installedError'), '');
|
||||
@@ -137,12 +149,12 @@ test('newest installed read wins over older success and failure without pruning
|
||||
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
f.run('st.view = "installed"');
|
||||
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
|
||||
const newest = f.run('loadInstalled()'); f.requests[2].reject(Error('latest offline')); await newest;
|
||||
await f.answer(0, {apps: []});
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.equal(f.run('st.installedError'), 'latest offline');
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
|
||||
const malformed = f.run('loadInstalled()'); await f.answer(3, {}); await malformed;
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
@@ -197,12 +209,12 @@ test('named native Store dialog enters, contains and restores focus across async
|
||||
|
||||
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
|
||||
const f = fixture(read('store.html'));
|
||||
await f.answer(1, {apps: [], sources: []});
|
||||
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
|
||||
f.run('st.view = "games"; st.apps = [app]; render()');
|
||||
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
|
||||
f.run('openDetail(app)');
|
||||
f.requests[0].reject(Error('offline')); await tick();
|
||||
await f.answer(1, {apps: [f.context.app], sources: []});
|
||||
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
|
||||
for (const listener of f.events.get('click')) listener({target: retry});
|
||||
assert.equal(f.document.activeElement.dataset.act, 'close');
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -16,6 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_android_import as backup
|
||||
import server
|
||||
from apk_fixtures import apk, manifest
|
||||
from test_quest import apk_bytes
|
||||
|
||||
PKG = 'com.Armature.VR4'
|
||||
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
|
||||
@@ -66,6 +67,41 @@ class BackupTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
|
||||
|
||||
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
|
||||
base = self.root / 'not-named-base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'arm64.apk'
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
plan = backup.inspect([str(split), str(base)])
|
||||
self.assertEqual(plan['apk'], base)
|
||||
self.assertEqual(plan['splits'], [split])
|
||||
self.assertEqual(backup.public(plan)['splits'], ['arm64.apk'])
|
||||
with patch.object(backup.quest, '_running', return_value=False), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META) as install:
|
||||
backup.install(plan)
|
||||
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
|
||||
|
||||
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
|
||||
base = self.root / 'base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'split.apk'
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
|
||||
files={'res/drawable/icon.png': b'png'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
|
||||
files={'lib/x86_64/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
|
||||
with patch.object(backup.android, 'read_meta', return_value=None):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
|
||||
@@ -204,6 +240,21 @@ class BundleTests(unittest.TestCase):
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
|
||||
source = self.bundle({
|
||||
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
|
||||
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}),
|
||||
}, suffix='.apks')
|
||||
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']['apk'], 'base.apk')
|
||||
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
|
||||
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))
|
||||
|
||||
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
|
||||
source = self.bundle()
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
@@ -221,7 +272,7 @@ class BundleTests(unittest.TestCase):
|
||||
|
||||
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(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
|
||||
({'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'),
|
||||
@@ -229,7 +280,7 @@ class BundleTests(unittest.TestCase):
|
||||
({'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'),
|
||||
({'manifest.json': b'{}'}, 'base APK'),
|
||||
]
|
||||
for entries, message in cases:
|
||||
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
|
||||
@@ -277,7 +328,8 @@ class BundleTests(unittest.TestCase):
|
||||
('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'):
|
||||
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.inspect([str(self.apk)])
|
||||
|
||||
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
|
||||
|
||||
@@ -7,11 +7,13 @@ import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import errno
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "ui"))
|
||||
import frame_autosync as fa # noqa: E402
|
||||
@@ -35,13 +37,15 @@ class FakeFrame:
|
||||
self.device, self.busy, self.down = "frame-a", False, False
|
||||
self.save_error = self.export_error = None
|
||||
|
||||
def list_shots(self):
|
||||
def list_shots(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_shots")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
return {"shots": list(self.shots)}
|
||||
|
||||
def list_clips(self):
|
||||
def list_clips(self, device):
|
||||
assert device == self.device
|
||||
self.calls.append("list_clips")
|
||||
if self.down:
|
||||
raise RuntimeError("ssh: connect to host frame port 22: Connection timed out")
|
||||
@@ -263,6 +267,134 @@ class AutoSyncTest(unittest.TestCase):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip"), fa.TRIES)
|
||||
self.assertIn("ffmpeg couldn't join", self.sync.state()["error"] or "")
|
||||
self.assertIn("Resume", self.sync.state()["error"] or "")
|
||||
|
||||
def test_explicit_retry_recovers_without_recopying_successful_items(self):
|
||||
for kind in ("shots", "clips"):
|
||||
for action in ("resume", "copy_all"):
|
||||
with self.subTest(kind=kind, action=action):
|
||||
self.frame = FakeFrame()
|
||||
f = self.frame
|
||||
sync = self.make()
|
||||
sync.update({"screenshots": True, "clips": True})
|
||||
f.shots, f.clips = [shot(1, NOW + 5)], [clip(1, NOW + 5)]
|
||||
error_attr = "save_error" if kind == "shots" else "export_error"
|
||||
copy_call = "save_shots" if kind == "shots" else "export_clip"
|
||||
setattr(f, error_attr, RuntimeError("source not ready"))
|
||||
for _ in range(fa.TRIES + 2):
|
||||
sync.tick()
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
self.assertIn("source not ready", sync.state()["error"] or "")
|
||||
setattr(f, error_attr, None)
|
||||
sync.tick() # recovery alone must not create unlimited automatic retries
|
||||
self.assertEqual(f.calls.count(copy_call), fa.TRIES)
|
||||
getattr(sync, action)()
|
||||
sync.tick()
|
||||
self.assertEqual(f.saved, [("frame-a", [shot(1, 0)["id"]])])
|
||||
self.assertEqual(f.exported, [("frame-a", clip(1, 0)["id"])])
|
||||
self.assertIsNone(sync.state()["error"])
|
||||
# Resume preserves the per-headset ledger even if a local file was deleted.
|
||||
for item in f.shots + f.clips:
|
||||
item["saved"] = False
|
||||
sync.resume()
|
||||
sync.tick()
|
||||
self.assertEqual((len(f.saved), len(f.exported)), (1, 1))
|
||||
self.sync.path.unlink(missing_ok=True)
|
||||
|
||||
def test_explicit_retry_is_still_bounded(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for action in ("resume", "copy_all"):
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
before = self.frame.calls.count("export_clip")
|
||||
getattr(self.sync, action)()
|
||||
for _ in range(fa.TRIES + 2):
|
||||
self.sync.tick()
|
||||
self.assertEqual(self.frame.calls.count("export_clip") - before, fa.TRIES)
|
||||
self.assertIn("bad source", self.sync.state()["error"] or "")
|
||||
|
||||
def test_failed_item_saved_by_hand_clears_error(self):
|
||||
self.sync.update({"clips": True})
|
||||
self.frame.clips = [clip(1, NOW + 5)]
|
||||
self.frame.export_error = RuntimeError("bad source")
|
||||
for _ in range(fa.TRIES + 1):
|
||||
self.sync.tick()
|
||||
self.frame.clips[0]["saved"] = True
|
||||
self.sync.tick()
|
||||
self.assertIsNone(self.sync.state()["error"])
|
||||
self.assertIn(clip(1, 0)["id"], self.sync._synced("frame-a", "clips"))
|
||||
|
||||
def test_switch_after_capture_does_not_list_the_new_headset(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
def switched():
|
||||
self.frame.device = "frame-b"
|
||||
return False
|
||||
self.sync.busy = switched # tick has already captured frame-a
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.frame.calls, [])
|
||||
|
||||
def test_list_callbacks_receive_the_captured_device(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.sync.list_shots = mock.Mock(return_value={"shots": []})
|
||||
self.sync.list_clips = mock.Mock(return_value={"clips": []})
|
||||
self.sync.tick()
|
||||
self.sync.list_shots.assert_called_once_with("frame-a")
|
||||
self.sync.list_clips.assert_called_once_with("frame-a")
|
||||
|
||||
def test_switch_during_listing_does_not_mark_or_copy_its_results(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
def listing(*_args):
|
||||
self.frame.device = "frame-b"
|
||||
return {kind: [dict(item, saved=True), dict(item, id="uncopied", saved=False)]}
|
||||
setattr(self.sync, "list_" + kind, listing)
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual(self.sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(self.sync._synced("frame-b", kind), set())
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_switch_between_kinds_does_not_read_clips_or_mark_transfer(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5)]
|
||||
self.frame.clips = [clip(1, NOW + 5, saved=True)]
|
||||
save = self.sync.save_shots
|
||||
def then_switch(device, ids):
|
||||
save(device, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync.save_shots = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
self.assertEqual(self.sync._synced("frame-a", "clips"), set())
|
||||
self.assertEqual(self.sync._synced("frame-a", "shots"), set())
|
||||
|
||||
def test_switch_after_bookkeeping_stops_before_next_kind(self):
|
||||
self.sync.update({"screenshots": True, "clips": True})
|
||||
self.frame.shots = [shot(1, NOW + 5, saved=True)]
|
||||
mark = self.sync._mark
|
||||
def then_switch(device, kind, ids):
|
||||
mark(device, kind, ids)
|
||||
self.frame.device = "frame-b"
|
||||
self.sync._mark = then_switch
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertNotIn("list_clips", self.frame.calls)
|
||||
|
||||
def test_switch_before_transfer_does_not_copy(self):
|
||||
for kind, item in (("shots", shot(1, NOW + 5)), ("clips", clip(1, NOW + 5))):
|
||||
with self.subTest(kind=kind):
|
||||
self.frame.device = "frame-a"
|
||||
self.sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
setattr(self.frame, kind, [item])
|
||||
def switched_space(_folder):
|
||||
self.frame.device = "frame-b"
|
||||
return 100 * GB
|
||||
self.sync.space = switched_space
|
||||
self.assertEqual(self.sync.tick(), fa.BUSY_WAIT)
|
||||
self.assertEqual((self.frame.saved, self.frame.exported), ([], []))
|
||||
|
||||
def test_worker_stops(self):
|
||||
stop = threading.Event()
|
||||
@@ -275,6 +407,75 @@ class AutoSyncTest(unittest.TestCase):
|
||||
|
||||
|
||||
class Server(unittest.TestCase):
|
||||
def test_switch_at_list_callback_entry_does_not_read_or_mark_the_new_headset(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
for kind in ("shots", "clips"):
|
||||
with self.subTest(kind=kind):
|
||||
active = {"id": "frame-a"}
|
||||
link = mock.Mock()
|
||||
link.active_device.side_effect = lambda: dict(active)
|
||||
listing = mock.Mock(return_value={kind: [
|
||||
shot(1, NOW + 5, saved=True) if kind == "shots" else clip(1, NOW + 5, saved=True)]})
|
||||
def switch_then_list(device):
|
||||
active["id"] = "frame-b"
|
||||
return server._sync_list(listing)(device)
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
sync = fa.AutoSync(switch_then_list, mock.Mock(), switch_then_list, mock.Mock(),
|
||||
lambda: active["id"], lambda: False,
|
||||
path=Path(folder) / (kind + ".json"), clock=lambda: NOW)
|
||||
sync.update({"screenshots": kind == "shots", "clips": kind == "clips"})
|
||||
sync.tick()
|
||||
listing.assert_not_called()
|
||||
sync.save_shots.assert_not_called()
|
||||
sync.export_clip.assert_not_called()
|
||||
self.assertEqual(sync._synced("frame-a", kind), set())
|
||||
self.assertEqual(sync._synced("frame-b", kind), set())
|
||||
self.assertIn("switched headsets", sync.state()["error"])
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_rejects_switch_before_reserving_work(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
listing = mock.Mock(return_value={"shots": [shot(1, NOW, saved=True)]})
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
server._sync_list(listing)("frame-a")
|
||||
listing.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_listing_reserves_work_on_the_captured_device(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a", "name": "Frame A"}
|
||||
def listing():
|
||||
self.assertGreater(server._work[0], 0)
|
||||
with self.assertRaises(server.Failure):
|
||||
server.devices_post({"action": "use", "id": "frame-b"})
|
||||
return {"shots": []}
|
||||
with mock.patch.object(server, "LINK", link), \
|
||||
mock.patch.object(server, "PRIVATE", False), mock.patch.object(server, "_work", [0]):
|
||||
self.assertEqual(server._sync_list(listing)("frame-a"), {"shots": []})
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_copy_rejects_switch_after_listing(self):
|
||||
import server
|
||||
link = mock.Mock()
|
||||
link.active_device.return_value = {"id": "frame-a"}
|
||||
with mock.patch.object(server, "LINK", link), mock.patch.object(server, "_work", [0]), \
|
||||
mock.patch.object(server, "save_shots") as save, \
|
||||
mock.patch.object(server.frame_clips, "export_clip") as export:
|
||||
server._sync_list(lambda: {"shots": []})("frame-a")
|
||||
link.active_device.return_value = {"id": "frame-b"}
|
||||
for copy in (lambda: server._sync_shots("frame-a", [shot(1, NOW)["id"]]),
|
||||
lambda: server._sync_clip("frame-a", clip(1, NOW)["id"])):
|
||||
with self.assertRaisesRegex(server.Failure, "switched headsets"):
|
||||
copy()
|
||||
save.assert_not_called()
|
||||
export.assert_not_called()
|
||||
self.assertEqual(server._work[0], 0)
|
||||
|
||||
def test_route_and_settings(self):
|
||||
import server
|
||||
self.assertIn("/api/autosync", server.POST)
|
||||
@@ -287,5 +488,29 @@ class Server(unittest.TestCase):
|
||||
server.auto_sync({"action": "now"}) # nothing connected
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node exercises the Settings renderer")
|
||||
class Settings(unittest.TestCase):
|
||||
def test_resume_is_available_after_a_copy_error(self):
|
||||
page = (Path(__file__).resolve().parents[1] / "ui/index.html").read_text(encoding="utf-8")
|
||||
script = page.split("const autoSync =", 1)[1].split("async function loadAutoSync", 1)[0]
|
||||
harness = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const nodes = {};
|
||||
const $ = id => nodes[id] ||= {};
|
||||
const HOST = {mobile: false};
|
||||
const esc = value => value;
|
||||
'''
|
||||
checks = r'''
|
||||
for (const state of [{error: 'Could not copy clip'}, {paused: 'Disk full'}, {}]) {
|
||||
renderAutoSync(state);
|
||||
assert.equal($('syncResume').hidden, !(state.error || state.paused));
|
||||
if (state.error) assert.match($('syncStatus').textContent, /Could not copy clip/);
|
||||
}
|
||||
'''
|
||||
result = subprocess.run(["node", "-e", harness + "const autoSync =" + script + checks],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -113,6 +113,108 @@ class Remote(unittest.TestCase):
|
||||
os.symlink(str(outside), str(base / 'linked-file'))
|
||||
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
||||
|
||||
def packed(self, part):
|
||||
out = io.BytesIO()
|
||||
remote.pack(part, out)
|
||||
out.seek(0)
|
||||
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
||||
return {m.name: archive.extractfile(m).read() for m in archive}
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_backup_rejects_every_ancestor_of_a_save_root(self):
|
||||
save = USER % 1234 + '/AppData/Roaming/Game/fixture.dat'
|
||||
# Each component below HOME, including the selected root itself.
|
||||
components = (USER % 1234 + '/AppData/Roaming').split('/')
|
||||
for end in range(1, len(components) + 1):
|
||||
ancestor = '/'.join(components[:end])
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
manifest(home, 1234, 'Synthetic game')
|
||||
path = write(home, save, b'synthetic fixture')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=True)
|
||||
# Only synthetic bytes: the lexical archive path hides .ssh.
|
||||
self.assertEqual(path.read_bytes(), b'synthetic fixture')
|
||||
with patch.object(remote, 'HOME', home):
|
||||
self.assertEqual(self.files('saves:1234'), [])
|
||||
self.assertNotIn('saves:1234', [p['id'] for p in remote.plan()['parts']])
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_pack_rechecks_links_changed_after_discovery(self):
|
||||
save = USER % 1234 + '/Documents/Game/fixture.dat'
|
||||
for ancestor in (remote.COMPAT + '/1234', USER % 1234 + '/Documents', save):
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
write(home, save, b'original save')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
part_files = remote.part_files
|
||||
|
||||
def discover_then_link(part):
|
||||
result = part_files(part)
|
||||
self.assertEqual(result[0], [(save, len(b'original save'))])
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=outside.is_dir())
|
||||
(home / save).write_bytes(b'synthetic redirected bytes')
|
||||
return result
|
||||
|
||||
with patch.object(remote, 'HOME', home), \
|
||||
patch.object(remote, 'part_files', side_effect=discover_then_link):
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
def test_legitimate_steam_layout_and_reference_files_still_pack(self):
|
||||
frame_home(self.home)
|
||||
manifest(self.home, 2800000002, 'Synthetic non-Steam game')
|
||||
save = USER % 2800000002 + '/Saved Games/fixture.dat'
|
||||
write(self.home, save, b'non-Steam save')
|
||||
controller = STEAM + '/steamapps/common/Steam Controller Configs/555/config/layout.vdf'
|
||||
write(self.home, controller, b'controller layout')
|
||||
for part in ('saves:1234', 'saves:2800000002', 'saves:linux',
|
||||
'settings:steam', 'settings:system'):
|
||||
with self.subTest(part=part):
|
||||
expected = {rel: (self.home / rel).read_bytes() for rel in self.files(part)}
|
||||
self.assertTrue(expected)
|
||||
self.assertEqual(self.packed(part), expected)
|
||||
self.assertEqual(self.packed('saves:2800000002'), {save: b'non-Steam save'})
|
||||
self.assertIn(controller, self.packed('settings:steam'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_settings_discovery_and_manifest_reads_reject_links(self):
|
||||
frame_home(self.home)
|
||||
for ancestor, part in (('.config', 'settings:system'),
|
||||
(STEAM + '/userdata', 'settings:steam')):
|
||||
with self.subTest(ancestor=ancestor):
|
||||
linked = self.home / ancestor
|
||||
moved = linked.with_name(linked.name + '-original')
|
||||
linked.rename(moved)
|
||||
linked.symlink_to(moved, target_is_directory=True)
|
||||
self.assertEqual(self.files(part), [])
|
||||
self.assertEqual(self.packed(part), {})
|
||||
if part == 'settings:steam':
|
||||
self.assertEqual(remote.accounts(), [])
|
||||
appmanifest = self.home / STEAM / 'steamapps/appmanifest_1234.acf'
|
||||
moved = appmanifest.with_suffix('.original')
|
||||
appmanifest.rename(moved)
|
||||
appmanifest.symlink_to(moved)
|
||||
with patch.object(Path, 'read_text', side_effect=AssertionError('must not read a link')):
|
||||
self.assertIsNone(remote.game_name('1234'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_home_alias_and_unused_steam_alias_preserve_normal_backups(self):
|
||||
frame_home(self.home)
|
||||
alias = self.home / 'home-alias'
|
||||
alias.symlink_to(self.home, target_is_directory=True)
|
||||
(self.home / '.steam/steam').symlink_to(self.home / STEAM, target_is_directory=True)
|
||||
expected = self.packed('saves:1234')
|
||||
with patch.object(remote, 'HOME', alias):
|
||||
self.assertEqual(self.packed('saves:1234'), expected)
|
||||
self.assertEqual(remote.game_name('1234'), 'Test Game')
|
||||
|
||||
def test_big_files_are_listed_as_skipped(self):
|
||||
frame_home(self.home)
|
||||
with patch.object(remote, 'MAX_FILE', 3):
|
||||
@@ -292,6 +394,13 @@ class Session(unittest.TestCase):
|
||||
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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Extension compatibility and filesystem boundaries, without running plugin code."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_extensions
|
||||
|
||||
|
||||
class ExtensionFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'extensions'
|
||||
self.root.mkdir()
|
||||
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def extension(self, name='test-panel', **fields):
|
||||
folder = self.root / name
|
||||
folder.mkdir(exist_ok=True)
|
||||
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
|
||||
'description': 'A read-only panel', 'entry': 'index.html', **fields}
|
||||
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
||||
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
|
||||
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
|
||||
return folder
|
||||
|
||||
def test_existing_and_explicit_v1_manifests_are_compatible(self):
|
||||
self.extension('legacy')
|
||||
self.extension('explicit', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
|
||||
[('explicit', 1), ('legacy', 1)])
|
||||
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
|
||||
(b'/* extension code */', 'text/javascript; charset=utf-8'))
|
||||
|
||||
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
|
||||
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
|
||||
with self.subTest(apiVersion=version):
|
||||
self.extension(apiVersion=version)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['extensions'], [])
|
||||
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
|
||||
frame_extensions.asset('test-panel', 'index.html')
|
||||
|
||||
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
|
||||
self.extension('future', apiVersion=2)
|
||||
self.extension('working', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
|
||||
self.assertEqual(result['errors'][0]['folder'], 'future')
|
||||
|
||||
def test_sample_is_discoverable_and_its_assets_are_served(self):
|
||||
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
|
||||
shutil.copytree(source, self.root / source.name)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual(result['extensions'][0]['id'], source.name)
|
||||
for asset in ('index.html', 'main.js', 'style.css'):
|
||||
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
|
||||
(source / asset).read_bytes())
|
||||
|
||||
def test_private_nested_and_traversal_paths_are_rejected(self):
|
||||
self.extension()
|
||||
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', name)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('../test-panel', 'main.js')
|
||||
|
||||
def test_oversized_assets_are_not_read(self):
|
||||
folder = self.extension()
|
||||
with (folder / 'large.js').open('wb') as output:
|
||||
output.truncate(5 * 1024 * 1024 + 1)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'large.js')
|
||||
|
||||
def test_symlinks_cannot_expose_files_outside_a_panel(self):
|
||||
folder = self.extension()
|
||||
outside = Path(self.tmp.name) / 'outside.js'
|
||||
outside.write_text('private file', encoding='utf-8')
|
||||
try:
|
||||
(folder / 'escape.js').symlink_to(outside)
|
||||
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable for this user')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'escape.js')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('alias', 'main.js')
|
||||
(folder / 'manifest.json').unlink()
|
||||
(folder / 'manifest.json').symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('test-panel', 'main.js')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Decky release validation and the removable user-service transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_decky
|
||||
|
||||
|
||||
def arm_binary(fill=b'A'):
|
||||
data = bytearray(b'\x7fELF' + fill * 2044)
|
||||
data[18:20] = (183).to_bytes(2, 'little')
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class DeckyRelease(unittest.TestCase):
|
||||
def test_official_arm_release_digest_is_checked(self):
|
||||
data = arm_binary()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
url = f'https://github.com/{frame_decky.REPO}/releases/download/v3.3.0/PluginLoader-arm64'
|
||||
info = {'tag_name':'v3.3.0', 'assets':[{'name':'PluginLoader-arm64', 'size':len(data),
|
||||
'digest':'sha256:'+digest, 'browser_download_url':url}]}
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
self.assertEqual(frame_decky.release_binary(), ('v3.3.0', digest, data))
|
||||
info['assets'][0]['digest'] = 'sha256:' + '0'*64
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||
frame_decky.release_binary()
|
||||
|
||||
def test_install_rejects_incompatible_headset_before_download(self):
|
||||
state = {'arch':'x86_64','variant':'vr','cef':True,'managed':False,'upstreamInstall':False}
|
||||
with patch.object(frame_decky, 'release_binary') as download:
|
||||
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
|
||||
frame_decky.change(lambda *a, **k: json.dumps(state), 'install')
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote service script runs on Linux')
|
||||
class DeckyServiceScript(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
home = Path(self.tmp.name)
|
||||
fake = home / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$FRAME_DECKY_TEST_FAIL" = "1" ] && [ "$2" = "enable" ]; then echo blocked >&2; exit 1; fi\nexit 0\n')
|
||||
fake.chmod(0o700)
|
||||
self.env = {**os.environ, 'HOME': self.tmp.name, 'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
||||
self.base = home / '.local/share/frame-control/decky'
|
||||
|
||||
def apply(self, action, data=None, version='v3.3.0', fail=False):
|
||||
token = secrets.token_hex(12)
|
||||
stage = Path('/tmp/frame-control-decky-' + token)
|
||||
if data is not None:
|
||||
stage.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
env = {**self.env, 'FRAME_DECKY_TEST_FAIL': '1' if fail else '0'}
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, digest, version],
|
||||
input=frame_decky.APPLY, capture_output=True, text=True, env=env, timeout=15)
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
|
||||
def test_install_update_rollback_and_remove(self):
|
||||
first = arm_binary(b'A')
|
||||
result = self.apply('install', first)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
receipt = self.base / '.frame-control/receipt.json'
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
self.assertTrue((Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service').is_file())
|
||||
|
||||
second = arm_binary(b'B')
|
||||
result = self.apply('update', second, version='v3.3.1', fail=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
|
||||
result = self.apply('update', second, version='v3.3.1')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), second)
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.base / 'PluginLoader').exists())
|
||||
self.assertFalse(receipt.exists())
|
||||
self.assertTrue((self.base / 'plugins').is_dir())
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Official Decky store transport and owned plugin transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_decky_store
|
||||
|
||||
|
||||
def archive(flags=(), extra=None):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as bundle:
|
||||
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)}))
|
||||
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
if extra:
|
||||
bundle.writestr(*extra)
|
||||
data = stream.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'visible': True, 'versions':
|
||||
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
|
||||
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
|
||||
[{'name': '1.0', 'hash': 'b' * 64}]},
|
||||
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
|
||||
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
with self.assertRaisesRegex(ValueError, 'SHA-256'):
|
||||
frame_decky_store.archive_info(data, '0' * 64, 'Example')
|
||||
|
||||
def test_rejects_root_x86_and_escape(self):
|
||||
for data in (archive(flags=['root'])[0],
|
||||
archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0],
|
||||
archive(extra=('../outside', b'oops'))[0]):
|
||||
with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]):
|
||||
with self.assertRaises(ValueError):
|
||||
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
|
||||
class StoreTransaction(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
home = Path(self.tmp.name)
|
||||
unit = home / '.config/systemd/user/frame-control-decky.service'
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text('[Service]\n')
|
||||
fake = home / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n')
|
||||
fake.chmod(0o700)
|
||||
self.env = {**os.environ, 'HOME': self.tmp.name,
|
||||
'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
||||
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
||||
self.folder.parent.mkdir(parents=True)
|
||||
|
||||
def apply(self, action, data=None):
|
||||
token = secrets.token_hex(12)
|
||||
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
||||
if data is not None:
|
||||
source.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
|
||||
def test_install_update_remove_only_owned_folder(self):
|
||||
first, _ = archive()
|
||||
result = self.apply('install', first)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42)
|
||||
second, _ = archive(extra=('Example/data.txt', 'new'))
|
||||
result = self.apply('update', second)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.folder / 'data.txt').read_text(), 'new')
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
self.folder.mkdir()
|
||||
(self.folder / 'plugin.json').write_text('{}')
|
||||
result = self.apply('remove')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(self.folder.exists())
|
||||
external = Path(self.tmp.name) / 'other-plugin'
|
||||
external.mkdir()
|
||||
(external / '.frame-control-store.json').write_text('{"storeId":42}')
|
||||
for item in self.folder.iterdir(): item.unlink()
|
||||
self.folder.rmdir()
|
||||
self.folder.symlink_to(external, target_is_directory=True)
|
||||
result = self.apply('remove')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
|
||||
class StoreHost(unittest.TestCase):
|
||||
def test_owned_plugin_can_be_removed_without_store_network(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
def ssh(command, **kwargs):
|
||||
return '{}'
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
frame_decky_store.change(ssh, 'remove', 42)
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -46,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',
|
||||
@@ -55,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)
|
||||
|
||||