mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 09:01:43 +01:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b43170fda | ||
|
|
958743c79d | ||
|
|
05308932e8 | ||
|
|
4e4853e82b | ||
|
|
3438c248d9 | ||
|
|
509ddbdc95 | ||
|
|
f2d293bd1a |
No files matched your search
@@ -148,7 +148,7 @@ Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
|
||||
|
||||
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.
|
||||
|
||||
日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
日本語: [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,
|
||||
@@ -245,6 +245,14 @@ network. Guest Wi-Fi/client isolation can prevent discovery and connections.
|
||||
check Developer Mode, the address and network isolation, then run setup again.
|
||||
For connections away from home, see [Tailscale](docs/tailscale.md).
|
||||
|
||||
**Does Frame Control lower streaming lag?** No. Frame Control manages the Frame
|
||||
over SSH. SteamVR or Steam Link carries the game stream. Frame Control does not
|
||||
carry that stream, lower ping, or need the Wireless Adapter. It can connect over
|
||||
Wi-Fi, the Wireless Adapter, or USB when your computer has an SSH route to the
|
||||
Frame. The wired doctor checks only that SSH route. A data-capable USB-A-to-C
|
||||
cable has not been tested for the Frame's USB network. See the
|
||||
[connection FAQ and Japanese quick start](docs/connection-faq.md).
|
||||
|
||||
**What it changes:** only what you click. Installs go to your user account on
|
||||
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
|
||||
games in `~/devkit-game`), and nothing
|
||||
@@ -290,7 +298,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [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 |
|
||||
| [Frame Control extensions](docs/extensions.md) | Local extension API, folder format and example |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, removal, 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 |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.26",
|
||||
"version": "0.4.28",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.26",
|
||||
"version": "0.4.28",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.26",
|
||||
"version": "0.4.28",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Connection and streaming lag FAQ
|
||||
|
||||
## Does Frame Control lower my streaming lag?
|
||||
|
||||
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
|
||||
game stream. Frame Control does not carry the game stream. It cannot lower your
|
||||
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
|
||||
game is not a Frame Control measurement.
|
||||
|
||||
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
|
||||
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
|
||||
route to the Frame. Use the route that lets the app connect. To improve game
|
||||
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
|
||||
The [wired connection doctor](wired-connection.md) checks only Frame Control's
|
||||
SSH route. A successful doctor check does not prove that the game stream uses
|
||||
the cable or has low lag.
|
||||
|
||||
## Can I use a USB-A-to-C cable?
|
||||
|
||||
A Mac reached the Frame over a USB-C connection on the
|
||||
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
|
||||
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
|
||||
cable might show a USB network interface on your PC, but this is unknown on
|
||||
the tested Frame OS and PC hosts. Check whether your PC shows that interface
|
||||
and whether Frame Control's wired doctor can reach the Frame at
|
||||
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
|
||||
for Frame Control if the USB route does not work.
|
||||
|
||||
## 日本語: 5 ステップで接続
|
||||
|
||||
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
|
||||
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
|
||||
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
|
||||
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
|
||||
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
|
||||
|
||||
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -1,6 +1,6 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.28**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
@@ -10,6 +10,7 @@ Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
@@ -18,5 +19,7 @@ Current for Frame Control **v0.4.26**, 25 October 2026. “Real Frame + physical
|
||||
| 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) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; no packaged physical-PC extension check recorded | No | Third-party extensions and Windows/macOS extension folders were not tested on physical hosts | 25 Oct, v0.4.27: [PR #194](https://github.com/saphid/frame-control/pull/194), [extension guide](extensions.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed | No | A usable Decky menu in the VR shell and individual plugin compatibility remain unverified | 25 Oct, v0.4.27: [PR #195](https://github.com/saphid/frame-control/pull/195), [headset findings](decky-frame.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
No individual Decky plugin has been tested on the headset.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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; it does not install plugins while their Frame UI and compatibility
|
||||
are unverified.
|
||||
|
||||
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.
|
||||
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. Then test one harmless plugin's
|
||||
install, visible UI, restart persistence and removal. Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as supported on the Frame.
|
||||
@@ -0,0 +1,32 @@
|
||||
# SteamOS PC OBS host feasibility check, 2026-10-25 UTC
|
||||
|
||||
The available x86_64 host, lxso2, runs Ubuntu 24.04.3. It has no `flatpak`
|
||||
command, OBS installation, SteamOS 3.9.5 VM or physical Steam Machine. This
|
||||
check therefore does **not** verify that Frame Control or Flatpak OBS runs on
|
||||
a SteamOS desktop or a read-only-root PC host.
|
||||
|
||||
The downloaded v0.4.27 Linux x86_64 AppImage matched GitHub's SHA-256 digest
|
||||
`42d774653c69b193c3d577743c32b8fe8dfeee7ea1f8cdf7369902962d647b83`.
|
||||
With `APPIMAGE_EXTRACT_AND_RUN=1`, it launched under Xvfb using an isolated
|
||||
profile and its bundled server returned HTTP 200. FUSE mounting, physical
|
||||
display rendering and SteamOS-specific dependencies were not tested.
|
||||
|
||||
The v0.4.27 tagged OBS helper then ran against the real Frame under the shared
|
||||
`/tmp/frame-test.lock`. Frame OS was 0.5.6 and SteamVR activity level was 3
|
||||
(standby). A receiver bound to `127.0.0.1:15099` on lxso2 accepted 497
|
||||
MPEG-TS packets, 93,436 bytes. Every datagram had complete 188-byte packets
|
||||
with sync bytes. `ffprobe` found H.264 video and AAC audio. Closing the
|
||||
helper's control pipe made it exit 0; no owned remote stream process remained.
|
||||
Battery read 100% Full before and after, and the owned lock was released. The
|
||||
temporary `.ts` file was removed. This proves the local UDP sender path on an
|
||||
Ubuntu host; it does not prove Flatpak OBS could receive it on SteamOS.
|
||||
|
||||
The public
|
||||
[OBS Flatpak manifest](https://github.com/flathub/com.obsproject.Studio/blob/master/com.obsproject.Studio.json)
|
||||
lists `--share=network`, which permits use of the host network. We read that
|
||||
manifest but did not install or run the Flatpak. A future check needs a real
|
||||
SteamOS 3.9.5 desktop host or VM with Flatpak OBS: launch the AppImage without
|
||||
root changes, start the source, record from the Media Source at
|
||||
`udp://127.0.0.1:15099`, verify video and audible game audio, then stop and
|
||||
confirm cleanup. The standby test cannot establish real game audio, A/V sync
|
||||
or active passthrough in the mirror.
|
||||
@@ -56,6 +56,42 @@ The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
|
||||
measured packet continuity and a short MP4 recording on the current SteamOS
|
||||
build. The headset was still in standby.
|
||||
|
||||
## SteamOS / Steam Machine PC host
|
||||
|
||||
Use **Desktop Mode** on the SteamOS PC. Download the Linux x86_64 AppImage to
|
||||
your home folder. Make it executable and open it there; SteamOS has a
|
||||
read-only system root, so these steps do not use `apt` or change `/usr`:
|
||||
|
||||
```sh
|
||||
chmod +x ~/Downloads/Frame-Control-linux-x86_64.AppImage
|
||||
~/Downloads/Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
If AppImage mounting reports a FUSE error, try
|
||||
`APPIMAGE_EXTRACT_AND_RUN=1 ~/Downloads/Frame-Control-linux-x86_64.AppImage`.
|
||||
This extracts to writable temporary space and needs enough free disk space.
|
||||
Set up the Frame's SSH connection in Frame Control, then use **Start OBS
|
||||
source** before you add or activate OBS's Media Source. Install OBS as the
|
||||
`com.obsproject.Studio` Flatpak through Discover or your normal user Flatpak
|
||||
setup. In that OBS Media Source, clear **Local File**, set **Input** to
|
||||
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. Make a short
|
||||
recording and check both picture and sound before a stream. Use **Stop OBS
|
||||
source** in Frame Control when done.
|
||||
|
||||
The OBS Flatpak's published manifest includes `--share=network`, which is
|
||||
needed for its local UDP receiver. Check your installed permissions with
|
||||
`flatpak info --show-permissions com.obsproject.Studio` if OBS receives no
|
||||
packets. Frame Control's helper sends to loopback on this PC; it does not
|
||||
require an open LAN port or an OBS plugin. The PC does not need a system
|
||||
`ffmpeg` package for the helper; encoding runs on the Frame.
|
||||
|
||||
**Test limit:** The v0.4.27 AppImage launched on an Ubuntu 24.04 x86_64 host
|
||||
under Xvfb. Its helper sent H.264 and AAC from a real Frame to a local UDP
|
||||
receiver on that host. No SteamOS 3.9.5 PC, Steam Machine, Flatpak OBS runtime,
|
||||
read-only-root host, or physical display was available for this check. The
|
||||
SteamOS steps and Flatpak receiver therefore remain **untested on SteamOS**.
|
||||
See the [test record](evidence/obs-steamos-host-2026-10-25.md).
|
||||
|
||||
## Known issue: game audio in OBS
|
||||
|
||||
A Frame owner [reported](https://x.com/AzizahStellazio/status/2114147449468293171)
|
||||
|
||||
@@ -25,3 +25,19 @@ uses the cable. The Wireless Adapter and Steam streaming can follow a different
|
||||
path. No cable is attached to the Linux test host, so this exact UI has only
|
||||
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
|
||||
fresh wired readback on the current Frame OS, remain unverified.
|
||||
|
||||
## If SSH works but the game stream stops
|
||||
|
||||
If the stream fails on USB, Wi-Fi and the Wireless Adapter, but this doctor
|
||||
reports a working SSH route, check SteamVR on the **PC**. Frame Control's
|
||||
`frame.local` reconnect only restores its headset management connection. It
|
||||
does not restart PC SteamVR or repair the game stream. On a Windows or Linux
|
||||
PC, save your work and close the VR game. Exit SteamVR from Steam on that PC,
|
||||
then start it again. This can show whether a PC SteamVR session is stuck; it
|
||||
does not fix the cause of repeated drops. SteamVR on macOS is not a supported
|
||||
PC streaming host.
|
||||
|
||||
Frame Control has no verified cross-platform command that safely stops only
|
||||
the intended PC SteamVR session. A forced restart could close a running game
|
||||
and lose unsaved progress. For that reason the app gives manual steps and does
|
||||
not add a one-click restart button.
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Decky release validation and the removable user-service transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_decky
|
||||
|
||||
|
||||
def arm_binary(fill=b'A'):
|
||||
data = bytearray(b'\x7fELF' + fill * 2044)
|
||||
data[18:20] = (183).to_bytes(2, 'little')
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class DeckyRelease(unittest.TestCase):
|
||||
def test_official_arm_release_digest_is_checked(self):
|
||||
data = arm_binary()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
url = f'https://github.com/{frame_decky.REPO}/releases/download/v3.3.0/PluginLoader-arm64'
|
||||
info = {'tag_name':'v3.3.0', 'assets':[{'name':'PluginLoader-arm64', 'size':len(data),
|
||||
'digest':'sha256:'+digest, 'browser_download_url':url}]}
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
self.assertEqual(frame_decky.release_binary(), ('v3.3.0', digest, data))
|
||||
info['assets'][0]['digest'] = 'sha256:' + '0'*64
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||
frame_decky.release_binary()
|
||||
|
||||
def test_install_rejects_incompatible_headset_before_download(self):
|
||||
state = {'arch':'x86_64','variant':'vr','cef':True,'managed':False,'upstreamInstall':False}
|
||||
with patch.object(frame_decky, 'release_binary') as download:
|
||||
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
|
||||
frame_decky.change(lambda *a, **k: json.dumps(state), 'install')
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote service script runs on Linux')
|
||||
class DeckyServiceScript(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
home = Path(self.tmp.name)
|
||||
fake = home / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$FRAME_DECKY_TEST_FAIL" = "1" ] && [ "$2" = "enable" ]; then echo blocked >&2; exit 1; fi\nexit 0\n')
|
||||
fake.chmod(0o700)
|
||||
self.env = {**os.environ, 'HOME': self.tmp.name, 'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
||||
self.base = home / '.local/share/frame-control/decky'
|
||||
|
||||
def apply(self, action, data=None, version='v3.3.0', fail=False):
|
||||
token = secrets.token_hex(12)
|
||||
stage = Path('/tmp/frame-control-decky-' + token)
|
||||
if data is not None:
|
||||
stage.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
env = {**self.env, 'FRAME_DECKY_TEST_FAIL': '1' if fail else '0'}
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, digest, version],
|
||||
input=frame_decky.APPLY, capture_output=True, text=True, env=env, timeout=15)
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
|
||||
def test_install_update_rollback_and_remove(self):
|
||||
first = arm_binary(b'A')
|
||||
result = self.apply('install', first)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
receipt = self.base / '.frame-control/receipt.json'
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
self.assertTrue((Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service').is_file())
|
||||
|
||||
second = arm_binary(b'B')
|
||||
result = self.apply('update', second, version='v3.3.1', fail=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
|
||||
result = self.apply('update', second, version='v3.3.1')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), second)
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.base / 'PluginLoader').exists())
|
||||
self.assertFalse(receipt.exists())
|
||||
self.assertTrue((self.base / 'plugins').is_dir())
|
||||
@@ -9,7 +9,7 @@ import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -121,6 +121,11 @@ class ServerGuards(unittest.TestCase):
|
||||
finally:
|
||||
shutil.rmtree(folder)
|
||||
|
||||
def test_decky_write_requires_experimental_acknowledgement(self):
|
||||
code, body = self.post('/api/decky', {'action': 'install'})
|
||||
self.assertEqual(code, 400)
|
||||
self.assertIn('experimental', body['error'])
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Experimental, removable per-user Decky Loader for Steam Frame (ARM64)."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
|
||||
|
||||
REPO = "SteamDeckHomebrew/decky-loader"
|
||||
API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
MAX_BINARY = 80 * 1024 * 1024
|
||||
TAG = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:[-.][A-Za-z0-9.-]+)?\Z")
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, os, pathlib, platform, subprocess, urllib.request
|
||||
home = pathlib.Path.home()
|
||||
managed = home / '.local/share/frame-control/decky'
|
||||
service = home / '.config/systemd/user/frame-control-decky.service'
|
||||
release = managed / '.frame-control/receipt.json'
|
||||
legacy = managed / 'settings/release.json'
|
||||
def active(name, user=False):
|
||||
cmd = ['systemctl'] + (['--user'] if user else []) + ['is-active', name]
|
||||
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
def plugins(folder):
|
||||
found = []
|
||||
if folder.is_dir() and not folder.is_symlink():
|
||||
for child in sorted(folder.iterdir()):
|
||||
if child.is_symlink() or not child.is_dir(): continue
|
||||
info = child / 'plugin.json'
|
||||
if info.is_symlink() or not info.is_file() or info.stat().st_size > 16384: continue
|
||||
try:
|
||||
data = json.loads(info.read_text())
|
||||
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
|
||||
'author': str(data.get('author', ''))[:80]})
|
||||
except (ValueError, OSError, UnicodeError): pass
|
||||
return found
|
||||
os_release = {}
|
||||
for line in pathlib.Path('/etc/os-release').read_text().splitlines():
|
||||
if '=' in line:
|
||||
k,v = line.split('=',1); os_release[k] = v.strip('"')
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:8080/json/list', timeout=2) as reply:
|
||||
targets = json.load(reply)
|
||||
cef = any(x.get('title') == 'SharedJSContext' and 'steamloopback.host' in x.get('url','') for x in targets)
|
||||
except (OSError, ValueError): cef = False
|
||||
try:
|
||||
metadata = json.loads((release if release.exists() else legacy).read_text())
|
||||
version = metadata.get('version') if metadata.get('managedBy') == 'Frame Control' else None
|
||||
except (OSError, ValueError): version = None
|
||||
print(json.dumps({'arch':platform.machine(), 'frameOS':os_release.get('VERSION_ID'),
|
||||
'variant':os_release.get('VARIANT_ID'), 'build':os_release.get('BUILD_ID'), 'cef':cef,
|
||||
'managed':service.is_file() and version is not None, 'running':active('frame-control-decky.service', True),
|
||||
'version':version, 'upstreamInstall':pathlib.Path('/etc/systemd/system/plugin_loader.service').exists(),
|
||||
'plugins':plugins(managed/'plugins'), 'upstreamPlugins':plugins(home/'homebrew/plugins')}))
|
||||
'''
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib, json, os, pathlib, subprocess, sys
|
||||
action, token, digest, version = sys.argv[1:]
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/decky'
|
||||
service = home / '.config/systemd/user/frame-control-decky.service'
|
||||
binary = base / 'PluginLoader'
|
||||
receipt = base / '.frame-control/receipt.json'
|
||||
legacy = base / 'settings/release.json'
|
||||
stage = pathlib.Path('/tmp/frame-control-decky-' + token)
|
||||
def systemd(*args):
|
||||
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=20)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip() or 'systemd failed')
|
||||
def owned():
|
||||
try: return json.loads((receipt if receipt.exists() else legacy).read_text()).get('managedBy') == 'Frame Control' and service.is_file()
|
||||
except (OSError, ValueError): return False
|
||||
try:
|
||||
if action == 'remove':
|
||||
if not owned(): raise RuntimeError('No Frame Control managed Decky install to remove')
|
||||
systemd('disable','--now','frame-control-decky.service')
|
||||
service.unlink()
|
||||
systemd('daemon-reload')
|
||||
binary.unlink(missing_ok=True)
|
||||
receipt.unlink(missing_ok=True)
|
||||
if legacy.exists() and json.loads(legacy.read_text()).get('managedBy') == 'Frame Control': legacy.unlink()
|
||||
print(json.dumps({'message':'Removed Frame Control managed Decky service and binary. Plugin files were kept. Restart Steam or the headset to clear its menu.'}))
|
||||
else:
|
||||
if action not in ('install','update'): raise RuntimeError('Invalid Decky action')
|
||||
if (home/'homebrew/services/PluginLoader').exists() or pathlib.Path('/etc/systemd/system/plugin_loader.service').exists():
|
||||
raise RuntimeError('An upstream Decky installation exists; remove it with its own installer first')
|
||||
if action == 'update' and not owned(): raise RuntimeError('No Frame Control managed Decky install to update')
|
||||
if action == 'install' and (service.exists() or receipt.exists() or legacy.exists() or binary.exists()):
|
||||
raise RuntimeError('Decky files already exist at the managed path')
|
||||
data = stage.read_bytes()
|
||||
if len(data) > 80*1024*1024 or len(data) < 1024 or hashlib.sha256(data).hexdigest() != digest:
|
||||
raise RuntimeError('Decky binary digest or size did not match')
|
||||
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != 183:
|
||||
raise RuntimeError('Decky release is not an ARM64 Linux binary')
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base/'plugins').mkdir(exist_ok=True)
|
||||
receipt.parent.mkdir(parents=True, exist_ok=True)
|
||||
service.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_binary = binary.read_bytes() if binary.exists() else None
|
||||
old_receipt = receipt.read_bytes() if receipt.exists() else legacy.read_bytes() if legacy.exists() else None
|
||||
old_service = service.read_bytes() if service.exists() else None
|
||||
if old_service: systemd('stop','frame-control-decky.service')
|
||||
try:
|
||||
next_binary = base / 'PluginLoader.next'
|
||||
next_binary.write_bytes(data)
|
||||
os.replace(next_binary, binary)
|
||||
binary.chmod(0o700)
|
||||
text = ('[Unit]\nDescription=Decky Loader (Frame Control experimental user install)\n'
|
||||
'After=default.target\n[Service]\nType=simple\nRestart=on-failure\n'
|
||||
'RestartSec=5\nExecStart='+str(binary)+'\n'
|
||||
'Environment=UNPRIVILEGED_PATH='+str(base)+'\n'
|
||||
'Environment=PRIVILEGED_PATH='+str(base)+'\n'
|
||||
'Environment=CHOWN_PLUGIN_PATH=0\nEnvironment=LIVE_RELOAD=0\n'
|
||||
'Environment=USE_CEF_CLOSE_WORKAROUND=0\nEnvironment=KEEP_SYSTEMD_SERVICE=1\n'
|
||||
'[Install]\nWantedBy=default.target\n')
|
||||
service.write_text(text)
|
||||
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':version,'sha256':digest})+'\n')
|
||||
if legacy.exists() and json.loads(legacy.read_text()).get('managedBy') == 'Frame Control': legacy.unlink()
|
||||
systemd('daemon-reload')
|
||||
systemd('enable','--now','frame-control-decky.service')
|
||||
systemd('is-active','frame-control-decky.service')
|
||||
except Exception:
|
||||
(base/'PluginLoader.next').unlink(missing_ok=True)
|
||||
if old_binary is None: binary.unlink(missing_ok=True)
|
||||
else: binary.write_bytes(old_binary); binary.chmod(0o700)
|
||||
if old_receipt is None: receipt.unlink(missing_ok=True)
|
||||
else: receipt.write_bytes(old_receipt)
|
||||
if old_service is None: service.unlink(missing_ok=True)
|
||||
else: service.write_bytes(old_service)
|
||||
subprocess.run(['systemctl','--user','daemon-reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if old_service: subprocess.run(['systemctl','--user','start','frame-control-decky.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
raise
|
||||
print(json.dumps({'message':'Decky Loader '+version+' is running as a user service. The VR menu is experimental.'}))
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
'''
|
||||
|
||||
|
||||
def status(ssh):
|
||||
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
|
||||
|
||||
def release_binary():
|
||||
request = urllib.request.Request(API, headers={'User-Agent': 'Frame-Control'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
info = json.load(response)
|
||||
version = info.get('tag_name')
|
||||
if not isinstance(version, str) or not TAG.fullmatch(version):
|
||||
raise ValueError('Invalid Decky release version')
|
||||
asset = next((a for a in info.get('assets', []) if a.get('name') == 'PluginLoader-arm64'), None)
|
||||
expected_url = f'https://github.com/{REPO}/releases/download/{version}/PluginLoader-arm64'
|
||||
if not asset or asset.get('browser_download_url') != expected_url or not isinstance(asset.get('size'), int) or not 1024 < asset['size'] <= MAX_BINARY:
|
||||
raise ValueError('Decky release has no valid ARM64 asset')
|
||||
digest = asset.get('digest', '')
|
||||
if not isinstance(digest, str) or not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
|
||||
raise ValueError('Decky release has no published SHA-256 digest')
|
||||
with urllib.request.urlopen(urllib.request.Request(expected_url, headers={'User-Agent': 'Frame-Control'}), timeout=45) as response:
|
||||
data = response.read(MAX_BINARY + 1)
|
||||
if len(data) != asset['size'] or hashlib.sha256(data).hexdigest() != digest[7:]:
|
||||
raise ValueError('Decky download did not match its published digest')
|
||||
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != 183:
|
||||
raise ValueError('Decky download is not an ARM64 Linux binary')
|
||||
return version, digest[7:], data
|
||||
|
||||
|
||||
def change(ssh, action):
|
||||
if action not in ('install', 'update', 'remove'):
|
||||
raise ValueError('Invalid Decky action')
|
||||
current = status(ssh)
|
||||
if action == 'remove':
|
||||
if not current['managed']:
|
||||
raise ValueError('No Frame Control managed Decky install to remove')
|
||||
return json.loads(ssh('python3 - remove none none none', stdin=APPLY, timeout=45))
|
||||
if current['arch'] != 'aarch64' or current['variant'] != 'vr' or not current['cef']:
|
||||
raise ValueError('Decky needs a Steam Frame with an active Steam CEF debug page')
|
||||
if current['upstreamInstall']:
|
||||
raise ValueError('An upstream Decky installation exists; use its own installer')
|
||||
if action == 'install' and current['managed']:
|
||||
raise ValueError('Decky is already installed; use Update')
|
||||
if action == 'update' and not current['managed']:
|
||||
raise ValueError('Decky is not installed by Frame Control')
|
||||
version, digest, data = release_binary()
|
||||
token = secrets.token_hex(12)
|
||||
remote = '/tmp/frame-control-decky-' + token
|
||||
try:
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
result = ssh(f'python3 - {action} {token} {digest} {version}', stdin=APPLY, timeout=50)
|
||||
return json.loads(result)
|
||||
finally:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
except Exception: pass
|
||||
+41
-3
@@ -1436,7 +1436,7 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
@@ -1448,6 +1448,15 @@
|
||||
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer" style="width:100%;height:440px;border:1px solid rgba(255,255,255,.12);border-radius:4px;background:#101820"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="decky">
|
||||
<div class="shelf-head"><h2>Decky Loader on Frame</h2><span class="spacer"></span><button class="small" id="deckyRefresh">Refresh</button></div>
|
||||
<p class="sub">Experimental. Decky's ARM64 backend can start on Frame OS, but its plugin menu has not been verified in the VR shell. Some Decky plugins need root and will not work with this user service.</p>
|
||||
<div id="deckyState" class="sub">Checking…</div>
|
||||
<div id="deckyPlugins" class="hint"></div>
|
||||
<label class="popt"><input type="checkbox" id="deckyAck"> I understand that the VR menu and individual plugins may not work. Install only if I want to test Decky.</label>
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
|
||||
<div class="hint">Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.</div>
|
||||
</section>
|
||||
<section class="panel" id="wireless" hidden>
|
||||
<h2>Wireless Adapter doctor</h2>
|
||||
<p class="sub">Read-only snapshot of this Windows PC. Works without a headset connection. Nothing on the Frame is read or changed.</p>
|
||||
@@ -2157,6 +2166,34 @@ $('extensionClose').onclick = () => {
|
||||
$('extensionFrame').removeAttribute('src');
|
||||
$('extensionPanel').hidden = true;
|
||||
};
|
||||
let deckyInfo = null;
|
||||
async function loadDecky() {
|
||||
try {
|
||||
const d = deckyInfo = await api('/api/decky');
|
||||
$('deckyState').textContent = `${d.frameOS || 'Unknown'} ${d.variant || ''} · ${d.arch} · CEF ${d.cef ? 'ready' : 'unavailable'} · ${d.managed ? `managed Decky ${d.version || ''} ${d.running ? 'running' : 'stopped'}` : d.upstreamInstall ? 'upstream Decky installation found' : 'Decky not installed'}`;
|
||||
const plugins = [...d.plugins.map(x => `${x.name} (managed)`), ...d.upstreamPlugins.map(x => `${x.name} (upstream)`)];
|
||||
$('deckyPlugins').textContent = plugins.length ? `Installed plugins: ${plugins.join(', ')}` : 'No Decky plugins found.';
|
||||
deckyButtons();
|
||||
} catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); }
|
||||
}
|
||||
function deckyButtons() {
|
||||
const ready = !!deckyInfo && deckyInfo.arch === 'aarch64' && deckyInfo.variant === 'vr' && deckyInfo.cef && !deckyInfo.upstreamInstall;
|
||||
const ack = $('deckyAck').checked;
|
||||
$('deckyInstall').disabled = !ack || !ready || deckyInfo.managed;
|
||||
$('deckyUpdate').disabled = !ack || !ready || !deckyInfo.managed;
|
||||
$('deckyRemove').disabled = !ack || !deckyInfo?.managed;
|
||||
}
|
||||
$('deckyAck').onchange = deckyButtons;
|
||||
$('deckyRefresh').onclick = loadDecky;
|
||||
for (const action of ['Install', 'Update', 'Remove']) {
|
||||
$(`decky${action}`).onclick = async () => {
|
||||
if (!$('deckyAck').checked || !deckyInfo) return;
|
||||
const verb = action.toLowerCase();
|
||||
if (verb === 'remove' && !confirm('Remove the Frame Control managed Decky service and binary? Plugin files will stay.')) return;
|
||||
await runJob(`${action} Decky Loader`, 'decky', () => api('/api/decky', {action:verb, acknowledged:true}));
|
||||
loadDecky();
|
||||
};
|
||||
}
|
||||
window.addEventListener('message', event => {
|
||||
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
|
||||
const request = event.data;
|
||||
@@ -6141,10 +6178,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools"};
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -6252,6 +6289,7 @@ function showPage(e) {
|
||||
if (tool === 'macview') loadMacView();
|
||||
if (tool === 'panels') loadPanelSwitcher();
|
||||
if (tool === 'extensions') loadExtensions();
|
||||
if (tool === 'decky') loadDecky();
|
||||
if (tool === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
|
||||
@@ -79,6 +79,7 @@ import frame_wireless_doctor # noqa: E402
|
||||
import frame_wired_doctor # noqa: E402
|
||||
import frame_compat_db # noqa: E402
|
||||
import frame_extensions # noqa: E402
|
||||
import frame_decky # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -549,6 +550,14 @@ def status(_body):
|
||||
return s
|
||||
|
||||
|
||||
def decky(body):
|
||||
action = body.get("action")
|
||||
if action not in ("install", "update", "remove"):
|
||||
raise Failure("Choose install, update, or remove", 400)
|
||||
if body.get("acknowledged") is not True:
|
||||
raise Failure("Confirm that Decky support on the Frame is experimental", 400)
|
||||
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
@@ -3322,6 +3331,7 @@ POST = {
|
||||
"/api/steam-pipewire": steam_pipewire_action,
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/decky": decky,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -3522,6 +3532,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
|
||||
elif path == "/api/extensions":
|
||||
self.send_json(frame_extensions.list_extensions())
|
||||
elif path == "/api/decky":
|
||||
self.send_json(frame_decky.status(ssh))
|
||||
elif path.startswith("/extension/"):
|
||||
parts = path.split("/")
|
||||
if len(parts) != 4:
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<section><div class="row"><label for="alias">Your configured headset SSH alias<input id="alias" value="frame" maxlength="64" autocomplete="off"></label>
|
||||
<button id="check">Check cable connection</button></div><p id="message" role="status" aria-live="polite">No checks have run.</p></section>
|
||||
<div id="results" aria-live="polite"></div>
|
||||
<section><h2>SSH works, but the PC stream still fails?</h2>
|
||||
<p>The doctor checks Frame Control's SSH route only. If the SteamVR stream stops on USB, Wi-Fi and the Wireless Adapter, the problem may be on the PC. On a Windows or Linux PC, first save your work and close the VR game. Exit SteamVR from Steam on the PC, then start SteamVR again. Frame Control reconnecting to <code>frame.local</code> will not restart PC SteamVR. This is a manual test, not a fix for the cause of repeated drops. Frame Control does not restart PC SteamVR automatically because it cannot safely know which game and session it would stop.</p></section>
|
||||
<p class="sub">The observed USB address is 10.86.200.233 on Frame OS tested in 2026. A different OS build may use another subnet. An unknown result does not prove the cable is broken. The doctor never changes IP settings, accepts a new host key, or bypasses SSH warnings.</p>
|
||||
</main><script>
|
||||
'use strict';
|
||||
|
||||
Reference in new issue
Block a user