Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c1241e23b |
No files matched your search
@@ -41,12 +41,8 @@ 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/obs-source.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
|
||||
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
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
@@ -71,29 +67,9 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: OBS lifecycle tests
|
||||
run: node app/test/obs-source.test.js
|
||||
- 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
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", "scripts/check-appimage-launch.py", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "desktop/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
@@ -70,9 +70,6 @@ jobs:
|
||||
- os: macos-latest
|
||||
script: dist
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: macos-15-intel
|
||||
script: dist:mac:x64
|
||||
files: app/dist/*.dmg app/dist/*.zip
|
||||
- os: windows-latest
|
||||
script: dist:win
|
||||
files: app/dist/*.exe app/dist/*.zip
|
||||
@@ -113,20 +110,6 @@ jobs:
|
||||
run: npm ci && npm run ${{ matrix.script }}
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
- name: Check the Mac bundle matches its runner
|
||||
if: runner.os == 'macOS'
|
||||
shell: bash
|
||||
run: |
|
||||
bundle=$(find app/dist -maxdepth 2 -name 'Frame Control.app' -type d -print -quit)
|
||||
test -n "$bundle"
|
||||
resources="$bundle/Contents/Resources"
|
||||
for binary in "$bundle/Contents/MacOS/Frame Control" "$resources/python/bin/python3" "$resources/tools/adb" "$resources/mac/bin/frame-mac-view"; do
|
||||
lipo "$binary" -verify_arch "$(uname -m)"
|
||||
done
|
||||
"$resources/python/bin/python3" -I -B -c 'import http.server, ssl, sqlite3; print(ssl.OPENSSL_VERSION)'
|
||||
- name: Launch the built Linux AppImage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: python3 scripts/check-appimage-launch.py app/dist/Frame-Control-linux-x86_64.AppImage
|
||||
# The installer, not just the unpacked build: its 7-Zip payload once dropped
|
||||
# the OpenXR layer's arm64 library without any error. Install it silently
|
||||
# and compare every installed file with the unpacked build.
|
||||
@@ -190,24 +173,3 @@ 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,141 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
|
||||
title directory was already removed. Sweep and verify all six exact reserved
|
||||
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
|
||||
work with POSIX sh and Python 3 without zsh, including from another working
|
||||
directory. Local regressions cover both fixes; no new headset run.
|
||||
|
||||
- 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).
|
||||
|
||||
- Keep Steam clip exports separate for each headset, Steam account and nested
|
||||
recording session. Manual export and automatic copying use the same folders;
|
||||
existing flat exports stay untouched and do not mark unrelated clips saved.
|
||||
[Recording guide](docs/recording.md#steam-clips).
|
||||
|
||||
- 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
|
||||
@@ -230,8 +95,3 @@
|
||||
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,9 +58,6 @@ 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,8 +25,6 @@ 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>
|
||||
|
||||
---
|
||||
@@ -46,9 +44,6 @@ 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).
|
||||
|
||||
@@ -78,10 +73,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 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).
|
||||
Browse the shots you take in the headset and save them to your Pictures folder, or have new ones and your Steam clips copied over automatically. Record what the headset shows, with its sound, or export Steam's clips at any length, as .mp4 files in your Videos folder ([not tested on a headset yet](docs/recording.md)).
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the 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.
|
||||
**⌨️ Type on the headset**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Copy text to the headset's clipboard and back again. Nothing else to install, anywhere. Press T to open it.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -132,24 +127,6 @@ 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
|
||||
@@ -173,39 +150,16 @@ distribution's. If you already have `adb`, the app uses yours.
|
||||
<details>
|
||||
<summary><b>macOS: the app isn't notarized</b></summary>
|
||||
|
||||
The app has an **ad hoc signature**, no Apple Developer ID signature, and no
|
||||
notarization. The DMG is unsigned. First verify the download against the
|
||||
`SHA256SUMS` from the same release, then open the DMG and drag **Frame Control**
|
||||
to **Applications**.
|
||||
|
||||
Check your processor before opening: v0.4.36 and earlier publish only
|
||||
`mac-arm64` for Apple Silicon. On an Intel Mac, this build cannot run;
|
||||
removing quarantine or installing Rosetta does not make an ARM app run on Intel.
|
||||
On a real Intel MacBook Air with macOS 15.7.7, opening v0.4.35 and v0.4.36
|
||||
reported: **“The application cannot be opened because it has an incorrect
|
||||
executable format.”** This is an architecture failure, not an observed
|
||||
Gatekeeper warning. See the [real-Mac test record](docs/evidence/mac-desktop-2026-10-26.md).
|
||||
|
||||
On a compatible Mac, if macOS blocks an unverified developer, try opening the
|
||||
app once, then go to **System Settings → Privacy & Security → Open Anyway**
|
||||
and confirm **Open**. Only do this for a download you verified and trust.
|
||||
[Apple's opening instructions](https://support.apple.com/102445) describe the
|
||||
current security controls. The exact warning and this override flow have not
|
||||
yet been verified here on Apple Silicon.
|
||||
|
||||
If a trusted, verified copy still reports “damaged” and offers no Open Anyway
|
||||
option, the Terminal fallback removes only this app's download quarantine
|
||||
(it does not sign or notarize the app):
|
||||
There's no paid Apple developer account behind it, so macOS says the app is
|
||||
damaged or can't be checked. Drag it to Applications, then clear the download
|
||||
quarantine once, only for a download you trust from the releases linked above:
|
||||
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine "/Applications/Frame Control.app"
|
||||
```
|
||||
|
||||
Then open it from Applications. Do not disable Gatekeeper globally. Depending
|
||||
on the features you use, macOS may also request Local Network access, permission
|
||||
to control Terminal, or Screen Recording; approve only the feature you intend
|
||||
to use. Those permission prompts were not verified in the release test above.
|
||||
|
||||
The first time, macOS also asks to allow local network access (for SSH) and
|
||||
control of Terminal (for the password prompts).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -229,10 +183,8 @@ 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 Gatekeeper warning, use the macOS steps
|
||||
open it from Applications. For the unsigned-app warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
- **Windows:** run the installer, then open **Frame Control** from Start. For
|
||||
the portable download, extract the entire `.zip` first and run
|
||||
@@ -268,14 +220,6 @@ 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
|
||||
@@ -310,18 +254,15 @@ 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,7 +17,6 @@ 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",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
|
||||
// packages for every build), into ../frame/kdeconnect/packages as listed in
|
||||
// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
|
||||
// node build/fetch-deps.js mac arm64 x64 | win x64 | linux x64 arm64
|
||||
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
@@ -15,7 +15,6 @@ const PY = "3.12.14+20260924";
|
||||
const PY_URL = (triple) => "https://github.com/astral-sh/python-build-standalone/releases/download/"
|
||||
+ `${PY.split("+")[1]}/cpython-${PY}-${triple}-install_only_stripped.tar.gz`;
|
||||
const PYTHON = {
|
||||
"mac-x64": ["x86_64-apple-darwin", "7ea9761b9069c10b9a20531d568645849d604c59e9c7f11f6659f1e1790c968e"],
|
||||
"mac-arm64": ["aarch64-apple-darwin", "c2edb321cd32ec2b170df208db0446dccc4398db602ca27cf2079098fb1f7d9d"],
|
||||
"win-x64": ["x86_64-pc-windows-msvc", "c5bf8edfe858c1df9891be498b5bbc8761d383df5b9790658b088fea4870433a"],
|
||||
"linux-x64": ["x86_64-unknown-linux-gnu", "269b2c99e4db15b242bf01832f4fea1e8f1a664f273cff519393f296e9820b41"],
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -12,7 +12,6 @@ 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);
|
||||
@@ -31,12 +30,10 @@ 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;
|
||||
|
||||
@@ -307,33 +304,6 @@ 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");
|
||||
const { createObsSource } = require("./obs-source");
|
||||
const obsSource = createObsSource({
|
||||
spawn,
|
||||
// Keep the button hidden until native Windows/Frame streaming is verified.
|
||||
supported: !IS_WIN,
|
||||
prepare: async alias => {
|
||||
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);
|
||||
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
|
||||
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
|
||||
},
|
||||
onState: state => {
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
|
||||
},
|
||||
});
|
||||
const publicObs = () => obsSource.status();
|
||||
const stopObs = () => obsSource.stop();
|
||||
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
|
||||
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}$/;
|
||||
@@ -344,7 +314,6 @@ 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 (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
@@ -547,60 +516,7 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
@@ -609,10 +525,7 @@ function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray)
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
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 }) => {
|
||||
@@ -666,8 +579,6 @@ 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 ? [{
|
||||
@@ -716,7 +627,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 (!argv.includes("--hidden")) showWindow();
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -724,41 +635,12 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", showWindow);
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
stopServer();
|
||||
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
|
||||
event.preventDefault();
|
||||
if (obsQuitWaiting) return;
|
||||
obsQuitWaiting = true;
|
||||
const child = obsSource.child;
|
||||
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(); });
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Own one Python relay. Async preparation can be cancelled before a process exists.
|
||||
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
|
||||
let child = null, attempt = 0;
|
||||
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
|
||||
const status = () => ({ ...state });
|
||||
function set(next, alias = state.alias, port = state.port, error = '') {
|
||||
state = { state: next, alias, port, error, supported };
|
||||
onState(status()); return status();
|
||||
}
|
||||
function stop() {
|
||||
++attempt;
|
||||
if (!child) return set('idle');
|
||||
set('stopping');
|
||||
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
||||
return status();
|
||||
}
|
||||
async function start(alias, port = 15099) {
|
||||
if (!supported) return status();
|
||||
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
|
||||
if (child || state.state === 'starting') return status();
|
||||
const id = ++attempt;
|
||||
set('starting', alias, port);
|
||||
try {
|
||||
const { executable, args = [], env } = await prepare(alias);
|
||||
if (id !== attempt) return status();
|
||||
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
|
||||
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
||||
child = proc;
|
||||
// The helper may exit between a click and closing its control pipe.
|
||||
proc.stdin.on('error', () => {});
|
||||
let output = '';
|
||||
proc.stderr.on('data', chunk => {
|
||||
output = (output + chunk.toString('utf8')).slice(-2048);
|
||||
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
|
||||
set('running', alias, port);
|
||||
});
|
||||
proc.once('error', error => {
|
||||
if (child !== proc) return;
|
||||
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
|
||||
});
|
||||
// close follows stderr EOF, so the final failure message is available.
|
||||
proc.once('close', (code, signal) => {
|
||||
if (child !== proc) return;
|
||||
const stopping = state.state === 'stopping';
|
||||
child = null;
|
||||
set(stopping ? 'idle' : 'failed', alias, port,
|
||||
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
|
||||
});
|
||||
return status();
|
||||
} catch (error) {
|
||||
if (id !== attempt) return status();
|
||||
return set('failed', alias, port, error.message);
|
||||
}
|
||||
}
|
||||
return { start, stop, status, get child() { return child; } };
|
||||
}
|
||||
module.exports = { createObsSource };
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.17",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.17",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.39",
|
||||
"version": "0.4.17",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -9,11 +9,10 @@
|
||||
"scripts": {
|
||||
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
|
||||
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
|
||||
"dist": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "FRAME_MAC_ARCH=arm64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
|
||||
"dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
|
||||
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never",
|
||||
"dist:mac:x64": "FRAME_MAC_ARCH=x86_64 sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac x64 && electron-builder --mac --x64 --publish never"
|
||||
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
@@ -37,12 +36,10 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"obs-source.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
@@ -56,8 +53,7 @@
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json",
|
||||
"framey-compatibility.json"
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -73,7 +69,6 @@
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
|
||||
@@ -21,15 +21,6 @@ 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: (port) => ipcRenderer.invoke("obs:start", port),
|
||||
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) => {
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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, []);
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createObsSource } = require('../obs-source');
|
||||
|
||||
function fixture(prepare) {
|
||||
const states = [], children = [];
|
||||
const source = createObsSource({
|
||||
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
|
||||
spawn(executable, args, options) {
|
||||
const child = new EventEmitter();
|
||||
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
|
||||
child.stdin.end = () => { child.stdin.writableEnded = true; };
|
||||
children.push({ child, executable, args, options }); return child;
|
||||
},
|
||||
onState: s => states.push(s),
|
||||
});
|
||||
return { source, states, children };
|
||||
}
|
||||
|
||||
test('Stop during Python discovery cancels the pending start', async () => {
|
||||
let resolve;
|
||||
const f = fixture(() => new Promise(r => { resolve = r; }));
|
||||
const pending = f.source.start('frame', 15100);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
f.source.stop();
|
||||
resolve({ executable: 'python', args: ['helper.py'], env: {} });
|
||||
await pending;
|
||||
assert.equal(f.children.length, 0);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
});
|
||||
|
||||
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
|
||||
const f = fixture(); await f.source.start('frame', 15100);
|
||||
const { child, args } = f.children[0];
|
||||
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
|
||||
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
|
||||
child.stderr.emit('data', Buffer.from('ready\n'));
|
||||
assert.equal(f.source.status().state, 'running');
|
||||
f.source.stop();
|
||||
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
|
||||
assert.equal(f.source.status().state, 'stopping');
|
||||
child.emit('close', 0, null);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
await f.source.start('other-frame', 15101);
|
||||
assert.equal(f.source.status().port, 15101);
|
||||
// An old child cannot overwrite the replacement's state.
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
});
|
||||
|
||||
test('concurrent starts spawn once and preparation failures are retryable', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(async () => {
|
||||
if (++attempts === 1) throw new Error('Python missing');
|
||||
return { executable: 'python', args: [], env: {} };
|
||||
});
|
||||
assert.equal((await f.source.start('frame')).state, 'failed');
|
||||
assert.match(f.source.status().error, /Python missing/);
|
||||
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
|
||||
assert.equal(f.children.length, 1);
|
||||
});
|
||||
|
||||
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
|
||||
const f = fixture();
|
||||
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
|
||||
await assert.rejects(f.source.start('frame', port), /port/i);
|
||||
}
|
||||
await assert.rejects(f.source.start('-bad'), /headset/i);
|
||||
assert.equal(f.children.length, 0);
|
||||
await f.source.start('frame');
|
||||
const { child } = f.children[0];
|
||||
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
|
||||
child.stderr.emit('data', Buffer.from('network lost\n'));
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'failed');
|
||||
assert.match(f.source.status().error, /network lost/);
|
||||
});
|
||||
|
||||
test('an unverified host hides the controls and cannot spawn a relay', async () => {
|
||||
let spawned = false;
|
||||
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
|
||||
assert.equal(source.status().supported, false);
|
||||
await source.start('frame');
|
||||
assert.equal(spawned, false);
|
||||
});
|
||||
@@ -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 base APK, any supported splits, and its OBB files together.
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
@@ -37,13 +37,9 @@ 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 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
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
@@ -59,5 +55,3 @@ 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
|
||||
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one base APK, optional supported splits and matching OBBs. These extensions describe
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
@@ -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
|
||||
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
@@ -463,14 +463,12 @@ 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 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.
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
|
||||
@@ -25,28 +25,8 @@ tested in another full Plasma session under a three-minute rollback timer.
|
||||
`ui/frame_backup.py session` reported Desktop Mode despite gamescope, and
|
||||
`ui/frame_backup.py game-mode` invoked the same guarded Return function used by
|
||||
the button. Game Mode became active and a second call made no switch. Battery
|
||||
remained at 99%; the timer and lock were removed. That test used the production
|
||||
CLI rather than clicking the desktop button.
|
||||
|
||||
Later on 2026-10-24, the published **v0.4.17 Linux x86_64 AppImage** passed the
|
||||
literal packaged-button check on the same Frame/build. With the shared lock and
|
||||
a verified three-minute rollback timer, the headset entered full Plasma Desktop
|
||||
Mode. In the packaged Electron window, **Tools → Backup & recovery → Check**
|
||||
reported Desktop Mode despite gamescope. A trusted automated pointer click on
|
||||
the visible **Return to Game Mode** button, accepting its confirmation, returned
|
||||
`switched: true` and `steamosctl switch-to-game-mode`. The systemd targets then
|
||||
confirmed Game Mode. A second confirmed button click returned `switched: false`;
|
||||
the Game Mode service invocation and SteamVR PID stayed unchanged. **Check**
|
||||
then reported Game Mode and disabled Return. The test app/server and virtual
|
||||
display were stopped; the timer and shared lock were removed. Steam and SteamVR remained running
|
||||
at 99% battery. [Commands, responses, cleanup and limits](evidence/return-game-mode-packaged-2026-10-24.json),
|
||||
[before](evidence/return-game-mode-packaged-2026-10-24-before.png) and
|
||||
[after](evidence/return-game-mode-packaged-2026-10-24-after.png).
|
||||
|
||||
This used the unchanged extracted AppImage payload with its bundled Python on
|
||||
Xvfb (`--no-sandbox --disable-gpu`), following the packaged Export test in #156.
|
||||
FUSE mounting, desktop integration, hardware rendering and the view inside a
|
||||
worn headset were not checked. Older SteamOS builds remain fixture-tested.
|
||||
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.
|
||||
|
||||
## Back up
|
||||
|
||||
@@ -100,21 +80,7 @@ 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, 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.
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
@@ -206,18 +172,11 @@ 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 in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
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,11 +9,6 @@ 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.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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 ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -1,30 +0,0 @@
|
||||
# What we have tested
|
||||
|
||||
Release baseline: Frame Control **v0.4.39**, 26 October 2026. Rows also identify verified post-release main changes explicitly. “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 |
|
||||
|---|---|---|---|---|
|
||||
| Mac desktop release — tested on a real Mac | Physical MacBook Air (MacBookAir9,1), Intel Core i3, macOS 15.7.7 (24G720): v0.4.35/v0.4.36 DMGs verified and installed; both fail to launch because they are ARM64-only. Separate Intel candidate launched: UI, native preference read/write (restored), bundled Python logs, latest-version check and offline OBS Start/Stop checked; released UI browsed 116 Decky store entries | No | Candidate results are not released-build acceptance. Native Open at Login state, Gatekeeper override, real OBS/Frame stream, native settings across restart, headset transfer and wearer interaction remain unverified | 26 Oct, v0.4.35/v0.4.36 and Intel candidate `6ea9eb2`: [real-Mac evidence](evidence/mac-desktop-2026-10-26.md) |
|
||||
| Core title smoke test | Linux lxso2 installed and removed reserved native/Proton titles on the real Frame; all 13 steps passed. ARM64 and Windows titles launched | No | The x86_64 title could not launch because Steam Linux Runtime 4.0 was not installed. The headset was unworn; visible rendering and controller input were not checked. Other feature paths retain their separate limits below | 26 Oct, v0.4.38 exact tag `d87b863`, Frame OS 0.5.6 build `20261022.6153909`: [latest release smoke](evidence/v0438-release-smoke-2026-10-26.md); [earlier detailed results](evidence/round2-smoke-2026-10-25.json) |
|
||||
| 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.0.2 on a Linux host: 187.367 s, 720p30 H.264/AAC stereo, 5,621 frames, no encoder skips; room passthrough appeared in the recording | No | Synthetic tone was measured, not real game audio or audible listening. Default audio led by about 0.43 s in this setup; a measured +480 ms OBS offset removed residual skew at test resolution. Source-image latency was 1.431 s in one trial; other hosts and long sessions are unverified | 25 Oct, v0.4.30 package plus PR #219 runtime: [validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | Windows 11 VM helper fixture passed, but a native bridge to the real Frame sent no packets | In-app OBS controls remain hidden; native Windows OBS and firewall path are unverified | 25 Oct, PR #219: [validation](evidence/obs-validation-2026-10-25.md), [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 | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-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 | Frame OS 0.5.6 registered and served the opt-in overlay. A later unattended check safely woke SteamVR and found the panel in stereo compositor and mirror captures; it removed its temporary service and restored power settings | No | No person wore the headset for that check. Physical controller input, scrolling, VR keyboard, comfort and legibility remain unverified. The large status view has browser and packaging checks, not a wearer review | 26 Oct, v0.4.29–0.4.39: [panel PR #201](https://github.com/saphid/frame-control/pull/201), [unattended evidence](evidence/framey-vr-2026-10-25/README.md), [new standby-gated exercise](evidence/framey-vr-standby-gate-2026-10-26.md), [worn checklist](framey-vr-check.md) |
|
||||
| Experimental Decky Loader manager | Frame OS 0.5.6 ARM64 user-service install/status/update/remove and managed ProtonDB install/update/remove passed earlier. A private Decky 3.3.0 service returned read-only ProtonDB and Steam Status setting RPCs; a later locked check verified the official Notebook default drawing/save/reopen flow and Simple Timer backend events in local Chromium | No | The loader remains opt-in. Installs/updates reject an archive whose Decky name collides with another installed plugin before download and under the headset transaction lock; this safety path has host/fake Frame tests, not a new real-headset collision test. Post-release main after PR #282 classifies the 116 exact-version plugins as 1 works (Notebook default flow only), 48 partial, 56 broken and 11 not applicable; v0.4.39 shipped the earlier 1/45/59/11 catalog. Steamback, PlayTime and Deck Settings became partial after detached read-only library/user snapshots and isolated amd64 backend checks; their official ARM64 backends, automatic behavior and VR panels remain unverified. A read-only Steam bridge returned the Frame country, install folders and system information; IsThereAnyDeal selected that country in local Chromium, but its deployed HTTP endpoint and deal lookup remain unverified. An isolated ARM64 LocalSend core answered its info endpoint, but the official x86-only plugin archive and file transfers remain blocked. Three official audio plugins had local real-backend controls and independent virtual-audio readbacks, but their ARM64 Frame audio sessions and physical output remain unverified. Most official backends and VR controls remain unverified. Physical controller use and wearer-visible official panels remain unverified | 26 Oct, v0.4.31–0.4.39: [managed store PR #208](https://github.com/saphid/frame-control/pull/208), [name-collision PR #269](https://github.com/saphid/frame-control/pull/269), [catalog PR #266](https://github.com/saphid/frame-control/pull/266), [backend event evidence](evidence/framey-events-backend-2026-10-26.json), [Steam read PR #274](https://github.com/saphid/frame-control/pull/274), [LocalSend evidence PR #275](https://github.com/saphid/frame-control/pull/275), [local audio evidence](evidence/framey-audio-backends-2026-10-26.md), [post-release library evidence](evidence/framey-library-context-2026-10-26.md), [compatibility table](framey-compatibility.md) |
|
||||
| Experimental Framey VR plugin panels and store | Unattended Frame OS 0.5.6 runs showed the Framey sample panel in stereo compositor and mirror captures. The latest standby-gated exercise restored the original power settings and released its shared lock. Synthetic OpenVR clicks changed a live sample setting, read its Decky backend file and confirmed it after a fresh iframe reopen | No | This was the repository sample, not an official store plugin. Physical controllers, native scrolling, VR keyboard, wearer comfort and legibility remain unverified. CSS Loader and SteamGridDB exact archives passed fake Frame install/remove and panel packing; this does not establish theme or artwork controls in VR. Panel-local backend events and React toasts have local/real-backend checks, but no wearer review. Pause Games and Tailscale Control settings render in local Chromium; their live actions are unverified. The 116-plugin catalog had 60 fake installs/removals and 56 expected blocks; post-release PR #282 recorded 101 local Chromium initializers ready and 15 failed, up from 98/18 in v0.4.39. Notebook is marked works only for its default drawing/save/reopen flow | 26 Oct, v0.4.38–0.4.39: [latest unworn sample evidence](evidence/framey-vr-standby-gate-2026-10-26.md), [backend event PR #270](https://github.com/saphid/frame-control/pull/270), [settings/cache PR #271](https://github.com/saphid/frame-control/pull/271), [post-release sample exercise](evidence/v0439-release-2026-10-26.md#post-release-framey-library-change-282), [worn checklist](framey-vr-check.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.
|
||||
@@ -1,237 +0,0 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
For the modding-path survey, licence review and update limits, see
|
||||
[plugin and modding research](plugins-research.md).
|
||||
|
||||
**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).
|
||||
|
||||
### Which of these plugins can open a Framey panel?
|
||||
|
||||
The separate Framey panel accepts a Frame Control store install with a supported
|
||||
API v1/v2 frontend or a legacy Decky IIFE frontend in `dist/index.js`.
|
||||
A backend load does not bypass this frontend check. We downloaded the 11 versions above
|
||||
again on 25 October 2026 and matched every ZIP's SHA-256 to the official store
|
||||
catalogue. All 11 had a frontend bundle, but their manifests differed:
|
||||
|
||||
| Manifest in the checked archive | Plugins | Framey panel result |
|
||||
| --- | --- | --- |
|
||||
| API v2 | ProtonDB Badges 1.3.4 | Eligible; settings rendered in local Chromium. No worn VR check. |
|
||||
| API v1 | Bluetooth 3.1.1, HLTB for Deck 2.0.12, SteamGridDB 1.8.1, Storage Cleaner 1.4.0, Wine Cellar 0.2.1, MagicPods 2.0.21 | Storage Cleaner rendered its empty-data panel in local Chromium. HLTB started and built a settings view in a local host test; its Steam game page patches have no target. Other panels remain untested. Wine Cellar and MagicPods fail the ARM64 archive check. None has a worn VR result. |
|
||||
| No `api_version` field | CSS Loader 2.1.3, Animation Changer 1.3.2, Audio Loader 1.6.1, PowerTools 2.0.3 | CSS Loader's legacy IIFE passes the Framey panel packaging check; its theme UI remains untested. Animation Changer's panel remains untested. Audio Loader and PowerTools request root and are blocked. |
|
||||
|
||||
The [archive audit record](evidence/framey-official-archive-audit-2026-10-25.json)
|
||||
has each store ID, exact version, SHA-256, manifest field, and bundle check.
|
||||
The archive audit itself is static PC inspection. Later local host and fake
|
||||
Frame tests cover the Storage Cleaner, HLTB, CSS Loader and SteamGridDB cases noted above; none shows
|
||||
that a plugin is visible or usable in the headset. The earlier real Frame backend checks in the table above
|
||||
remain the only device evidence for these versions. A new store version needs a
|
||||
new check because its manifest and binaries can change.
|
||||
|
||||
Pinned CSS Loader 2.1.3 and SteamGridDB 1.8.1 archives now pass the fake Frame
|
||||
managed install/remove flow. CSS Loader's `plugin.json` has no `api_version` and
|
||||
its older IIFE bundle passes the Framey packaging check added in
|
||||
[#261](https://github.com/saphid/frame-control/pull/261). The fixture does not
|
||||
render its theme controls. SteamGridDB declares API v1 and its bundle passes Framey's packaging
|
||||
check, but it looks up Steam library modules while loading and patches game
|
||||
context menus. Those modules and menus do not exist in Framey's isolated
|
||||
iframe, so its artwork picker cannot be called a usable VR panel. Framey does
|
||||
not apply its Steam library changes. These checks did not run CSS themes or
|
||||
modify game artwork on the real Frame.
|
||||
|
||||
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. An update is also refused if its
|
||||
new plugin name is already used by another installed plugin, because Decky
|
||||
routes backend calls by name. This collision check has local tests only.
|
||||
For a managed plugin with a valid recorded SHA-256, Update and Remove recheck
|
||||
that installed receipt under the headset transaction lock. If another store
|
||||
action changed it after the list was read, refresh the installed list and
|
||||
review the action again. This race check has local transaction tests; older
|
||||
receipts without a valid hash still use the existing ownership check.
|
||||
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. Its separate Framey iframe hosts
|
||||
API v2 pages and API v1 bundles that use Decky's modern frontend initializer,
|
||||
with settings, local routes, and panel toasts where supported.
|
||||
Steam route patches and system notifications remain unavailable. The panel
|
||||
still needs a worn-headset visual and controller check. See the
|
||||
[current compatibility table](frame-plugin-author.md#framey-decky-api-compatibility).
|
||||
|
||||
## 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 experimental [Framey panel host](framey-loader.md) can render compatible
|
||||
plugin settings inside Frame Control's separate SteamVR dashboard panel.
|
||||
The real headset still needs a worn-user visibility and controller check.
|
||||
|
||||
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.
|
||||
The desktop app and headset panel share one lock for managed Decky changes.
|
||||
If another change is in progress, wait for it to finish and try again.
|
||||
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.
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,79 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"date_utc": "2026-10-24",
|
||||
"version": "v0.4.17",
|
||||
"steam_os_version": "0.5.6",
|
||||
"steam_os_build": "20261015.6141207",
|
||||
"result": "PASS: the real Steam-saved clip exported beyond 60 seconds",
|
||||
"method": "Steam manual recording of a reserved test title, then Frame Control's production frame_clips.export_clip path; no packaged GUI button click",
|
||||
"source": {
|
||||
"kind": "saved Steam clip",
|
||||
"duration_seconds": 100.801,
|
||||
"chunks": 68,
|
||||
"gaps": 0
|
||||
},
|
||||
"export": {
|
||||
"format": "MP4",
|
||||
"bytes": 1377849,
|
||||
"sha256": "82ef31f65e6933d122bc326d664beb2eb2e3ba6969f26c0032552e80fbe8d823",
|
||||
"duration_seconds_ffprobe": 100.833335,
|
||||
"video": "H.264, 1920x1080, 6049 frames",
|
||||
"audio": "AAC, 48000 Hz, stereo",
|
||||
"full_ffmpeg_decode": "passed without errors"
|
||||
},
|
||||
"cleanup": "Restored Steam recording mode to its prior off setting; stopped and removed only the reserved title and its processes; no recording or running Steam apps remained; Game Mode active, battery 100%, shared test lock released",
|
||||
"limits": [
|
||||
"This exercised the production export code path, not a packaged GUI Export click.",
|
||||
"Visible gameplay quality, perceptual audio/video sync, trims and longer recordings were not checked.",
|
||||
"The newly saved Steam source clip was retained; no user recording was deleted."
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
{
|
||||
"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."
|
||||
]
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,20 +0,0 @@
|
||||
# API v1 panel check, 25 October 2026
|
||||
|
||||
The unmodified official Storage Cleaner 1.4.0 archive (store ID 50, SHA-256
|
||||
`1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591`)
|
||||
declares `api_version: 1`, but its bundled frontend connects to Decky's modern
|
||||
frontend API. Framey admitted that bundle from a local store receipt and
|
||||
rendered it in Chromium inside an iframe with `sandbox="allow-scripts
|
||||
allow-popups"`.
|
||||
|
||||
A test parent returned empty lists and `0B` for the plugin's two list and two
|
||||
size backend calls. Chromium reported `framey:ready`, displayed Shader Cache
|
||||
and Compatibility Data sections, and showed all four cleanup buttons disabled.
|
||||
No cleanup action was invoked. The backend was a stub; the test did not run the
|
||||
plugin's Python code or connect to a headset.
|
||||
|
||||
This confirms a panel shell for this exact archive with empty data. Storage
|
||||
Cleaner may remove compatibility data and local game saves when used with a
|
||||
real backend. Its cleanup actions, game names, SteamClient app details, VR
|
||||
visibility, and controller input remain unverified on Frame. Do not infer that
|
||||
its deletion controls are safe or complete in VR from this browser check.
|
||||
@@ -1,677 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"timeUtc": "2026-10-26T01:30:56.939937+00:00",
|
||||
"baseCommit": "442b5ec",
|
||||
"hostChange": "SliderField editableValue numeric input",
|
||||
"verification": "local Chromium + real Decky 3.3.0 amd64 container; no real Frame/worn VR",
|
||||
"decky": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"audioVersions": {
|
||||
"pipewire": "1.4.2",
|
||||
"wireplumber": "0.5.8",
|
||||
"pactl": "17.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 38,
|
||||
"name": "Volume Mixer",
|
||||
"version": "1.2.1-1",
|
||||
"sha256": "11b259ad234d0e97cfef4e6a2af3f1edfb0908d5482765af56b86b0a4601c5f3",
|
||||
"folder": "VolumeMixer-decky"
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"name": "Volume Boost",
|
||||
"version": "0.0.7",
|
||||
"sha256": "2cc576a7f60751306d690268e227df626e59d3733680ced753de4c116629a9cc",
|
||||
"folder": "volume-boost"
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"name": "Mixer",
|
||||
"version": "0.1.0",
|
||||
"sha256": "4b3546170e5fbd5f9afad3b24555498ee51caac7cec9e4c86321b18cdd4b5106",
|
||||
"folder": "decky-volume-mixer"
|
||||
}
|
||||
],
|
||||
"isolation": {
|
||||
"network": "none",
|
||||
"devices": "none",
|
||||
"hostAudioSockets": "none",
|
||||
"audio": "private PipeWire/Pulse null sink; two silent streams; WirePlumber policy-only"
|
||||
},
|
||||
"fixtureBoundaries": [
|
||||
"Outer headset status/installed-plugin responses were fixture-shaped. Actual ui/headset.html, React 18.3.1, opaque allow-scripts iframe and unmodified official bundles ran in Chromium.",
|
||||
"The relay used Decky 3.3.0 official websocket RPC routes directly inside the isolated container; Steam CEF and the production SSH/CDP method transport were not part of this audio run.",
|
||||
"Actual published amd64 Decky binary and official unmodified Python backends executed. No ARM64 or real Frame audio claim.",
|
||||
"Previous full-catalog fake-Frame evidence tests archive/install/remove transport only; its backends remain stubbed."
|
||||
],
|
||||
"sharedHeadsetLock": {
|
||||
"acquired": false,
|
||||
"owner": "delightvr-r4-a56d43b4c0",
|
||||
"headsetReadsOrChanges": false
|
||||
},
|
||||
"audioBefore": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mutatingCalls": [
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "75%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "74%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 73,
|
||||
"channel": "both"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "73%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 75,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "75%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 74,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.5
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "50%",
|
||||
"front-right": "50%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 38,
|
||||
"method": "set_input_volume",
|
||||
"args": [
|
||||
{
|
||||
"index": 46,
|
||||
"volume": 0.45
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "45%",
|
||||
"front-right": "45%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
37
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "37%",
|
||||
"front-right": "37%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
38
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "73%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "left"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "74%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 58,
|
||||
"method": "set_volume_state",
|
||||
"args": [
|
||||
{
|
||||
"value": 42,
|
||||
"channel": "right"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": true,
|
||||
"success": true
|
||||
},
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "38%",
|
||||
"front-right": "38%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "save_settings",
|
||||
"args": [
|
||||
{
|
||||
"filters": [
|
||||
"FrameySilent0"
|
||||
],
|
||||
"boostLimit": 0
|
||||
}
|
||||
],
|
||||
"result": true
|
||||
},
|
||||
{
|
||||
"id": 144,
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
],
|
||||
"result": true,
|
||||
"independentAudioReadback": {
|
||||
"sinks": [
|
||||
{
|
||||
"index": 24,
|
||||
"name": "framey-silent-test",
|
||||
"channels": {
|
||||
"front-left": "42%",
|
||||
"front-right": "42%"
|
||||
}
|
||||
}
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"index": 45,
|
||||
"name": "FrameySilent0",
|
||||
"channels": {
|
||||
"front-left": "100%",
|
||||
"front-right": "100%"
|
||||
}
|
||||
},
|
||||
{
|
||||
"index": 46,
|
||||
"name": "FrameySilent1",
|
||||
"channels": {
|
||||
"front-left": "39%",
|
||||
"front-right": "39%"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"volumeMixer38": "Discovery, slider click and ArrowDown: only FrameySilent1 changed 100\u219250\u219245%; FrameySilent0 stayed100%.",
|
||||
"volumeBoost58": "Sync both channels 75\u219274\u219273%; unsynced right 75\u219274% preserved left73%; reopen restored unchecked sync and73/74. Numeric left42 preserved right74; re-sync set right42 and disabled both right inputs.",
|
||||
"mixer144": "Numeric37\u219238\u219239 changed FrameySilent1 only; server readbacks37/38/39%. Invalid101 and off-step37.5 produced no set_volume RPC. Filter FrameySilent0 saved with boostLimit0, survived actual Decky shutdown/restart and reopened showing only FrameySilent1 at38%.",
|
||||
"invalidNumericBrowserMessages": {
|
||||
"inputs": [
|
||||
"101",
|
||||
"37.5",
|
||||
"39"
|
||||
],
|
||||
"setVolumeMessages": [
|
||||
{
|
||||
"method": "set_volume",
|
||||
"args": [
|
||||
41,
|
||||
39
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"notRun": [
|
||||
"boost above100%",
|
||||
"Bluetooth playback commands",
|
||||
"reset_volumes",
|
||||
"physical headset audio",
|
||||
"worn VR",
|
||||
"VR keyboard/controller input"
|
||||
]
|
||||
},
|
||||
"pipewireConfig": "context.properties = { core.daemon = true core.name = framey-audio }\ncontext.spa-libs = { audio.convert.* = audioconvert/libspa-audioconvert support.* = support/libspa-support }\ncontext.modules = [\n { name = libpipewire-module-protocol-native }\n { name = libpipewire-module-access }\n { name = libpipewire-module-spa-node-factory }\n { name = libpipewire-module-client-node }\n { name = libpipewire-module-client-device }\n { name = libpipewire-module-adapter }\n { name = libpipewire-module-metadata }\n { name = libpipewire-module-link-factory }\n { name = libpipewire-module-session-manager }\n]\ncontext.objects = [\n\n { factory = spa-node-factory args = { factory.name = support.node.driver node.name = Dummy-Driver node.group = pipewire.dummy priority.driver = 20000 } }\n { factory = adapter args = { factory.name = support.null-audio-sink node.name = framey-silent-test node.description = \"Framey Silent Test\" media.class = Audio/Sink audio.position = [ FL FR ] } }\n]",
|
||||
"remaining": "All three remain partial until real ARM64 Frame dependencies and audio-session mapping are verified under the shared lock.",
|
||||
"cleanup": {
|
||||
"testContainerRemoved": true,
|
||||
"browserRelayStopped": true,
|
||||
"headsetUntouched": true,
|
||||
"testServicesOnHeadset": false
|
||||
},
|
||||
"catalogRefresh": {
|
||||
"checkedAtUtc": "2026-10-26T01:34:07.257386+00:00",
|
||||
"plugins": 116,
|
||||
"idsVersionsDigestsUnchanged": true
|
||||
},
|
||||
"regression": {
|
||||
"serverTests": {
|
||||
"count": 1612,
|
||||
"skipped": 16,
|
||||
"result": "OK"
|
||||
},
|
||||
"siteTests": {
|
||||
"passed": 2,
|
||||
"failed": 0
|
||||
},
|
||||
"focusedFrameyTests": 38,
|
||||
"officialFrontendAudit": {
|
||||
"attempted": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"readyStateChanges": [],
|
||||
"initializerOnly": true,
|
||||
"backendAndNetworkDenied": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
# Official audio plugins: local real-backend checks
|
||||
|
||||
Volume Mixer 1.2.1-1 (38), Volume Boost 0.0.7 (58) and Mixer 0.1.0 (144)
|
||||
passed useful audio controls in local Chromium with their **unmodified official
|
||||
Python backends running in actual Decky 3.3.0**. All three remain **partial**:
|
||||
this run used amd64 Linux, and Frame ARM64 dependencies/audio-session mapping
|
||||
are unverified. The shared headset lock was occupied; no headset reads or
|
||||
changes occurred. No worn-VR result is claimed.
|
||||
|
||||
The private test container and browser relay were removed after verification.
|
||||
|
||||
The [machine-readable observations](framey-audio-backends-2026-10-26.json)
|
||||
pin all three official archives and the published Decky binary digest, and
|
||||
record each browser-triggered write with independent `pactl` channel readbacks.
|
||||
|
||||
| Plugin | Useful behavior checked | Still unverified on Frame |
|
||||
| --- | --- | --- |
|
||||
| Volume Mixer | Discover two playback streams; slider click and ArrowDown change only the selected stream, 100 → 50 → 45%; other stream remains 100% | ARM64 libpulse/libdbus, actual user audio session, Bluetooth playback controls |
|
||||
| Volume Boost | Both-channel slider; separate right-channel change preserves left; reopen restores sync preference and actual values; exact numeric left entry; syncing disables both right controls | `pactl`, default sink, hard-coded runtime UID, physical output, boost above 100% |
|
||||
| Mixer | Exact stream volume 37 → 38%, independently read back; invalid 101 and off-step 37.5 produce no write; playback filter saved, survives actual Decky restart, hides the intended stream on reopen | `pw-dump`/`wpctl`, actual Frame session, physical output, worn VR |
|
||||
|
||||
## Reproduced host gap
|
||||
|
||||
Both Volume Boost and Mixer request `SliderField.editableValue`. Framey
|
||||
previously ignored that prop and rendered only a range slider. It now supplies
|
||||
an accessible numeric input with the same minimum, maximum, step and disabled
|
||||
state, and emits numeric `onChange` values. Empty, non-finite, out-of-range and
|
||||
browser-invalid drafts do not reach a backend. No official archive was patched.
|
||||
This also makes the control available to other plugins requesting that prop;
|
||||
it does not establish their compatibility.
|
||||
|
||||
## Runtime and isolation
|
||||
|
||||
The disposable Debian 13 container ran PipeWire 1.4.2, WirePlumber 0.5.8 and
|
||||
PulseAudio client tools 17.0. Docker used `--network none --cap-drop ALL
|
||||
--security-opt no-new-privileges`, no host audio sockets and no sound devices.
|
||||
Only the archive cache and test inputs were mounted, read-only. Debian 12 was
|
||||
insufficient for the published Decky binary's GLIBC requirement; that initial
|
||||
runtime did not start.
|
||||
|
||||
Private environment:
|
||||
|
||||
```text
|
||||
XDG_RUNTIME_DIR=/tmp/framey-audio-runtime
|
||||
PIPEWIRE_REMOTE=framey-audio
|
||||
PULSE_SERVER=unix:/tmp/framey-audio-runtime/pulse-native
|
||||
DBUS_SYSTEM_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/framey-audio-runtime/bus
|
||||
```
|
||||
|
||||
XDG config/data/state and both Decky paths were below the private test base.
|
||||
Decky ran unprivileged with `CHOWN_PLUGIN_PATH=0`, `LIVE_RELOAD=0`,
|
||||
`USE_CEF_CLOSE_WORKAROUND=0` and `KEEP_SYSTEMD_SERVICE=1`. A private D-Bus
|
||||
session satisfied Volume Mixer's import; there was no real BlueZ service.
|
||||
Its fixed `/tmp/VolumeMixer-decky.log` existed only inside the container.
|
||||
|
||||
The JSON includes the complete PipeWire server configuration: only a dummy
|
||||
driver and null stereo sink, no hardware factories. A second PipeWire process
|
||||
loaded protocol-native, client-node, adapter, metadata and protocol-pulse with
|
||||
the private absolute socket. `wireplumber --profile policy` managed this server;
|
||||
that installed profile contains no hardware monitors. Only private default-sink
|
||||
metadata was selected. Two `paplay --raw --format=s16le --rate=48000 --channels=2
|
||||
/dev/zero` clients supplied silence named `FrameySilent0` and `FrameySilent1`.
|
||||
Without policy, successful volume RPCs initially left readbacks unchanged; those
|
||||
fixture attempts are excluded from the passing observations.
|
||||
|
||||
The browser relay served actual `ui/headset.html`, vendored React 18.3.1,
|
||||
`ui/framey-host.js` and the exact archived frontends in an opaque `allow-scripts`
|
||||
iframe. Outer headset/installed-plugin responses were fixture-shaped. Backend
|
||||
RPC used the official Decky websocket protocol inside the isolated container:
|
||||
`loader/call_legacy_plugin_method` with keyword objects for 38/58 and
|
||||
`loader/call_plugin_method` with positional arguments for 144. This bypassed
|
||||
Steam CEF and the production SSH/CDP transport; neither is an audio verification
|
||||
claim from this run.
|
||||
|
||||
The relay allowed discovery/settings reads, bounded volume writes only to
|
||||
identified `FrameySilent` streams/the private default sink, and Mixer filters
|
||||
`[]` or `["FrameySilent0"]` with boost limit 0. It denied Bluetooth commands,
|
||||
`reset_volumes` and boosts. Every volume write was followed by a separate
|
||||
`pactl -f json list sinks` / `list sink-inputs` read. A clean Decky shutdown and
|
||||
restart retained Mixer's actual settings file and filter behavior.
|
||||
|
||||
Earlier full-catalog fake-Frame evidence for these exact digests covers
|
||||
install/receipt/frontend/removal transport, with plugin execution stubbed.
|
||||
The new evidence adds actual local backend execution; it does not turn that
|
||||
older fake-Frame result into an ARM64 backend test.
|
||||
|
||||
## Regression checks
|
||||
|
||||
The required server suite passed 1,612 tests (16 skips); all 38 focused Framey
|
||||
tests and both website tests passed. The 116-plugin browser audit retained
|
||||
98 initializer-ready / 18 failed, with no change to any ready state. That audit
|
||||
keeps backend/network actions denied. A fresh official catalog fetch confirmed
|
||||
all 116 IDs, versions and digests unchanged.
|
||||
|
||||
## Next device check
|
||||
|
||||
Acquire the normal atomic `/tmp/frame-test.lock`; refuse an existing owner.
|
||||
First inspect available tools/libraries and audio runtime UID. If supported,
|
||||
repeat with a private ARM64 Decky user service, isolated audio socket, dummy
|
||||
sink/streams and hardware discovery disabled. Check Volume Mixer's fixed log
|
||||
path for collisions before loading it. Never attach the fixture to the normal
|
||||
headset output/default server. Remove only test-owned services/processes/files,
|
||||
read back original service/battery state and release only the matching lock.
|
||||
Keep all three partial until that check passes; physical output, Bluetooth and
|
||||
worn-VR controls need separate evidence.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Bluetooth 3.1.1 compatibility fixture, 25 October 2026
|
||||
|
||||
The unmodified official Decky store archive for Bluetooth 3.1.1 (store ID 18,
|
||||
SHA-256 `f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5`)
|
||||
passes Frame Control's archive rules. The fake Frame e2e harness installed it
|
||||
through the store API, verified the managed receipt and frontend bytes, then
|
||||
removed it. An earlier real Frame check recorded the backend's `Bluetooth
|
||||
plugin loaded` line while SteamVR was in standby.
|
||||
|
||||
In a local Node host test, its official frontend starts and builds an initial
|
||||
panel tree. Framey supplies empty Steam token maps and readable English
|
||||
fallbacks for the three Bluetooth labels that lack plugin-owned fallbacks.
|
||||
The test leaves React effects and click
|
||||
handlers inert and sends no backend calls. It does not inspect paired devices,
|
||||
change Bluetooth state, exercise a controller, or prove the panel is visible in
|
||||
VR. No worn-headset check has passed for this plugin.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Framey loader evidence, 25 October 2026
|
||||
|
||||
## Local browser and fake Frame
|
||||
|
||||
The official Decky store returned 116 validated entries. ProtonDB Badges was
|
||||
entry 14, release 1.3.4, SHA-256
|
||||
`90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358`.
|
||||
Its published ZIP passed Frame Control's store manifest and path validator. The current
|
||||
`dist/index.js` bundle was loaded in an opaque-origin iframe in local Chromium
|
||||
with the Framey React/DFL shim. The [browser screenshot](framey-browser-2026-10-25.png)
|
||||
shows the plugin's settings panel. A pointer click on **Enable Badge on
|
||||
Library** issued `set_setting('settings', …)` through the iframe's request
|
||||
bridge. The local test parent returned a stub backend reply; it did not use a
|
||||
real headset or Decky backend. A separate fake Frame CEF fixture returned
|
||||
`get_setting` and `set_setting` responses through Frame Control's CDP bridge;
|
||||
that fixture does not model the real Decky process.
|
||||
|
||||
The disposable fake Frame end-to-end harness passed the store catalogue,
|
||||
acknowledgement and HTTP auth checks, hash-verified install, update, and
|
||||
removal through the real Frame Control HTTP server and SSH transaction. The
|
||||
full fake Frame suite passed 48 tests; the full unit suite passed 1,559 tests
|
||||
(16 skipped). Unit tests covered unsafe archives,
|
||||
unmanaged-folder preservation, asset isolation, backend-call scope,
|
||||
and safe aborts of the worn-headset check.
|
||||
|
||||
## Real headset status
|
||||
|
||||
Read-only SSH on Frame OS 0.5.6 found battery at 98–99%. SteamVR activity was
|
||||
initially 3 (standby), briefly 0, then 3 again. During the active probe the
|
||||
shared `/tmp/frame-test.lock` belonged to `obs-aacapv3evh-20261025`. It later
|
||||
became free after SteamVR returned to standby. Framey did not acquire, replace,
|
||||
or remove it. The active probe found no installed Frame Control VR panel or
|
||||
Framey plugin; the scripted worn check exited before acquiring a lock or
|
||||
altering the headset. There is **no** wearer-visible VR
|
||||
menu screenshot, controller-input proof, or live settings-persistence result
|
||||
from this round.
|
||||
|
Before Width: | Height: | Size: 62 KiB |
@@ -1,19 +0,0 @@
|
||||
# Framey cache check, 25 October 2026
|
||||
|
||||
The official Steam Status 1.2.3 ZIP pinned in
|
||||
`tests/fixtures/framey-official/123.zip` was loaded through Framey's local
|
||||
HTTP panel in Chromium. The iframe retained `sandbox="allow-scripts
|
||||
allow-popups"`, so its native storage remained unavailable. The new Framey
|
||||
storage facade accepted a set/get probe without a `SecurityError`. Steam
|
||||
Status rendered and issued four `get_settings` calls to a stub parent backend.
|
||||
Its `steamstat_last_status` cache entry was about 33 KiB, below the bridge's
|
||||
128 KiB cap. After reopening the iframe, the parent supplied the saved cache
|
||||
snapshot and the plugin wrote the same cache key again. The browser log had
|
||||
zero `SecurityError` or `localStorage` errors.
|
||||
|
||||
The parent was a local test page with in-memory Decky backend replies. One
|
||||
public status fetch also failed in the test browser, so this check does not
|
||||
establish offline correctness or the freshness of Steam Status data. The
|
||||
per-plugin save/reopen protocol, stale panel session rejection, and cache
|
||||
limits are covered by `tests/page/framey_cache.mjs`. Frame OS persistence,
|
||||
live Decky settings, VR visibility, and controller input remain unverified.
|
||||
@@ -1,418 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:12:47.296778+00:00",
|
||||
"method": "Local Chromium actual Framey headset page and opaque official plugin iframe; production method/event bridge relayed by SSH to real ARM64 Decky 3.3.0 in a private removable user service. Test relay restricts RPC methods and timer durations. Backend code is unmodified, no fake plugin replies. Only file paths to private install and outer status/data APIs differ. Not worn VR.",
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"hostSourceSha256": {
|
||||
"framey-host.js": "3bad52546c0b78c20e6479c91899f1f3864b74687c6fce37e776abededfb5f1c",
|
||||
"framey-events.js": "89046f4c89bdab0f578b910170a044d5ada540db0f7b71d6a370c68b4b008c3f",
|
||||
"framey_panel.py": "8ca4b3ba5f0d51c51bb3601f9ccd24c4af397fbf8f63c383a3042e1ae3f7b39b",
|
||||
"headset.html": "ebaf1711639a929ec7287c01dbbbe8eb08f000b852fdc877f31c9d99e5fe69fc"
|
||||
},
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"decky": "inactive",
|
||||
"panel": "inactive"
|
||||
},
|
||||
"powerSettingsChanged": false,
|
||||
"plugins": [
|
||||
{
|
||||
"id": 19,
|
||||
"name": "Notebook",
|
||||
"version": "0.1.5",
|
||||
"sha256": "c35b09de4bba4b4157fbe84cb7b0d20fc3924f78fa3c48ae17686b4314c1cc0c",
|
||||
"calls": [
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 0,
|
||||
"empty": true,
|
||||
"data": ""
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "savePage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"timestamp": 1792973680303
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "listPages",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": [
|
||||
{
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false
|
||||
}
|
||||
],
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadLastSelectedPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": 0,
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"method": "loadPage",
|
||||
"args": [
|
||||
{
|
||||
"appid": 0,
|
||||
"page": 0
|
||||
}
|
||||
],
|
||||
"result": {
|
||||
"result": {
|
||||
"result": {
|
||||
"page": 0,
|
||||
"timestamp": 1792973680303,
|
||||
"empty": false,
|
||||
"data": "[{\"drawMode\":true,\"strokeColor\":\"white\",\"strokeWidth\":4,\"paths\":[{\"x\":583.3984375,\"y\":155.997802734375}]}]"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [],
|
||||
"checks": {
|
||||
"pointerStrokeSaved": true,
|
||||
"sameStrokeLoadedAfterBackendRestart": true,
|
||||
"defaultAppId": 0,
|
||||
"existingNotesTouched": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"name": "Simple Timer",
|
||||
"version": "1.0.0",
|
||||
"sha256": "973d927e9d9a1feec04679c564af7e389a729b6735d2a988ffc2cb46ce4eb312",
|
||||
"calls": [
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
300
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "cancel_timer",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "start_timer",
|
||||
"args": [
|
||||
5
|
||||
],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_remaining_seconds",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_recents",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"method": "load_subtle_mode",
|
||||
"args": [],
|
||||
"result": {
|
||||
"result": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 4,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 5,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 6,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
294.9945251941681
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 7,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
289.99201130867004
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 8,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
284.9877219200134
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 9,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 10,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 11,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 12,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
-0.005679607391357422
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 13,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 14,
|
||||
"event": "simple_timer_event",
|
||||
"args": [
|
||||
"Your session has ended!",
|
||||
true
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 1,
|
||||
"event": "simple_timer_seconds_updated",
|
||||
"args": [
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 2,
|
||||
"event": "simple_timer_refresh_recents",
|
||||
"args": [
|
||||
[
|
||||
5,
|
||||
300
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"sequence": 3,
|
||||
"event": "simple_timer_subtle",
|
||||
"args": [
|
||||
true
|
||||
]
|
||||
}
|
||||
],
|
||||
"checks": {
|
||||
"uiStart300Seconds": true,
|
||||
"uiCancel": true,
|
||||
"shortTimer5Seconds": true,
|
||||
"completionToastVisible": true,
|
||||
"recentTimersAndSubtleModeSurvivedRestart": true,
|
||||
"subscriptionsAfterPanelClose": 0
|
||||
},
|
||||
"upstreamError": "Simple Timer 1.0.0 _main after restarting with an expired timer raises AttributeError: 'Plugin' object has no attribute 'timer_task' (main.py:94,129). Frontend read requests still load recent timers and subtle mode."
|
||||
}
|
||||
],
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true,
|
||||
"serviceActive": false,
|
||||
"independentlyRechecked": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
"regressions": {
|
||||
"unitTests": 1606,
|
||||
"unitSkips": 16,
|
||||
"browserPlugins": 116,
|
||||
"browserReady": 96,
|
||||
"browserFailed": 20,
|
||||
"fakeFrameInstalled": 60,
|
||||
"fakeFrameRejected": 56
|
||||
},
|
||||
"limitations": [
|
||||
"Timer sound asset and suspend action unsupported; no suspend/power action run.",
|
||||
"Closing a Framey panel removes its event channel; it is not a global background notification service.",
|
||||
"Notebook default notebook only (appid 0): no per-game auto-selection or background artwork.",
|
||||
"No worn VR, physical controller, keyboard, or optical check."
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Legacy Decky frontend bridge
|
||||
|
||||
The exact official MusicControl 1.1.6 archive (`12.zip` in the test fixtures)
|
||||
has no `api_version` field and returns its initializer from a Decky IIFE.
|
||||
Framey now wraps that expression in an ES module inside the existing
|
||||
opaque-origin iframe. It passes a legacy `serverAPI` to the initializer.
|
||||
|
||||
Local Chromium ran the unchanged official frontend, including React effects,
|
||||
with all backend calls rejected and network access blocked. It emitted
|
||||
`framey:ready` without a JavaScript startup error during the observation window.
|
||||
This proves initialization, not playback, MPRIS access, or a useful populated UI.
|
||||
|
||||
The fake Frame installed, packed, checked the frontend bytes and removed this
|
||||
exact archive. Its synthetic Sample Panel also read a legacy keyword argument,
|
||||
wrote a setting, and read that setting through the modern API. This exercised
|
||||
the real HTTP → SSH → CDP path with a fake Decky backend. The RPC contract was
|
||||
checked against Decky's `handle_plugin_method_call_legacy`: it calls with a
|
||||
keyword object and returns `{success, result}`. Invalid argument shapes and
|
||||
unmanaged plugin IDs are rejected.
|
||||
|
||||
Verification: `python3 -m unittest discover -s tests -p 'test_framey*.py'`
|
||||
(21 passed), `node tests/page/framey_actions.mjs`, and
|
||||
`scripts/e2e.sh test_framey` (2 passed). No official Python backend code or
|
||||
headset state was changed by these checks. Real Frame backend behavior,
|
||||
controller input, playback and worn VR use remain unverified.
|
||||
@@ -1,326 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:23:08.939647+00:00",
|
||||
"theme": "Detached read-only Steam library and current-user context",
|
||||
"baselineMain": "3921e59",
|
||||
"hostFileSha256": "3f70e9769c6c95d39cfa329ccf3f822df85d411e9295bdf888508b4738679e9d",
|
||||
"realFrame": {
|
||||
"platform": "Frame OS 0.5.6 ARM64",
|
||||
"path": "SSH via lxso2; Steam SharedJSContext CDP",
|
||||
"lock": "/tmp/frame-test.lock atomic mkdir; matching owner released in finally after every probe",
|
||||
"apps": 1106,
|
||||
"games": 895,
|
||||
"compactBytesWithoutUser": 88451,
|
||||
"invalidRows": 0,
|
||||
"missingGameOverviews": 0,
|
||||
"currentUser": "17-digit string verified; value withheld",
|
||||
"mutations": "none; no wake, service, setting, screenshot or library changes; no device artifacts created",
|
||||
"limits": "read-only CEF projection, not an official backend or worn-VR test"
|
||||
},
|
||||
"browser": {
|
||||
"mode": "opaque iframe Chromium; detached real Frame snapshot replay; all backend/network actions denied",
|
||||
"attempted": 116,
|
||||
"initializerReady": 101,
|
||||
"failed": 15,
|
||||
"initializerGains": [
|
||||
46,
|
||||
53,
|
||||
66
|
||||
],
|
||||
"regressions": [],
|
||||
"focused": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "TypeError: saveInfos.map is not a function"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_account_id",
|
||||
"get_saveinfos"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"set_id_map_fronend"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Failed to set current user: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"set_current_user",
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks",
|
||||
"daily_statistics_for_period"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: Error: Read-only compatibility audit: live Steam API blocked"
|
||||
}
|
||||
],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 132,
|
||||
"name": "SuggestMe",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load config: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load library status: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load play next list: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load excluded games: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load filter presets: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load history: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load non-steam info: Error: Read-only compatibility audit: backend method blocked"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[SuggestMe] Failed to load collections: Error: Read-only compatibility audit: backend method blocked"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"log_message",
|
||||
"get_config",
|
||||
"get_library_status",
|
||||
"get_play_next_list",
|
||||
"get_excluded_games",
|
||||
"get_filter_presets",
|
||||
"get_suggestion_history",
|
||||
"get_non_steam_games",
|
||||
"get_collections",
|
||||
"get_available_genres",
|
||||
"get_available_tags",
|
||||
"get_available_community_tags"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 134,
|
||||
"name": "Deck Progress Tracker",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_settings",
|
||||
"get_tag_statistics"
|
||||
],
|
||||
"library": "snapshot replay"
|
||||
},
|
||||
{
|
||||
"id": 135,
|
||||
"name": "Deck Shelves",
|
||||
"ready": false,
|
||||
"error": "Failed to resolve module specifier \"./chunks/pluginApi-B8sV7970.js\". Invalid relative url or base scheme isn't hierarchical.",
|
||||
"logs": [],
|
||||
"methods": [],
|
||||
"library": "snapshot replay"
|
||||
}
|
||||
]
|
||||
},
|
||||
"fakeFrame": {
|
||||
"attempted": 116,
|
||||
"installedRemoved": 60,
|
||||
"expectedBlockers": 56,
|
||||
"backendExecution": "stubbed",
|
||||
"cleanup": "private Compose containers/network/volumes removed"
|
||||
},
|
||||
"localBackends": {
|
||||
"timeUtc": "2026-10-26T02:18:51.848256+00:00",
|
||||
"verification": "local Chromium + actual Decky 3.3.0 amd64, detached real Frame SSH snapshot; no worn VR",
|
||||
"calls": [
|
||||
{
|
||||
"id": 46,
|
||||
"method": "set_account_id",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"method": "get_saveinfos",
|
||||
"resultType": "dict",
|
||||
"resultCount": 2
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "set_current_user",
|
||||
"resultType": "NoneType"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "daily_statistics_for_period",
|
||||
"resultType": "dict",
|
||||
"resultCount": 3
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "fetch_playtime_information",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"method": "statistics_for_last_two_weeks",
|
||||
"resultType": "list",
|
||||
"resultCount": 0
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"tag": "v3.3.0",
|
||||
"name": "PluginLoader",
|
||||
"digest": "sha256:e80d03e641d1766a33a817cb27ab8d2e0a4633685e5d27e4de948a6687526181"
|
||||
},
|
||||
"legacyReadback": {
|
||||
"plugin": 46,
|
||||
"method": "get_saveinfos",
|
||||
"replyType": 1,
|
||||
"success": true,
|
||||
"saveInfoCount": 0
|
||||
}
|
||||
},
|
||||
"uiChecks": {
|
||||
"Steamback": "Supported games panel, actual private backend set_account_id and get_saveinfos returned successfully; empty isolated Steam save data",
|
||||
"PlayTime": "Statistics panel (empty database) and routed General settings/tabs rendered; actual private backend selected user and returned statistics",
|
||||
"Deck Settings": "Installed game picker rendered real Frame install-folder/name snapshot; search modal rendered",
|
||||
"Shotty": "Initializer repaired; actual Decky 3.3.0 backend fails importing unused click.get_app_dir: ModuleNotFoundError; no screenshot methods invoked"
|
||||
},
|
||||
"notVerified": [
|
||||
"Real ARM64 official backends in this theme",
|
||||
"Worn VR/controllers/keyboard",
|
||||
"Live MobX or running-game observations",
|
||||
"Steamback backups/restores and automatic notifications",
|
||||
"PlayTime automatic recording, existing databases or account switches while panel remains open",
|
||||
"Deck Settings remote reports/proxy fetch and settings changes",
|
||||
"Shotty screenshot aggregation/rescue"
|
||||
],
|
||||
"cleanup": {
|
||||
"localContainer": "stopped/removed; docker ps -a matching name empty",
|
||||
"headsetLockReleased": true,
|
||||
"httpHelpers": "three task-local HTTP servers stopped"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"id": 37,
|
||||
"name": "Discord Status",
|
||||
"version": "1.5.0",
|
||||
"sha256": "00a45dd919abb6e050685e1e408ce8023b9c4a09d6e91ba528d50aed2a63bf45"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"version": "1.0.0-1",
|
||||
"sha256": "ed5f2fbb47cdfc3aa9a4349a139ab20c29976117a3be0de7e8a588bb265ee7f3"
|
||||
},
|
||||
{
|
||||
"id": 53,
|
||||
"name": "Shotty",
|
||||
"version": "0.1.0",
|
||||
"sha256": "ab7f6618b264a0cb766525e788cef6c3e1478c0fef98a701225e5e8af441e2fe"
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"version": "3.2.5",
|
||||
"sha256": "10fc3beea48991c77cb02867e919b618d4cd6d5e9c6a6d0ccd5266c13214039f"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"version": "1.2.6",
|
||||
"sha256": "23c2a933a6883e2622f0184c33a76ce4a4e88c0a7dcd730f9bab71b8c3702132"
|
||||
}
|
||||
],
|
||||
"validation": {
|
||||
"serverTests": 1617,
|
||||
"serverSkips": 16,
|
||||
"siteTests": 18,
|
||||
"fakeFramePanel": "2 targeted panel/store flows passed; private Compose resources removed",
|
||||
"focusedFrameyTests": 40
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
# Framey detached Steam library context — 2026-10-26
|
||||
|
||||
Framey now reads a bounded library snapshot before importing official frontends
|
||||
that reference `appStore`, `collectionStore` or `m_CurrentUser`. It supplies app
|
||||
IDs/names/types/game IDs, sorting/playtime scalars, the actual all-games
|
||||
collection and the current public Steam ID. It does not copy credentials,
|
||||
balances, account names, filesystem paths or executable Steam objects.
|
||||
|
||||
The projection is a fixed, parameterless `GetLibrarySnapshot` read through the
|
||||
existing installed-store-plugin boundary. The parent checks the iframe source,
|
||||
session, method and empty argument list. The host validates rows before exposing
|
||||
detached Maps and lookup methods. Plugin patches remain local to that iframe.
|
||||
Missing or invalid data leaves the globals unavailable and logs a clear warning;
|
||||
there is no invented account or empty-library fallback. Reopening the panel
|
||||
refreshes the snapshot. Live MobX observations, account-switch notifications and
|
||||
running-game events are outside this change.
|
||||
|
||||
Library-sized backend arguments now have a 256 KiB UTF-8 JSON budget. The old
|
||||
8 KiB ceiling rejected normal app-name maps. Payloads travel as quoted literals
|
||||
on SSH stdin, avoiding operating-system limits on a single command argument.
|
||||
Store receipt ownership, method validation and legacy keyword envelopes still
|
||||
apply. The library read has a separate 2 MiB / 10,000-app limit; other Steam
|
||||
reads retain their 256 KiB response limit.
|
||||
|
||||
## Observations
|
||||
|
||||
| Plugin | Verified behavior | Remaining limit |
|
||||
| --- | --- | --- |
|
||||
| Steamback 1.0.0-1 | Panel opens; account setup and empty save-list reads succeed through actual Decky 3.3.0 amd64 | Private container had no Steam saves. ARM64 backend, backup/restore and automatic hooks unverified. Partial. |
|
||||
| PlayTime 3.2.5 | Actual backend selects the user, returns empty statistics; statistics panel and routed settings/tabs render | No sessions were created. ARM64 backend, existing databases, live observations and automatic recording unverified. Partial. |
|
||||
| Deck Settings 1.2.6 | Installed game picker renders the real Frame folder/name snapshot; search modal opens | Remote reports/proxy fetch, settings changes and ARM64 backend unverified. Partial. |
|
||||
| Shotty 0.1.0 | Library-based initializer now completes | Exact backend fails importing `click.get_app_dir`: `ModuleNotFoundError: No module named 'click'`. Archive does not bundle it. Broken. |
|
||||
| Discord Status 1.5.0 | Detached overview lookups available | Live running-app observations and artwork helpers remain absent; Discord activity unverified. Broken. |
|
||||
|
||||
Exact plugin versions/digests, runtime digest, scoped calls and counts are in the
|
||||
[redacted observations](framey-library-context-2026-10-26.json). The canonical
|
||||
table/store labels use the same digest-scoped JSON. None of these plugins is
|
||||
promoted to **works** by this pass.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
- **Real Frame SSH:** atomic `/tmp/frame-test.lock` acquisition for every probe,
|
||||
matching-owner release in `finally`. Frame OS 0.5.6 ARM64 Steam CEF returned
|
||||
1,106 apps and 895 game IDs; no missing collection members or invalid rows.
|
||||
The compact library-only response was 88,451 bytes. Current-user field shape
|
||||
was separately inspected. Values remain private; only aggregate counts are
|
||||
published. No wake, power/service changes or device artifacts were needed.
|
||||
- **Local Chromium:** production `headset.html`, current host, opaque iframe and
|
||||
unmodified digest-checked official frontends. Library/user and install-folder
|
||||
data were detached snapshots read from the Frame, replayed locally. This is
|
||||
not a deployed headset HTTP or worn-VR test.
|
||||
- **Actual local backends:** published Decky 3.3.0 amd64 executable, private
|
||||
unprivileged container, network disabled, capabilities dropped, no devices,
|
||||
host home, saves or Steam files mounted. Only account setup within private
|
||||
plugin data and read methods were allowed. Backup, restore, aggregation,
|
||||
screenshot rescue and game actions were denied. Shotty's import failure was
|
||||
observed from the runtime log before invoking screenshot methods.
|
||||
- **Catalog regression:** 116 exact frontends attempted, 101 initializer-ready
|
||||
and 15 failed, compared with 98 ready before this theme. Steamback, Shotty and
|
||||
PlayTime gained initializer readiness; no previous ready state regressed.
|
||||
Backend/network calls stayed denied in this sweep. Readiness alone does not
|
||||
establish useful operation.
|
||||
- **Fake Frame:** 116 archive/transport cases; 60 install/remove cycles and 56
|
||||
expected blockers. Official backend execution was stubbed. Its private
|
||||
Compose containers, network and volumes were removed.
|
||||
|
||||
The browser audit accepts `--library-snapshot /private/library.json` to reproduce
|
||||
the detached replay. This flag does not convert the audit into live Steam or
|
||||
backend evidence. Never commit personal library snapshots or account values.
|
||||
|
||||
No worn-VR, physical controller, keyboard, automatic recording or backup claim
|
||||
is made. Further device backend checks still require the shared hardware lock.
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26",
|
||||
"plugin": {
|
||||
"id": 125,
|
||||
"name": "Decky Localsend",
|
||||
"officialArchiveStatus": "x86_64; blocked; no substitutions or safeguard changes"
|
||||
},
|
||||
"source": {
|
||||
"url": "https://github.com/MoYoez/LocalSend-Go",
|
||||
"revision": "66edc509a28b9f877e7374504892dcbc653a4da4",
|
||||
"license": "MIT",
|
||||
"licenseUrl": "https://github.com/MoYoez/LocalSend-Go/blob/66edc509a28b9f877e7374504892dcbc653a4da4/LICENSE",
|
||||
"pluginLicense": "BSD-3-Clause; distinct from the MIT helper",
|
||||
"officialElfBuildInfo": "Go 1.25.7, CGO_ENABLED=0, GOARCH=amd64; no embedded VCS revision. Exact source correspondence unproven."
|
||||
},
|
||||
"build": {
|
||||
"toolchain": "go1.25.7",
|
||||
"environment": {
|
||||
"GOTOOLCHAIN": "go1.25.7",
|
||||
"CGO_ENABLED": "0",
|
||||
"GOOS": "linux",
|
||||
"GOARCH": "arm64"
|
||||
},
|
||||
"command": "go build -trimpath -o localsend-core .",
|
||||
"elfMachine": 183,
|
||||
"architecture": "aarch64",
|
||||
"staticallyLinked": true,
|
||||
"sha256": "e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9",
|
||||
"rebuildSha256Matches": true
|
||||
},
|
||||
"realFrame": {
|
||||
"os": "Frame OS 0.5.6 ARM64",
|
||||
"sharedLockOwner": "kp0z857q58-live-steam-reads-20261026",
|
||||
"helpExitCode": 0,
|
||||
"officialBackendFlagsAllRecognized": true,
|
||||
"helpFlags": [
|
||||
"-doNotMakeSessionFolder",
|
||||
"-log",
|
||||
"-scanTimeout",
|
||||
"-skipNotify",
|
||||
"-useAlias",
|
||||
"-useAutoSave",
|
||||
"-useAutoSaveFromFavorites",
|
||||
"-useConfigPath",
|
||||
"-useDefaultUploadFolder",
|
||||
"-useDownload",
|
||||
"-useHttp",
|
||||
"-useMultcastAddress",
|
||||
"-useMultcastPort",
|
||||
"-usePin",
|
||||
"-useReferNetworkInterface",
|
||||
"-useWebOutPath"
|
||||
],
|
||||
"runtimeExitCode": 0,
|
||||
"isolation": "unshare -Urn; loopback and private veth pair only; no external interface/default route",
|
||||
"receiving": "-useAutoSave=false and -useAutoSaveFromFavorites=false; no upload/transfer requests",
|
||||
"notifications": "-skipNotify",
|
||||
"readOnlyRequest": "GET http://127.0.0.1:53317/api/localsend/v2/info",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"protocolVersion": "2.0",
|
||||
"deviceType": "headless",
|
||||
"responseKeys": [
|
||||
"alias",
|
||||
"deviceModel",
|
||||
"deviceType",
|
||||
"download",
|
||||
"fingerprint",
|
||||
"version"
|
||||
],
|
||||
"aliasMatches": true
|
||||
},
|
||||
"cleanup": {
|
||||
"helperExited": true,
|
||||
"filesCreated": [
|
||||
"config.yaml",
|
||||
"helper.log"
|
||||
]
|
||||
},
|
||||
"privateDirectoryAbsent": true,
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"sharedLockReleasedAfterTests": true,
|
||||
"independentReacquireAudit": "Deferred: another job had acquired the shared lock; it was left untouched. The helper process exited and TemporaryDirectory removal completed within the original locked probe."
|
||||
},
|
||||
"failedProbe": {
|
||||
"interface": "loopback only",
|
||||
"result": "Upstream rejects lo as a discovery interface and exits. Repeated with a veth pair wholly inside the private namespace; no source changes."
|
||||
},
|
||||
"unverified": [
|
||||
"Exact source correspondence to shipped official ELF",
|
||||
"Unmodified official Python backend invoking this ARM64 artifact",
|
||||
"HTTPS, discovery on real network, pairing and file transfers",
|
||||
"Framey frontend/proxy protocol integration",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
# LocalSend ARM64 build and isolated Frame probe
|
||||
|
||||
The official Decky Localsend archive remains **broken/blocked**: its helper is
|
||||
x86_64. A source rebuild is now proven to execute on the ARM64 Frame and answer
|
||||
the LocalSend 2.0 info endpoint. This is a core runtime result, not a working
|
||||
official-plugin or file-transfer claim. [Machine-readable evidence](framey-localsend-arm64-2026-10-26.json)
|
||||
records the exact scope and cleanup.
|
||||
|
||||
## Reproduce the build
|
||||
|
||||
The helper is MIT-licensed; the separate official Python plugin is BSD-3-Clause.
|
||||
Use the pinned helper source and retain its license for any redistribution.
|
||||
No binary is redistributed in Frame Control, and no installer digest or
|
||||
architecture safeguard was changed.
|
||||
|
||||
```sh
|
||||
framey_build_dir=$(mktemp -d)
|
||||
git clone https://github.com/MoYoez/LocalSend-Go "$framey_build_dir/source"
|
||||
git -C "$framey_build_dir/source" checkout --detach 66edc509a28b9f877e7374504892dcbc653a4da4
|
||||
cd "$framey_build_dir/source"
|
||||
env GOTOOLCHAIN=go1.25.7 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||
go build -trimpath -o "$framey_build_dir/localsend-core" .
|
||||
sha256sum "$framey_build_dir/localsend-core"
|
||||
```
|
||||
|
||||
Two builds produced the same static aarch64 ELF SHA-256:
|
||||
`e79238f6051ce34f172f765ae918d3d902b819e27377989a55e5b1eba10e46f9`.
|
||||
The official ELF uses Go 1.25.7 too, but embeds no VCS revision, so the exact
|
||||
source correspondence is still unproven. The rebuilt helper recognizes every
|
||||
CLI flag supplied by the official Python backend.
|
||||
|
||||
## Real Frame scope
|
||||
|
||||
Tests ran under the normal atomic `/tmp/frame-test.lock` with a private temporary
|
||||
directory. `-h` exited before configuration or networking. For the health probe,
|
||||
`unshare -Urn` created a private user/network namespace with loopback and a veth
|
||||
pair entirely inside it: no external interface or default route. The veth used
|
||||
the documentation-only `192.0.2.1/24` address. Upstream rejects a loopback-only
|
||||
discovery interface, which is why the second isolated attempt used a veth.
|
||||
|
||||
The helper used a private config/upload path, `-skipNotify`,
|
||||
`-useAutoSave=false`, `-useAutoSaveFromFavorites=false`, `-useHttp=true`,
|
||||
`-useReferNetworkInterface probe0` and `-scanTimeout 1`. The test client sent only
|
||||
`GET /api/localsend/v2/info` over loopback; it returned version `2.0`, device type
|
||||
`headless` and the private test alias. No upload, transfer, pairing or external
|
||||
discovery request ran. The helper exited; its private config/log and executable
|
||||
directory were removed; no service or power setting changed. The lock was
|
||||
released. An independent cleanup reacquisition found another job's lock and
|
||||
left it alone; cleanup success comes from the original probe's process wait and
|
||||
temporary-directory exit, not that deferred audit.
|
||||
|
||||
Before enabling an ARM64 official package, verify the unmodified Python backend
|
||||
and frontend/proxy protocol with this source revision, then HTTPS, discovery and
|
||||
an explicitly safe private transfer. The official store must publish a matching
|
||||
ARM64 archive or Frame Control must gain a separately reviewed, digest-scoped
|
||||
build/package path. Compiling the core does not justify accepting the current
|
||||
x86 archive or marking the full plugin works.
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-25",
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"method": "Downloaded exact current official store versions; local SHA-256 matched each store hash; inspected ZIP plugin.json and dist/index.js without running plugin code.",
|
||||
"limit": "Static inspection on PC. Prior backend load results are documented in docs/decky-frame.md; VR visibility and controller input remain unverified.",
|
||||
"plugins": [
|
||||
{
|
||||
"storeId": 7,
|
||||
"name": "CSS Loader",
|
||||
"storeVersion": "2.1.3",
|
||||
"sha256": "99beaf1374af82408e6d056e0b0147e5fca544e1035fa7ab88419231697bc231",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 14,
|
||||
"name": "ProtonDB Badges",
|
||||
"storeVersion": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"manifestApiVersion": 2,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 16,
|
||||
"name": "Audio Loader",
|
||||
"storeVersion": "1.6.1",
|
||||
"sha256": "fd5a090c2e2cd4da6723d7bd08937ab31b9f8d4663510431713da531343e303f",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"_root"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 18,
|
||||
"name": "Bluetooth",
|
||||
"storeVersion": "3.1.1",
|
||||
"sha256": "f07f7b74f31da15b5e3a8234a3f00faa01365b56b8f496aca7bb79120be7b8a5",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 21,
|
||||
"name": "PowerTools",
|
||||
"storeVersion": "2.0.3",
|
||||
"sha256": "47614f53b8c538c4caa15f89a01e4ab106fa328e89f78545bacb3166d104d964",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [
|
||||
"root",
|
||||
"global-dfl"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 23,
|
||||
"name": "Animation Changer",
|
||||
"storeVersion": "1.3.2",
|
||||
"sha256": "f2c62b90ca60d8a80b6d0f75d8027552b1509c7a05842c6f4a24a9072846d133",
|
||||
"manifestApiVersion": null,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 26,
|
||||
"name": "HLTB for Deck",
|
||||
"storeVersion": "2.0.12",
|
||||
"sha256": "bd54a23b0b8ec0798aa590e5be236522a593529d7680e77fb888e2971bf3d558",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 36,
|
||||
"name": "SteamGridDB",
|
||||
"storeVersion": "1.8.1",
|
||||
"sha256": "e84f5b1f111af8862b06218479491fbf61036e1bfd9f16f93f9901e9b9ebc5e0",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 50,
|
||||
"name": "Storage Cleaner",
|
||||
"storeVersion": "1.4.0",
|
||||
"sha256": "1afa1a6f129c5d419de0e0bf0f6db8d8e50385a5659179346e996031ea372591",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 68,
|
||||
"name": "Wine Cellar",
|
||||
"storeVersion": "0.2.1",
|
||||
"sha256": "7e077b933c234414d172b5c6d1c804b026bf39fa4e18e6663495a282637031e8",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [
|
||||
"debug"
|
||||
],
|
||||
"frontendBundle": true
|
||||
},
|
||||
{
|
||||
"storeId": 82,
|
||||
"name": "MagicPods",
|
||||
"storeVersion": "2.0.21",
|
||||
"sha256": "abe2eef5788cb3ff6e73fcb9783e30a54b127e28c761a9bf99e64ab5fc4be9ab",
|
||||
"manifestApiVersion": 1,
|
||||
"manifestFlags": [],
|
||||
"frontendBundle": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-25T23:35:22.022586+00:00",
|
||||
"method": "Real Frame ARM64 Decky 3.3.0, isolated removable user service; explicit read-only plugin methods only",
|
||||
"plugins": [
|
||||
{
|
||||
"id": 14,
|
||||
"version": "1.3.4",
|
||||
"sha256": "90a3453ac02709cad64096e7539fd8141fa56f99ae3441fc24a4990dc9684358",
|
||||
"method": "get_setting",
|
||||
"result": {
|
||||
"result": "default"
|
||||
},
|
||||
"errors": [
|
||||
"Transient CEF reconnect during Decky frontend injection; succeeded on retry."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 123,
|
||||
"version": "1.2.3",
|
||||
"sha256": "9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e",
|
||||
"method": "get_settings",
|
||||
"result": {
|
||||
"keys": [
|
||||
"enable_notification_antiflood",
|
||||
"enable_notifications",
|
||||
"gateway_api_key",
|
||||
"gateway_url",
|
||||
"refresh_interval_seconds",
|
||||
"show_history",
|
||||
"show_regions",
|
||||
"show_trending_games"
|
||||
],
|
||||
"defaultRefreshInterval": 120
|
||||
},
|
||||
"errors": []
|
||||
}
|
||||
],
|
||||
"before": {
|
||||
"battery": [
|
||||
100
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"service": {
|
||||
"created": true
|
||||
},
|
||||
"decky": {
|
||||
"version": "v3.3.0",
|
||||
"sha256": "8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6"
|
||||
},
|
||||
"passed": true,
|
||||
"cleanup": {
|
||||
"baseAbsent": true,
|
||||
"unitAbsent": true
|
||||
},
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"deckyService": "inactive",
|
||||
"panelService": "inactive"
|
||||
},
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
# Framey session cache browser check, 25 October 2026
|
||||
|
||||
Frame Control v0.4.34's opaque Framey iframe made the official Steam Status
|
||||
1.2.3 frontend log `SecurityError` for its `localStorage` cache. This follow-up
|
||||
used a per-panel memory substitute. It did **not** change the iframe's sandbox
|
||||
flags or give plugin code Frame Control's UI key.
|
||||
|
||||
A local Chromium page loaded the unmodified official Steam Status 1.2.3 archive
|
||||
from `tests/fixtures/framey-official/123.zip` (SHA-256
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`).
|
||||
The page used `sandbox="allow-scripts allow-popups"` and returned `{}` for
|
||||
four `get_settings` calls. That reply was a stub, not the Decky backend.
|
||||
|
||||
The iframe sent `framey:ready`. Its test script wrote and read a cache item.
|
||||
The parent could not read the iframe document (`contentDocument` was null).
|
||||
The plugin displayed Steam Status, Store, Community, WebAPI, Connection
|
||||
Managers and Players Online. Its public status request returned HTTP 200.
|
||||
No cache `SecurityError` appeared in the captured console, while the
|
||||
same archive had logged that error with the v0.4.34 host.
|
||||
|
||||
Node host tests also checked item updates, removal, clear, key order, string
|
||||
conversion, quota rejection, and preservation of an existing item after a
|
||||
rejected write. The substitute keeps at most 256 items and 512 KiB of keys and
|
||||
values **in memory**. Closing or reloading the panel loses this cache. This
|
||||
check did not test offline behavior, persistent plugin settings, a live Decky
|
||||
backend, SteamVR visibility, controller input, or headset performance.
|
||||
@@ -1,99 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:40:00Z",
|
||||
"scope": "Actual SteamClient parameterless reads through Steam CEF on ARM64 Frame. Official frontends ran in local Chromium with plugin backend/network calls denied. Private loopback relay used the production read bridge with fixture installed-plugin status and the real frame_steam module supplied in memory because the optional headset service was not deployed. This does not verify a deployed real-Frame HTTP endpoint.",
|
||||
"allowlist": [
|
||||
"User.GetIPCountry",
|
||||
"InstallFolder.GetInstallFolders",
|
||||
"System.GetSystemInfo"
|
||||
],
|
||||
"probeResults": {
|
||||
"GetIPCountry": {
|
||||
"ok": true,
|
||||
"type": "string",
|
||||
"length": 2,
|
||||
"value": "redacted"
|
||||
},
|
||||
"GetInstallFolders": {
|
||||
"ok": true,
|
||||
"folders": 1,
|
||||
"apps": 29,
|
||||
"pathsAndTitles": "redacted"
|
||||
},
|
||||
"GetSystemInfo": {
|
||||
"ok": true,
|
||||
"fields": 27,
|
||||
"identifiers": "redacted"
|
||||
}
|
||||
},
|
||||
"officialPanels": {
|
||||
"IsThereAnyDeal for Deck": {
|
||||
"observation": {
|
||||
"id": 81,
|
||||
"name": "IsThereAnyDeal for Deck",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"settings_load",
|
||||
"settings_save"
|
||||
]
|
||||
},
|
||||
"countryDropdownMatchesLiveFrameValue": true,
|
||||
"backendSettingsSave": "attempted by official frontend; denied; no setting written",
|
||||
"dealLookups": "not run"
|
||||
},
|
||||
"Deck Settings": {
|
||||
"observation": {
|
||||
"id": 93,
|
||||
"name": "Deck Settings",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:AuthorFooter] Error fetching avatar image: Error: Decky proxy fetch is unavailable in Framey"
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "[decky-game-settings:GameSelectView] Error fetching installed games: TypeError: Cannot read properties of undefined (reading 'allGamesCollection')"
|
||||
}
|
||||
],
|
||||
"methods": []
|
||||
},
|
||||
"remainingBlocker": "collectionStore.allGamesCollection"
|
||||
}
|
||||
},
|
||||
"boundariesTested": [
|
||||
"Only three explicit zero-argument APIs",
|
||||
"Installed store plugin required",
|
||||
"Headset-service gate",
|
||||
"Iframe source and current session required",
|
||||
"Late replies dropped after reopen",
|
||||
"256 KiB UTF-8 response cap and socket cleanup"
|
||||
],
|
||||
"deviceState": {
|
||||
"sharedLock": "owned normally; released after testing",
|
||||
"servicesInstalledOrStarted": false,
|
||||
"powerChanges": false,
|
||||
"deviceFilesForSteamProbe": false
|
||||
},
|
||||
"unverified": [
|
||||
"Deployed headset HTTP endpoint",
|
||||
"Plugin backend settings/deal lookups",
|
||||
"Steam page patches and live library context",
|
||||
"Worn VR/controller/keyboard"
|
||||
],
|
||||
"catalogRefresh": {
|
||||
"source": "https://plugins.deckbrew.xyz/plugins",
|
||||
"checkedAtUtc": "2026-10-26T00:44:00Z",
|
||||
"visiblePlugins": 116,
|
||||
"idVersionDigestChanges": 0
|
||||
},
|
||||
"localRegressionAudit": {
|
||||
"exactArchivedFrontends": 116,
|
||||
"ready": 98,
|
||||
"failed": 18,
|
||||
"initializationRegressions": 0,
|
||||
"pluginBackendAndNetworkActions": "denied, including live Steam read APIs"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
# Steam Status in the local Framey browser, 25 October 2026
|
||||
|
||||
This check used the unmodified official Steam Status 1.2.3 archive already
|
||||
pinned in `tests/fixtures/framey-official/123.zip`. Its SHA-256 is
|
||||
`9053308e04a74f44745b45b9f4f50af3673348d367e7b14bacc5677de707ac4e`.
|
||||
The archive declares Decky API v2. No headset was contacted for this check.
|
||||
|
||||
On a Linux PC, we extracted the archive to a disposable plugin folder with a
|
||||
Frame Control store receipt. A local HTTP server served the page from
|
||||
`framey_panel.panel(123)`, Frame Control's React 18.3.1 runtime and its
|
||||
`framey-host.js`. A parent page put the panel in an iframe with
|
||||
`sandbox="allow-scripts"` and returned `{}` for the plugin's `get_settings`
|
||||
backend calls. This was a **stub**, not the Decky service.
|
||||
|
||||
Chromium reported `framey:ready` and four `get_settings` calls. The page showed
|
||||
Steam Status, Store, Community, WebAPI, Connection Managers, Players Online,
|
||||
and other controls. Its own public `https://steamstatus.schelstraete.org/api/v1/status`
|
||||
request returned HTTP 200 and populated the status view. A click on **Refresh**
|
||||
sent Framey's trusted pointer event to the parent; this test did not prove a
|
||||
fresh status fetch or settings write. The [browser screenshot](framey-steam-status-browser-2026-10-25.png)
|
||||
contains only the local test page and public service data.
|
||||
|
||||
The plugin logged `SecurityError` while reading and writing `localStorage` in
|
||||
the opaque-origin iframe. It caught the errors and rendered anyway. Its cache
|
||||
and offline behavior in Framey are **not verified**. The real Decky backend,
|
||||
live settings, SteamVR visibility, controller input and headset performance
|
||||
were not tested. This result supports only local frontend rendering for this
|
||||
exact archive and Frame Control v0.4.34 host code.
|
||||
|
Before Width: | Height: | Size: 69 KiB |
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"checkedAtUtc": "2026-10-26T00:29:00Z",
|
||||
"scope": "Exact archived frontends only. Production Framey host with bounded SteamClient JSON cache, image fallback and inert Steam subscriptions/context. Opaque iframe; all backend calls and network denied. No headset or plugin backends used in this follow-up.",
|
||||
"baseHostCommit": "52a0974",
|
||||
"before": {
|
||||
"ready": 96,
|
||||
"failed": 20
|
||||
},
|
||||
"after": {
|
||||
"ready": 98,
|
||||
"failed": 18
|
||||
},
|
||||
"regressions": [],
|
||||
"observations": [
|
||||
{
|
||||
"id": 13,
|
||||
"name": "Pause Games",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"load_settings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"name": "Tailscale Control",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_tailscale_state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"name": "Steamback",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_CurrentUser')",
|
||||
"logs": [],
|
||||
"methods": []
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"name": "PlayTime",
|
||||
"ready": false,
|
||||
"error": "Cannot read properties of undefined (reading 'm_mapApps')",
|
||||
"logs": [
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Impossible to get \"RunningApps\" observe. Events will not be attached."
|
||||
},
|
||||
{
|
||||
"kind": "error",
|
||||
"message": "%c PlayTime %c ERROR background: #16a085; color: black; background: #FF000040; Unable to get settings, saving defaults Error: Not found"
|
||||
}
|
||||
],
|
||||
"methods": [
|
||||
"fetch_playtime_information",
|
||||
"statistics_for_last_two_weeks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 120,
|
||||
"name": "Reroll",
|
||||
"ready": true,
|
||||
"error": null,
|
||||
"logs": [],
|
||||
"methods": [
|
||||
"get_setting",
|
||||
"version"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tailscaleFocusedCheck": {
|
||||
"rendered": [
|
||||
"Settings",
|
||||
"Toggle Tailscale",
|
||||
"Exit Node IP",
|
||||
"Toggle LAN Access",
|
||||
"Advanced Settings",
|
||||
"Device Status",
|
||||
"Tailscale Setup"
|
||||
],
|
||||
"assetError": "Hardcoded localhost:1337 QR SVG blocked by image CSP",
|
||||
"actions": "Inspected only; no toggles, exit nodes, installs or connection actions"
|
||||
},
|
||||
"pauseGamesFocusedCheck": {
|
||||
"rendered": [
|
||||
"Pause before Suspend",
|
||||
"Pause on focus loss"
|
||||
],
|
||||
"actions": "Inspected only; no process signaling or suspend actions"
|
||||
},
|
||||
"storageChecks": [
|
||||
"Per-plugin cache survives panel reopen",
|
||||
"Missing key rejects Not found",
|
||||
"JSON serialization and cache quota enforced",
|
||||
"Malformed and stale-session cache updates rejected"
|
||||
],
|
||||
"unverified": [
|
||||
"Real backends for these plugins",
|
||||
"Tailscale network state/actions",
|
||||
"Live games and pause/resume",
|
||||
"Worn VR/controller/keyboard"
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
# Framey unattended VR evidence, 25 October 2026 UTC
|
||||
|
||||
The committed runner exited **0** on the real Frame, using the existing
|
||||
`ssh lxso2 'ssh frame …'` route from lxso1. No person wore the headset.
|
||||
Frame OS **0.5.6**, build **20261022.6153909**, ARM64, SteamVR **2.18.2**,
|
||||
Decky **3.3.0**. The run began at **23:27:34 UTC**; the Frame's own clock/logs
|
||||
use UTC+11 and therefore show 26 October.
|
||||
|
||||
```sh
|
||||
python3 scripts/framey-vr-check.py --via lxso2 --exercise --output /tmp/framey-vr-verified
|
||||
```
|
||||
|
||||
[Full machine result](result.json) records source commit
|
||||
`cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af`, bundle digest, before/after
|
||||
SteamVR listings, power settings, input observations, and private image hashes.
|
||||
The checked app code includes the sample settings-path and iframe-reopen fixes
|
||||
and main through `d77e2bd`. This is source-bundle testing, not a newly published
|
||||
release/AppImage test. The sample has a random test name and store-shaped local
|
||||
receipt; it is not presented as an official catalogue listing.
|
||||
|
||||
| Check | Observed | Scope |
|
||||
| --- | --- | --- |
|
||||
| Overlay registration and visibility | `IVROverlay_027.IsOverlayVisible=true` | Actual Frame Control gamescope overlay, not a browser fixture |
|
||||
| Transform | type **5**, DashboardTab; absolute getter returns **18**, WrongTransformType | Dashboard owns placement; no transform/dock setters used |
|
||||
| Texture queries | main size 0×0; image read **24**, InvalidTexture | Direct texture refresh not established; compositor capture used instead |
|
||||
| Stereo compositor | Before/after SHA-256 differ; final image visibly contains Framey tabs, quick action and saved result | Actual `IVRScreenshots_001` production screenshot path |
|
||||
| OBS mirror | Current run's random name and “Saved setting is on” appear after draining 60 frames | `/dev/video99` pixels; OBS app itself was not launched in this task |
|
||||
| OpenVR click | Move/down/up reaches intended control; iframe pointer event is trusted | `IVROverlayView_003.PostOverlayEvent`, no DOM `.click()` |
|
||||
| Live setting | off → on; real backend file `{"enabled":true}`; fresh iframe reads on | Actual Framey HTTP → Steam CEF → Decky websocket → Python backend; no stubs |
|
||||
| Tabs/quick action | Settings/Panel clicked; “Saved setting is on” returned | Actual shipped host controls and sample action |
|
||||
| Slider | 10 → **50** | Native OpenVR click on actual DFL slider, diagnostic UI-only value |
|
||||
| Text | **framey-ci** | OpenVR pointer focus, then CDP `Input.insertText`; not SteamVR keyboard proof |
|
||||
| Smooth scroll attempt | `scrollBefore=0`, `scrollAfter=0` | No scrolling established by this attempt; do not count it as a pass |
|
||||
| Battery | **99% → 99%** | Probed only under the owned lock |
|
||||
| Power restoration | original pause **false**, timeout **5.000000**, exact values read back | Temporary values false/120; no idle-suspend timer or proximity changes |
|
||||
| Cleanup | `cleanupErrors=[]`, `powerRecoveryRemoved=true`, `lockReleased=true` | Temporary managed services, sample/settings/profile, timer and restore script removed |
|
||||
|
||||
`IsDashboardVisible` and `IsActiveDashboardOverlay` are false while the panel is
|
||||
visible in these captures: this is an existing placed/docked panel. They cannot
|
||||
be used alone as a Framey visibility verdict. Activity changes from standby 3
|
||||
to active 0 without wearing the headset, so activity alone is not worn proof.
|
||||
Native OpenVR events also produce trusted pointers, so `event.isTrusted` alone
|
||||
is not physical-controller proof. The worn script now requires explicit
|
||||
`--worn-confirmed` and reports trusted pointer input with that narrower name.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
These are inset panel crops from the raw captures, with all other pixels
|
||||
replaced by solid black. No UI content was synthesized. [Image provenance](images.json)
|
||||
contains source hashes, mask polygons, crops and published hashes. Raw images
|
||||
remain private under `/tmp/framey-vr-verified` on lxso1. They are not committed.
|
||||
|
||||
## Bugs found and fixed
|
||||
|
||||
1. The author sample used `decky.decky_SETTINGS_DIR`, absent from real Decky
|
||||
3.3.0. The actual iframe displayed its backend `AttributeError`. Decky's
|
||||
[pinned module](https://github.com/SteamDeckHomebrew/decky-loader/blob/v3.3.0/backend/decky_loader/plugin/imports/decky.py)
|
||||
exports `DECKY_PLUGIN_SETTINGS_DIR`. The sample, docs and Python test fixture
|
||||
now use that name. The old fixture invented the same wrong attribute and
|
||||
masked the failure. The live fixed backend save/reopen is in `result.json`.
|
||||
2. Reopening the same plugin changed only `#session`, so Chromium retained the
|
||||
old document/host/cache session. It could retain an error and eventually
|
||||
show a false “No compatible panel loaded” message. A fresh query parameter
|
||||
forces a new document while keeping the fragment session. The cache
|
||||
regression now checks document URLs, rather than manually assuming a new
|
||||
iframe. The real reopen with the saved setting passed.
|
||||
|
||||
## Methods tried and limits
|
||||
|
||||
Read-only `vrcmd` stats/overlay listings, the shipped OpenVR ABI, compositor
|
||||
logs and actual CEF/Chromium targets were inspected first. A simple
|
||||
`--handlewakeup` initially produced the Frame Control status panel in a stereo
|
||||
screenshot. The full test uses a bounded screen timeout so it stays active.
|
||||
Decky's service startup reloads Steam's web helper. During exploratory service
|
||||
recreation, SteamVR logged `UpdateInputFocus: Failed to give focus to panel`
|
||||
and returned a hidden, zero-sized main overlay despite a successful focus
|
||||
request. Retagging only the test browser's `STEAM_GAME` property recovered it;
|
||||
no Steam, SteamVR, gamescope or headset restart was needed. The final run did
|
||||
not need this recovery. The runner records it when used.
|
||||
|
||||
The first mirror image was demonstrably stale: it contained a previous test's
|
||||
random plugin name while the contemporaneous stereo image contained the current
|
||||
one. Reading 60 frames before selecting the mirror image fixed that observation;
|
||||
the final two published images contain the same current run name.
|
||||
|
||||
The diagnostic itself initially failed its cleanup check because an idle
|
||||
systemd watchdog service was already unloaded. It restored power and removed
|
||||
the test services, retained the lock, then the exact owned remainder was cleaned
|
||||
up. Cleanup now checks `LoadState` and tolerates an already-unloaded transient
|
||||
unit. Subsequent full runs, including the committed run here, exited 0 with no
|
||||
cleanup errors. A four-minute power-recovery timer was created and cancelled;
|
||||
forced host death or network loss was not fault injected.
|
||||
|
||||
A null driver or virtual tracked controller was not needed for pointer delivery
|
||||
and was not installed. No root/system files, tracking/proximity configuration,
|
||||
Steam global binaries, or spatial transforms were edited. PR #113 was untouched.
|
||||
This check does not establish every official plugin, sustained frame rate,
|
||||
backend persistence across Steam/Decky reboot, physical controller tracking or
|
||||
haptics, a functioning virtual keyboard, native scrolling, comfort or legibility
|
||||
through the optics. Follow the [three-minute worn checklist](../../framey-vr-check.md#three-minute-worn-checklist).
|
||||
|
||||
## Local checks
|
||||
|
||||
- Full Python suite after integrating current main and worn-confirmation gate:
|
||||
**1,595 tests, 16 skipped, OK**. The final added scroll/mirror helpers also
|
||||
passed the **24 focused Framey tests** and the real-device run above.
|
||||
- App and website test commands passed under **Node 24.22.1**, matching CI's
|
||||
Node major. System Node 22 cancelled unrelated Meta sign-in timer tests;
|
||||
those files were not changed. Node 24 was used for final app verification.
|
||||
- `git diff --check` and Python compilation passed. Hosted CI status belongs to
|
||||
the PR; local checks do not substitute for its macOS/Windows/ARM64 jobs.
|
||||
|
||||
A separate [locked cleanup audit](cleanup-verification.json) confirms both test
|
||||
services inactive, no panel bundle or Decky binary, no test plugins/profiles or
|
||||
remaining timers, power restored to false/5, battery 99→99, and its own lock
|
||||
released. It also removed the stale DevTools discovery file created during the
|
||||
earlier exploratory use of the original browser profile.
|
||||
@@ -1,2 +0,0 @@
|
||||
{"batteryBefore": [99], "headsetService": {"code": 4, "out": "inactive", "err": ""}, "deckyService": {"code": 4, "out": "inactive", "err": ""}, "panelBundleExists": false, "deckyBinaryExists": false, "testPluginFolders": [], "testProfiles": [], "timers": {"code": 0, "out": "", "err": ""}, "pause": {"code": 0, "out": "power.pauseCompositorOnStandby=false", "err": ""}, "timeout": {"code": 0, "out": "power.turnOffScreensTimeout=5.000000", "err": ""}, "removedOwnedStaleDevToolsDiscovery": true, "batteryAfter": [99], "lockReleased": true}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
[
|
||||
{
|
||||
"source": "stereo-private.png",
|
||||
"sourceSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
|
||||
"published": "stereo-left-panel-redacted.png",
|
||||
"publishedSha256": "0de6fbf0f31d0587c8ba91817406acf455b8adb10d343bd56daa605f9a3051c7",
|
||||
"maskPolygon": [
|
||||
[
|
||||
50,
|
||||
435
|
||||
],
|
||||
[
|
||||
910,
|
||||
365
|
||||
],
|
||||
[
|
||||
940,
|
||||
920
|
||||
],
|
||||
[
|
||||
700,
|
||||
910
|
||||
],
|
||||
[
|
||||
350,
|
||||
945
|
||||
],
|
||||
[
|
||||
90,
|
||||
1000
|
||||
]
|
||||
],
|
||||
"crop": [
|
||||
50,
|
||||
365,
|
||||
941,
|
||||
1001
|
||||
],
|
||||
"background": "solid black; only inset panel pixels retained"
|
||||
},
|
||||
{
|
||||
"source": "mirror-private.png",
|
||||
"sourceSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
|
||||
"published": "mirror-panel-redacted.png",
|
||||
"publishedSha256": "86fa296a36171efca6a9aa627ec3589496df73f3c03f8f9428454c411a904a77",
|
||||
"maskPolygon": [
|
||||
[
|
||||
455,
|
||||
445
|
||||
],
|
||||
[
|
||||
1420,
|
||||
390
|
||||
],
|
||||
[
|
||||
1470,
|
||||
940
|
||||
],
|
||||
[
|
||||
1150,
|
||||
910
|
||||
],
|
||||
[
|
||||
850,
|
||||
940
|
||||
],
|
||||
[
|
||||
495,
|
||||
1020
|
||||
]
|
||||
],
|
||||
"crop": [
|
||||
455,
|
||||
390,
|
||||
1471,
|
||||
1021
|
||||
],
|
||||
"background": "solid black; only inset panel pixels retained"
|
||||
}
|
||||
]
|
||||
|
Before Width: | Height: | Size: 150 KiB |
@@ -1,189 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"time": "2026-10-25T23:27:34.130334+00:00",
|
||||
"controllerHardwareVerified": false,
|
||||
"wornVisualComfortVerified": false,
|
||||
"sourceCommit": "cb9ad1b6cb7302008783d5f7fe42fa77a4dd43af",
|
||||
"before": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"dashboardVisible": false,
|
||||
"stats": [
|
||||
{
|
||||
"activity_level": 3,
|
||||
"operation": "status",
|
||||
"version": "2.18.2",
|
||||
"version_timestamp": 1791331621
|
||||
}
|
||||
],
|
||||
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a400000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001a500000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a600000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001a700000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85370000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a800000018\n'valve.steam.gamepadui.barpopup.85370001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85370001 (85370001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001a900000019\n'valve.steam.gamepadui.tooltip.85370002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85370002 (85370002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001aa0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85370003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ab0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"timeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"powerRecovery": {
|
||||
"timer": "framey-vr-5e068da08ef73637-restore.timer",
|
||||
"script": "/tmp/framey-vr-5e068da08ef73637-restore.py",
|
||||
"originalPause": "false",
|
||||
"originalTimeout": "5.000000"
|
||||
},
|
||||
"deckyInstall": {
|
||||
"message": "Decky Loader v3.3.0 is running as a user service. The VR menu is experimental."
|
||||
},
|
||||
"panelInstall": {
|
||||
"message": "Installed Frame Control on the headset. Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.",
|
||||
"version": "4968ad070054d005",
|
||||
"overlay": "valve.steam.desktopgame.2001947813"
|
||||
},
|
||||
"overlay": {
|
||||
"state": {
|
||||
"interface": "IVROverlay_027",
|
||||
"visible": true,
|
||||
"dashboardVisible": false,
|
||||
"activeDashboard": false,
|
||||
"texture": {
|
||||
"error": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"transform": {
|
||||
"error": 18,
|
||||
"typeError": 0,
|
||||
"type": 5,
|
||||
"origin": 0,
|
||||
"matrix": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"error": 0,
|
||||
"method": 1,
|
||||
"scaleError": 0,
|
||||
"scale": [
|
||||
1920.0,
|
||||
1080.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"imageRead": {
|
||||
"error": 24,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
}
|
||||
},
|
||||
"beforeStereoSha256": "5d0635d02e44d7cf2d5c249e72ea01c5cd161996427c1012cfd1c42fa5aa5751",
|
||||
"input": {
|
||||
"clickTransport": "IVROverlayView_003.PostOverlayEvent",
|
||||
"textTransport": "CDP Input.insertText after OpenVR focus",
|
||||
"controllerHardwareVerified": false,
|
||||
"initialText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: off",
|
||||
"savedText": "Framey sample\n\nSetting saved\n\nDismiss\nPanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"pointerEvents": [
|
||||
{
|
||||
"trusted": true,
|
||||
"x": 594.6666870117188,
|
||||
"y": 280.0625
|
||||
}
|
||||
],
|
||||
"trustedOverlayPointer": true,
|
||||
"diskSetting": {
|
||||
"enabled": true
|
||||
},
|
||||
"reopenedText": "PanelSettings\nQuick actions\nRead saved setting\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"reopenedChecked": true,
|
||||
"quickActionText": "PanelSettings\nQuick actions\nRead saved setting\n\nSaved setting is on\n\nFramey settings sample\nExample setting\nStored by the Decky Python backend\nReload saved setting\nAbout this setting\n\nSaved setting: on",
|
||||
"sliderValue": "50",
|
||||
"textValue": "framey-ci",
|
||||
"scrollBefore": 0,
|
||||
"scrollAfter": 0,
|
||||
"nativeScrollMoved": false,
|
||||
"finalOverlay": {
|
||||
"interface": "IVROverlay_027",
|
||||
"visible": true,
|
||||
"dashboardVisible": false,
|
||||
"activeDashboard": false,
|
||||
"texture": {
|
||||
"error": 0,
|
||||
"width": 0,
|
||||
"height": 0
|
||||
},
|
||||
"transform": {
|
||||
"error": 18,
|
||||
"typeError": 0,
|
||||
"type": 5,
|
||||
"origin": 0,
|
||||
"matrix": [
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
[
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"error": 0,
|
||||
"method": 1,
|
||||
"scaleError": 0,
|
||||
"scale": [
|
||||
1920.0,
|
||||
1080.0
|
||||
]
|
||||
}
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"stereoSha256": "25944a3f3ffe0c217ef25cbc3f5e08a104f9d8901075b755c928ec5049a4cb3c",
|
||||
"compositorChanged": true,
|
||||
"mirrorSha256": "44d193943ca3164269feeea38f5cbfb0d58cefb79d6c0e8231eab4a5069985b8",
|
||||
"passed": true,
|
||||
"after": {
|
||||
"battery": [
|
||||
99
|
||||
],
|
||||
"dashboardVisible": false,
|
||||
"stats": [
|
||||
{
|
||||
"activity_level": 0,
|
||||
"operation": "status",
|
||||
"version": "2.18.2",
|
||||
"version_timestamp": 1791331621
|
||||
}
|
||||
],
|
||||
"overlays": "---- OVERLAYS ---- \n'system.HeadsetView' -- 'Headset View', 2048x1280 not_visible VROverlayType_Basic \n'system.toast' -- 'Toast', not_visible VROverlayType_Basic \n'system.cursor.ibeam' -- 'system.cursor.ibeam', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000d00000002\n'system.pointer' -- 'system.pointer', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000e00000003\n'system.pointer.secondary' -- 'system.pointer.secondary', 64x64 not_visible VROverlayType_Basic \n flags=0x00200000 +premult overlayhandle=0x0000000f00000004\n'system.standalone' -- 'Standalone Overlay Manager', not_visible VROverlayType_Basic \n'valve.steam.desktopgame.0' -- 'Gamescope', not_visible VROverlayType_Dashboard_Main \n flags=0x29A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000001100000006\n'valve.steam.desktopgame.0.thumb' -- 'Gamescope (Thumb)', 256x256 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x0000001200000007\n'valve.steam.desktopgame.0.layer1' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001300000008\n'valve.steam.desktopgame.0.layer2' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001400000009\n'valve.steam.desktopgame.0.layer3' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000150000000a\n'valve.steam.desktopgame.0.layer4' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000160000000b\n'valve.steam.desktopgame.0.layer5' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000170000000c\n'valve.steam.desktopgame.0.layer6' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000180000000d\n'valve.steam.desktopgame.0.layer7' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x000000190000000e\n'valve.steam.desktopgame.0.layer8' -- 'Gamescope Layer', not_visible VROverlayType_Subview \n flags=0x28200000 +premult, +clickstab overlayhandle=0x0000001a0000000f\n'gamescope.gamescope-0.cursor.0' -- 'Mouse Cursor', not_visible VROverlayType_Basic \n flags=0x60000000 overlayhandle=0x0000001b00000010\n'system.systemui' -- 'systemui', 1860x2048 not_visible VROverlayType_Basic \n flags=0x08230008 -dash, +interactive, +smoothscroll, +premult, +clickstab overlayhandle=0x0000001c00000011\n'system.vrwebhelper.controllerbinding' -- 'controllerbinding', 2400x1350 not_visible VROverlayType_Dashboard_Main \n flags=0x09228108 -dash, +scrollwheel, +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x0000003800000012\n'system.vrwebhelper.controllerbinding.thumb' -- 'controllerbinding (Thumb)', 16x16 not_visible VROverlayType_Dashboard_Thumbnail \n'valve.steam.gamepadui.main' -- 'SteamVR', 0x0 not_visible VROverlayType_Dashboard_Main \n flags=0x69A28000 +dash, +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c600000014\n'valve.steam.gamepadui.main.thumb' -- 'Webhelper (Thumb)', 300x300 not_visible VROverlayType_Dashboard_Thumbnail \n flags=0x20200000 +premult overlayhandle=0x000001c700000015\n'valve.steam.browserview_MainBrowser_2' -- 'data:text/html,<body><%2Fbody>', 0x0 not_visible VROverlayType_Subview \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001c800000016\n'valve.steam.gamepadui.keyboard' -- 'SteamVR - Keyboard', 0x0 not_visible VROverlayType_Basic \n flags=0x31A30000 +interactive, +smoothscroll, +premult, +keyboard, +2cursor overlayhandle=0x000001c900000017\n'valve.steam.gamepadui.bar' -- 'SteamVR - valve.steam.gamepadui.bar (85430000)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001ca00000018\n'valve.steam.gamepadui.barpopup.85430001' -- 'SteamVR - valve.steam.gamepadui.barpopup.85430001 (85430001)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cb00000019\n'valve.steam.gamepadui.tooltip.85430002' -- 'SteamVR - valve.steam.gamepadui.tooltip.85430002 (85430002)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cc0000001a\n'valve.steam.gamepadui.volumelevel' -- 'SteamVR - valve.steam.gamepadui.volumelevel (85430003)', 0x0 not_visible VROverlayType_Basic \n flags=0x69A20000 +smoothscroll, +premult, +keyboard, +clickstab overlayhandle=0x000001cd0000001b\n'valve.steam.gamepadui.floatingfooterLine truncated
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"timeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"powerRecoveryRemoved": true,
|
||||
"lockReleased": true,
|
||||
"cleanupErrors": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 107 KiB |
@@ -1,120 +0,0 @@
|
||||
{
|
||||
"timeUtc": "2026-10-26T01:52:44.513151+00:00",
|
||||
"testedMainCommit": "c62bffc8efe88e107a82e2f2ef481228021acc88",
|
||||
"change": "uncommitted standby/proximity preflight in this PR",
|
||||
"preflight": {
|
||||
"activity": 3,
|
||||
"proximityRaw": [
|
||||
6.3125
|
||||
],
|
||||
"batteryPercent": [
|
||||
99
|
||||
],
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"screenTimeout": "power.turnOffScreensTimeout=5.000000"
|
||||
},
|
||||
"result": {
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"stereoChanged": true,
|
||||
"mirrorCaptured": true,
|
||||
"trustedSyntheticOpenVRPointer": true,
|
||||
"liveDeckySavedSetting": {
|
||||
"enabled": true
|
||||
},
|
||||
"settingPersistedAfterReopen": true,
|
||||
"quickActionObserved": true,
|
||||
"sliderValue": "50",
|
||||
"textValue": "framey-ci",
|
||||
"nativeScrollMoved": false
|
||||
},
|
||||
"cleanup": {
|
||||
"batteryPercent": [
|
||||
99
|
||||
],
|
||||
"proximityRaw": [
|
||||
6.1875
|
||||
],
|
||||
"pause": "power.pauseCompositorOnStandby=false",
|
||||
"screenTimeout": "power.turnOffScreensTimeout=5.000000",
|
||||
"powerRecoveryRemoved": true,
|
||||
"lockReleased": true,
|
||||
"errors": []
|
||||
},
|
||||
"limits": {
|
||||
"controllerHardwareVerified": false,
|
||||
"wornVisualComfortVerified": false,
|
||||
"vrKeyboardVerified": false
|
||||
},
|
||||
"combinedHeadChecks": [
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:01:05.938830+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": false,
|
||||
"overlayVisible": false,
|
||||
"inputPassed": false,
|
||||
"reason": "Timed out waiting for live UI: 0",
|
||||
"retaggedTestWindow": true,
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:02:44.600755+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": false,
|
||||
"overlayVisible": false,
|
||||
"inputPassed": false,
|
||||
"reason": "Timed out waiting for live UI: 0",
|
||||
"retaggedTestWindow": true,
|
||||
"batteryBefore": [
|
||||
99
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:05:00.560460+00:00",
|
||||
"head": "bc72c41be5aa4bc80192145afefb1c6fe31a47fc",
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"inputPassed": true,
|
||||
"reason": null,
|
||||
"retaggedTestWindow": false,
|
||||
"batteryBefore": [
|
||||
99
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
},
|
||||
{
|
||||
"timeUtc": "2026-10-26T02:07:28.654641+00:00",
|
||||
"head": "db079eec9f64a865fca8a1a51b5304e071596383",
|
||||
"localChange": "bounded overlay wait on this PR branch",
|
||||
"passed": true,
|
||||
"overlayVisible": true,
|
||||
"inputPassed": true,
|
||||
"reason": null,
|
||||
"retaggedTestWindow": false,
|
||||
"batteryBefore": [
|
||||
100
|
||||
],
|
||||
"batteryAfter": [
|
||||
99
|
||||
],
|
||||
"powerRestored": true,
|
||||
"lockReleased": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
# Framey unworn exercise with standby gate
|
||||
|
||||
At 2026-10-26 01:52 UTC, this PR's `scripts/framey-vr-check.py --exercise`
|
||||
acquired the shared lock on a real Frame running the `c62bffc8` main bundle.
|
||||
The new read-only preflight found SteamVR activity 3 (standby), proximity raw
|
||||
6.3125 and battery 99% before allowing temporary installation or power changes.
|
||||
The lock owner was unique to this run.
|
||||
|
||||
The documented unworn exercise passed: the Frame Control overlay was visible,
|
||||
stereo compositor pixels changed, `/dev/video99` was captured, a synthetic
|
||||
trusted OpenVR pointer reached the sample, a live Decky setting was saved and
|
||||
persisted after a fresh panel reopen, and the quick action, slider and text
|
||||
field produced their expected values. Native scroll did not move. The test
|
||||
retagged only its temporary panel window once to recover focus. Public numeric
|
||||
results are in [framey-vr-standby-gate-2026-10-26.json](framey-vr-standby-gate-2026-10-26.json).
|
||||
Room-containing stereo and mirror captures remain private in `/tmp`.
|
||||
|
||||
Cleanup returned battery 99%, `power.pauseCompositorOnStandby=false` and
|
||||
`power.turnOffScreensTimeout=5.000000`, matching the preflight. The temporary
|
||||
power recovery unit was removed, no cleanup errors were reported, and only
|
||||
this run's lock was released. Local tests also checked that activity 1, 2 or
|
||||
unknown refuses both `--wake` and `--exercise` before any installation or
|
||||
power change.
|
||||
|
||||
Synthetic pointer events and captures do not prove physical controller use,
|
||||
wearer-visible readability, optical comfort or VR keyboard behavior. Those
|
||||
remain unverified.
|
||||
|
||||
## Combined v0.4.39 candidate reruns
|
||||
|
||||
After #278 and #279 joined main, the combined `bc72c41` branch ran the same
|
||||
locked exercise three times. The first two runs (02:01 and 02:02 UTC) loaded
|
||||
the sample UI in Chromium, but SteamVR kept its overlay hidden even after the
|
||||
existing test-window focus retry. Synthetic input reached no panel control;
|
||||
both runs failed. The third run (02:05 UTC) showed the overlay without that
|
||||
retry and passed the sample setting, input, stereo and mirror checks. Every run
|
||||
restored the original power settings, removed its recovery timer and released
|
||||
its own lock with no cleanup errors. The [numeric summary](framey-vr-standby-gate-2026-10-26.json)
|
||||
records all three outcomes.
|
||||
|
||||
The intermittent hidden-overlay condition remains a reliability limit. The
|
||||
runner now records a small read-only diagnostic and refuses to send synthetic
|
||||
input if the overlay stays hidden after its focus retry. A successful Chromium
|
||||
panel alone is not treated as VR visibility.
|
||||
|
||||
At 02:07 UTC, a fourth combined-head run with a bounded overlay wait passed
|
||||
without a focus retry. The sample setting/input and stereo checks passed; power
|
||||
settings were restored, the recovery timer was removed, and the owned lock was
|
||||
released. This single pass does not prove that the wait eliminates intermittent
|
||||
hidden overlays. Battery changed from 100% to 99% during the run.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Linux PC host feasibility on lxso2, 2026-10-24
|
||||
|
||||
**Blocked on this host's current desktop environment. No feature enabled and
|
||||
no feature PR opened.** Investigation for [issue #23](https://github.com/saphid/frame-control/issues/23),
|
||||
starting at `4e45648` (main after #154). This concerns flat desktop panels,
|
||||
not Linux PC VR streaming.
|
||||
|
||||
## Decision
|
||||
|
||||
The smallest useful candidate is one explicitly selected **whole screen**,
|
||||
captured through the ScreenCast portal's restricted PipeWire connection,
|
||||
encoded locally with GStreamer/x264, and sent to the existing viewer through
|
||||
the shared SSH controller. Input, individual windows and virtual displays
|
||||
would remain unsupported until separately verified. This candidate was **not
|
||||
implemented**: lxso2 currently cannot exercise the real capture permission path.
|
||||
|
||||
Development packages could unblock compilation, but would not create a desktop,
|
||||
portal backend or consented screen. Synthetic encoding, an Xvfb screen grab or
|
||||
using the Frame as its own host would not verify Linux PC desktop capture.
|
||||
No host packages or services were changed.
|
||||
|
||||
## Evidence
|
||||
|
||||
Host: `lxso2`, Linux x86-64, kernel `6.8.0-139-generic`.
|
||||
|
||||
- `DISPLAY`, `WAYLAND_DISPLAY` and `XDG_SESSION_TYPE` were unset. The user bus
|
||||
was reachable at `/run/user/1000/bus`.
|
||||
- `/run/user/1000/pipewire-0` and `/run/user/1000/wayland-0` did not exist.
|
||||
No processes matched Xorg, Xwayland, Xvfb, GNOME Shell, KWin Wayland, Weston,
|
||||
PipeWire, WirePlumber or xdg-desktop-portal at the process check.
|
||||
- D-Bus `ListNames` returned D-Bus, systemd and anonymous connections, with no
|
||||
`org.freedesktop.portal.Desktop`. `ListActivatableNames` also had no desktop
|
||||
portal. These calls queried D-Bus itself without activating capture services.
|
||||
- `dpkg-query` found no `xdg-desktop-portal*` or `pipewire*` packages.
|
||||
`libgstreamer1.0-0:amd64` was installed at `1.24.2-1ubuntu0.1`, but
|
||||
`gstreamer1.0-tools` was uninstalled. `gst-launch-1.0` and `gst-inspect-1.0`
|
||||
were absent from PATH. Xvfb was installed, but supplies no portal by itself.
|
||||
- `pkg-config --modversion` failed for `gstreamer-1.0`,
|
||||
`gstreamer-app-1.0`, `gstreamer-video-1.0` and `libpipewire-0.3`.
|
||||
`gio-unix-2.0` reported `2.80.0`.
|
||||
- `desktop/bundle/pc-host.so` did not exist.
|
||||
`python3 desktop/build.py --with-x264 --out /tmp/frame-linux-pc-feasibility`
|
||||
exited **1** before compilation: required GStreamer pkg-config modules were
|
||||
missing. No native build or encoder runtime success is claimed.
|
||||
|
||||
Reproduce the non-activating portal checks in the intended desktop user's
|
||||
session, using that session's actual runtime directory and bus:
|
||||
|
||||
```sh
|
||||
gdbus call --session --dest org.freedesktop.DBus \
|
||||
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListNames
|
||||
gdbus call --session --dest org.freedesktop.DBus \
|
||||
--object-path /org/freedesktop/DBus --method org.freedesktop.DBus.ListActivatableNames
|
||||
pkg-config --modversion gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0 gio-unix-2.0
|
||||
```
|
||||
|
||||
## Existing implementation reviewed
|
||||
|
||||
- `ui/frame_pcview.py` selects PCView only on Windows and rejects Linux even
|
||||
when enabled. Linux installers do not carry its native bundle.
|
||||
- `desktop/portal.c` creates a **RemoteDesktop** session, requests pointer and
|
||||
keyboard devices, and selects monitors and windows (`types=3`). It is not
|
||||
ScreenCast-only or screen-only. Its restricted fd/node and fresh-connection
|
||||
handling are reusable.
|
||||
- `ui/frame_pc_agent.py` calls the selection a shared window or screen and
|
||||
picks VA-API when the element is present, falling back to x264. A bounded
|
||||
x264 slice needs explicit encoder policy and unsupported-input behavior;
|
||||
exposing the existing Linux path alone would not meet that scope.
|
||||
- `app/main.js` starts Python with the inherited environment; it does not
|
||||
supply a compositor, portal backend or capture permission.
|
||||
- `.github/workflows/pc-host.yml` installs native dependencies and tests
|
||||
synthetic x264/protocol behavior with fake capture consent/input. This does
|
||||
not establish interactive Linux desktop capture.
|
||||
- Issue #23 and its six existing comments distinguish prior synthetic
|
||||
helper/viewer results from untested Linux desktop capture and consent.
|
||||
|
||||
## Verification and limits
|
||||
|
||||
`python3 -m unittest discover -s tests -p 'test_pc*.py' -v` exited **0**:
|
||||
27 tests ran successfully, with one additional native-class setup skip
|
||||
(`native PC libraries not built on this host`). Coverage includes the compiled
|
||||
C controller trace, Python adapters, grants and default-off/Windows-only gates.
|
||||
It does not prove native GStreamer capture or local-client streaming.
|
||||
|
||||
No fake/local native stream, consent prompt, real desktop capture, Frame viewer
|
||||
run, benchmark, input or window mode was tested in this investigation. No SSH
|
||||
connection to the Frame was needed; no lock was acquired and no headset install,
|
||||
launch, stop or settings change was attempted. Issue #113 was left alone.
|
||||
No issue comments or other public messages were sent.
|
||||
|
||||
## Concrete unblock and next slice
|
||||
|
||||
1. Supply an interactive Linux desktop, physical or VM, with a working
|
||||
ScreenCast portal/backend, PipeWire and accessible consent dialog. Installing
|
||||
the portal frontend alone is insufficient. A VM can establish software
|
||||
capture/x264 behavior while physical-host behavior remains unverified.
|
||||
2. Install the native dependencies listed in `pc-host.yml` in that environment
|
||||
and build a local `--with-x264` bundle. Resolve x264 distribution
|
||||
licensing/source obligations before shipping it: the current build labels
|
||||
that option test-only and the Windows installer omits it.
|
||||
3. Implement a ScreenCast-only session with monitor-only selection, no
|
||||
RemoteDesktop/device request, disabled input in protocol and viewer, hidden
|
||||
window/virtual-display modes, and explicit x264 selection. Preserve the
|
||||
default-off gate and existing Mac/Windows paths.
|
||||
4. Verify consent acceptance/refusal/cancellation, Stop/reopen, connection loss
|
||||
and portal closure with a local client decoding actual captured content.
|
||||
Run the shared benchmark and record results without inferring hardware or
|
||||
physical laser behavior.
|
||||
5. Only after local capture works, acquire the shared Frame lock once and
|
||||
follow [the headset procedure](../testing.md#headset-smoke-test) for a brief
|
||||
viewer test. If occupied, leave the lock alone and defer device testing.
|
||||
|
||||
These are prerequisites and proposed scope, not a ready feature. The Linux
|
||||
host request in #23 remains open.
|
||||
|
Before Width: | Height: | Size: 204 KiB |
@@ -1,112 +0,0 @@
|
||||
# Physical Mac desktop check, 26 October 2026
|
||||
|
||||
Host: **MacBook Air (MacBookAir9,1), Intel Core i3 1.1 GHz, macOS 15.7.7
|
||||
(24G720), x86_64**. This is physical hardware. The console is an unattended
|
||||
server with display/automation restrictions. No Apple Silicon runtime result
|
||||
is inferred from this test.
|
||||
|
||||
## Released installers: verified, installed, cannot run on Intel
|
||||
|
||||
GitHub's latest release was v0.4.35 at the start; v0.4.36 became latest during
|
||||
the check. Each DMG and its SHA256SUMS were downloaded from that exact release
|
||||
with `gh release download`. Both passed `shasum -a 256 -c SHA256SUMS
|
||||
--ignore-missing` (only the downloaded asset was checked):
|
||||
|
||||
| Release | Asset | SHA-256 |
|
||||
|---|---|---|
|
||||
| v0.4.35 | Frame-Control-mac-arm64.dmg | `eda670748679ca02fcf242870cd53954b22cf8b93af47a193c9903bd08a8334f` |
|
||||
| v0.4.36 | Frame-Control-mac-arm64.dmg | `4d13068f0cfd3616823d63c82333afa7e368c1d57d375e9564f5f7ed4f0ebbd7` |
|
||||
|
||||
Each read-only DMG was mounted with `hdiutil attach -readonly -nobrowse`.
|
||||
There was no existing `/Applications/Frame Control.app`; the app was copied
|
||||
there with `ditto`, the command-line equivalent of the DMG's drag to
|
||||
Applications. `open "/Applications/Frame Control.app"` returned exactly:
|
||||
|
||||
> The application cannot be opened because it has an incorrect executable format.
|
||||
|
||||
`file` identified the executable as `Mach-O 64-bit executable arm64`.
|
||||
`codesign -dv` identified an ad hoc signature with no TeamIdentifier. The
|
||||
v0.4.35 `spctl -a -vv` assessment returned `rejected`. The v0.4.36 assessment
|
||||
stalled and is not counted as a completed assessment.
|
||||
|
||||
**No Gatekeeper warning dialog was observed.** LaunchServices rejected the
|
||||
architecture first. The CLI download had no `com.apple.quarantine` attribute,
|
||||
so this was not a browser-quarantine test. Neither right-click Open, Open
|
||||
Anyway nor removing quarantine was tested. On this Intel Mac there is no
|
||||
honest opening workaround for either released ARM64 app. Rosetta does not
|
||||
run ARM64 apps on Intel. The README now separates that fact from Apple's
|
||||
recommended Open Anyway flow for compatible Macs and labels the latter
|
||||
unverified here. No global Gatekeeper setting was changed.
|
||||
|
||||
## Separate Intel candidate: partial native runtime pass
|
||||
|
||||
[PR #262](https://github.com/saphid/frame-control/pull/262), commit `6ea9eb2`,
|
||||
adds native x64 DMG/ZIP builds, pinned x64 Python, an x86_64 Swift helper, and
|
||||
CI architecture checks. Swift 6.2.4 rejected an ambiguous
|
||||
`Data.withUnsafeMutableBytes` overload in the helper; an explicit
|
||||
`UnsafeMutableRawBufferPointer` annotation compiled successfully on this Air.
|
||||
|
||||
`npm run dist:mac:x64` completed locally, producing an ad hoc signed app,
|
||||
ZIP and DMG. The unpacked candidate was copied to
|
||||
`/Applications/Frame Control Intel Test.app` and launched with telemetry off,
|
||||
a task-local server data directory and a temporary loopback debug port.
|
||||
This is **not** the published v0.4.36 binary.
|
||||
|
||||
The first launch stalled for several minutes before app code ran. A process
|
||||
sample showed only `_dyld_start`, a 12 KiB footprint and no loaded image
|
||||
list, while `syspolicyd` used about 89% CPU. It later proceeded without any
|
||||
system-service restart; the app window and server loaded. The initial stall
|
||||
is a host observation, not proof of a Frame Control bug. Restarting that
|
||||
security daemon was rejected by automatic approval review, and was not done.
|
||||
|
||||
| Check | Observed result and limit |
|
||||
|---|---|
|
||||
| Native window and IPC bridge | The Electron renderer displayed Apps and the offline identity warning; `window.frameApp` was present. Native renderer inspected through its temporary loopback CDP port; this does not prove every macOS permission dialog or physical click |
|
||||
| Settings | Native preference read returned `{}`. Writing `mvSeparate="0"` read back correctly. The original preference file was restored byte-for-byte (or removed when originally absent) in a finally block. Persistence across an app restart is not claimed |
|
||||
| Open at Login | Source is opt-in and focused login-item tests passed. Native menu read via System Events timed out; an OS login session and actual checkbox state remain unverified. No login item was enabled |
|
||||
| Logs | `~/Library/Logs/Frame Control/server.log` exists. Its startup line proves the candidate used `Contents/Resources/python/bin/python3` and its bundled `ui/server.py`; HTTP reads and offline failures were logged. Show Server Log menu interaction was not verified |
|
||||
| Update check | Actual `frameApp.update.check()` completed with `status="none"`, `current="0.4.36"`, latest `0.4.36`, and `error=null`. No update was installed; native dialog dismissal was not verified |
|
||||
| OBS setup and lifecycle | Native status reported supported/idle, Start on port 15123 reported starting, Stop reported stopping, then idle. The Frame's SSH identity rejection prevented a headset stream. This checks local lifecycle only: no OBS app, audio/video delivery or receiver tested |
|
||||
| Decky store | The exact released UI/server resources, separately served by this Mac's existing Python 3.9.6, loaded 116 official entries in local Chromium. Forty rows rendered; searching ProtonDB found ProtonDB Badges 1.3.4; all Install buttons stayed disabled while offline. No loader or plugin was installed. [Browser screenshot](mac-decky-browser-2026-10-26.png) |
|
||||
| Other UI | Released Apps, Settings, Privacy & updates, Tools, Capture & Live and Decky views rendered. The privacy page correctly reported telemetry blocked by `FRAME_CONTROL_TELEMETRY=0`; sharing/contact choices were left alone. The activity log displayed the offline failure. The browser path hides native OBS buttons as designed; it is not a substitute for the native lifecycle check above |
|
||||
| Framey preview | No standalone local preview control was found in the desktop UI. The Framey panel belongs to the headset runtime; no VR or plugin-backend result is claimed |
|
||||
|
||||
The released server refused a stale SSH host identity rather than silently
|
||||
trusting it. That safety check was preserved; no host pin was replaced just
|
||||
to make the test pass. Capture, media, game launches, power controls, installs,
|
||||
sign-ins and notifications were not triggered without a working connection.
|
||||
|
||||
## Headset: read-only relay checks, no changes
|
||||
|
||||
`frame.local` resolved on the Air, but direct TCP/SSH did not connect. The
|
||||
coordinator host lxso2 could reach it. The app's read-only `frame_status.py`
|
||||
was piped through lxso2 to the Frame and returned:
|
||||
|
||||
- SteamOS **0.5.6**, build **20261022.6153909**, variant `vr`.
|
||||
- Battery **99%, Charging**.
|
||||
- Home storage: 996,984,774,656 bytes total, 769,023,275,008 bytes free.
|
||||
- A read-only search counted **14** Steam screenshot JPEGs; none was copied.
|
||||
|
||||
The shared `/tmp/frame-test.lock` was owned by another Framey test on each
|
||||
check. This job did not acquire, remove or replace it. File upload/delete,
|
||||
OBS on the device and every other headset mutation were skipped. These relay
|
||||
reads do not prove the native Mac app's direct SSH or file-transfer path.
|
||||
The headset was never wiped or rebooted.
|
||||
|
||||
## Validation and follow-up
|
||||
|
||||
A disposable Linux checkout of exact commit `6ea9eb2` on lxso2 passed:
|
||||
**1,590 server tests (16 skipped)** and **46 app tests**. On the physical Air,
|
||||
**14 focused login-item/preferences/OBS tests** and **18 website tests** passed.
|
||||
Full Mac server/release suites were attempted but spawned helper programs
|
||||
stalled and timed out during the policy-service problem; they are not a pass.
|
||||
Node 22's complete app run cancelled sign-in tests around an unreferenced
|
||||
timeout; the exact same app suite passed on lxso2. CI uses Node 24.
|
||||
|
||||
Required next steps: pass exact-head Mac packaging/architecture CI, publish
|
||||
an Intel asset through coordinator job mppkd9qczt, then repeat a normal
|
||||
browser-download/quarantine installation of that release on this Air. Verify
|
||||
Open Anyway and Open at Login through an authorized interactive console.
|
||||
Acquire the shared headset lock before a temporary transfer or real OBS test.
|
||||
Preserve all unverified labels until those checks actually pass. PR #113 was
|
||||
not changed. No social post was made.
|
||||
@@ -1,142 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,32 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,125 +0,0 @@
|
||||
# OBS end-to-end validation — 25 October 2026
|
||||
|
||||
**Passthrough: yes.** Real room pixels behind SteamVR panels appeared in the
|
||||
live `/dev/video99` mirror, the saved OBS recording, and a decoded frame at
|
||||
four seconds in a production MP4. The Frame was safely woken with
|
||||
`vrcmd --handlewakeup`; no camera, power or compositor settings were changed.
|
||||
This establishes the checked SteamVR mirror, not every game/mode or Steam's
|
||||
own clips. Treat recordings as potentially showing the room.
|
||||
|
||||
## Environment and provenance
|
||||
|
||||
- Host: lxso2, Linux x86_64. Real OBS 30.0.2 in disposable Ubuntu 24.04/Xvfb,
|
||||
using software llvmpipe rendering and the Media Source `ffmpeg_source`.
|
||||
- Frame: SteamOS 0.5.6, build `20261022.6153909`, SteamVR 2.18.2, reachable
|
||||
with the existing `ssh frame` connection. Hardware use was serialized with
|
||||
coordinator mppkd9qczt through `/tmp/frame-test.lock` and a unique owner.
|
||||
- Released v0.4.30 AppImage SHA-256:
|
||||
`b1335bb06130f2cce64a0dd40a8cb60a52c68effc258dc4bc35f8c40c822d5bb`.
|
||||
The long recording started through this app's actual Electron IPC, Python
|
||||
helper and SSH stream. Robustness checks then used that released runtime
|
||||
with the final changed resources and repacked `app.asar`; this was not a
|
||||
newly built AppImage. No OBS plugin or host FFmpeg is required by the app.
|
||||
- Windows release ZIP SHA-256:
|
||||
`a393cc1653f14f162b4a3e56803f7fe9bea7b04904d18577b0b2d7d7c2d95b03`.
|
||||
Checked against release SHA256SUMS; bundled Python 3.12.14.
|
||||
|
||||
Machine-readable measurements and the manual stimulus/analysis programs are
|
||||
in [obs-validation-2026-10-25/](obs-validation-2026-10-25/).
|
||||
Room images and videos remain private under `/tmp/obs-aacapv3evh` on lxso2;
|
||||
only their hashes are committed in `private-artifacts.json`.
|
||||
|
||||
## Recording, sound, sync and latency
|
||||
|
||||
| Check | Measured result | What it establishes |
|
||||
| --- | --- | --- |
|
||||
| Real OBS recording | 187.367 s; 15,784,412 bytes; H.264 1280×720 at 30 fps; 5,621 decoded video frames; AAC 48 kHz stereo | More than three minutes through the packaged Linux path |
|
||||
| OBS dropped frames | 0 encoder skips; 90 render lag/stall frames, approximately 1.6% of recording frames | The software-rendered host was not lossless; this is not a hardware-rendered performance benchmark |
|
||||
| Audio | 1 kHz stimulus; FFT peak 996.094 Hz at 11.719 Hz bin resolution; peak −13.207 dBFS; 0 clipped samples; measured harmonic energy −81.32 dB relative to fundamental | Non-silent, no numerical clipping or large harmonic distortion in the checked tone; no listening or real-game quality verdict |
|
||||
| Baseline A/V | Audio leads video by median 426.67 ms; range 360–426.67 ms across 36 pulses | Default OBS setup needs sync checking; not suitable for a claim of automatic accurate sync |
|
||||
| Live image latency | 1,431.4 ms, n=1; clock exchange RTT 3.409 ms; screenshot polling about 20–35 ms | Overlay submission to OBS source screenshot, not physical headset motion-to-photon latency |
|
||||
| Production MP4 | 5.121 s; 1920×1080, 30 fps, 153 video frames; AAC stereo; request to saved result 7.435 s | A separate production recorder saved room passthrough pixels too |
|
||||
|
||||
The manual stimulus places an owned black/white OpenVR marker over the mirror
|
||||
and plays a 200 ms, 1 kHz tone every five seconds through the Frame's default
|
||||
output. `pa_simple_get_latency` estimates when the submitted samples play;
|
||||
marker submission is aligned to that estimate. This measures software output,
|
||||
not an externally measured loudspeaker/physical-display event. Most submission
|
||||
jitter was below 0.2 ms; compositor timing and the 30 fps/10 ms analysis grids
|
||||
still limit precision. FFmpeg decodes both tracks; the analysis detects marker
|
||||
edges and groups AAC pre-echo onsets within 100 ms.
|
||||
|
||||
A separate initial +400 ms OBS Sync Offset trial left 83.33 ms of audio lead.
|
||||
Changing the offset during an already active source from 0 to +367 ms did not
|
||||
improve that run (366.67 ms before, 370 ms after). Do not assume a universal
|
||||
offset or that a live setting change has taken effect: reopen the source and
|
||||
verify another recording. Three subsequent fresh-source trials, setting the offset **before activating**
|
||||
the Media Source, measured 480 ms audio lead at 0 ms, 70 ms lead at +400 ms,
|
||||
and **0 ms at +480 ms** across three pulses (30 fps video and 10 ms audio
|
||||
analysis resolution). This proves a setup-specific workaround, not a universal
|
||||
default. See `calibration-initial.json`. Full trial measurements are retained. Zero OBS
|
||||
buffering caused missing/distorted sound and was rejected. Zero mux delay
|
||||
and alternate timestamp experiments did not improve latency and were not
|
||||
merged. The default capture/encoding settings remain unchanged.
|
||||
|
||||
## Robustness and fixes
|
||||
|
||||
| Failure or scenario | Fix / result | Proof |
|
||||
| --- | --- | --- |
|
||||
| Stop during asynchronous Python discovery could still launch later | Cancel the pending generation; expose Stop while starting | Controller regression test |
|
||||
| Late stderr containing an earlier ready message changed stopping back to running | Only accept ready while starting; ignore stale child events | Original released block reproduced in `race-before.json`; controller regression |
|
||||
| A non-responsive SSH process defeated timeout termination | Bounded terminate/wait then kill; SSH connection/keepalive bounds | Real SSH child deliberately stopped with SIGSTOP; clear UI failure in 15.009 s including 5 s forced cleanup; both processes gone |
|
||||
| Timeout with the app's stdin still open aborted Python at shutdown | Read the control descriptor directly, avoiding a blocked buffered-stdin daemon lock | Previously reproduced fatal shutdown; final real stall shows `The Frame OBS video stopped for 10 seconds`; subprocess regression holds stdin open |
|
||||
| Three Start/Stop cycles | First packets 0.890 / 0.701 / 0.709 s; Stop to idle 61.7 / 52.7 / 53.0 ms | Final patched packaged runtime, ports 15101–15103, `robustness.json` |
|
||||
| Restart after stall | First packet 0.691 s | Same runtime and real Frame |
|
||||
| Quit while streaming | No surviving helper at 2.021 s | Same runtime; final audit found no test FFmpeg/stimulus process |
|
||||
| Port 15099 occupied | App now offers a validated alternate port and matching copyable URL | OBS occupied 15099 while real packets arrived on alternate ports; UDP cannot identify or guarantee the receiver |
|
||||
| OBS before/after Start; receiver restarts | Live decoding verified, including recreated sources while relay stays active; an already-open stalled receiver may need hide/show or properties OK | Documented recovery, not automatic OBS control |
|
||||
| Standby/wake | Safe wake changed a blank mirror to visible room/panels | Private mirror and saved recordings |
|
||||
|
||||
A forced SSH stall tests the transport failure and error/cleanup behavior.
|
||||
It does **not** prove real Wi-Fi loss or full headset suspend/resume. No physical
|
||||
network interface was disabled and no headset reset was performed. There is
|
||||
no automatic reconnect: restore connectivity and press Start. Valid blank
|
||||
standby frames do not trigger a packet timeout.
|
||||
|
||||
## Windows and macOS
|
||||
|
||||
**Windows controls remain hidden.** A disposable Windows 11 VM ran the final
|
||||
helper using the released app's bundled Python and a native executable SSH
|
||||
fixture. It stayed alive for three seconds without a UDP receiver, then sent
|
||||
1,316-byte packets when a receiver opened. Closing its control pipe returned
|
||||
exit 0 with no surviving SSH child (`windows-pipe.json`; stop measured below
|
||||
the VM timer's resolution in the final run). The helper hides the SSH console
|
||||
and tolerates Winsock's delayed UDP receiver-unreachable error.
|
||||
|
||||
The real-headset Windows attempt delivered zero packets through a temporary
|
||||
host SSH bridge. Native Windows OBS, direct Windows-to-Frame SSH, firewall
|
||||
behavior and packaged app Start/Stop have therefore not passed end to end.
|
||||
No local FFmpeg was installed; encoding belongs on the Frame. No firewall
|
||||
exception, headset authorized key, or Windows scheduled task was installed.
|
||||
|
||||
**macOS:** no physical Mac or macOS VM was available. Python tests and the
|
||||
five controller lifecycle tests run in the macOS CI matrix; these cannot
|
||||
establish capture performance, audio quality, packaged launch or OBS behavior
|
||||
on a physical Mac.
|
||||
|
||||
## Validation and remaining limits
|
||||
|
||||
Local suite: **1,514 Python tests passed, 16 skipped**, 46 app tests and 18
|
||||
site tests passed. The latest helper additionally passed all 11 focused OBS
|
||||
tests; the controller passed all five focused lifecycle tests. Cross-platform
|
||||
CI results belong to the linked PR, not these host measurements.
|
||||
|
||||
Still unverified: perceptual listening, real-game audio routing, accurate
|
||||
default A/V sync, broad latency/performance across hosts, hours-long sessions,
|
||||
physical Wi-Fi drop/full suspend recovery, physical macOS and native Windows
|
||||
OBS. The app/docs now explain copyable URL/format, receiver recovery, port
|
||||
selection, passthrough privacy and OBS Virtual Camera's video-only limit.
|
||||
The Discord guide provides the corresponding audio-sharing choices.
|
||||
|
||||
Cleanup completed: Steam and vrcompositor remained running, battery 98%,
|
||||
no test FFmpeg/stimulus process remained, UDP ports 15099/15101/15102/15103/15105
|
||||
were bindable, task guest SSH config and the temporary bridge were removed,
|
||||
and the owned headset lock was released. The disposable Windows VM was
|
||||
stopped with its disk retained. See `cleanup.json`.
|
||||
@@ -1,23 +0,0 @@
|
||||
import json,subprocess,sys,statistics
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
video=Path(sys.argv[1]); root=video.parent
|
||||
v=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:v','-vf','scale=64:36','-pix_fmt','rgb24','-f','rawvideo','-']),dtype=np.uint8).reshape(-1,36,64,3)
|
||||
a=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:a','-ac','1','-ar','48000','-f','f32le','-']),dtype='<f4')
|
||||
# Locate the high contrast square marker, excluding the background's low variations.
|
||||
gray=v.astype(float).mean(axis=3); spread=np.percentile(gray,98,axis=0)-np.percentile(gray,2,axis=0)
|
||||
y,x=np.unravel_index(spread.argmax(),spread.shape); track=gray[:,y,x]; lo,hi=np.percentile(track,[2,98]); white=track>(lo+hi)/2;onsets=np.where(np.diff(white.astype(int))==1)[0]+1
|
||||
rms=np.sqrt(np.mean(a[:len(a)//480*480].reshape(-1,480)**2,axis=1)); audible=rms>.003;beeps=(np.where(np.diff(audible.astype(int))==1)[0]+1)/100
|
||||
beeps=beeps[np.r_[True,np.diff(beeps)>.1]]
|
||||
visual=onsets/30; offsets=[]
|
||||
for t in visual:
|
||||
if len(beeps):
|
||||
b=beeps[np.argmin(abs(beeps-t))]
|
||||
if abs(b-t)<2:offsets.append(float((b-t)*1000))
|
||||
active=a[abs(a)>.003]; peaks,props=signal.find_peaks(rms,height=.003,distance=100)
|
||||
# Spectrum of one well inside a tone, with Hann window; harmonic distortion estimate.
|
||||
start=int((beeps[2]+.05)*48000) if len(beeps)>2 else 0;sample=a[start:start+4096];freq=np.fft.rfftfreq(len(sample),1/48000);power=abs(np.fft.rfft(sample*np.hanning(len(sample))))**2
|
||||
base=power[(freq>960)&(freq<1040)].sum();harm=sum(power[(freq>h*1000-40)&(freq<h*1000+40)].sum() for h in range(2,10))
|
||||
result={'duration_s':len(v)/30,'video_frames':len(v),'marker_pixel_xy':[int(x),int(y)],'marker_levels':[float(lo),float(hi)],'visual_markers_s':visual.tolist(),'audio_onsets_s':beeps.tolist(),'audio_minus_video_ms':offsets,'offset_median_ms':statistics.median(offsets) if offsets else None,'offset_min_max_ms':[min(offsets),max(offsets)] if offsets else None,'peak_dbfs':float(20*np.log10(abs(a).max())),'clipped_samples':int((abs(a)>=.999).sum()),'tone_frequency_hz':float(freq[power.argmax()]),'harmonics_to_fundamental_db':float(10*np.log10(harm/base)),'rms_peak':float(rms.max())}
|
||||
(root/(video.stem+'-analysis.json')).write_text(json.dumps(result,indent=2));np.save(root/(video.stem+'-marker-spread.npy'),spread);print(json.dumps(result,indent=2))
|
||||
@@ -1,136 +0,0 @@
|
||||
{
|
||||
"duration_s": 187.36666666666667,
|
||||
"video_frames": 5621,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
9.766666666666667,
|
||||
14.766666666666667,
|
||||
19.766666666666666,
|
||||
24.766666666666666,
|
||||
29.766666666666666,
|
||||
34.766666666666666,
|
||||
39.766666666666666,
|
||||
44.766666666666666,
|
||||
49.766666666666666,
|
||||
54.766666666666666,
|
||||
59.766666666666666,
|
||||
64.76666666666667,
|
||||
69.76666666666667,
|
||||
74.76666666666667,
|
||||
79.76666666666667,
|
||||
84.76666666666667,
|
||||
89.76666666666667,
|
||||
94.76666666666667,
|
||||
99.76666666666667,
|
||||
104.76666666666667,
|
||||
109.76666666666667,
|
||||
114.76666666666667,
|
||||
119.76666666666667,
|
||||
124.76666666666667,
|
||||
129.76666666666668,
|
||||
134.76666666666668,
|
||||
139.76666666666668,
|
||||
144.7,
|
||||
149.7,
|
||||
154.7,
|
||||
159.7,
|
||||
164.7,
|
||||
169.7,
|
||||
174.7,
|
||||
179.7,
|
||||
184.7
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
9.34,
|
||||
14.34,
|
||||
19.34,
|
||||
24.34,
|
||||
29.34,
|
||||
34.34,
|
||||
39.34,
|
||||
44.34,
|
||||
49.34,
|
||||
54.34,
|
||||
59.34,
|
||||
64.34,
|
||||
69.34,
|
||||
74.34,
|
||||
79.34,
|
||||
84.34,
|
||||
89.34,
|
||||
94.34,
|
||||
99.34,
|
||||
104.34,
|
||||
109.34,
|
||||
114.34,
|
||||
119.34,
|
||||
124.34,
|
||||
129.34,
|
||||
134.34,
|
||||
139.34,
|
||||
144.34,
|
||||
149.34,
|
||||
154.34,
|
||||
159.34,
|
||||
164.34,
|
||||
169.34,
|
||||
174.34,
|
||||
179.34,
|
||||
184.34
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-426.66666666666765,
|
||||
-426.66666666666765,
|
||||
-426.66666666666583,
|
||||
-426.66666666666583,
|
||||
-426.66666666666583,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666765,
|
||||
-426.6666666666765,
|
||||
-426.6666666666765,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852
|
||||
],
|
||||
"offset_median_ms": -426.6666666666623,
|
||||
"offset_min_max_ms": [
|
||||
-426.6666666666765,
|
||||
-359.9999999999852
|
||||
],
|
||||
"peak_dbfs": -13.2072114944458,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -81.32048537168609,
|
||||
"rms_peak": 0.15280573070049286
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
[
|
||||
{
|
||||
"duration_s": 21.033333333333335,
|
||||
"video_frames": 631,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
4.9,
|
||||
9.9,
|
||||
14.9,
|
||||
19.9
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
4.42,
|
||||
9.42,
|
||||
14.42,
|
||||
19.42
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-480.00000000000045,
|
||||
-480.00000000000045,
|
||||
-480.00000000000045,
|
||||
-479.9999999999969
|
||||
],
|
||||
"offset_median_ms": -480.00000000000045,
|
||||
"offset_min_max_ms": [
|
||||
-480.00000000000045,
|
||||
-479.9999999999969
|
||||
],
|
||||
"peak_dbfs": -13.202757835388184,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.94567523863627,
|
||||
"rms_peak": 0.15256504714488983,
|
||||
"offset_ms": 0,
|
||||
"recording": "2026-10-25 06-31-15.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 21.066666666666666,
|
||||
"video_frames": 632,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
10.6,
|
||||
15.6,
|
||||
20.6
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
5.62,
|
||||
10.53,
|
||||
15.53,
|
||||
20.53
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-70.00000000000028,
|
||||
-70.00000000000028,
|
||||
-70.00000000000028
|
||||
],
|
||||
"offset_median_ms": -70.00000000000028,
|
||||
"offset_min_max_ms": [
|
||||
-70.00000000000028,
|
||||
-70.00000000000028
|
||||
],
|
||||
"peak_dbfs": -13.215741157531738,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.99685616364809,
|
||||
"rms_peak": 0.15322808921337128,
|
||||
"offset_ms": 400,
|
||||
"recording": "2026-10-25 06-31-46.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 21.0,
|
||||
"video_frames": 630,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
7.6,
|
||||
12.6,
|
||||
17.6
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
7.6,
|
||||
12.6,
|
||||
17.6
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"offset_median_ms": 0.0,
|
||||
"offset_min_max_ms": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"peak_dbfs": -13.219660758972168,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.85259564406981,
|
||||
"rms_peak": 0.15266509354114532,
|
||||
"offset_ms": 480,
|
||||
"recording": "2026-10-25 06-32-18.mkv"
|
||||
}
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
import json,sys,time,subprocess,os
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,'/tmp/obs-aacapv3evh');from control import OBS
|
||||
r=Path('/tmp/obs-aacapv3evh');o=OBS();
|
||||
try:o.request('RemoveInput',{'inputName':'Frame mirror'})
|
||||
except RuntimeError:pass
|
||||
time.sleep(1)
|
||||
h=subprocess.Popen([sys.executable,'scripts/obs-headset-source.py','--control-stdin'],stdin=subprocess.PIPE,stderr=(r/'calibration-initial-helper.log').open('w'))
|
||||
p=subprocess.Popen(['ssh','frame','XDG_RUNTIME_DIR=/run/user/1000 timeout 100 python3 /tmp/obs-aacapv3evh/stimulus.py'],stdout=(r/'calibration-initial-stimulus.jsonl').open('w'),stderr=(r/'calibration-initial-stimulus.log').open('w'))
|
||||
results=[]
|
||||
try:
|
||||
for offset in (0,400,480):
|
||||
name='Frame calibration '+str(offset)
|
||||
data=o.request('CreateInput',{'sceneName':'Scene','inputName':name,'inputKind':'ffmpeg_source','inputSettings':{'is_local_file':False,'input':'udp://127.0.0.1:15099','input_format':'mpegts','buffering_mb':2,'restart_on_activate':True},'sceneItemEnabled':False})
|
||||
o.request('SetInputAudioSyncOffset',{'inputName':name,'inputAudioSyncOffset':offset})
|
||||
print('setting',o.request('GetInputAudioSyncOffset',{'inputName':name}),flush=True)
|
||||
o.request('SetSceneItemEnabled',{'sceneName':'Scene','sceneItemId':data['sceneItemId'],'sceneItemEnabled':True})
|
||||
time.sleep(7);o.request('StartRecord');time.sleep(21);rec=o.request('StopRecord');video=r/Path(rec['outputPath']).name
|
||||
env=dict(os.environ,PYTHONPATH=str(r/'deps'));a=json.loads(subprocess.check_output([sys.executable,str(r/'analyse.py'),str(video)],env=env));a.update(offset_ms=offset,recording=video.name);results.append(a);print(json.dumps(a),flush=True)
|
||||
o.request('RemoveInput',{'inputName':name})
|
||||
finally:
|
||||
h.stdin.close();h.wait(timeout=10);p.terminate();p.wait(timeout=10);(r/'calibration-initial-result.json').write_text(json.dumps(results,indent=2))
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"offset_ms": 367,
|
||||
"records": [
|
||||
{
|
||||
"duration_s": 19.966666666666665,
|
||||
"video_frames": 599,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
6.133333333333334,
|
||||
7.266666666666667,
|
||||
12.266666666666667,
|
||||
17.266666666666666
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
6.9,
|
||||
11.9,
|
||||
16.9
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
766.6666666666666,
|
||||
-366.66666666666623,
|
||||
-366.66666666666714,
|
||||
-366.66666666666714
|
||||
],
|
||||
"offset_median_ms": -366.6666666666667,
|
||||
"offset_min_max_ms": [
|
||||
-366.66666666666714,
|
||||
766.6666666666666
|
||||
],
|
||||
"peak_dbfs": -13.215461730957031,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -84.12980132719696,
|
||||
"rms_peak": 0.15284518897533417,
|
||||
"phase": "before",
|
||||
"recording": "2026-10-25 06-28-11.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 20.066666666666666,
|
||||
"video_frames": 602,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
4.4,
|
||||
9.4,
|
||||
14.4,
|
||||
19.4
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
4.03,
|
||||
9.03,
|
||||
14.03,
|
||||
19.03
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-370.0000000000001,
|
||||
-370.000000000001,
|
||||
-370.000000000001,
|
||||
-369.99999999999744
|
||||
],
|
||||
"offset_median_ms": -370.00000000000057,
|
||||
"offset_min_max_ms": [
|
||||
-370.000000000001,
|
||||
-369.99999999999744
|
||||
],
|
||||
"peak_dbfs": -13.167834281921387,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -82.16381051643725,
|
||||
"rms_peak": 0.15364883840084076,
|
||||
"phase": "after",
|
||||
"recording": "2026-10-25 06-28-34.mkv"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"bindable_udp_ports": [
|
||||
15099,
|
||||
15101,
|
||||
15102,
|
||||
15103,
|
||||
15105
|
||||
],
|
||||
"remote_task_ffmpeg_and_stimulus_processes": 0,
|
||||
"steam_and_vrcompositor_running": true,
|
||||
"battery_percent": 98,
|
||||
"headset_lock_released": true,
|
||||
"guest_task_ssh_host_block_removed": true,
|
||||
"temporary_ssh_bridge_stopped": true,
|
||||
"persistent_headset_authentication_changes": false
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"duration_s": 36.06666666666667,
|
||||
"video_frames": 1082,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
9.433333333333334,
|
||||
14.433333333333334,
|
||||
19.433333333333334,
|
||||
24.433333333333334,
|
||||
29.433333333333334,
|
||||
34.43333333333333
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
9.35,
|
||||
14.35,
|
||||
19.35,
|
||||
24.35,
|
||||
29.35,
|
||||
34.35
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-83.33333333333393,
|
||||
-83.33333333333393,
|
||||
-83.33333333333215,
|
||||
-83.33333333333215,
|
||||
-83.33333333333215,
|
||||
-83.3333333333286
|
||||
],
|
||||
"offset_median_ms": -83.33333333333215,
|
||||
"offset_min_max_ms": [
|
||||
-83.33333333333393,
|
||||
-83.3333333333286
|
||||
],
|
||||
"peak_dbfs": -13.15299129486084,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -82.4335935832891,
|
||||
"rms_peak": 0.15312190353870392
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"clock": {
|
||||
"rtt": 0.0034094941802322865,
|
||||
"offset_remote_minus_host": -2894789.9195547644
|
||||
},
|
||||
"hits": [
|
||||
{
|
||||
"source_index": 1000,
|
||||
"delay_ms": 1431.418131571263,
|
||||
"query_ms": 11.743285227566957
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"programs": [
|
||||
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"r_frame_rate": "30/1",
|
||||
"nb_read_frames": "153"
|
||||
},
|
||||
{
|
||||
"codec_name": "aac",
|
||||
"sample_rate": "48000",
|
||||
"channels": 2,
|
||||
"r_frame_rate": "0/0",
|
||||
"nb_read_frames": "233"
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "5.121000",
|
||||
"size": "3932019"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"elapsed_s": 7.434504003729671,
|
||||
"result": {
|
||||
"state": "saved",
|
||||
"file": "/tmp/obs-aacapv3evh/mp4/rec-20261025-062251.mp4",
|
||||
"audio": true,
|
||||
"seconds": 5,
|
||||
"message": "Saved rec-20261025-062251.mp4 to /tmp OBS evidence"
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
{
|
||||
"result": {
|
||||
"outputPath": "/evidence/2026-10-25 06-08-22.mkv"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"elapsed": 30.000104404054582,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 9068.64453125,
|
||||
"averageFrameRenderTime": 18.458413,
|
||||
"cpuUsage": 20.24916849878798,
|
||||
"memoryUsage": 543.7109375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 959,
|
||||
"renderSkippedFrames": 4,
|
||||
"renderTotalFrames": 1783,
|
||||
"webSocketSessionIncomingMessages": 14,
|
||||
"webSocketSessionOutgoingMessages": 16
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 2243010,
|
||||
"outputDuration": 30866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:00:30.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 60.002103955019265,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8942.09375,
|
||||
"averageFrameRenderTime": 13.660508,
|
||||
"cpuUsage": 23.863888888888887,
|
||||
"memoryUsage": 544.56640625,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 1859,
|
||||
"renderSkippedFrames": 5,
|
||||
"renderTotalFrames": 2683,
|
||||
"webSocketSessionIncomingMessages": 16,
|
||||
"webSocketSessionOutgoingMessages": 18
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 4951606,
|
||||
"outputDuration": 60866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:01:00.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 90.0040782331489,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8900.6015625,
|
||||
"averageFrameRenderTime": 12.110281,
|
||||
"cpuUsage": 23.680555555555557,
|
||||
"memoryUsage": 545.74609375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 2759,
|
||||
"renderSkippedFrames": 13,
|
||||
"renderTotalFrames": 3583,
|
||||
"webSocketSessionIncomingMessages": 18,
|
||||
"webSocketSessionOutgoingMessages": 20
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 7552329,
|
||||
"outputDuration": 90866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:01:30.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 120.0062853959389,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8603.08984375,
|
||||
"averageFrameRenderTime": 14.892247,
|
||||
"cpuUsage": 24.66111111111111,
|
||||
"memoryUsage": 546.52734375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 3659,
|
||||
"renderSkippedFrames": 13,
|
||||
"renderTotalFrames": 4483,
|
||||
"webSocketSessionIncomingMessages": 20,
|
||||
"webSocketSessionOutgoingMessages": 22
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 10124530,
|
||||
"outputDuration": 120866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:02:00.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 150.00849257130176,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8484.58203125,
|
||||
"averageFrameRenderTime": 15.3434,
|
||||
"cpuUsage": 23.169444444444444,
|
||||
"memoryUsage": 548.5546875,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 4559,
|
||||
"renderSkippedFrames": 78,
|
||||
"renderTotalFrames": 5383,
|
||||
"webSocketSessionIncomingMessages": 22,
|
||||
"webSocketSessionOutgoingMessages": 24
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 12643085,
|
||||
"outputDuration": 150833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:02:30.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 180.01099411491305,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8382.75,
|
||||
"averageFrameRenderTime": 31.402812,
|
||||
"cpuUsage": 23.717094301899365,
|
||||
"memoryUsage": 548.51953125,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 5459,
|
||||
"renderSkippedFrames": 90,
|
||||
"renderTotalFrames": 6283,
|
||||
"webSocketSessionIncomingMessages": 24,
|
||||
"webSocketSessionOutgoingMessages": 26
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 15130232,
|
||||
"outputDuration": 180833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:03:00.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 185.01674677524716,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8366.79296875,
|
||||
"averageFrameRenderTime": 22.859219,
|
||||
"cpuUsage": 23.766666666666666,
|
||||
"memoryUsage": 510.28515625,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 5609,
|
||||
"renderSkippedFrames": 90,
|
||||
"renderTotalFrames": 6433,
|
||||
"webSocketSessionIncomingMessages": 26,
|
||||
"webSocketSessionOutgoingMessages": 28
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 15580161,
|
||||
"outputDuration": 185833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:03:05.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "2026-10-25 06-08-22.mkv",
|
||||
"bytes": 15784412,
|
||||
"sha256": "f2760ed28bf48274cc8c936428edbc389cd3a9f4b9cd2a61e699a04bf4487b50",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "rec-20261025-062251.mp4",
|
||||
"bytes": 3932019,
|
||||
"sha256": "c71de1a49b75159c587fddd012361b9e6626a4d5cc41b79696343a17b73c151a",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "mirror-wake.png",
|
||||
"bytes": 1741004,
|
||||
"sha256": "0754466948d5b9e8448f9ed67c8582e19802367df6ed4002afc64f42e1763f53",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "private-mp4-frame4.png",
|
||||
"bytes": 1002068,
|
||||
"sha256": "08d87bc9ab0e62115b42cca7dfcf7e4db11848a0ede273034a92a69297c6775b",
|
||||
"published": false
|
||||
}
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"baseline": "d79efb4",
|
||||
"after_stop": {
|
||||
"state": "stopping",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"after_late_stderr": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
[
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 1,
|
||||
"port": 15101,
|
||||
"first_packet_s": 0.890392117202282,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.06173023069277406,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15101,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 2,
|
||||
"port": 15102,
|
||||
"first_packet_s": 0.7008698149584234,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.0526565620675683,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15102,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 3,
|
||||
"port": 15103,
|
||||
"first_packet_s": 0.7092029112391174,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.052990387193858624,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15103,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "stalled-ssh",
|
||||
"ssh_pid": 3025339,
|
||||
"elapsed_s": 15.008651106152683,
|
||||
"state": {
|
||||
"state": "failed",
|
||||
"alias": "frame",
|
||||
"port": 15105,
|
||||
"error": "OBS source stopped (1). The Frame OBS video stopped for 10 seconds",
|
||||
"supported": true
|
||||
},
|
||||
"helper_gone": true,
|
||||
"ssh_gone": true
|
||||
},
|
||||
{
|
||||
"test": "restart-after-stall",
|
||||
"first_packet_s": 0.6910299891605973
|
||||
},
|
||||
{
|
||||
"test": "quit-streaming",
|
||||
"checked_after_s": 2.020789209753275,
|
||||
"surviving_helpers": []
|
||||
}
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import ctypes as C, json, math, os, signal, struct, subprocess, sys, threading, time
|
||||
# Put frame_media_player.py and its two imports beside this helper on the Frame.
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from frame_media_player import Overlay, OverlayBusy
|
||||
os.environ['XDG_RUNTIME_DIR']='/run/user/1000'
|
||||
class Spec(C.Structure):
|
||||
_fields_=[('format',C.c_int),('rate',C.c_uint32),('channels',C.c_uint8)]
|
||||
class Attr(C.Structure):
|
||||
_fields_=[(k,C.c_uint32) for k in ('maxlength','tlength','prebuf','minreq','fragsize')]
|
||||
p=C.CDLL('libpulse-simple.so.0'); p.pa_simple_new.restype=C.c_void_p
|
||||
p.pa_simple_new.argtypes=[C.c_char_p,C.c_char_p,C.c_int,C.c_char_p,C.c_char_p,C.POINTER(Spec),C.c_void_p,C.POINTER(Attr),C.POINTER(C.c_int)]
|
||||
p.pa_simple_write.argtypes=[C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_int)]
|
||||
p.pa_simple_get_latency.argtypes=[C.c_void_p,C.POINTER(C.c_int)];p.pa_simple_get_latency.restype=C.c_uint64
|
||||
p.pa_simple_free.argtypes=[C.c_void_p]
|
||||
err=C.c_int(); spec=Spec(3,48000,2); attr=Attr(0xffffffff,9600,0,1920,0xffffffff)
|
||||
stream=p.pa_simple_new(None,b'OBS validation',1,None,b'aligned marker',C.byref(spec),None,C.byref(attr),C.byref(err))
|
||||
if not stream: raise RuntimeError('pulse '+str(err.value))
|
||||
vr=Overlay(); handle=vr.create('framecontrol.obs.validation.aacapv3evh',2,1.5,order=100)
|
||||
quit=threading.Event(); events=[]
|
||||
signal.signal(signal.SIGTERM,lambda *_:quit.set())
|
||||
def wake():
|
||||
while not quit.is_set():
|
||||
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--handlewakeup'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
quit.wait(1)
|
||||
threading.Thread(target=wake,daemon=True).start()
|
||||
try:
|
||||
previous=False
|
||||
for i in range(19000):
|
||||
if quit.is_set():break
|
||||
tone= i>=500 and (i-500)%500<20
|
||||
block=b''.join(struct.pack('<hh',int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0,int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0) for j in range(480))
|
||||
b=C.create_string_buffer(block)
|
||||
if p.pa_simple_write(stream,b,len(block),C.byref(err)):raise RuntimeError('write '+str(err.value))
|
||||
if tone != previous or i==0:
|
||||
latency=p.pa_simple_get_latency(stream,C.byref(err))/1e6
|
||||
expected=time.monotonic()+latency-.01
|
||||
time.sleep(max(0,expected-time.monotonic()))
|
||||
before=time.monotonic()
|
||||
try:vr.pixels(handle,(b'\xff\xff\xff\xff' if tone else b'\x00\x00\x00\xff')*64*64,64,64)
|
||||
except OverlayBusy:continue
|
||||
after=time.monotonic()
|
||||
event={'tone':tone,'index':i,'expected_audio':expected,'visual_submit':before,'visual_return':after,'submit_minus_audio_ms':(before-expected)*1000,'pulse_latency_ms':latency*1000}
|
||||
print(json.dumps(event),flush=True); events.append(event)
|
||||
previous=tone
|
||||
finally:
|
||||
quit.set();vr.close();p.pa_simple_free(stream)
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"alive_without_receiver": true,
|
||||
"platform": "win32",
|
||||
"has_udp_connreset": false,
|
||||
"packet_bytes": 1316,
|
||||
"ssh_pid": 2668,
|
||||
"stop_seconds": 0.0,
|
||||
"helper_exit": 0,
|
||||
"ssh_child_alive": false
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,280 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,168 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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))
|
||||
@@ -1,152 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"checkedAt": "2026-10-25T06:03:45.387452+00:00",
|
||||
"method": "Read-only Python probe over the existing frame SSH alias; no device changes",
|
||||
"baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c",
|
||||
"deckyStatus": {
|
||||
"arch": "aarch64",
|
||||
"frameOS": "0.5.6",
|
||||
"variant": "vr",
|
||||
"build": "20261022.6153909",
|
||||
"cef": true,
|
||||
"managed": false,
|
||||
"running": false,
|
||||
"version": null,
|
||||
"upstreamInstall": false,
|
||||
"plugins": [],
|
||||
"upstreamPlugins": []
|
||||
},
|
||||
"platform": {
|
||||
"userServiceManager": true,
|
||||
"rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/",
|
||||
"homeMountOptions": "rw,relatime",
|
||||
"homebrewExists": false,
|
||||
"cefMarkerExists": false,
|
||||
"cefTargetTitles": [
|
||||
"SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)",
|
||||
"SteamVR - valve.steam.gamepadui.bar (53930000)",
|
||||
"SteamVR",
|
||||
"SteamVR - Notifications",
|
||||
"SteamVR - valve.steam.gamepadui.floatingfooter (53930004)",
|
||||
"SteamVR - valve.steam.gamepadui.volumelevel (53930003)",
|
||||
"SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)",
|
||||
"SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)",
|
||||
"SteamVR - Keyboard",
|
||||
"data:text/html,<body><%2Fbody>",
|
||||
"SharedJSContext"
|
||||
]
|
||||
},
|
||||
"limits": [
|
||||
"No Decky install/update/uninstall repeated in this retry",
|
||||
"No OS update or reboot performed",
|
||||
"No wearer-visible menu or controller input verified"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 44 KiB |
@@ -1,226 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
# Round 2 retry, 25 October 2026
|
||||
|
||||
The provider retry completed four further fixes, each in an isolated child
|
||||
worktree with its own issue, branch, PR, tests, docs and Announcement draft.
|
||||
The coordinator reviewed each patch, independently ran focused tests, verified
|
||||
its exact head and complete CI, merged it, then read back GitHub's merged state.
|
||||
PR #113 was untouched. No social announcements were posted.
|
||||
|
||||
## Merged fixes
|
||||
|
||||
| Change | PR | Merge commit | Coordinator focused tests | Final CI |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Skip linked backup ancestors and recheck before packing | [#215](https://github.com/saphid/frame-control/pull/215) | `da34d94` | 34 passed | 11 checks passed at `8cc03c8` |
|
||||
| Clean orphan smoke metadata/shortcuts; POSIX runner without zsh | [#216](https://github.com/saphid/frame-control/pull/216) | `d5fa8fe` | 17 passed | 10 checks passed at `a5daa4a` |
|
||||
| Separate Steam clips by headset/account/full session ID | [#217](https://github.com/saphid/frame-control/pull/217) | `1255ef2` | 62 passed | 10 checks passed at `7dfe777` |
|
||||
| Retry failed automatic copies and bind listings to the captured headset | [#218](https://github.com/saphid/frame-control/pull/218) | `fdd60d3` | 33 passed | 10 checks passed at `af2bb17` |
|
||||
|
||||
Two initial CI fixture failures were fixed before merging: macOS temporary-path
|
||||
aliases needed a resolved working directory in the wrapper test; Windows
|
||||
needed explicit UTF-8 when reading the Settings test's HTML fixture.
|
||||
Concurrent main changes #208/#209/#210 were preserved. Changelog conflicts
|
||||
retained both entries. No history was rewritten.
|
||||
|
||||
## Integrated validation
|
||||
|
||||
At main `1255ef2`, after all four merges:
|
||||
|
||||
- `python3 -m unittest discover -s tests`: 1,559 tests, 16 skipped, passed in
|
||||
157.335 seconds. Loopback networking was enabled for local HTTP fixtures.
|
||||
- `node --test app/test/*.test.js site/test/*.test.mjs`: 59 tests passed.
|
||||
- Worker backup validation also passed both Docker recovery-kit E2E tests in
|
||||
an isolated Compose project, then removed its containers, volumes and network.
|
||||
- Local proof logs: `/tmp/frame-round2-integrated-tests.log` and
|
||||
`/tmp/frame-round2-integrated-node.log`. Individual focused logs use
|
||||
`/tmp/frame-round2-{backup,smoke,clips,autosync}-review.log`.
|
||||
|
||||
## Real Frame smoke
|
||||
|
||||
The current host is lxso2; its existing direct `ssh frame` alias works.
|
||||
Another OBS task initially held `/tmp/frame-test.lock`, so only read-only
|
||||
checks were done until it released the lock. This coordinator then acquired it
|
||||
atomically and labeled it `frame-round2-vdw4yh1xv8-20261025`.
|
||||
|
||||
The merged `scripts/frame-smoke.sh` ran directly without zsh at 06:38:37 UTC.
|
||||
All 13 steps passed on SteamOS 0.5.6, BUILD_ID `20261022.6153909`. The ARM64
|
||||
and Proton titles ran. The x86-64 Linux title reported its runtime missing,
|
||||
an explicitly accepted smoke outcome rather than a successful execution.
|
||||
Installs/removals succeeded; the Windows title's remove
|
||||
step stopped five test-owned PIDs. Both final process sweeps were empty.
|
||||
Independent readback found no reserved title folders or metadata, SteamVR's
|
||||
PID was unchanged, and the three observed gamescope focus properties matched
|
||||
before/after. Battery went from 99% Charging to 100% Full. Only this task's
|
||||
lock was removed, and its absence was verified.
|
||||
|
||||
[Sanitized structured proof](round2-smoke-2026-10-25.json). Raw local results:
|
||||
`/tmp/frame-round2-smoke-results/2026-10-25T063837Z-20261022.6153909.json`;
|
||||
console log `/tmp/frame-round2-headset-smoke.log`; state readbacks
|
||||
`/tmp/frame-round2-smoke-before.txt` and `/tmp/frame-round2-smoke-after.txt`.
|
||||
|
||||
The headset was unworn. Visible rendering/controller input was not verified.
|
||||
No pairing, password/security change, reboot or factory reset was performed.
|
||||
The new backup/clip/auto-sync paths have local fixtures and cross-platform CI;
|
||||
this smoke does not establish a new real-data backup/clip/UI test. Synthetic
|
||||
orphan metadata was not seeded on the real device. Backup path checks retain
|
||||
a documented race with concurrent filesystem changes.
|
||||
|
||||
## Prior round-two work reconciled
|
||||
|
||||
The failed coordinator checkout was still at v0.4.7. It was first updated to
|
||||
main `d79efb4`, already v0.4.30. Earlier PRs had completed clipboard testing
|
||||
(#124), original smoke process cleanup (#126), Windows VM capture (#129),
|
||||
battery health (#125), setup doctor (#127), and read-only storage (#128).
|
||||
Later evidence already covered real clip export (#147/#149/#154/#156) and
|
||||
Return to Game Mode (#143/#157). These remain earlier evidence, not tests
|
||||
performed by this retry. Physical Windows GPU/input and current physical
|
||||
macOS checks remain incomplete; see the current support matrix.
|
||||
|
||||
Decision: fix reproduced data/cleanup/retry defects rather than automate game
|
||||
moves without a verified transaction API or promise unverified phone-background
|
||||
sync. Prepare v0.4.31; publication still requires its Linux AppImage install test.
|
||||
|
||||
## Delegation provenance
|
||||
|
||||
All implementations completed. Task IDs use prefix
|
||||
`node:delegated-task:command%3Amcp%3Ad9bc35d8-0e9c-42c2-b798-25bd8af94d81%3Adelegate-task%3A`
|
||||
followed by one of:
|
||||
|
||||
- `frame-r2-retry-smoke-orphans-20261025-r1`
|
||||
- `frame-r2-backup-symlink-20261025-r1`
|
||||
- `frame-r2-clip-collision-20261025-r1`
|
||||
- `frame-r2-autosync-retry-20261025-r1`
|
||||
@@ -1,123 +0,0 @@
|
||||
{
|
||||
"source_commit": "1255ef2bbf2e2e5fd86f8b984b592bed56f20780",
|
||||
"started": "2026-10-25T06:38:37Z",
|
||||
"os_release": {
|
||||
"VERSION_ID": "0.5.6",
|
||||
"VARIANT_ID": "vr",
|
||||
"BUILD_ID": "20261022.6153909"
|
||||
},
|
||||
"passed": 13,
|
||||
"failed": 0,
|
||||
"paired": false,
|
||||
"devkit_utils_there_before": true,
|
||||
"battery_before": {
|
||||
"capacity": 99,
|
||||
"statuses": [
|
||||
"Charging",
|
||||
"Charging"
|
||||
]
|
||||
},
|
||||
"battery_after": {
|
||||
"capacity": 100,
|
||||
"statuses": [
|
||||
"Full",
|
||||
"Full"
|
||||
]
|
||||
},
|
||||
"steamvr_pid_before": 124586,
|
||||
"steamvr_pid_after": 124586,
|
||||
"observed_focus_properties_unchanged": true,
|
||||
"reserved_files_and_processes_absent_after": true,
|
||||
"own_shared_lock_released": true,
|
||||
"headset_worn": false,
|
||||
"steps": [
|
||||
{
|
||||
"step": "cleanup: leftovers from earlier runs",
|
||||
"ok": true,
|
||||
"seconds": 7.8,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "devkit service: properties.json",
|
||||
"ok": true,
|
||||
"seconds": 0.0
|
||||
},
|
||||
{
|
||||
"step": "status",
|
||||
"ok": true,
|
||||
"seconds": 0.6
|
||||
},
|
||||
{
|
||||
"step": "arm64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.7
|
||||
},
|
||||
{
|
||||
"step": "arm64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "arm64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.4,
|
||||
"stopped_process_counts": [
|
||||
2,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "x86_64: install",
|
||||
"ok": true,
|
||||
"seconds": 7.6
|
||||
},
|
||||
{
|
||||
"step": "x86_64: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.2,
|
||||
"outcome": "runtime missing"
|
||||
},
|
||||
{
|
||||
"step": "x86_64: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.2,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "exe: install",
|
||||
"ok": true,
|
||||
"seconds": 7.5
|
||||
},
|
||||
{
|
||||
"step": "exe: launch",
|
||||
"ok": true,
|
||||
"seconds": 3.3,
|
||||
"outcome": "running"
|
||||
},
|
||||
{
|
||||
"step": "exe: remove",
|
||||
"ok": true,
|
||||
"seconds": 6.5,
|
||||
"stopped_process_counts": [
|
||||
5,
|
||||
0
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "cleanup: everything this run installed",
|
||||
"ok": true,
|
||||
"seconds": 7.4,
|
||||
"stopped_process_counts": [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,25 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,70 +0,0 @@
|
||||
# Steam clip export collision fixtures, 25 October 2026
|
||||
|
||||
Base: `d79efb4` (Frame Control v0.4.30). Issue:
|
||||
[#212](https://github.com/saphid/frame-control/issues/212).
|
||||
Host: Linux 6.8.0-139-generic, Python 3.12.3, ffmpeg 6.1.1.
|
||||
|
||||
## Baseline reproduction
|
||||
|
||||
Loaded the unmodified `ui/frame_clips.py` from `d79efb4` into a temporary
|
||||
Python module and exported three fake sources into a temporary directory:
|
||||
|
||||
```
|
||||
1/clips/clip_620/video/bg
|
||||
2/clips/clip_620/video/bg
|
||||
1/clips/clip_620/video/other
|
||||
```
|
||||
|
||||
Each source's payload was its own ID. All three exports returned the same
|
||||
destination, `clip_620.mp4`; listing marked all three saved. The final file
|
||||
contained only the last source ID. No SSH or headset was used.
|
||||
|
||||
## Regression coverage
|
||||
|
||||
`tests/test_clips.py` exports five IDs on each of two fake headsets, including
|
||||
equal clip names across accounts, nested sessions with equal leaf names,
|
||||
different leaf names under one clip and the background/clip distinction.
|
||||
All ten payloads survive at distinct destinations. Listing marks only the
|
||||
identities already exported. A pre-existing flat MP4 retains its original
|
||||
bytes and does not mark any identity saved. Normal export still returns
|
||||
`message` and `file`, removes its remote temporary export and clears local
|
||||
staging; a failed re-export preserves the completed file.
|
||||
|
||||
`tests/test_clip_copy.py` exercises the server's list and manual export
|
||||
functions and its actual auto-sync callbacks with fake transport. A manual
|
||||
copy is recognised by auto-sync, all other account/session identities copy,
|
||||
and switching to a second headset copies its own sources without overwriting
|
||||
the first. A delayed manual job refuses a changed headset with status 409.
|
||||
Both local phone mode and local headset runtime reject Steam clip list,
|
||||
export and auto-sync requests with status 400. The separate `Recorder` path
|
||||
is unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
- `PYTHONPATH=tests python3 -m unittest test_clips test_clip_copy test_autosync test_shot_copy test_headset_actions -v`
|
||||
— 66 tests passed in 17.288 seconds, no skips. Includes real ffmpeg/ffprobe
|
||||
export of a generated 65-second DASH fixture and recording stop/cleanup.
|
||||
- `PYTHONPATH=tests python3 -m unittest test_server -v`
|
||||
— 44 tests passed in 12.315 seconds, no skips.
|
||||
- `node --test site/test/*.test.mjs` — 2 tests passed.
|
||||
- `python3 -m unittest discover -s tests -v` — 1,518 tests run in
|
||||
181.025 seconds; OK, 16 skipped for unavailable platform/tool prerequisites.
|
||||
- `python3 -m py_compile ui/frame_clips.py ui/server.py tests/test_clips.py tests/test_clip_copy.py`
|
||||
and `git diff --check` — passed.
|
||||
|
||||
The server and full Python suites require localhost sockets and were run
|
||||
outside the network sandbox. Tests import `tests/sandbox.py` to isolate app
|
||||
data, SSH settings, home directories and service URLs.
|
||||
|
||||
## Limits and upgrade behaviour
|
||||
|
||||
No real headset was accessed: the shared device lock is held by other work.
|
||||
This is Linux fixture and server coverage, not a fresh packaged-app or
|
||||
Windows/macOS verification. Existing real-headset export evidence in the
|
||||
recording guide predates this destination change.
|
||||
|
||||
Old flat exports cannot prove headset/account/session identity and are left
|
||||
untouched. Previously remembered auto-sync IDs retain their existing
|
||||
watermark/history behaviour; **Copy everything now** or manual **Export**
|
||||
creates an identified copy. No release, version bump, merge or social post
|
||||
is included. PR #113 is outside this task.
|
||||
|
Before Width: | Height: | Size: 188 KiB |
@@ -1,22 +0,0 @@
|
||||
# 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.
|
||||