Compare commits

..
Author SHA1 Message Date
SAPHID 8b706cf8dd Record Round 3 headset mirror and charge-limit checks 2026-10-24 21:21:19 +00:00
69 changed files with 80 additions and 3765 deletions

No files matched your search

-21
View File
@@ -173,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
-59
View File
@@ -2,65 +2,6 @@
## Unreleased
- Save Steam screenshots under a separate folder for each headset, Steam
account and game. Equal screenshot filenames no longer overwrite one another;
existing files in the old flat folder are left in place.
[Copying guide](docs/recording.md).
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
No adapter setting changes or report uploads are made.
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
and test one connection condition at a time. In-app disable/re-enable remains
deferred until an active Steam adapter instance can be identified safely.
[Guide and limits](docs/windows-wireless-doctor.md).
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
on Windows and Command-Tab on macOS. Single-window panels keep their own
source, so the button is hidden there.
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
lets the Python relay close its SSH child on Stop, app quit or parent exit;
add a short [Discord guide](docs/streaming-to-discord.md).
- Add a read-only wired connection doctor for a PC using the Frame's observed
USB-C network: show the local route, SSH port and pinned-key login result
without changing IP, host keys or headset settings.
[Usage and limits](docs/wired-connection.md).
- Start the Android app catalogue without waiting for the headset's installed-app
list. Browsing remains available if that status request is slow or fails;
installation still waits for a confirmed list to avoid duplicate installs.
- Verify the packaged OBS Start and Stop control with a real Frame, and record
37.600 seconds of headset mirror video and non-silent output audio in OBS.
Listening, sync and active passthrough remain unverified.
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
- Bundle the OBS source helper in Linux and macOS installers and add a
**Capture & Live** Start/Stop control. The app selects the active Frame and
closes its SSH stream on a headset switch or app exit. A real Frame stream
reached OBS with video and non-silent audio; listening and sync remain
unverified. [Setup and limits](docs/obs-headset-source.md).
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
default-output audio to a local OBS Media Source. A real Frame supplied a
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
remain unverified. [Setup and limits](docs/obs-headset-source.md).
- Link emulator setup guidance from Store → Emulators. Existing Steam library
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
transfer still needs a verified per-instance workflow.
[Guide](docs/emulators.md).
- Record the current Frame OS mirror and charge-limit checks. Active
passthrough in the mirror is still unknown; this build has no supported
user-writable 80% charge-limit control.
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
-3
View File
@@ -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
+1 -32
View File
@@ -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>
---
@@ -132,24 +130,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
@@ -206,10 +186,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
@@ -245,14 +223,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
@@ -298,7 +268,6 @@ 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 |
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control in VR](docs/headset-app.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 |
-1
View File
@@ -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",
-53
View File
@@ -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 };
+7 -154
View File
@@ -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,62 +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");
let obsChild = null;
let obsState = { state: "idle", alias: "", error: "" };
function publicObs() { return { ...obsState, supported: true }; }
function setObs(state, alias = "", error = "") {
obsState = { state, alias, error };
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
return publicObs();
}
function stopObs() {
if (!obsChild) return publicObs();
setObs("stopping", obsState.alias);
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
return publicObs();
}
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", async (e) => {
if (!fromUi(e)) return null;
if (obsChild) return publicObs();
const alias = activeAlias();
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
// A concurrent click or a headset switch may have happened while finding Python.
if (obsChild) return publicObs();
if (alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias, "--control-stdin"],
{ env, stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
obsChild = child;
setObs("starting", alias);
let output = "";
child.stderr.on("data", (chunk) => {
output = (output + chunk.toString("utf8")).slice(-2048);
if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias);
});
child.once("error", (err) => {
if (obsChild !== child) return;
obsChild = null;
setObs("failed", alias, `Could not start OBS source: ${err.message}`);
});
child.once("exit", (code, signal) => {
if (obsChild !== child) return;
const stopping = obsState.state === "stopping" || quitting;
obsChild = null;
setObs(stopping ? "idle" : "failed", alias,
stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`);
});
return publicObs();
});
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -373,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 (obsChild && obsState.alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -576,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,
@@ -638,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 }) => {
@@ -695,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 ? [{
@@ -745,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);
});
@@ -753,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 (!obsChild || obsQuitReady) return;
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsChild;
let timer;
child.once("close", () => {
clearTimeout(timer);
obsQuitReady = true;
app.quit();
});
stopObs();
timer = setTimeout(() => {
if (child.exitCode === null) {
if (IS_WIN) {
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
} else child.kill("SIGKILL");
}
obsQuitReady = true;
app.quit();
}, 8000);
});
process.on("exit", () => { stopObs(); stopServer(); });
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.29",
"version": "0.4.20",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.29",
"version": "0.4.20",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -3
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.29",
"version": "0.4.20",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -40,7 +40,6 @@
"install-link.js",
"updater.js",
"preferences.js",
"login-item.js",
"meta-signin.js",
"package.json",
"build/icon.png"
@@ -70,7 +69,6 @@
"to": "scripts",
"filter": [
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
-9
View File
@@ -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: () => ipcRenderer.invoke("obs:start"),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
ipcRenderer.on("obs:state", (_e, state) => cb(state));
},
},
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
-57
View File
@@ -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, []);
});
}
-37
View File
@@ -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 ネットワークが使えるかは、まだ確認できていません。
-26
View File
@@ -1,26 +0,0 @@
# What we have tested
Current for Frame Control **v0.4.29**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|---|---|---|---|---|
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
| Experimental Frame Control SteamVR panel | Real Frame OS 0.5.6 installed its user service, registered an overlay, served authenticated live status and a read-only extension, saved a screenshot and short MP4, and removed owned resources | No | Headset was unworn in standby: overlay reported `not_visible`, stereo capture was dark; wearer-visible pixels and controller input are unverified | 25 Oct, v0.4.29: [PR #198](https://github.com/saphid/frame-control/pull/198), [PR #201](https://github.com/saphid/frame-control/pull/201), [panel guide](headset-app.md) |
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed; ProtonDB Badges loaded in Decky backend and Steam shared UI registry | No | Decky and plugin UI in the VR shell, wearer-visible badges, controller input and other plugins remain unverified | 25 Oct, v0.4.29: [PR #195](https://github.com/saphid/frame-control/pull/195), [PR #202](https://github.com/saphid/frame-control/pull/202), [headset findings](decky-frame.md) |
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
-131
View File
@@ -1,131 +0,0 @@
# Decky Loader on Steam Frame
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
verified. Frame Control therefore labels its user-service manager experimental.
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
VR user interface has not been seen or used.
## 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; 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. 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.
-54
View File
@@ -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).
-58
View File
@@ -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,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.
-73
View File
@@ -1,73 +0,0 @@
# Frame Control extensions
Extensions add a local panel to Frame Control's Tools page. They are plain HTML,
CSS and JavaScript. Frame Control discovers them from its per-user data folder;
there is no build step, server process, or headset installation. Open an extension
to run it. Closing it unloads its page.
## Install the example
Copy [`examples/extensions/headset-glance`](../examples/extensions/headset-glance)
to the `extensions` folder shown on Tools → Extensions, then select **Refresh**
and **Open Headset Glance**. The folder is normally:
- Linux: `~/.local/share/frame-control/extensions`
- macOS: `~/Library/Application Support/Frame Control/extensions`
- Windows: `%APPDATA%\Frame Control\extensions`
To remove it, close the panel and delete only its `headset-glance` folder. The
desktop app reads the folder on its computer. The iPhone app and the opt-in
[SteamVR headset panel](headset-app.md) read the folder on the Frame. To use an
extension in VR, copy its folder to
`steamos@frame:~/.local/share/frame-control/extensions/`, then select
**Extensions → Refresh extensions → Open** in the headset panel. The iframe,
read-only bridge and asset rules are the same there.
## Make an extension
Create `extensions/your-id/manifest.json` and `index.html`. IDs use lowercase
letters, digits and hyphens, start with a letter, and are at most 40 characters.
The manifest ID must match the folder name:
```json
{
"id": "your-id",
"name": "Your feature",
"version": "1.0.0",
"description": "What the panel does.",
"entry": "index.html"
}
```
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
top-level window. Content Security Policy restricts scripts, images, and
`fetch` requests, but an extension can still navigate its own iframe to an
external site. Treat third-party extensions as untrusted code and review them
before installation.
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
shows a complete client:
```js
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
```
Supported actions:
| Action | Value |
| --- | --- |
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
`status` is the app's latest successful status snapshot. When that reading fails,
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
should use explicit, narrow actions and keep the iframe isolated. A third-party
extension is code you choose to run locally: inspect its files before copying
them into the folder. A Decky plugin is a separate Steam client plugin and does
not use this API.
+1 -5
View File
@@ -84,11 +84,7 @@ counts them while they run.
- **Volume** and mute (`wpctl`).
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it.
The catalogue can load while the installed-app list is still being read. If
that read fails, browsing remains available, but installation waits for a
confirmed list so Frame Control does not make a duplicate app instance.
**Report an APK** records
Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. With **Share compatibility results** on (Privacy & updates), they also
-84
View File
@@ -1,84 +0,0 @@
# Frame Control in the headset
**Experimental, Frame OS 0.5.6.** Tools → Frame Control in VR installs a local
panel in SteamVR's dashboard. It is a way to reach Frame Control while wearing
the headset, independent of Decky's Quick Access Menu. The panel starts only
after you opt in; its server binds to `127.0.0.1` and it uses a normal user
service. No `sudo`, root filesystem change, or Steam client patch is needed.
The companion copies its bundled UI over the existing trusted SSH connection.
On the Frame, `frame-control-headset.service` runs a loopback server and one
Chromium app window on gamescope. The window receives a dedicated `STEAM_GAME`
id, so SteamVR registers `valve.steam.desktopgame.2001947813` as a dashboard
panel. **Show in SteamVR** requests that dashboard tab; the wearer can select
it again from SteamVR's dashboard. Closing the panel stops its browser and
server. The service is not enabled at boot.
The page shows battery, free home storage and SteamVR status. It can request
SteamVR's main Game Mode dashboard, list and force quit a running game after an
unsaved-progress confirmation, save an OpenVR screenshot to
`~/Pictures/FrameControl`, and record the SteamVR mirror to
`~/Videos/FrameControl`. Recordings have a 20-minute limit and use a separate
work folder from desktop Frame Control's recorder; they may have no audio if
the monitor source is unavailable. Captures made in standby can be dark.
The page also discovers extensions from
`~/.local/share/frame-control/extensions` and opens them in sandboxed iframes.
It offers the version 1 read-only `capabilities` and `status` bridge from
[the extension guide](extensions.md). Installing an extension still means
copying its folder to the Frame; the panel does not download third-party code.
The per-run UI key is passed in the URL fragment, removed from the address bar after load,
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
line and server log. The usual host/origin and anti-framing checks still apply.
## Real Frame test, 2026-10-25
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
user service, served `/headset`, read live `/api/status` from the browser, and
registered a mapped Chromium window with SteamVR under the dedicated overlay
key. The server rejected a request with the wrong UI key. A read-only status
probe reported the user service running and the overlay registered.
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
screenshot was a uniform dark frame. The documented temporary wake attempt
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
still left the overlay `not_visible`; the setting was restored to 5 and the
dashboard hidden. The X window's `xwd` image was black too. These observations
prove panel creation and page/API execution, **not wearer-visible pixels or
laser/controller input**. A worn-headset check is still required before
describing the panel as visually verified.
The test then removed the managed service and app bundle. Its unit became
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
battery was 100%. Steam and SteamVR were left running. The browser profile and
extensions folder were deliberately kept.
A second test on the same build installed the expanded panel with the
`headset-glance` example extension. The headset's authenticated loopback API
returned live status (99% battery), discovered and served the extension, and
listed no running games. The Game Mode dashboard request succeeded; an invalid
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
reported an audio source. The per-run UI key was absent from the server log.
After another wake and dashboard request, SteamVR still reported the panel
`not_visible`. No wearer-visible screenshot or controller interaction was
obtained. The test removed its example extension and captured media, then
uninstalled the service. The unit, port, bundle and overlay were absent after
removal; Steam and SteamVR remained running.
## Remove
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
run:
```sh
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
```
That command removes only Frame Control's managed panel code. The Chromium
profile remains in `~/.local/share/frame-control/headset/profile` so local
extension data is not discarded. Inspect and delete that folder separately if
you no longer want it. Third-party extensions are in
`~/.local/share/frame-control/extensions` and are never removed by this tool.
-6
View File
@@ -7,12 +7,6 @@ the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
On a **whole Mac display** panel, click **Switch Mac app (⌘Tab)** to send one
Command-Tab chord to the Mac. The button is absent from single-window panels;
those already keep their own source. It requires the same Mac Accessibility
permission as other viewer keyboard input. The chord and button have automated
checks, but a worn-headset click has not been verified.
The confidence labels are the same as in [ssh.md](ssh.md).
## Why this design
-110
View File
@@ -1,110 +0,0 @@
# Headset view in OBS
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
OBS on a Windows, Linux or macOS computer. The helper uses the same `/dev/video99` video
source and default-output monitor as **Capture & Live → Record**. It sends a
local MPEG-TS stream; no port is opened to the LAN or internet.
1. Connect to the Frame and start SteamVR. OBS runs on the computer; the Frame
already has `ffmpeg`.
2. In the **Frame Control desktop app**, open **Capture & Live** and click
**Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching
headsets, or quitting the app closes its SSH stream. It uses the selected
headset and the app's bundled helper.
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
recording levels.
From a source checkout, you can instead install `python3` on the computer and
run:
```sh
python3 scripts/obs-headset-source.py
```
Ctrl+C stops the checkout helper. The app closes a control pipe when you click
Stop or quit; the helper then closes its SSH child, including on Windows. On
Windows, install Python 3 and OpenSSH Client if Frame Control cannot find them.
The Windows app Start/Stop path still needs a physical Windows/Frame test.
If no first packet arrives within 20 seconds, or an active stream sends no
packet for 10 seconds, the helper closes SSH and shows an error instead of
leaving **Start OBS source** waiting or a frozen source running indefinitely.
To share the OBS view in a call, see the short
[Discord setup guide](streaming-to-discord.md).
`--height 1080 --fps 60` increases quality and Frame CPU use. The default is
720p30. `--port 15100` selects another loopback port; use the same port in
OBS. If the Frame's PulseAudio monitor cannot be read, the helper exits with
an error. Use `--no-audio` only when a silent view is useful. To inspect the
stream without OBS, run `ffprobe -v error -show_streams
udp://127.0.0.1:15099` in a second terminal while the helper is running.
This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
passthrough camera feed. The mirror may be blank when the headset is idle or
unworn. Whether active passthrough pixels appear in it is still unverified.
The helper does not send a microphone. It is designed for one local OBS
receiver. **Verified 2026-10-25:** the helper bundled in the Linux AppImage
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
default output was present in the saved audio. The headset was in standby, so
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
and audio/video sync and active passthrough remain unverified. See the
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
measured packet continuity and a short MP4 recording on the current SteamOS
build. The headset was still in standby.
## SteamOS / Steam Machine PC host
Use **Desktop Mode** on the SteamOS PC. Download the Linux x86_64 AppImage to
your home folder. Make it executable and open it there; SteamOS has a
read-only system root, so these steps do not use `apt` or change `/usr`:
```sh
chmod +x ~/Downloads/Frame-Control-linux-x86_64.AppImage
~/Downloads/Frame-Control-linux-x86_64.AppImage
```
If AppImage mounting reports a FUSE error, try
`APPIMAGE_EXTRACT_AND_RUN=1 ~/Downloads/Frame-Control-linux-x86_64.AppImage`.
This extracts to writable temporary space and needs enough free disk space.
Set up the Frame's SSH connection in Frame Control, then use **Start OBS
source** before you add or activate OBS's Media Source. Install OBS as the
`com.obsproject.Studio` Flatpak through Discover or your normal user Flatpak
setup. In that OBS Media Source, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. Make a short
recording and check both picture and sound before a stream. Use **Stop OBS
source** in Frame Control when done.
The OBS Flatpak's published manifest includes `--share=network`, which is
needed for its local UDP receiver. Check your installed permissions with
`flatpak info --show-permissions com.obsproject.Studio` if OBS receives no
packets. Frame Control's helper sends to loopback on this PC; it does not
require an open LAN port or an OBS plugin. The PC does not need a system
`ffmpeg` package for the helper; encoding runs on the Frame.
**Test limit:** The v0.4.27 AppImage launched on an Ubuntu 24.04 x86_64 host
under Xvfb. Its helper sent H.264 and AAC from a real Frame to a local UDP
receiver on that host. No SteamOS 3.9.5 PC, Steam Machine, Flatpak OBS runtime,
read-only-root host, or physical display was available for this check. The
SteamOS steps and Flatpak receiver therefore remain **untested on SteamOS**.
See the [test record](evidence/obs-steamos-host-2026-10-25.md).
## Known issue: game audio in OBS
A Frame owner [reported](https://x.com/AzizahStellazio/status/2114147449468293171)
that OBS **Game Capture / Application Audio Capture** can lose game audio when
the headset turns on. OBS support attributed this to Valve/SteamVR. Frame
Control cannot change that game-audio routing. If it happens, use Frame
Control's **Start OBS source** and OBS **Media Source** steps above to capture
the headset view and its output audio. You can also try OBS **Desktop Audio
Capture** for the computer's output, then check its meter and a short test
recording before you stream. Do not leave two active sources for the same audio
unless you want duplicate sound.
The real Frame/OBS check above proved that a synthetic tone from the Frame's
output reached the Media Source recording. It did **not** use a running game,
listen to the audio, reproduce the reported Game Capture failure, or measure
audio/video sync. Real game audio while the headset is awake remains unverified.
-6
View File
@@ -13,12 +13,6 @@ whole screens. Place the panel with the dashboard's Float
in World, Move and Size, point and click with the laser, scroll with the
thumbstick, and type on the PC's keyboard.
On a whole-screen panel, click **Switch PC window (Alt+Tab)** to switch the
foreground Windows window. It sends one explicit Alt-Tab chord through the
viewer's existing input channel. The button is absent from single-window panels
when a WGC-enabled build supports them. A real Windows/Frame button click has
not been verified; use the PC keyboard if focus does not move as expected.
**It's experimental.** A Windows 11 VM has captured its real desktop with
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
hardware encoding and physical laser input remain unverified. See
-15
View File
@@ -1,8 +1,5 @@
# Recording and Steam clips
For a live OBS Media Source with headset output audio, see
[Headset view in OBS](obs-headset-source.md).
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
an `.mp4` on your computer. It can also export the clips and background
recordings from Steam's own game recording, at any length, as one `.mp4` each.
@@ -107,18 +104,6 @@ about once a minute and copies what's new with the same code as **Save** and
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
`~/Videos/SteamFrame`.
Each newly saved screenshot lives under
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
The headset folder uses a short hash of Frame Control's device identity.
Steam can use the same screenshot filename for two games or accounts; these
folders keep both originals. Existing screenshots in the old flat
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
matched reliably to one headset and game, so **Copy everything now** can create
a new, correctly identified copy. It never overwrites the old file.
One real Frame screenshot was copied through this path on 25 October 2026;
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
case was tested with separate fake sources, not two real same-name screenshots.
- **Only what's new.** It copies what you take after turning it on, so the
shots and clips already on the headset don't all arrive at once. **Copy
everything now** copies everything that isn't on this computer yet, once.
+2 -6
View File
@@ -13,9 +13,7 @@ count. So a build reaches people only when you publish it, after testing it.
```
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
attaches everything to a **draft** release for that tag. After all eight
installers upload, the workflow also attaches `SHA256SUMS` built from
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
attaches everything to a **draft** release for that tag. Nobody is
offered a draft. Editing a draft while installers are uploading can make
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
draft by its exact version title, restores the tag association and retries
@@ -34,9 +32,7 @@ count. So a build reaches people only when you publish it, after testing it.
```
The script checks that the tag is on GitHub and that all eight installers
are attached, each with the SHA-256 digest GitHub records. It also checks
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
installer digests. It attaches
are attached, each with the SHA-256 digest GitHub records. It attaches
`update.json` (the version, the notes, the release page and each
installer's digest), then publishes the release and marks it latest. It
uses only the REST API: `gh release view` can't find a draft whose
-20
View File
@@ -1,20 +0,0 @@
# Share the Frame view in Discord
1. Start the **OBS source** in Frame Control and add it to OBS as described in
[Headset view in OBS](obs-headset-source.md). Confirm that OBS shows the
headset mirror and that its audio meter moves before joining a call.
2. In OBS, click **Start Virtual Camera**. In Discord's **Voice & Video**
settings, select **OBS Virtual Camera** as the camera, then turn it on in the
call. This sends the OBS scene as video.
3. If the call needs the Frame's game sound, route OBS's monitored Media Source
audio to a virtual audio input installed on your computer, then select that
input as Discord's microphone. Keep headphones on to avoid feedback. Test
this in a private call first; a virtual camera alone carries no audio.
4. Stop the OBS source in Frame Control when you finish. It closes the SSH
stream; OBS may keep showing its last frame until the source is hidden.
Frame Control sends the Frame's **output audio**, not its microphone. Discord
audio routing differs by computer and requires a separate virtual audio device
if you want game sound in the call. This guide has not been verified in a real
Discord call. The SteamVR mirror can be blank while the headset is idle, and
active passthrough in that mirror remains unverified.
-1
View File
@@ -52,7 +52,6 @@
<body>
<main>
<h1>Frame Control 0.3.1: features by OS</h1>
<p><strong>This is a September 2026 archive.</strong> For current coverage and hardware limits, read the <a href="current-support-matrix.md">v0.4.25 support matrix</a>.</p>
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
-40
View File
@@ -48,46 +48,6 @@ this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## When a stream drops immediately or the adapter will not reconnect
Read PC adapters **before** starting a stream, then refresh immediately after a
drop. Compare the *exact* PnP instance IDs, presence and problem codes. Note
whether the PC's own Wi-Fi drops too. A new `#2` name is not proof that this
instance caused the failure. Keep the time of the drop and the two snapshots
for Steam Support; the doctor does not save or upload them.
The app shows **Changes since previous read** after the second click. It
compares exact PnP IDs, known status fields and reported channels/bands; an
unknown value is never treated as a state change. If the PnP source was
unavailable in either read, it declines to compare devices. Each successful
read becomes the baseline for the next one. **Forget previous read** clears the
baseline and comparison; closing the app clears them too. The before/after
comparison stays in local memory and is not uploaded or added to telemetry.
Reported changes can reflect sequential query timing or a service recovering,
not a fault or permission to disable any instance.
To narrow a drop at stream start, try one condition at a time: wired PC
networking instead of PC Wi-Fi, wired headphones instead of 2.4 GHz audio, and
spacing the USB radio dongles apart. Recheck the stream after each change. The
PC Wi-Fi channel in this report is not the Steam dongle's channel, so a matching
or different number does not establish interference.
If the adapter will not reconnect, stop the failed stream and close Steam's
streaming session before touching its USB connection. Refresh the inventory. If
the dongle is absent, reconnect it once at the PC, understanding that this
interrupts any connection using it, then refresh again. If the **same exact**
PnP instance now reports problem code 22 (disabled), review that instance in
Windows Device Manager and its normal enable action. Do not enable or disable a
different numbered instance by guesswork. For a missing device or another
problem code, record the before/after ID and code for Steam Support; do not
guess a driver or change security settings.
These are user-controlled checks, not an automatic repair. We have no physical
Steam Wireless Adapter dropout to reproduce and no verified mapping from a
Wi-Fi Direct virtual instance to Steam's active session. An in-app disable and
re-enable control remains deferred until that mapping and a recovery path are
tested on real hardware.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
-43
View File
@@ -1,43 +0,0 @@
# Wired connection doctor
If you use a USB-C cable because the Wireless Adapter is unreliable, open
**Devices & connection → Wired connection doctor** in the desktop app. Enter
the headset's configured SSH alias and click **Check cable connection**. From
a source checkout, run `python3 scripts/frame-wired-doctor.py --alias frame`.
The on-demand check reads the computer's selected route to the Frame USB
address, checks whether port 22 answers, then runs remote `true` over SSH with
the existing pinned host key. It does not change network settings or the
headset, accept a new key, or attempt a password. A failure is shown as unknown
or warning; the doctor never claims a cable works from an IP address alone.
The known address is `10.86.200.233` on the Frame's `10.86.200.232/29` USB
network. A Mac with the Frame attached used `10.86.200.234/29` and completed a
strict SSH login on 2026-09-28; see [the observed link](how-the-frame-works.md).
If the route check says unknown, first confirm a data-capable USB-C cable, a
visible PC network interface and a current Frame OS build. Other OS builds may
use another subnet. If the port answers but SSH identity fails, connect using
the normal pinned headset alias and resolve the pairing or key warning there;
do not disable host-key checking.
This checks **Frame Control's SSH route**, not whether SteamVR's game stream
uses the cable. The Wireless Adapter and Steam streaming can follow a different
path. No cable is attached to the Linux test host, so this exact UI has only
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
fresh wired readback on the current Frame OS, remain unverified.
## If SSH works but the game stream stops
If the stream fails on USB, Wi-Fi and the Wireless Adapter, but this doctor
reports a working SSH route, check SteamVR on the **PC**. Frame Control's
`frame.local` reconnect only restores its headset management connection. It
does not restart PC SteamVR or repair the game stream. On a Windows or Linux
PC, save your work and close the VR game. Exit SteamVR from Steam on that PC,
then start it again. This can show whether a PC SteamVR session is stuck; it
does not fix the cause of repeated drops. SteamVR on macOS is not a supported
PC streaming host.
Frame Control has no verified cross-platform command that safely stops only
the intended PC SteamVR session. A forced restart could close a running game
and lose unsaved progress. For that reason the app gives manual steps and does
not add a one-click restart button.
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<title>Headset Glance</title>
<main>
<h1>Headset Glance</h1>
<p id="summary">Reading the headset status…</p>
<button id="refresh">Refresh</button>
</main>
<script src="main.js"></script>
</html>
@@ -1,29 +0,0 @@
let nextId = 0;
const pending = new Map();
window.addEventListener('message', event => {
if (event.source !== parent || event.data?.type !== 'frame-control:response') return;
const resolve = pending.get(event.data.id);
if (resolve) {
pending.delete(event.data.id);
resolve(event.data.value);
}
});
function request(action) {
return new Promise(resolve => {
const id = ++nextId;
pending.set(id, resolve);
parent.postMessage({type: 'frame-control:request', id, action}, '*');
});
}
async function refresh() {
const status = await request('status');
document.getElementById('summary').textContent = status.connected
? `Frame OS ${status.os.version || 'unknown'} · Battery ${status.battery.percent ?? '?'}% · SteamVR ${status.steamvr ? 'running' : 'stopped'}`
: 'No headset status is available yet.';
}
document.getElementById('refresh').addEventListener('click', refresh);
refresh();
@@ -1,7 +0,0 @@
{
"id": "headset-glance",
"name": "Headset Glance",
"version": "1.0.0",
"description": "Shows a small read-only summary of the connected Steam Frame.",
"entry": "index.html"
}
@@ -1,4 +0,0 @@
body { margin: 0; background: #101820; color: #dcdedf; font: 16px system-ui, sans-serif; }
main { padding: 24px; }
h1 { font-size: 20px; }
button { border: 0; border-radius: 4px; padding: 8px 14px; background: #1a9fff; color: #fff; cursor: pointer; }
-10
View File
@@ -1,10 +0,0 @@
#!/usr/bin/env python3
"""Source-checkout entry point for the PC's read-only USB-C route checks."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
from frame_wired_doctor import main # noqa: E402
if __name__ == '__main__':
raise SystemExit(main())
-184
View File
@@ -1,184 +0,0 @@
#!/usr/bin/env python3
"""Send the Frame's SteamVR mirror and output audio to a local OBS Media Source.
OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
the network: this process sends MPEG-TS packets to the computer's loopback.
"""
import argparse
import shlex
import signal
import socket
import subprocess
import sys
import threading
import time
PACKET = 188
DATAGRAM = PACKET * 7
STARTUP_TIMEOUT = 20
STREAM_STALL = 10
def remote_command(height=720, fps=30, audio=True):
if height not in (720, 1080) or fps not in (30, 60):
raise ValueError('height must be 720 or 1080; fps must be 30 or 60')
rate = 3 if height == 720 else 6
clock = '-thread_queue_size 1024 -use_wallclock_as_timestamps 1'
video = f'{clock} -f v4l2 -video_size 1920x1080 -i /dev/video99'
sound = f' {clock} -f pulse -i @DEFAULT_MONITOR@' if audio else ''
maps = '-map 0:v:0 -map 1:a:0' if audio else '-map 0:v:0'
encode = (f'-vf fps={fps},scale=-2:{height},format=yuv420p '
f'-c:v libx264 -preset ultrafast -tune zerolatency -g {fps * 2} -bf 0 '
f'-b:v {rate}M -maxrate {rate}M -bufsize {rate}M '
'-x264-params repeat-headers=1')
if audio:
encode += ' -af aresample=async=1 -c:a aac -b:a 160k'
return ("export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}; "
"test -e /dev/video99 || { echo 'SteamVR mirror /dev/video99 is missing' >&2; exit 3; }; "
f'ffmpeg -hide_banner -loglevel warning -nostdin {video}{sound} '
f'{maps} {encode} -f mpegts -flush_packets 1 -')
def relay(source, destination, on_packet=None):
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
pending = bytearray()
total = 0
while data := source.read(DATAGRAM):
pending.extend(data)
size = min(len(pending) // PACKET * PACKET, DATAGRAM)
if size:
destination.send(bytes(pending[:size]))
if on_packet:
on_packet()
del pending[:size]
total += size
if pending:
raise RuntimeError('Frame stream ended with an incomplete MPEG-TS packet')
return total
def watch_control(stream, proc, stopped):
"""A desktop app closes its pipe to stop SSH, including on Windows.
Windows TerminateProcess does not run Python's finally block. The pipe also
closes if the app crashes, so the SSH connection is closed in either case.
"""
try:
stream.read(1)
except OSError:
pass
stopped.set()
if proc.poll() is None:
proc.terminate()
class StreamWatchdog:
"""Close SSH if the first packet or a later packet takes too long."""
def __init__(self, proc, startup=None, stall=None):
self.proc = proc
self.startup = STARTUP_TIMEOUT if startup is None else startup
self.stall = STREAM_STALL if stall is None else stall
self.condition = threading.Condition()
self.last_packet = time.monotonic()
self.ready = False
self.closed = False
self.reason = ''
self.thread = threading.Thread(target=self._watch, daemon=True)
self.thread.start()
def packet(self):
with self.condition:
self.ready = True
self.last_packet = time.monotonic()
self.condition.notify()
def close(self):
with self.condition:
self.closed = True
self.condition.notify()
self.thread.join(timeout=1)
def _watch(self):
with self.condition:
while not self.closed:
left = self.last_packet + (self.stall if self.ready else self.startup) - time.monotonic()
if left <= 0:
self.reason = (f'The Frame sent no OBS video for {self.startup:g} seconds' if not self.ready
else f'The Frame OBS video stopped for {self.stall:g} seconds')
break
self.condition.wait(left)
else:
return
if self.proc.poll() is None:
self.proc.terminate()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
parser.add_argument('--port', type=int, default=15099, help='local OBS UDP port (default: 15099)')
parser.add_argument('--height', type=int, choices=(720, 1080), default=720)
parser.add_argument('--fps', type=int, choices=(30, 60), default=30)
parser.add_argument('--no-audio', action='store_true', help='video only when the Frame audio monitor is unavailable')
parser.add_argument('--control-stdin', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if not 1024 <= args.port <= 65535:
parser.error('port must be between 1024 and 65535')
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
parser.error('frame must be one SSH host or alias')
command = remote_command(args.height, args.fps, not args.no_audio)
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
target = ('127.0.0.1', args.port)
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
stopped = threading.Event()
watchdog = StreamWatchdog(proc)
if args.control_stdin:
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc, stopped), daemon=True).start()
try:
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
code = proc.wait(timeout=10)
if stopped.is_set():
return 0
if watchdog.reason:
raise RuntimeError(watchdog.reason)
if code or not amount:
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
'Check SteamVR and the audio monitor; try --no-audio if needed.')
return 0
except KeyboardInterrupt:
return 0
finally:
watchdog.close()
proc.stdout.close()
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
class _Sender:
def __init__(self, sock, target):
self.sock, self.target = sock, target
self.ready = False
def send(self, data):
self.sock.sendto(data, self.target)
if not self.ready:
self.ready = True
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
if __name__ == '__main__':
try:
sys.exit(main())
except (OSError, RuntimeError) as error:
print(error, file=sys.stderr)
sys.exit(1)
+1 -34
View File
@@ -70,38 +70,6 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
done
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
# The workflow uploads this only after all eight platform builds complete.
# Regenerate it from GitHub's installer digests and compare its own GitHub
# digest, so a stale or partial checksum file cannot pass the release gate.
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
expected_sum=$(sha256sum "$tmp/SHA256SUMS" | cut -d ' ' -f 1)
checksum_digest=$(python3 -c 'import json,sys
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
}
echo "ok SHA256SUMS"
# Every published release carries the same concrete verification instructions.
# Keep the release's own notes, and append this section only once. The tag URL
# is stable even while a draft temporarily has an untagged-* API name.
python3 - "$tmp/release.json" "$(dirname "$0")/release-safety-note.md" "$tag" > "$tmp/release-with-note.json" <<'EOF'
import json, pathlib, sys
d = json.load(open(sys.argv[1]))
body = (d.get("body") or "").rstrip()
if "## Is it safe?" not in body:
note = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").replace("{tag}", sys.argv[3]).strip()
body = (body + "\n\n" if body else "") + note
d["body"] = body
json.dump(d, sys.stdout)
EOF
mv "$tmp/release-with-note.json" "$tmp/release.json"
python3 - "$tmp/release.json" > "$tmp/release-body.txt" <<'EOF'
import json, sys
sys.stdout.buffer.write(json.load(open(sys.argv[1]))["body"].encode("utf-8"))
EOF
# update.json: what running copies read (app/updater.js), from github.com's
# latest/download link rather than the rate-limited REST API.
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
@@ -119,7 +87,6 @@ print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))'
if [ -n "$dry" ]; then
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
cat "$tmp/update.json"
echo "dry run: would include the Is it safe? section in the release body"
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
exit 0
fi
@@ -131,6 +98,6 @@ gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
echo "ok update.json"
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
echo "running copies will offer $tag at their next check"
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
GitHub computes each asset digest from its uploaded bytes. The workflow runs
this only after all platform builds finish; publish-release.sh checks that the
uploaded SHA256SUMS bytes still match these digests before publishing.
"""
import argparse
import json
import re
import sys
INSTALLERS = (
"Frame-Control-mac-arm64.dmg",
"Frame-Control-mac-arm64.zip",
"Frame-Control-Setup-x64.exe",
"Frame-Control-win-x64.zip",
"Frame-Control-linux-x86_64.AppImage",
"Frame-Control-linux-arm64.AppImage",
"Frame-Control-linux-amd64.deb",
"Frame-Control-linux-arm64.deb",
)
def find_release(lines, tag):
releases = [json.loads(line) for line in lines if line.strip()]
hits = [r for r in releases if r.get("tag_name") == tag]
if not hits:
title = re.compile(r"Frame Control " + re.escape(tag.removeprefix("v")) + r"(: .*)?")
hits = [r for r in releases if r.get("draft")
and str(r.get("tag_name") or "").startswith("untagged-")
and title.fullmatch(r.get("name") or "")]
if len(hits) != 1:
raise ValueError(f"expected one release for {tag}; found {len(hits)}")
return hits[0]
def checksums(release):
assets = release.get("assets") or []
lines = []
for name in INSTALLERS:
matches = [a for a in assets if a.get("name") == name]
if len(matches) != 1:
raise ValueError(f"expected one asset named {name}; found {len(matches)}")
digest = matches[0].get("digest") or ""
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
raise ValueError(f"missing SHA-256 digest for {name}")
lines.append(f"{digest[7:].lower()} {name}")
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--tag", help="find exact release in JSON-lines input")
parser.add_argument("--id", action="store_true", help="print the selected release id")
args = parser.parse_args()
data = sys.stdin.read()
release = find_release(data.splitlines(), args.tag) if args.tag else json.loads(data)
if args.id:
print(int(release["id"]))
else:
# Windows text stdout changes LF to CRLF. SHA256SUMS must have the same
# bytes on every runner, including publish-release.sh's verification.
sys.stdout.buffer.write(checksums(release).encode("ascii"))
if __name__ == "__main__":
try:
main()
except ValueError as exc:
sys.exit(str(exc))
-11
View File
@@ -1,11 +0,0 @@
## Is it safe?
Download only from this GitHub release. Check your installer against [SHA256SUMS](https://github.com/saphid/frame-control/releases/download/{tag}/SHA256SUMS). A matching hash proves that your file matches the file in this release. It does not prove that any program is free of malware.
- Windows PowerShell: `Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256`
- macOS Terminal: `shasum -a 256 Frame-Control-mac-arm64.dmg`
- Linux terminal: `sha256sum Frame-Control-linux-x86_64.AppImage`
Find the same filename in `SHA256SUMS` and compare all 64 hex characters. [Read the source](https://github.com/saphid/frame-control) and [see the build workflow](https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml). The Windows installer is **not code-signed**; SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**. Gatekeeper may warn or block it. Follow the opening steps in the README only if you trust this download.
日本語: 公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
+1 -8
View File
@@ -212,7 +212,7 @@
<div class="head">
<span class="kicker">Download</span>
<h2>Get Frame Control</h2>
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a> · <a href="https://github.com/saphid/frame-control/blob/main/docs/current-support-matrix.md">What we tested</a></p>
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
</div>
<div class="downloads">
<div class="dl" id="download-mac" data-os="mac">
@@ -257,13 +257,6 @@
<h2>Good questions</h2>
</div>
<div class="faq">
<details id="verify-download">
<summary>Is it safe? How do I check my download?</summary>
<div><p>Starting with <b>v0.4.26</b>, download Frame Control and <a href="https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS">SHA256SUMS</a> from the same GitHub release. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Compare all 64 characters with the line for your filename.</p>
<p>Windows PowerShell: <code>Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256</code><br>macOS Terminal: <code>shasum -a 256 Frame-Control-mac-arm64.dmg</code><br>Linux terminal: <code>sha256sum Frame-Control-linux-x86_64.AppImage</code></p>
<p>The <a href="https://github.com/saphid/frame-control">source</a> and <a href="https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml">build workflow</a> are public. The Windows installer is not code-signed, so SmartScreen may warn. The macOS app has only an ad hoc signature, not an Apple Developer ID signature; the DMG is unsigned and not notarized, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.</p>
<p lang="ja">日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。</p></div>
</details>
<details id="mac-opening">
<summary>macOS says the app is damaged or can't be checked</summary>
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:</p>
-23
View File
@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import vm from 'node:vm';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const page = readFileSync(join(root, 'ui', 'mac-view.html'), 'utf8');
const match = page.match(/function windowSwitchEvents\(hostName\) \{[\s\S]*?\n\}/);
assert.ok(match, 'viewer exposes a bounded host-specific switch sequence');
const events = vm.runInNewContext(`(${match[0]})`);
for (const [host, modifier, mod] of [
['mac', 'MetaLeft', 'meta'], ['windows', 'AltLeft', 'alt'],
]) {
const sequence = JSON.parse(JSON.stringify(events(host)));
assert.deepEqual(sequence.map(x => [x.e, x.code]),
[['down', modifier], ['down', 'Tab'], ['up', 'Tab'], ['up', modifier]]);
assert.deepEqual(sequence[1].mods, [mod]);
assert.deepEqual(sequence[2].mods, [mod]);
}
assert.deepEqual(JSON.parse(JSON.stringify(events('linux'))), []);
assert.match(page, /switchButton\.hidden = !src\.startsWith\("display:"\)/);
console.log('window switch sequences passed');
-41
View File
@@ -1,41 +0,0 @@
// Run the actual wired-doctor page script without browser or device I/O.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
class Element {
constructor() { this.value = ''; this.disabled = false; this.textContent = ''; this.children = []; }
append(...nodes) { this.children.push(...nodes); }
prepend(...nodes) { this.children.unshift(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
}
const elements = Object.fromEntries(['back','alias','check','message','results'].map(id => [id, new Element()]));
elements.alias.value = 'frame';
const requests = [];
let respond;
const context = vm.createContext({
document: {getElementById: id => elements[id], createElement: () => new Element()},
URLSearchParams, location: {search: '?key=test-key'},
fetch: (url, options) => { requests.push({url, options}); return new Promise(resolve => {respond = resolve;}); },
});
const source = readFileSync(new URL('../../ui/wired-doctor.html', import.meta.url), 'utf8');
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1], context);
assert.equal(requests.length, 0, 'page load must not probe the PC or Frame');
assert.equal(elements.back.href, '/?key=test-key#devices');
let promise = elements.check.onclick();
assert.equal(elements.check.disabled, true);
assert.equal(requests[0].url, '/api/host/wired?alias=frame');
assert.equal(requests[0].options.headers['X-Frame-UI'], 'test-key');
respond({ok: true, json: async () => ({state: 'unknown', checks: [{title: '<script>bad</script>',status: 'unknown',detail: 'No known route'}]})});
await promise;
assert.equal(elements.results.children.length, 1);
assert.equal(elements.results.children[0].children[0].textContent, '<script>bad</script>', 'device text must stay plain text');
assert.equal(elements.check.disabled, false);
promise = elements.check.onclick();
assert.equal(elements.results.children.length, 0, 'stale result cleared before retry');
respond({ok: false, json: async () => ({error: 'PC route unavailable'})});
await promise;
assert.match(elements.message.textContent, /route unavailable/);
assert.equal(elements.results.children.length, 0);
assert.equal(elements.check.disabled, false);
console.log('wired doctor UI: on-demand, escaped data, stale-result clearing and retry passed');
+9 -21
View File
@@ -95,20 +95,8 @@ test('Assistant Back encodes the injected key for the same-origin workspace only
assert.match(html, /referrerpolicy="no-referrer"/);
});
test('catalogue browsing starts while installed-app discovery is still pending', async () => {
const f = fixture(read('store.html'));
assert.deepEqual(f.requests.map(r => r.url.split('?')[0]), ['/api/android', '/api/search']);
await f.answer(1, {apps: [{name: 'Example Game', category: 'games', package: 'example.game',
offers: [{source: 'test', id: 'example'}]}], sources: []});
assert.equal(f.run('st.installedState'), 'loading');
assert.match(f.element('content').innerHTML, /Example Game/);
await f.answer(0, {apps: [{package: 'example.game', label: 'Example Game'}]});
assert.match(f.element('content').innerHTML, /Installed/);
});
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
const f = fixture(read('store.html'));
await f.answer(1, {apps: [], sources: []});
f.run('st.view = "installed"; render()');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
f.requests[0].reject(Error('offline')); await tick();
@@ -117,16 +105,16 @@ test('installed read failure is persistent, retry recovers, and cached entries a
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const retry = f.element('content').querySelector('[data-retry-installed]');
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.requests[2].url, '/api/android');
await f.answer(2, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.requests[1].url, '/api/android');
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.run('st.installedState'), 'ready');
const failed = f.run('loadInstalled()');
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
f.requests[3].reject(Error('refresh failed')); await failed;
f.requests[2].reject(Error('refresh failed')); await failed;
assert.match(f.element('content').innerHTML, /Cached app/);
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
assert.match(f.element('content').innerHTML, /data-retry-installed/);
const empty = f.run('loadInstalled()'); await f.answer(4, {apps: []}); await empty;
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
});
@@ -134,12 +122,12 @@ test('installed read failure is persistent, retry recovers, and cached entries a
test('newest installed read wins over older success and failure without pruning cached data', async () => {
const f = fixture(read('store.html'));
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
const newer = f.run('loadInstalled()'); await f.answer(2, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installed.has("new.app")'), true);
assert.equal(f.run('st.needsData.has("new.app")'), true);
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
await f.answer(4, {apps: []}); await newest; // request 1 is the independent bootstrap catalogue search
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
f.requests[3].reject(Error('late failure')); await old;
assert.equal(f.run('st.installedState'), 'ready');
assert.equal(f.run('st.installedError'), '');
@@ -149,12 +137,12 @@ test('newest installed read wins over older success and failure without pruning
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"');
const newest = f.run('loadInstalled()'); f.requests[2].reject(Error('latest offline')); await newest;
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installedState'), 'error');
assert.equal(f.run('st.installedError'), 'latest offline');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const malformed = f.run('loadInstalled()'); await f.answer(3, {}); await malformed;
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
});
@@ -209,12 +197,12 @@ test('named native Store dialog enters, contains and restores focus across async
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
const f = fixture(read('store.html'));
await f.answer(1, {apps: [], sources: []});
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
f.requests[0].reject(Error('offline')); await tick();
await f.answer(1, {apps: [f.context.app], sources: []});
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.document.activeElement.dataset.act, 'close');
-94
View File
@@ -1,94 +0,0 @@
"""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())
-65
View File
@@ -1,65 +0,0 @@
"""Headset panel packaging, ownership checks and window targeting."""
import sandbox # noqa: F401
import io
import json
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_headset
import frame_headset_runtime
class HeadsetBundle(unittest.TestCase):
def test_bundle_is_deterministic_and_contains_the_local_ui(self):
first = frame_headset.bundle()
self.assertEqual(first, frame_headset.bundle())
with tarfile.open(fileobj=io.BytesIO(first), mode='r:gz') as archive:
names = set(archive.getnames())
self.assertIn('ui/headset.html', names)
self.assertIn('ui/frame_headset_runtime.py', names)
self.assertIn('ui/server.py', names)
self.assertFalse(any('\\' in name for name in names))
self.assertFalse(any('__pycache__' in name for name in names))
def test_incompatible_device_is_rejected_before_upload(self):
state = {'arch': 'x86_64', 'variant': 'vr'}
calls = []
def ssh(*args, **kwargs):
calls.append(args)
return json.dumps(state)
with patch.object(frame_headset, 'bundle') as bundle:
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
frame_headset.change(ssh, 'install')
bundle.assert_not_called()
self.assertEqual(len(calls), 1)
@unittest.skipIf(os.name == 'nt', 'remote script runs on Linux')
def test_unowned_install_is_never_removed(self):
with tempfile.TemporaryDirectory() as home:
token = secrets.token_hex(12)
result = subprocess.run([sys.executable, '-', 'remove', token, 'none'],
input=frame_headset.APPLY, text=True, capture_output=True,
env={**os.environ, 'HOME': home}, timeout=5)
self.assertNotEqual(result.returncode, 0)
self.assertIn('No Frame Control managed', result.stderr)
class HeadsetWindow(unittest.TestCase):
def test_uses_only_its_marker_title(self):
tree = ' 0x101 "Other panel"\n 0xabc "Frame Control VR [fc-headset]"\n'
with patch.object(frame_headset_runtime, 'command', return_value=tree):
self.assertEqual(frame_headset_runtime.panel_window(), '0xabc')
with patch.object(frame_headset_runtime, 'command', return_value=' 0x101 "Other panel"'):
self.assertIsNone(frame_headset_runtime.panel_window())
if __name__ == '__main__':
unittest.main()
-47
View File
@@ -1,47 +0,0 @@
"""Local headset actions keep writes scoped to the opt-in VR server."""
import sandbox # noqa: F401
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
import frame_clips
import server
class HeadsetActions(unittest.TestCase):
def test_quick_actions_are_unavailable_outside_headset_runtime(self):
with patch.object(server, 'HEADSET_RUNTIME', False):
with self.assertRaisesRegex(server.Failure, 'local VR panel'):
server.headset_quick({'action':'game-mode'})
def test_game_mode_targets_steamvr_main_dashboard(self):
with patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server.frame_panels, 'focus') as focus:
response = server.headset_quick({'action':'game-mode'})
focus.assert_called_once_with('valve.steam.gamepadui.main')
self.assertIn('Game Mode', response['message'])
def test_screenshot_is_saved_only_in_user_pictures(self):
with tempfile.TemporaryDirectory() as home, patch.object(server.Path, 'home', return_value=Path(home)), \
patch.object(server, 'HEADSET_RUNTIME', True), patch.object(server, 'headset_view', return_value=b'PNG data'):
result = server.headset_quick({'action':'screenshot'})
file = Path(result['file'])
self.assertEqual(file.parent, Path(home) / 'Pictures/FrameControl')
self.assertEqual(file.read_bytes(), b'PNG data')
if os.name != 'nt': # Windows reports emulated mode bits for user files.
self.assertEqual(file.stat().st_mode & 0o777, 0o600)
def test_local_recording_uses_separate_work_folder(self):
recorder = frame_clips.Recorder(Mock(), Mock(), Mock(), Mock(),
work='.cache/frame-control/headset-video',
destination_label='~/Videos/FrameControl')
self.assertIn('headset-video', frame_clips.probe_command(recorder.work))
self.assertIn('headset-video', frame_clips.record_command('rec-20261025-030000', False, work=recorder.work))
self.assertNotIn('headset-video', frame_clips.probe_command())
if __name__ == '__main__':
unittest.main()
-169
View File
@@ -1,169 +0,0 @@
"""The OBS relay must preserve transport packets and keep its output local."""
import importlib.util
import io
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'obs-headset-source.py'
spec = importlib.util.spec_from_file_location('obs_headset_source', SCRIPT)
obs = importlib.util.module_from_spec(spec)
spec.loader.exec_module(obs)
class Receiver:
def __init__(self):
self.chunks = []
def send(self, data):
self.chunks.append(data)
class ObsSourceTests(unittest.TestCase):
def test_desktop_packages_the_helper_as_a_required_resource(self):
root = SCRIPT.parents[1]
build = json.loads((root / 'app' / 'package.json').read_text())['build']
scripts = next(item for item in build['extraResources'] if item['to'] == 'scripts')
self.assertIn(SCRIPT.name, scripts['filter'])
self.assertIn('scripts/' + SCRIPT.name, (root / 'app' / 'build' / 'check-resources.js').read_text())
def test_relay_keeps_whole_transport_packets(self):
stream = bytes(range(188)) * 101
receiver = Receiver()
self.assertEqual(obs.relay(io.BytesIO(stream), receiver), len(stream))
self.assertEqual(b''.join(receiver.chunks), stream)
self.assertTrue(all(len(chunk) % 188 == 0 and len(chunk) <= 1316
for chunk in receiver.chunks))
def test_incomplete_packet_fails(self):
with self.assertRaisesRegex(RuntimeError, 'incomplete'):
obs.relay(io.BytesIO(b'x' * 189), Receiver())
def test_remote_command_uses_recording_sources(self):
with_audio = obs.remote_command()
self.assertIn('/dev/video99', with_audio)
self.assertIn('@DEFAULT_MONITOR@', with_audio)
self.assertIn('-map 1:a:0', with_audio)
self.assertIn('-f mpegts', with_audio)
self.assertNotIn('@DEFAULT_MONITOR@', obs.remote_command(audio=False))
def test_watchdog_ends_a_stalled_child_on_every_host_platform(self):
class Child:
def __init__(self):
self.stopped = threading.Event()
def poll(self):
return 255 if self.stopped.is_set() else None
def terminate(self):
self.stopped.set()
for ready in (False, True):
with self.subTest(ready=ready):
child = Child()
watchdog = obs.StreamWatchdog(child, startup=0.1, stall=0.1)
if ready:
watchdog.packet()
try:
self.assertTrue(child.stopped.wait(1), 'a stalled SSH child was not terminated')
self.assertIn('stopped' if ready else 'no OBS video', watchdog.reason)
finally:
watchdog.close()
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
def test_relay_stops_its_ssh_child_on_app_signal_or_control_pipe(self):
for mode in ('signal', 'control-pipe'):
with self.subTest(mode=mode):
self._check_stop_mode(mode)
def _check_stop_mode(self, mode):
with tempfile.TemporaryDirectory() as folder, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
directory = Path(folder)
stopped = directory / 'stopped'
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import os, signal, sys, time\n"
"def stop(*_):\n"
" open(os.environ['FAKE_SSH_STOPPED'], 'w').close()\n"
" sys.exit(255)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"sys.stdout.buffer.write(b'G' * 1316)\n"
"sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
receiver.bind(('127.0.0.1', 0))
receiver.settimeout(8)
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
'FAKE_SSH_STOPPED': str(stopped)}
arguments = [sys.executable, str(SCRIPT), '--frame', 'fake-frame',
'--port', str(receiver.getsockname()[1])]
if mode == 'control-pipe':
arguments.append('--control-stdin')
process = subprocess.Popen(arguments,
stdin=subprocess.PIPE if mode == 'control-pipe' else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env)
try:
packet, _ = receiver.recvfrom(1500)
self.assertEqual(packet, b'G' * 1316)
if mode == 'control-pipe':
process.stdin.close()
process.wait(timeout=8)
errors = process.stderr.read()
else:
process.send_signal(signal.SIGTERM)
_, errors = process.communicate(timeout=8)
self.assertEqual(process.returncode, 0, errors.decode())
self.assertIn(b'Frame Control OBS stream is ready', errors)
for _ in range(30):
if stopped.exists():
break
time.sleep(0.1)
self.assertTrue(stopped.exists(), 'the SSH child survived relay shutdown')
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)
process.stdout.close()
process.stderr.close()
@unittest.skipIf(sys.platform == 'win32', 'the stand-in SSH script uses a POSIX shebang')
def test_startup_and_stream_stalls_end_ssh_with_a_clear_error(self):
for mode in ('startup', 'stream'):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as folder:
directory = Path(folder)
fake = directory / 'ssh'
fake.write_text("#!/usr/bin/env python3\n"
"import os, signal, sys, time\n"
"def stop(*_):\n"
" open(os.environ['FAKE_SSH_STOPPED'], 'w').close()\n"
" sys.exit(255)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"if os.environ['FAKE_SSH_MODE'] == 'stream':\n"
" sys.stdout.buffer.write(b'G' * 1316)\n"
" sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
stopped = directory / 'stopped'
env = {'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
'FAKE_SSH_STOPPED': str(stopped), 'FAKE_SSH_MODE': mode}
with mock.patch.dict(os.environ, env), \
mock.patch.object(obs, 'STARTUP_TIMEOUT', 0.3), \
mock.patch.object(obs, 'STREAM_STALL', 0.3):
with self.assertRaisesRegex(RuntimeError, 'no OBS video' if mode == 'startup' else 'video stopped'):
obs.main(['--frame', 'fake-frame'])
self.assertTrue(stopped.exists(), 'SSH survived the relay timeout')
if __name__ == '__main__':
unittest.main()
+1 -1
View File
@@ -9,7 +9,7 @@ import subprocess
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadHeadsetPanel', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
RUN = r'''
-14
View File
@@ -7,7 +7,6 @@ Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import hashlib
import os
import subprocess
import tempfile
@@ -27,9 +26,6 @@ def draft(tag_name="untagged-c6ddfed7f75d67db2e99", name="Frame Control 9.8.7",
if assets is None:
assets = [{"id": 100 + i, "name": n, "size": 1000 + i, "digest": "sha256:" + "%064x" % i}
for i, n in enumerate(INSTALLERS)]
body = "".join(f"{i:064x} {n}\n" for i, n in enumerate(INSTALLERS)).encode()
assets.append({"id": 200, "name": "SHA256SUMS", "size": len(body),
"digest": "sha256:" + hashlib.sha256(body).hexdigest()})
return {"id": rid, "tag_name": tag_name, "name": name, "draft": True, "prerelease": False,
"body": "notes", "html_url": "https://github.com/saphid/frame-control/releases/tag/" + tag_name,
"assets": assets}
@@ -83,16 +79,6 @@ class PublishRelease(unittest.TestCase):
self.assertIsNone(upload)
self.assertFalse(any(c[1:3] == ["-X", "PATCH"] for c in log))
def test_refuses_missing_or_stale_checksums(self):
for assets in (draft()["assets"][:-1],
draft()["assets"][:-1] + [{"id": 200, "name": "SHA256SUMS",
"digest": "sha256:" + "0" * 64}]):
p, log, upload = self.run_script([draft(assets=assets)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
self.assertIn("SHA256SUMS is absent or does not match", p.stderr)
self.assertIsNone(upload)
self.assertFalse(any("PATCH" in c for c in log))
def test_refuses_ambiguous_or_absent_drafts_and_missing_tags(self):
p, _, _ = self.run_script([draft(rid=1), draft(rid=2)], "v9.8.7")
self.assertNotEqual(p.returncode, 0)
-38
View File
@@ -1,38 +0,0 @@
"""Checksum generator uses only complete, unambiguous GitHub asset metadata."""
import sandbox # noqa: F401
import hashlib
import importlib.util
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "release-checksums.py"
spec = importlib.util.spec_from_file_location("release_checksums", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Checksums(unittest.TestCase):
def test_complete_list_matches_bytes(self):
assets = []
for i, name in enumerate(module.INSTALLERS):
assets.append({"name": name, "digest": "sha256:" + hashlib.sha256(str(i).encode()).hexdigest()})
actual = module.checksums({"assets": assets})
self.assertEqual(len(actual.splitlines()), 8)
self.assertEqual(actual.splitlines()[0], hashlib.sha256(b"0").hexdigest() + " " + module.INSTALLERS[0])
def test_missing_duplicate_and_bad_digest_fail(self):
assets = [{"name": name, "digest": "sha256:" + "0" * 64} for name in module.INSTALLERS]
for changed in (assets[:-1], assets + [assets[0]],
[dict(assets[0], digest="sha256:bad"), *assets[1:]]):
with self.assertRaises(ValueError):
module.checksums({"assets": changed})
def test_tag_or_exact_untagged_draft(self):
lines = ['{"id": 1, "tag_name": "untagged-a", "draft": true, "name": "Frame Control 9.8.7"}',
'{"id": 2, "tag_name": "v9.8.8", "draft": false, "name": "Frame Control 9.8.8"}']
self.assertEqual(module.find_release(lines, "v9.8.7")["id"], 1)
self.assertEqual(module.find_release(lines, "v9.8.8")["id"], 2)
if __name__ == "__main__":
unittest.main()
-40
View File
@@ -94,46 +94,6 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(headers["X-Frame-Options"], "DENY")
self.assertIn(b"<title>APK Store</title>", payload)
def test_local_extension_is_listed_and_sandboxed(self):
folder = Path(os.environ["FRAME_CONTROL_DATA_DIR"]) / "extensions" / "test-glance"
folder.mkdir(parents=True, exist_ok=True)
(folder / "manifest.json").write_text(json.dumps({"id": "test-glance", "name": "Test Glance",
"version": "1.0", "description": "Test status", "entry": "index.html"}))
(folder / "index.html").write_text('<script src="main.js"></script>')
(folder / "main.js").write_text('parent.postMessage({type:"frame-control:request",id:1,action:"status"},"*");')
try:
code, _, body = self.request("GET", "/api/extensions", headers={"X-Frame-UI": "1"})
self.assertEqual(code, 200)
self.assertIn("test-glance", [x["id"] for x in json.loads(body)["extensions"]])
code, headers, body = self.request("GET", "/extension/test-glance/index.html")
self.assertEqual(code, 200)
self.assertIn(b"main.js", body)
self.assertIn("frame-ancestors 'self'", headers["Content-Security-Policy"])
self.assertNotIn("X-Frame-Options", headers)
self.assertEqual(self.request("GET", "/extension/test-glance/manifest.json")[0], 404)
self.assertEqual(self.request("GET", "/extension/test-glance/../index.html")[0], 404)
try:
(folder / "escape.js").symlink_to(Path(__file__))
except OSError: # Windows may not allow an unprivileged test to create a symlink.
pass
else:
self.assertEqual(self.request("GET", "/extension/test-glance/escape.js")[0], 404)
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_headset_page_and_install_acknowledgement(self):
code, _, body = self.request('GET', '/headset')
self.assertEqual(code, 200)
self.assertIn(b'Frame Control VR [fc-headset]', body)
code, body = self.post('/api/headset', {'action': 'install'})
self.assertEqual(code, 400)
self.assertIn('opt-in', body['error'])
def test_foreign_host_rejected(self):
# DNS rebinding: a hostile name pointed at 127.0.0.1.
for path in ("/", "/api/status"):
-92
View File
@@ -1,92 +0,0 @@
"""Screenshot copies retain the headset, Steam account and game identity."""
import sandbox # noqa: F401
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "ui"))
import server # noqa: E402
NAME = "20261025020000_1.jpg"
FIRST = f"1/620/{NAME}"
SECOND = f"1/730/{NAME}"
class ShotCopyTest(unittest.TestCase):
def setUp(self):
self.folder = Path(tempfile.mkdtemp(prefix="frame-shot-copy-"))
self.addCleanup(shutil.rmtree, self.folder, ignore_errors=True)
patch = mock.patch.object(server, "SHOTS_DIR", self.folder)
patch.start()
self.addCleanup(patch.stop)
patch = mock.patch.object(server, "LINK", None)
patch.start()
self.addCleanup(patch.stop)
self.payloads = {
server.shot_path(FIRST): b"first game",
server.shot_path(SECOND): b"second game",
}
def scp(self, argv, **_kwargs):
target = Path(argv[-1])
for source in argv[1:-1]:
if not source.startswith("frame-a:") and not source.startswith("frame-b:"):
continue
remote = source.split(":", 1)[1]
(target / Path(remote).name).write_bytes(self.payloads[remote])
return subprocess.CompletedProcess(argv, 0, "", "")
def test_equal_names_survive_across_games_and_headsets(self):
# A pre-upgrade flat file has no provable source identity. Leave it alone.
legacy = self.folder / NAME
legacy.write_bytes(b"older screenshot")
with mock.patch.object(server, "FRAME", "frame-a"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp) as copy:
self.assertEqual(server.save_shots({"ids": [FIRST, SECOND]})["saved"], 2)
self.assertEqual(copy.call_count, 2) # scp cannot flatten equal names together
first = server.shot_local_path(FIRST)
second = server.shot_local_path(SECOND)
self.assertEqual(first.read_bytes(), b"first game")
self.assertEqual(second.read_bytes(), b"second game")
self.assertNotEqual(first, second)
self.assertEqual(legacy.read_bytes(), b"older screenshot")
listing = "\n".join([
f"1/760/remote/620/screenshots/{NAME}\t10\t1792893600",
f"1/760/remote/730/screenshots/{NAME}\t11\t1792893601",
])
with mock.patch.object(server, "ssh", return_value=listing):
self.assertTrue(all(s["saved"] for s in server.list_shots()["shots"]))
with mock.patch.object(server.frame_host, "reveal_path") as reveal:
server.open_thing({"what": "shot", "id": SECOND})
reveal.assert_called_once_with(second)
self.payloads[server.shot_path(FIRST)] = b"another headset"
with mock.patch.object(server, "FRAME", "frame-b"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh", side_effect=self.scp):
self.assertEqual(server.save_shots({"ids": [FIRST]})["saved"], 1)
other = server.shot_local_path(FIRST)
self.assertNotEqual(other, first)
self.assertEqual(other.read_bytes(), b"another headset")
self.assertEqual(first.read_bytes(), b"first game")
def test_missing_staged_file_does_not_claim_saved(self):
with mock.patch.object(server, "FRAME", "frame-a"), \
mock.patch.object(server, "ensure_master"), \
mock.patch.object(server.frame_host, "run_ssh",
return_value=subprocess.CompletedProcess([], 0, "", "")):
with self.assertRaisesRegex(server.Failure, "did not contain every"):
server.save_shots({"ids": [FIRST]})
self.assertFalse(server.shot_local_path(FIRST).exists())
self.assertFalse(list(self.folder.glob(".incoming-*")))
if __name__ == "__main__":
unittest.main()
-17
View File
@@ -1,17 +0,0 @@
"""The viewer sends one explicit native window-switch chord per click."""
from pathlib import Path
import shutil
import subprocess
import unittest
class WindowSwitch(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is required for the viewer script check')
def test_host_chords_and_display_only_guard(self):
script = Path(__file__).parent / 'page' / 'window_switch.mjs'
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
-138
View File
@@ -1,138 +0,0 @@
"""The wired doctor never requires a cable or contacts the real Frame in tests."""
import sandbox # noqa: F401
from pathlib import Path
import http.client
import json
import subprocess
import sys
import threading
import unittest
from unittest.mock import patch
import shutil
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_wired_doctor as wired
class FakeProbe:
def __init__(self, source='10.86.200.234', port=True, ssh_code=0, stderr=''):
self.source, self.port, self.ssh_code, self.stderr = source, port, ssh_code, stderr
self.calls = []
def route_source(self):
self.calls.append('route')
return self.source
def port_open(self):
self.calls.append('port')
return self.port
def host_key_name(self, alias):
self.calls.append('config')
return alias + '.example.ts.net'
def ssh_check(self, alias, name):
self.calls.append(('ssh', alias, name))
return subprocess.CompletedProcess([], self.ssh_code, '', self.stderr)
class WiredDoctor(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the page script')
def test_page_is_on_demand_and_clears_stale_results(self):
result = subprocess.run(['node', str(Path(__file__).parent / 'page/wired_doctor.mjs')],
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_known_usb_route_and_pinned_login(self):
probe = FakeProbe()
report = wired.diagnose('frame', probe=probe)
self.assertEqual(report['state'], 'ready')
self.assertEqual([c['status'] for c in report['checks']], ['ok', 'ok', 'ok'])
self.assertEqual(probe.calls, ['route', 'port', 'config', ('ssh', 'frame', 'frame.example.ts.net')])
def test_other_route_never_contacts_target(self):
for source in ('100.91.1.2', '192.168.1.10', '127.0.0.1'):
probe = FakeProbe(source=source)
report = wired.diagnose(probe=probe)
self.assertEqual(report['state'], 'unknown')
self.assertEqual(probe.calls, ['route'])
self.assertIn('no headset was contacted', report['checks'][-1]['detail'])
def test_closed_port_skips_ssh_and_key_mismatch_is_not_bypassed(self):
closed = FakeProbe(port=False)
self.assertEqual(wired.diagnose(probe=closed)['state'], 'partial')
self.assertEqual(closed.calls, ['route', 'port'])
mismatch = FakeProbe(ssh_code=255, stderr='Host key verification failed.')
report = wired.diagnose(probe=mismatch)
self.assertEqual(report['checks'][-1]['status'], 'warning')
self.assertIn('do not bypass', report['checks'][-1]['detail'])
def test_invalid_alias_and_headset_mode_do_not_probe(self):
probe = FakeProbe()
with self.assertRaises(ValueError):
wired.diagnose('-oProxyCommand=bad', probe=probe)
self.assertFalse(wired.diagnose(local=True, probe=probe)['supported'])
self.assertEqual(probe.calls, [])
def test_real_command_uses_strict_identity_and_disables_proxy(self):
with patch.object(wired.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 0, '', '')) as run:
wired.Probe().ssh_check('frame', 'frame.example.ts.net')
argv = run.call_args.args[0]
self.assertIn('StrictHostKeyChecking=yes', argv)
self.assertIn('HostName=10.86.200.233', argv)
self.assertIn('HostKeyAlias=frame.example.ts.net', argv)
self.assertIn('ProxyCommand=none', argv)
self.assertIn('ControlMaster=no', argv)
self.assertEqual(argv[-2:], ['frame', 'true'])
self.assertEqual(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
class HttpBoundary(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server_module = server
cls.httpd = server.LoopbackServer(('127.0.0.1', 0), server.Handler)
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.thread.join()
def request(self, path, authorized=True):
conn = http.client.HTTPConnection('127.0.0.1', self.httpd.server_address[1], timeout=5)
headers = {'X-Frame-UI': self.server_module.UI_KEY} if authorized else {}
conn.request('GET', path, headers=headers)
response = conn.getresponse()
result = response.status, response.read()
conn.close()
return result
def test_auth_alias_and_page_without_headset_link(self):
with patch.object(self.server_module, 'ssh', side_effect=AssertionError('no generic SSH')), \
patch.object(self.server_module, 'working', side_effect=AssertionError('no headset lock')), \
patch.object(wired, 'diagnose', return_value={'supported': True, 'checks': []}) as probe:
self.assertEqual(self.request('/api/host/wired', authorized=False)[0], 403)
probe.assert_not_called()
code, data = self.request('/api/host/wired?alias=bedroom')
self.assertEqual(code, 200)
self.assertTrue(json.loads(data)['supported'])
probe.assert_called_once_with('bedroom', local=self.server_module.LOCAL)
code, page = self.request('/wired-doctor')
self.assertEqual(code, 200)
self.assertIn(b'No checks have run', page)
def test_invalid_alias_and_headset_page_guard(self):
self.assertEqual(self.request('/api/host/wired?alias=-bad')[0], 400)
with patch.object(self.server_module, 'LOCAL', True):
self.assertEqual(self.request('/wired-doctor')[0], 400)
code, body = self.request('/api/host/wired')
self.assertEqual(code, 200)
self.assertFalse(json.loads(body)['supported'])
if __name__ == '__main__':
unittest.main()
-27
View File
@@ -37,33 +37,6 @@ test('click reads once, disables repeated clicks and renders partial inventory',
assert.match(f.el('wirelessResult').innerHTML,/distinct Wi-Fi Direct/);
assert.match(f.el('wirelessStatus').textContent,/Partial PC snapshot/);
});
test('two explicit reads compare exact identities and known state changes without claiming a repair',async()=>{
const f=fixture();
const first={...sample,sources:{pnp:'ok'},devices:[{...sample.devices[0],id:'SWD\\WIFIDIRECT\\one',
name:'Adapter <one>',present:true,problemCode:0}]};
const second={...first,observedAt:'2026-10-24T12:01:00Z',devices:[
{...first.devices[0],present:false,problemCode:22},
{...first.devices[0],id:'SWD\\WIFIDIRECT\\two',name:'Adapter <two>'}]};
let calls=0; f.context.api=async()=> ++calls===1 ? first : second;
await f.el('wirelessRead').onclick();
assert.match(f.el('wirelessComparison').innerHTML,/Read PC adapters again/);
assert.equal(f.el('wirelessForget').disabled,false);
await f.el('wirelessRead').onclick();
const change=f.el('wirelessComparison').innerHTML;
for(const phrase of ['Changes since previous read','Newly listed','presence Yes → No',
'problem code 0 → 22','not a safe disable target','&lt;one&gt;','&lt;two&gt;']) assert.ok(change.includes(phrase),phrase);
assert.doesNotMatch(change,/<one>|<two>/);
f.el('wirelessForget').onclick();
assert.equal(f.el('wirelessComparison').textContent,'');
assert.equal(f.el('wirelessForget').disabled,true);
});
test('incomplete PnP reads cannot imply a device appeared or vanished',()=>{
const f=fixture(); f.context.before={...sample,sources:{pnp:'unavailable'}};
f.context.after={...sample,sources:{pnp:'ok'},devices:[]};
const out=f.run('compareWireless(before,after)');
assert.match(out,/comparison unavailable/);
assert.doesNotMatch(out,/No longer listed|Newly listed/);
});
test('unsupported, busy and failed probes leave no stale inventory and allow retry',async()=>{
for(const result of [{state:'unsupported',message:'Windows only'},{state:'busy',message:'Try again'},new Error('Denied')]) {
const f=fixture();f.el('wirelessResult').textContent='stale inventory';
+15 -30
View File
@@ -77,7 +77,7 @@ def recording_name(now=None):
return time.strftime("rec-%Y%m%d-%H%M%S", time.localtime(now))
def probe_command(work=WORK):
def probe_command():
"""Checks before a recording, run on the Frame: prints audio=1 or audio=0.
One recording runs at a time, so anything left in the folder is from one that was
@@ -85,7 +85,7 @@ def probe_command(work=WORK):
return (f"set -u; export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; "
f"[ -e {STREAM_DEVICE} ] || {{ echo 'No headset view device ({STREAM_DEVICE}). Is SteamVR running?' >&2; exit 3; }}; "
f"command -v ffmpeg >/dev/null || {{ echo 'ffmpeg is missing on the Frame' >&2; exit 3; }}; "
f"d=\"$HOME/{work}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
f"d=\"$HOME/{WORK}\"; mkdir -p \"$d\" && rm -f \"$d\"/rec-*; "
f"free=$(df -Pk \"$d\" | awk 'NR==2 {{print $4}}'); "
f"[ \"${{free:-0}}\" -ge {NEED_KB} ] || {{ echo 'Not enough free space on the Frame to record: "
f"it needs {NEED_KB // 1024 // 1024 + 1} GB' >&2; exit 4; }}; "
@@ -93,7 +93,7 @@ def probe_command(work=WORK):
f">/dev/null 2>&1; then echo audio=1; else echo audio=0; fi")
def record_command(name, audio, max_seconds=MAX_SECONDS, work=WORK):
def record_command(name, audio, max_seconds=MAX_SECONDS):
"""The recording, run on the Frame through `bash -c`.
ffmpeg runs in the background while the shell watches its stdin, which is the SSH
@@ -118,7 +118,7 @@ def record_command(name, audio, max_seconds=MAX_SECONDS, work=WORK):
codecs += f" -af aresample=async=1 -c:a aac -b:a {AUDIO_RATE}k"
# ffmpeg's stdin is a FIFO the shell holds open on fd 3 (opening it waits for both
# ends, so ffmpeg is started first); it's unlinked once both ends are open.
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{work}\"; "
script = (f"export XDG_RUNTIME_DIR=${{XDG_RUNTIME_DIR:-/run/user/$(id -u)}}; d=\"$HOME/{WORK}\"; "
f"trap '' PIPE; c=\"$d/{name}.ctl\"; rm -f \"$c\"; mkfifo \"$c\" || exit 1; "
f"ffmpeg -hide_banner -loglevel error -y {inputs} {maps} {codecs} -t {max_seconds} "
f"\"$d/{name}.mkv\" <\"$c\" >/dev/null 2>\"$d/{name}.log\" & p=$!; "
@@ -131,20 +131,20 @@ def record_command(name, audio, max_seconds=MAX_SECONDS, work=WORK):
return "bash -c " + shlex.quote(script)
def finish_command(name, work=WORK):
def finish_command(name):
"""After the recording: make the .mp4 the computer gets (no re-encode), on the Frame."""
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return (f"cd \"$HOME/{work}\" || exit 1; "
return (f"cd \"$HOME/{WORK}\" || exit 1; "
f"[ -s {name}.mkv ] || {{ tail -c 2000 {name}.log >&2 2>/dev/null; echo 'The recording is empty' >&2; exit 1; }}; "
f"ffmpeg -hide_banner -loglevel error -nostdin -y -i {name}.mkv -map 0 -c copy -movflags +faststart {name}.mp4 "
f"&& rm -f {name}.mkv && wc -c < {name}.mp4")
def cleanup_command(name, work=WORK):
def cleanup_command(name):
if not REC_NAME.fullmatch(name):
raise ValueError(f"bad recording name {name!r}")
return f"rm -f \"$HOME/{work}/{name}\".*"
return f"rm -f \"$HOME/{WORK}/{name}\".*"
class Recorder:
@@ -158,12 +158,10 @@ class Recorder:
release(proc) forgets a channel once it has closed
"""
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None,
work=WORK, destination_label='~/Videos/SteamFrame'):
def __init__(self, open_channel, run, fetch, start_thread, release=lambda proc: None, folder=None):
self.open_channel, self.run, self.fetch = open_channel, run, fetch
self.start_thread, self.release = start_thread, release
self.folder = Path(folder) if folder else VIDEOS_DIR
self.work, self.destination_label = work, destination_label
self._lock = threading.Lock()
self._state = {"state": "idle"}
self._proc = None
@@ -181,12 +179,11 @@ class Recorder:
raise ClipError("Already recording, or still saving the last recording", 409)
self._state = {"state": "starting"}
try:
out = self.run(probe_command() if self.work == WORK else probe_command(self.work), 30)
out = self.run(probe_command(), 30)
audio = "audio=1" in out
name = recording_name()
errors = tempfile.TemporaryFile()
proc = self.open_channel(record_command(name, audio) if self.work == WORK else
record_command(name, audio, work=self.work), errors)
proc = self.open_channel(record_command(name, audio), errors)
lines = _line_reader(proc.stdout)
first = _next_line(lines, 20)
if first != "started":
@@ -225,18 +222,6 @@ class Recorder:
_close(proc.stdin) # the shell on the Frame sees EOF and stops ffmpeg
return {"state": "stopping", "message": "Stopping the recording…"}
def shutdown(self, timeout=30):
"""Finish an owned recording before a headset user service exits."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
state = self.status()['state']
if state == 'recording':
self.stop()
elif state not in ('starting', 'stopping', 'saving'):
break
time.sleep(.2)
return self.status()
def _watch(self, proc, name, audio, lines, errors):
# Waits for ffmpeg to finish: after Stop, or at the time limit (a margin
# past it, so a stuck channel can't hold the recording open forever).
@@ -257,14 +242,14 @@ class Recorder:
self._state = dict(self._state, state="saving")
try:
self.save(name)
msg = f"Saved {name}.mp4 to {self.destination_label}" + ("" if audio else " (no sound)")
msg = f"Saved {name}.mp4 to ~/Videos/SteamFrame" + ("" if audio else " (no sound)")
state = {"state": "saved", "file": str(self.folder / f"{name}.mp4"), "audio": audio,
"seconds": seconds, "message": msg}
except Exception as e:
state = {"state": "failed", "error": f"Couldn't save the recording: {e}"}
finally:
try:
self.run(cleanup_command(name) if self.work == WORK else cleanup_command(name, self.work), 20)
self.run(cleanup_command(name), 20)
except Exception:
pass # the next recording's checks remove it
with self._lock:
@@ -272,12 +257,12 @@ class Recorder:
def save(self, name):
"""Make the .mp4 on the Frame and copy it here, never leaving a partial file in the folder."""
out = self.run(finish_command(name) if self.work == WORK else finish_command(name, self.work), 600)
out = self.run(finish_command(name), 600)
size = int(out.strip().splitlines()[-1]) if out.strip() else 0
_make_room(self.folder, size)
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=self.folder))
try:
self.fetch(f"{self.work}/{name}.mp4", incoming / f"{name}.mp4")
self.fetch(f"{WORK}/{name}.mp4", incoming / f"{name}.mp4")
os.replace(incoming / f"{name}.mp4", self.folder / f"{name}.mp4")
finally:
shutil.rmtree(incoming, ignore_errors=True)
-193
View File
@@ -1,193 +0,0 @@
"""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
-67
View File
@@ -1,67 +0,0 @@
"""Small, local Frame Control extensions. Code runs only in a sandboxed iframe."""
import json
import re
from pathlib import Path
import frame_host
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8", ".json": "application/json",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml"}
def root():
return frame_host.data_dir("extensions")
def _manifest(folder):
if folder.is_symlink() or not ID.fullmatch(folder.name):
raise ValueError("invalid extension directory")
manifest = folder / "manifest.json"
if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 16_384:
raise ValueError("missing or oversized manifest.json")
data = json.loads(manifest.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("id") != folder.name:
raise ValueError("manifest id must match its folder")
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
value = data.get(key)
if not isinstance(value, str) or not value.strip() or len(value) > limit:
raise ValueError(f"invalid {key}")
entry = data.get("entry", "index.html")
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
raise ValueError("entry must be a regular index.html")
return {key: data[key] for key in ("id", "name", "version", "description")}
def list_extensions():
base = root()
if not base.exists():
return {"extensions": [], "errors": [], "folder": str(base)}
found, errors = [], []
for folder in sorted(base.iterdir()):
if folder.name.startswith(".") or not folder.is_dir():
continue
try:
found.append(_manifest(folder))
except (ValueError, OSError, UnicodeError, json.JSONDecodeError) as exc:
errors.append({"folder": folder.name, "error": str(exc)})
return {"extensions": found, "errors": errors, "folder": str(base)}
def asset(extension_id, name):
"""Return bytes and MIME; never follow symlinks or serve outside one valid folder."""
if not ID.fullmatch(extension_id) or not ASSET.fullmatch(name) or name.startswith("."):
raise FileNotFoundError("invalid extension path")
folder = root() / extension_id
_manifest(folder)
file = folder / name
if name == "manifest.json" or file.is_symlink() or not file.is_file():
raise FileNotFoundError("extension asset not found")
ctype = TYPES.get(file.suffix.lower())
if not ctype or file.stat().st_size > 5 * 1024 * 1024:
raise FileNotFoundError("unsupported extension asset")
return file.read_bytes(), ctype
-203
View File
@@ -1,203 +0,0 @@
"""Install and control the removable SteamVR Frame Control panel on the Frame.
The companion sends a deterministic app bundle over SSH. The Frame owns a user
service and a browser profile; nothing is installed under /etc or as root.
"""
import gzip
import hashlib
import io
import json
from pathlib import Path
import secrets
import tarfile
ROOT = Path(__file__).resolve().parent.parent
ARCHIVE_DIRS = ('ui', 'apk-catalog', 'frame/android', 'frame/devkit-utils', 'frame/openxr-compat', 'scripts')
MAX_ARCHIVE = 32 * 1024 * 1024
PROBE = r'''
import json, os, pathlib, platform, subprocess
home = pathlib.Path.home()
base = home / '.local/share/frame-control/headset'
unit = home / '.config/systemd/user/frame-control-headset.service'
try: receipt = json.loads((base/'receipt.json').read_text())
except (OSError, ValueError): receipt = {}
try: state = json.loads((base/'state.json').read_text())
except (OSError, ValueError): state = {}
release = {}
for line in pathlib.Path('/etc/os-release').read_text().splitlines():
if '=' in line:
k, v = line.split('=', 1); release[k] = v.strip('"')
active = subprocess.run(['systemctl','--user','is-active','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
overlay = 'valve.steam.desktopgame.2001947813'
try:
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--overlays'],
capture_output=True, text=True, timeout=5)
registered = p.returncode == 0 and ("'"+overlay+"'") in p.stdout
except (OSError, subprocess.TimeoutExpired): registered = False
print(json.dumps({'arch':platform.machine(),'variant':release.get('VARIANT_ID'),
'os':release.get('VERSION_ID'),'build':release.get('BUILD_ID'),
'managed':unit.is_file() and receipt.get('managedBy') == 'Frame Control',
'version':receipt.get('version'),'running':active,'overlayRegistered':registered,
'window':state.get('window') if active else None,
'extensionsFolder':str(home/'.local/share/frame-control/extensions')}))
'''
APPLY = r'''
import hashlib, io, json, os, pathlib, platform, shutil, subprocess, sys, tarfile, time
action, token, digest = sys.argv[1:]
home = pathlib.Path.home()
base = home / '.local/share/frame-control/headset'
bundle = base / 'bundle'
receipt = base / 'receipt.json'
unit = home / '.config/systemd/user/frame-control-headset.service'
upload = pathlib.Path('/tmp/frame-control-headset-' + token)
stage = base / ('stage-' + token)
old = base / ('old-' + token)
overlay = 'valve.steam.desktopgame.2001947813'
def systemd(*args):
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=55)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'user service failed')
def managed():
try: return unit.is_file() and json.loads(receipt.read_text()).get('managedBy') == 'Frame Control'
except (OSError, ValueError): return False
def show():
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if (base/'state.json').is_file():
p = subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--showdashboard',overlay],
capture_output=True, text=True, timeout=10)
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'dashboard rejected panel')
return {'message':'Asked SteamVR to show Frame Control. Select its panel in the dashboard if it is hidden.',
'overlay':overlay}
time.sleep(.25)
raise RuntimeError('The headset panel did not register within 45 seconds; check the user service log')
try:
if action == 'remove':
if not managed(): raise RuntimeError('No Frame Control managed headset panel to remove')
systemd('stop','frame-control-headset.service')
unit.unlink()
systemd('daemon-reload')
shutil.rmtree(bundle)
receipt.unlink()
(base/'server.log').unlink(missing_ok=True)
print(json.dumps({'message':'Removed the Frame Control headset service and app bundle. Extensions and browser profile were kept.'}))
elif action == 'close':
if not managed(): raise RuntimeError('The headset panel is not installed by Frame Control')
systemd('stop','frame-control-headset.service')
print(json.dumps({'message':'Closed the Frame Control headset panel.'}))
elif action == 'open':
if not managed(): raise RuntimeError('Install the headset panel first')
systemd('start','frame-control-headset.service')
print(json.dumps(show()))
else:
if action not in ('install','update'): raise RuntimeError('Invalid headset panel action')
if platform.machine() != 'aarch64': raise RuntimeError('This headset panel needs the Frame ARM64 build')
if action == 'install' and (unit.exists() or receipt.exists() or bundle.exists()):
raise RuntimeError('Headset panel files already exist; no existing installation was changed')
if action == 'update' and not managed(): raise RuntimeError('No Frame Control managed panel to update')
data = upload.read_bytes()
if not 1024 < len(data) <= 32*1024*1024 or hashlib.sha256(data).hexdigest() != digest:
raise RuntimeError('Headset bundle size or digest did not match')
base.mkdir(parents=True, exist_ok=True, mode=0o700)
stage.mkdir(mode=0o700)
with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
members = archive.getmembers()
if not members or any(not m.isfile() or not m.name.startswith(('ui/','apk-catalog/','frame/android/',
'frame/devkit-utils/','frame/openxr-compat/','scripts/')) or '..' in pathlib.PurePosixPath(m.name).parts
or m.size > 8*1024*1024 for m in members):
raise RuntimeError('Invalid headset bundle member')
archive.extractall(stage, members=members, filter='data')
if not (stage/'ui/server.py').is_file() or not (stage/'ui/frame_headset_runtime.py').is_file():
raise RuntimeError('Incomplete headset bundle')
unit.parent.mkdir(parents=True, exist_ok=True)
previous_unit = unit.read_bytes() if unit.exists() else None
previous_receipt = receipt.read_bytes() if receipt.exists() else None
if action == 'update':
systemd('stop','frame-control-headset.service')
bundle.rename(old)
try:
stage.rename(bundle)
unit.write_text('[Unit]\nDescription=Frame Control headset panel\nAfter=gamescope-session.target steamvr.service\n'
'[Service]\nType=simple\nExecStart=/usr/bin/python3 -I -u '+str(bundle/'ui/frame_headset_runtime.py')+'\n'
'Restart=no\nKillMode=control-group\nTimeoutStopSec=45\n')
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':digest[:16]})+'\n')
systemd('daemon-reload')
systemd('start','frame-control-headset.service')
result = show()
except Exception:
subprocess.run(['systemctl','--user','stop','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if bundle.exists(): shutil.rmtree(bundle)
if old.exists(): old.rename(bundle)
if previous_unit is None: unit.unlink(missing_ok=True)
else: unit.write_bytes(previous_unit)
if previous_receipt is None: receipt.unlink(missing_ok=True)
else: receipt.write_bytes(previous_receipt)
subprocess.run(['systemctl','--user','daemon-reload'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if previous_unit: subprocess.run(['systemctl','--user','start','frame-control-headset.service'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
raise
if old.exists(): shutil.rmtree(old)
print(json.dumps({'message':'Installed Frame Control on the headset. '+result['message'],
'version':digest[:16],'overlay':overlay}))
finally:
upload.unlink(missing_ok=True)
if stage.exists(): shutil.rmtree(stage)
'''
def bundle():
"""Pack app code shipped with the desktop app; never include user data."""
raw = io.BytesIO()
with tarfile.open(fileobj=raw, mode='w') as archive:
for dirname in ARCHIVE_DIRS:
folder = ROOT / dirname
if not folder.is_dir():
continue
for path in sorted(folder.rglob('*')):
if not path.is_file() or path.is_symlink() or '__pycache__' in path.parts:
continue
if path.suffix not in ('.py', '.html', '.js', '.css', '.json', '.sh', '.gz', '.zst', '.so', '.txt', '.md') and path.name not in ('ssh',):
continue
data = path.read_bytes()
info = tarfile.TarInfo(path.relative_to(ROOT).as_posix())
info.size, info.mtime, info.uid, info.gid = len(data), 0, 0, 0
info.mode = 0o755 if path.stat().st_mode & 0o111 else 0o644
archive.addfile(info, io.BytesIO(data))
with io.BytesIO() as out:
with gzip.GzipFile(fileobj=out, mode='wb', mtime=0) as gz:
gz.write(raw.getvalue())
data = out.getvalue()
if len(data) > MAX_ARCHIVE:
raise ValueError('Headset app bundle is too large')
return data
def status(ssh):
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
def change(ssh, action):
if action not in ('install', 'update', 'open', 'close', 'remove'):
raise ValueError('Invalid headset panel action')
current = status(ssh)
if action in ('install', 'update', 'open') and (current['arch'] != 'aarch64' or current['variant'] != 'vr'):
raise ValueError('This feature needs a Steam Frame running Frame OS')
data = bundle() if action in ('install', 'update') else None
digest = hashlib.sha256(data or b'').hexdigest()
token = secrets.token_hex(12)
remote = '/tmp/frame-control-headset-' + token
try:
if data is not None:
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
return json.loads(ssh(f'python3 - {action} {token} {digest}', stdin=APPLY, timeout=100))
finally:
if data is not None:
try: ssh('rm -f ' + remote, timeout=10)
except Exception: pass
-149
View File
@@ -1,149 +0,0 @@
"""Run Frame Control's opt-in headset panel as one bounded user service.
This process owns only its loopback server and Chromium profile. Steam, SteamVR,
gamescope, other browser profiles, and the user's extensions are never stopped.
"""
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
BASE = Path.home() / '.local/share/frame-control/headset'
PORT = 47813
APPID = 2001947813
OVERLAY = f'valve.steam.desktopgame.{APPID}'
VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
TITLE = '[fc-headset]'
class RuntimeError_(RuntimeError):
pass
def browser_command():
chrome = Path.home() / 'chromium-xr/chrome'
if chrome.is_file() and os.access(chrome, os.X_OK):
return [str(chrome)]
if Path('/usr/bin/chromium').is_file():
return ['/usr/bin/chromium']
if subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
return ['flatpak', 'run', 'org.chromium.Chromium']
raise RuntimeError_('Chromium is needed on the Frame to show the headset panel')
def command(*args):
result = subprocess.run(args, capture_output=True, text=True, timeout=5,
env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
if result.returncode:
raise RuntimeError_((result.stderr or result.stdout).strip()[-400:] or f'{args[0]} failed')
return result.stdout
def panel_window():
for line in command('xwininfo', '-root', '-children').splitlines():
match = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-headset\]', line)
if match:
return match[1]
return None
def wait_for_server(proc):
deadline = time.monotonic() + 15
while time.monotonic() < deadline and proc.poll() is None:
try:
with urllib.request.urlopen(f'http://127.0.0.1:{PORT}/headset', timeout=.5) as response:
if response.status == 200 and TITLE.encode() in response.read(200_000):
return
except (OSError, urllib.error.URLError):
pass
time.sleep(.2)
raise RuntimeError_('The headset loopback server did not start')
def terminate_owned(proc, timeout=5):
if proc and proc.poll() is None:
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
def serve():
bundle = BASE / 'bundle/ui'
key = os.urandom(24).hex()
BASE.mkdir(parents=True, exist_ok=True, mode=0o700)
state_file = BASE / 'state.json'
env = {**os.environ, 'FRAME_LOCAL': '1', 'FRAME_DEVICE': 'headset',
'FRAME_HEADSET_RUNTIME': '1', 'FRAME_UI_KEY': key}
env.pop('WAYLAND_DISPLAY', None)
env['DISPLAY'] = ':0'
env['LD_PRELOAD'] = '' # Steam's overlay preload crashes Chromium's zygote.
server = browser = None
stopping = False
def stop(*_):
nonlocal stopping
stopping = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
command('xprop', '-root', 'GAMESCOPE_FOCUSABLE_WINDOWS')
with (BASE / 'server.log').open('ab') as log:
server = subprocess.Popen([sys.executable, '-I', '-u', '-B', str(bundle / 'server.py'),
'--port', str(PORT)], env=env, stdin=subprocess.DEVNULL,
stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
wait_for_server(server)
browser = subprocess.Popen([*browser_command(), '--ozone-platform=x11',
'--user-data-dir=' + str(BASE / 'profile'),
'--no-first-run', '--no-default-browser-check',
'--password-store=basic', '--disable-session-crashed-bubble',
'--noerrdialogs', '--window-size=1200,800',
'--app=http://127.0.0.1:%d/headset#%s' % (PORT, key)],
env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
deadline = time.monotonic() + 30
win = None
while time.monotonic() < deadline and browser.poll() is None and not stopping:
win = panel_window()
if win:
break
time.sleep(.25)
if not win:
raise RuntimeError_('The Frame Control browser window did not appear')
command('xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(APPID))
deadline = time.monotonic() + 8
while time.monotonic() < deadline:
if OVERLAY in command(VRCMD, '--overlays'):
break
time.sleep(.25)
else:
raise RuntimeError_('SteamVR did not register the Frame Control panel')
state_file.write_text(json.dumps({'port': PORT, 'window': win, 'overlay': OVERLAY,
'started': time.time()}) + '\n')
state_file.chmod(0o600)
print('Frame Control headset panel registered as ' + OVERLAY, flush=True)
while not stopping and server.poll() is None and browser.poll() is None:
time.sleep(.5)
finally:
state_file.unlink(missing_ok=True)
terminate_owned(browser)
terminate_owned(server, timeout=35) # allow the local recorder to finalize its MP4
if __name__ == '__main__':
try:
serve()
except (OSError, RuntimeError_, subprocess.SubprocessError) as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
+1 -2
View File
@@ -411,8 +411,7 @@ class MacView:
# A single-use ticket for this source, not Frame Control's key: the URL
# is visible in the Frame's process list.
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
params = {"src": src, "t": ticket, "tag": tag, "host": self.host,
"codec": q["codec"], "max": q["max"], "fps": q["fps"],
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
-129
View File
@@ -1,129 +0,0 @@
"""Read-only PC checks for the Frame's observed USB-C network path.
The known Frame USB address was observed on a Mac in 2026-09. It is a clue,
not a promise that every Frame OS or host uses the same address. Never set an
interface address, accept a new SSH host key, or change a network route here.
"""
import argparse
import ipaddress
import json
import re
import socket
import subprocess
import frame_host
TARGET = '10.86.200.233'
SUBNET = ipaddress.ip_network('10.86.200.232/29')
ALIAS = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
class Probe:
def route_source(self):
# UDP connect chooses a route but sends no packet.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect((TARGET, 22))
return sock.getsockname()[0]
def port_open(self):
with socket.create_connection((TARGET, 22), timeout=2):
return True
def host_key_name(self, alias):
result = frame_host.run_ssh(['ssh', '-G', alias], stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=5)
if result.returncode:
raise OSError('SSH configuration unavailable')
settings = dict(line.split(None, 1) for line in result.stdout.splitlines() if ' ' in line)
name = settings.get('hostkeyalias') or settings.get('hostname') or alias
if not name or len(name) > 253 or name.startswith('-') or any(c.isspace() for c in name):
raise ValueError('Invalid SSH host-key name')
return name
def ssh_check(self, alias, host_key_name):
return frame_host.run_ssh([
'ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'NumberOfPasswordPrompts=0', '-o', 'ConnectTimeout=5',
'-o', 'ConnectionAttempts=1', '-o', 'ProxyCommand=none',
'-o', 'ProxyJump=none', '-o', 'PermitLocalCommand=no',
'-o', 'RemoteCommand=none', '-o', 'ClearAllForwardings=yes',
'-o', 'ControlMaster=no', '-o', 'ControlPath=none',
'-o', f'HostName={TARGET}',
'-o', f'HostKeyAlias={host_key_name}', alias, 'true',
], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=9)
def check(identifier, title, status, detail):
return {'id': identifier, 'title': title, 'status': status, 'detail': detail}
def diagnose(alias='frame', *, probe=None, local=False):
"""No headset request unless the PC chooses the known local USB subnet."""
if local:
return {'supported': False, 'state': 'unsupported', 'checks': [],
'message': 'Run this doctor on your computer, not the headset.'}
if not ALIAS.fullmatch(alias):
raise ValueError('Choose one configured headset alias.')
probe = probe or Probe()
checks = []
try:
source = probe.route_source()
wired = ipaddress.ip_address(source) in SUBNET and source != TARGET
except (OSError, ValueError):
source, wired = None, False
if wired:
checks.append(check('route', 'PC route to Frame USB address', 'ok',
f'This PC chooses {source} on the previously observed Frame USB subnet. This alone does not prove the cable or headset identity.'))
else:
checks.append(check('route', 'PC route to Frame USB address', 'unknown',
'No route using the previously observed 10.86.200.232/29 USB subnet. Check a data-capable USB-C cable, port and the PC network interface. This OS may use a different subnet.'))
checks += [check('port', 'USB path SSH port', 'unknown', 'Skipped: the known local USB route was not observed.'),
check('ssh', 'Pinned headset SSH identity', 'unknown', 'Skipped: no known USB route; no headset was contacted.')]
return {'supported': True, 'state': 'unknown', 'alias': alias, 'checks': checks}
try:
open_port = probe.port_open()
except OSError:
open_port = False
if not open_port:
checks += [check('port', 'USB path SSH port', 'warning',
'Port 22 did not answer on the observed USB route. Check the cable and whether Developer Mode SSH is enabled on the Frame.'),
check('ssh', 'Pinned headset SSH identity', 'unknown', 'Skipped: port 22 did not answer.')]
return {'supported': True, 'state': 'partial', 'alias': alias, 'checks': checks}
checks.append(check('port', 'USB path SSH port', 'ok', 'Port 22 answered on the observed USB route. This does not authenticate the headset.'))
try:
host_key_name = probe.host_key_name(alias)
result = probe.ssh_check(alias, host_key_name)
except (OSError, ValueError, subprocess.TimeoutExpired):
result = None
if result and result.returncode == 0:
checks.append(check('ssh', 'Pinned headset SSH identity', 'ok',
'A read-only SSH command succeeded over the USB address using strict host-key checking.'))
else:
error = (result.stderr or '') if result else ''
reason = ('The saved host key was not accepted. Verify and pair through the normal headset alias first; do not bypass host-key checking.'
if 'Host key verification failed' in error or 'No ' in error and 'host key is known' in error
else 'The USB SSH login did not complete. Check the saved headset key and Developer Mode SSH; no key or security setting was changed.')
checks.append(check('ssh', 'Pinned headset SSH identity', 'warning', reason))
return {'supported': True, 'state': 'ready' if checks[-1]['status'] == 'ok' else 'partial',
'alias': alias, 'checks': checks}
def main(argv=None):
parser = argparse.ArgumentParser(description='Read-only wired Frame connection doctor')
parser.add_argument('--alias', default='frame', help='configured SSH alias (default: frame)')
parser.add_argument('--json', action='store_true', help='print a JSON report')
args = parser.parse_args(argv)
try:
report = diagnose(args.alias)
except ValueError as error:
parser.error(str(error))
if args.json:
print(json.dumps(report, indent=2))
else:
for item in report['checks']:
print(f"{item['status'].upper():7} {item['title']}: {item['detail']}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
-97
View File
@@ -1,97 +0,0 @@
<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Frame Control VR [fc-headset]</title>
<style>
:root{color-scheme:dark;font:22px/1.4 system-ui,sans-serif;background:#0d1925;color:#f3f7fa}
*{box-sizing:border-box}body{margin:0;padding:30px;max-width:1280px}h1{font-size:40px;margin:0 0 6px}
h2{font-size:29px;margin:25px 0 10px}p{margin:0 0 18px;color:#b9c9d6}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}
.card,.block{background:#1b3042;border:1px solid #40576c;border-radius:14px;padding:20px}.card{min-height:140px}
.label{display:block;color:#b9c9d6;font-size:19px}.value{display:block;font-size:34px;font-weight:700;margin-top:10px}
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}.stack{display:grid;gap:12px}
button{font:inherit;background:#23618c;color:white;border:1px solid #72baf0;border-radius:12px;padding:13px 20px;cursor:pointer;min-height:62px}
button:focus-visible{outline:5px solid #f8d370;outline-offset:3px}button:disabled{opacity:.55;cursor:default}
button.secondary{background:#253c50;border-color:#617f97}button.danger{background:#78372d;border-color:#d78d79}
small{color:#b9c9d6}#message,#actionStatus{min-height:1.5em;margin:12px 0}.game{display:flex;gap:14px;justify-content:space-between;align-items:center}
iframe{display:block;width:100%;height:560px;background:#0d1925;border:1px solid #617f97;border-radius:12px;margin-top:14px}
@media(max-width:720px){.cards{grid-template-columns:1fr}body{padding:18px}.game{display:block}.game button{margin-top:12px}}
</style></head><body>
<h1>Frame Control</h1><p>Your Frame, from inside SteamVR. Select this panel again from the dashboard after switching away.</p>
<div class="cards">
<div class="card"><span class="label">Battery</span><span class="value" id="battery">…</span></div>
<div class="card"><span class="label">Home storage free</span><span class="value" id="storage">…</span></div>
<div class="card"><span class="label">SteamVR</span><span class="value" id="steamvr">…</span></div>
</div>
<p id="message" role="status" aria-live="polite"></p>
<div class="row"><button id="refresh">Refresh status</button><button id="gameMode" class="secondary">Return to Game Mode</button>
<button id="screenshot" class="secondary">Save screenshot</button><button id="record" class="secondary">Start recording</button></div>
<p id="actionStatus" role="status" aria-live="polite"></p>
<h2>Running games</h2><div class="row"><button id="gamesRefresh" class="secondary">Refresh games</button></div>
<div id="games" class="stack" style="margin-top:12px">Loading…</div>
<h2>Extensions</h2><p>Local additions run only when opened in the isolated panel below. They can read status but cannot change headset settings through the extension API.</p>
<div class="row"><button id="extensionsRefresh" class="secondary">Refresh extensions</button><span id="extensionsFolder"><small></small></span></div>
<div id="extensions" class="stack" style="margin-top:12px">Loading…</div>
<div id="extensionPanel" hidden><div class="row" style="margin-top:16px"><strong id="extensionTitle"></strong><button id="extensionClose" class="secondary">Close extension</button></div>
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe></div>
<script>
const key=location.hash.slice(1)||sessionStorage.getItem('frameHeadsetKey')||'';
if(key)sessionStorage.setItem('frameHeadsetKey',key);
history.replaceState(null,'',location.pathname);
const byId=id=>document.getElementById(id);
let latestStatus=null, activeExtension=null, recordBusy=false;
async function api(path,body){const response=await fetch(path,{method:body?'POST':'GET',
headers:{'X-Frame-UI':key,'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
const data=await response.json();if(!response.ok)throw Error(data.error||'Frame request failed');return data;}
async function refresh(){try{const status=latestStatus=await api('/api/status');
byId('battery').textContent=status.battery?.percent==null?'Unavailable':`${status.battery.percent}%`;
const free=status.disk?.home?.free;
byId('storage').textContent=Number.isFinite(free)?`${(free/1e9).toFixed(1)} GB`:'Unavailable';
byId('steamvr').textContent=status.services?.steamvr?'Running':'Unavailable';
byId('message').textContent=`Frame OS ${status.os?.version||'unknown'} · Updated ${new Date().toLocaleTimeString()}`;
}catch(error){latestStatus=null;byId('message').textContent=error.message;}}
async function act(action){const button=byId(action==='game-mode'?'gameMode':'screenshot');button.disabled=true;
try{const result=await api('/api/headset/quick',{action});byId('actionStatus').textContent=result.message;}
catch(error){byId('actionStatus').textContent=error.message;}finally{button.disabled=false;}}
async function refreshGames(){const list=byId('games');try{const data=await api('/api/running');list.replaceChildren();
for(const game of data.running){const row=document.createElement('div');row.className='block game';
const name=document.createElement('strong');name.textContent=game.name||`App ${game.appid}`;
const quit=document.createElement('button');quit.className='danger';quit.textContent=`Force quit ${name.textContent}`;
quit.onclick=async()=>{if(!confirm(`Force quit ${name.textContent}? Unsaved progress may be lost.`))return;
quit.disabled=true;try{const result=await api('/api/running',{action:'quit',appid:String(game.appid)});
byId('actionStatus').textContent=result.message||`Quit ${name.textContent}`;await refreshGames();}
catch(error){byId('actionStatus').textContent=error.message;quit.disabled=false;}};
row.append(name,quit);list.append(row);}if(!data.running.length)list.textContent='No games are running.';
}catch(error){list.textContent=error.message;}}
async function refreshRecord(){try{const data=await api('/api/record');
byId('record').textContent=['recording','starting'].includes(data.state)?'Stop recording':'Start recording';
byId('record').disabled=recordBusy||['stopping','saving'].includes(data.state);
if(data.state==='recording')byId('actionStatus').textContent=`Recording ${Math.round(data.elapsed||0)} s${data.audio?' with sound':' without sound'}`;
else if(data.state==='saved'||data.state==='failed')byId('actionStatus').textContent=data.message||data.error;
}catch(error){byId('record').disabled=true;byId('actionStatus').textContent=error.message;}}
async function toggleRecord(){if(recordBusy)return;recordBusy=true;byId('record').disabled=true;
try{const state=await api('/api/record');const action=state.state==='recording'?'stop':'start';
const result=await api('/api/record',{action});byId('actionStatus').textContent=result.message||result.state;}
catch(error){byId('actionStatus').textContent=error.message;}finally{recordBusy=false;refreshRecord();}}
async function refreshExtensions(){const list=byId('extensions');try{const data=await api('/api/extensions');
byId('extensionsFolder').textContent=`Folder: ${data.folder}`;list.replaceChildren();
for(const ext of data.extensions){const row=document.createElement('div');row.className='block game';
const label=document.createElement('span');label.textContent=`${ext.name} · ${ext.description}`;
const open=document.createElement('button');open.textContent=`Open ${ext.name}`;open.onclick=()=>{
activeExtension=ext.id;byId('extensionTitle').textContent=ext.name;
byId('extensionFrame').src=`/extension/${encodeURIComponent(ext.id)}/index.html`;
byId('extensionPanel').hidden=false;byId('extensionPanel').scrollIntoView();};
row.append(label,open);list.append(row);}if(!data.extensions.length)list.textContent='No extensions installed.';
if(data.errors.length){const errors=document.createElement('p');errors.textContent=data.errors.map(x=>`${x.folder}: ${x.error}`).join('; ');list.append(errors);}
}catch(error){list.textContent=error.message;}}
byId('refresh').onclick=refresh;byId('gameMode').onclick=()=>act('game-mode');byId('screenshot').onclick=()=>act('screenshot');
byId('gamesRefresh').onclick=refreshGames;byId('record').onclick=toggleRecord;byId('extensionsRefresh').onclick=refreshExtensions;
byId('extensionClose').onclick=()=>{activeExtension=null;byId('extensionFrame').removeAttribute('src');byId('extensionPanel').hidden=true;};
window.addEventListener('message',event=>{if(!activeExtension||event.source!==byId('extensionFrame').contentWindow)return;
const request=event.data;if(!request||request.type!=='frame-control:request'||!Number.isSafeInteger(request.id))return;
let value;if(request.action==='capabilities')value={apiVersion:1,actions:['capabilities','status']};
else if(request.action==='status')value={connected:!!latestStatus,
os:{version:latestStatus?.os?.version||null,build:latestStatus?.os?.build||null},
battery:{percent:latestStatus?.battery?.percent??null},steamvr:!!latestStatus?.services?.steamvr};
else return;event.source.postMessage({type:'frame-control:response',id:request.id,value},'*');});
refresh();refreshGames();refreshRecord();refreshExtensions();
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
</script></body></html>
+6 -216
View File
@@ -1240,11 +1240,6 @@
</div>
<div class="hint" id="recStatus" role="status" hidden></div>
<div class="hint" id="viewHint"></div>
<div id="obsControl" class="hint" hidden>
<button class="small" id="obsBtn">Start OBS source</button>
<span id="obsStatus" role="status"></span>
</div>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Set up the headset view and sound in OBS →</a></p>
</section>
@@ -1436,41 +1431,13 @@
<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 &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>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><a href="#headsetPanel"><span>Frame Control in VR</span><small>Open tools inside the headset</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#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 &amp; heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup &amp; recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display &amp; preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant &amp; AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
<div class="grid-3" id="toolWorkspace">
<section class="panel" id="extensions">
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
<p class="sub">Add local tools to Frame Control. An extension runs only when you open it, in an isolated panel with read-only headset status.</p>
<div id="extensionsList" class="sub">Loading…</div>
<div id="extensionsFolder" class="hint"></div>
<div id="extensionPanel" hidden style="margin-top:16px">
<div class="shelf-head"><h2 id="extensionTitle"></h2><span class="spacer"></span><button class="small" id="extensionClose">Close</button></div>
<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="headsetPanel">
<div class="shelf-head"><h2>Frame Control in VR</h2><span class="spacer"></span><button class="small" id="headsetRefresh">Refresh</button></div>
<p class="sub">Open a Frame Control panel in SteamVR's dashboard. The panel runs on the Frame through a loopback server and an opt-in user service.</p>
<div id="headsetState" class="sub">Checking…</div>
<label class="popt"><input type="checkbox" id="headsetAck"> I want to install this local headset panel. Closing or removing it leaves SteamVR and my extensions running.</label>
<div class="row" style="margin-top:10px"><button class="small action" id="headsetInstall" disabled>Install &amp; open</button><button class="small" id="headsetUpdate" disabled>Update</button><button class="small" id="headsetOpen" disabled>Show in SteamVR</button><button class="small" id="headsetClose" disabled>Close</button><button class="small danger" id="headsetRemove" disabled>Remove</button></div>
<p class="hint">Open SteamVR's dashboard to find Frame Control. Remove stops its user service and removes its app bundle; your extension folders and browser profile stay on the Frame.</p>
</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>
<div class="row"><button class="action" id="wirelessRead">Read PC adapters</button><button id="wirelessForget" disabled>Forget previous read</button></div>
<button class="action" id="wirelessRead">Read PC adapters</button>
<p id="wirelessStatus" role="status" aria-live="polite">Choose Read PC adapters to start. No automatic polling.</p>
<div id="wirelessComparison" aria-live="polite"></div>
<div id="wirelessResult"></div>
<div class="card" style="margin-top:16px"><h3>Before changing an adapter</h3>
<p>Multiple Microsoft Wi-Fi Direct Virtual Adapter instances can be normal. A number such as #2, a disconnected link or a non-present device does not establish which instance is extra or safe to disable. This tool cannot reliably identify the Steam dongle's primary or active instance, so it offers no disable action.</p>
@@ -1480,14 +1447,6 @@
<p>A Razer or other 2.4 GHz audio dongle can share spectrum with Wi-Fi. This inventory cannot measure interference, detect the audio dongle's channel or prove what caused a dropout or PC shutdown.</p>
<p>Try wired headphones briefly and compare. Space USB radio dongles apart with an extension lead; if your PC uses 2.4 GHz Wi-Fi, compare Ethernet or an existing 5/6 GHz network. Change one thing at a time and keep notes. No network or security settings are changed here.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If streaming drops as it starts</h3>
<p>Read PC adapters before starting, then refresh immediately after a drop. Compare exact PnP instance IDs, presence and problem codes; a new numbered name alone does not identify a fault. Note whether the PC's own Wi-Fi also drops.</p>
<p>Retry once with wired PC networking or wired audio if available, then with the radio dongles spaced apart. Change one condition at a time. The PC Wi-Fi channel shown above is not the Steam dongle channel, and this check cannot prove RF interference.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If the adapter will not reconnect</h3>
<p>Stop the failed stream, close Steam's streaming session, and check that the adapter still appears in the refreshed PC snapshot. If it is absent, reconnect the USB dongle once at the PC and read again; this will interrupt any connection using it.</p>
<p>If the same exact PnP instance shows problem code 22 (disabled), use Windows Device Manager to review that instance and its enable action. Do not enable or disable a different numbered instance by guesswork. If it stays missing or reports another problem code, keep the before/after IDs and error for Steam Support. Frame Control does not change the driver, radio or network settings.</p>
</div>
</section>
<section class="panel" id="powerReport">
@@ -1901,7 +1860,6 @@
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
<p><a href="/wired-doctor">Wired connection doctor ↗</a><br><span class="sub">Check the USB-C network route and pinned SSH login from this computer.</span></p>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
@@ -2149,102 +2107,6 @@
<script>
const $ = (id) => document.getElementById(id);
let activeExtension = null;
async function loadExtensions() {
try {
const data = await api('/api/extensions');
$('extensionsFolder').textContent = `Put a folder with manifest.json and index.html in ${data.folder}`;
$('extensionsList').innerHTML = data.extensions.length
? data.extensions.map(x => `<div class="row" style="margin:7px 0"><button class="small" data-extension="${esc(x.id)}">Open ${esc(x.name)}</button><span class="sub">${esc(x.description)} · ${esc(x.version)}</span></div>`).join('')
: 'No extensions installed.';
if (data.errors.length) $('extensionsList').innerHTML += `<div class="hint">${data.errors.map(x => `${esc(x.folder)}: ${esc(x.error)}`).join('<br>')}</div>`;
$('extensionsList').querySelectorAll('[data-extension]').forEach(button => button.onclick = () => {
const ext = data.extensions.find(x => x.id === button.dataset.extension);
if (!ext) return;
activeExtension = ext.id;
$('extensionTitle').textContent = ext.name;
$('extensionFrame').src = `/extension/${encodeURIComponent(ext.id)}/index.html`;
$('extensionPanel').hidden = false;
});
} catch (error) { $('extensionsList').textContent = error.message; }
}
$('extensionsRefresh').onclick = loadExtensions;
$('extensionClose').onclick = () => {
activeExtension = null;
$('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();
};
}
let headsetInfo = null;
async function loadHeadsetPanel() {
try {
const d = headsetInfo = await api('/api/headset');
$('headsetState').textContent = `${d.os || 'Unknown Frame OS'} · ${d.arch} · ${d.managed ? `installed ${d.version || ''}` : 'not installed'} · ${d.running ? d.overlayRegistered ? 'SteamVR panel registered' : 'service starting' : 'stopped'}`;
} catch (error) { headsetInfo = null; $('headsetState').textContent = error.message; }
headsetButtons();
}
function headsetButtons() {
const d = headsetInfo, ready = !!d && d.arch === 'aarch64' && d.variant === 'vr';
$('headsetInstall').disabled = !ready || !!d.managed || !$('headsetAck').checked;
$('headsetUpdate').disabled = !ready || !d.managed || !$('headsetAck').checked;
$('headsetOpen').disabled = !ready || !d.managed;
$('headsetClose').disabled = !d?.running;
$('headsetRemove').disabled = !d?.managed || !$('headsetAck').checked;
}
$('headsetAck').onchange = headsetButtons;
$('headsetRefresh').onclick = loadHeadsetPanel;
for (const action of ['Install', 'Update', 'Open', 'Close', 'Remove']) {
$(`headset${action}`).onclick = async () => {
if (!headsetInfo) return;
const verb = action.toLowerCase();
if (verb === 'remove' && !confirm('Remove the Frame Control headset user service and app bundle? Your extensions and browser profile will stay.')) return;
await runJob(`${action} Frame Control in VR`, 'headset-panel',
() => api('/api/headset', {action:verb, acknowledged:$('headsetAck').checked}));
loadHeadsetPanel();
};
}
window.addEventListener('message', event => {
if (!activeExtension || event.source !== $('extensionFrame').contentWindow) return;
const request = event.data;
if (!request || request.type !== 'frame-control:request' || !Number.isSafeInteger(request.id)) return;
let value;
if (request.action === 'capabilities') value = {apiVersion: 1, actions: ['capabilities', 'status']};
else if (request.action === 'status') {
const snapshot = stateRead.valid ? state : null;
value = {connected: !!snapshot,
os: {version: snapshot?.os?.version || null, build: snapshot?.os?.build || null},
battery: {percent: snapshot?.battery?.percent ?? null}, steamvr: !!snapshot?.services?.steamvr};
}
else return;
event.source.postMessage({type:'frame-control:response', id:request.id, value}, '*');
});
// Every /api/ request carries this header. The desktop server takes "1"; the
// iPhone app's server, on the Frame, takes a fresh key it puts in the page URL.
const UI_KEY = new URLSearchParams(location.search).get("key") || "1";
@@ -4526,7 +4388,7 @@ function renderCats() {
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
$("storeHeading").hidden = !sourceState.category;
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>${sourceState.category === "emulators" ? '<p><a href="https://github.com/saphid/frame-control/blob/main/docs/emulators.md" target="_blank" rel="noopener noreferrer">Set up an emulator and your own games →</a></p>' : ""}`;
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>`;
}
function renderStore() {
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
@@ -5627,33 +5489,6 @@ $("recBtn").onclick = async e => {
if (res) showRec(res); else pollRec();
};
// The desktop app owns the local OBS relay, so it can close SSH when the app
// exits. The browser and mobile views keep the command-line guide instead.
if (window.frameApp?.obs) {
const obs = window.frameApp.obs;
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
let state = { state: "idle" };
function showObs(next) {
if (!next) return;
state = next;
wrap.hidden = !next.supported;
button.textContent = next.state === "running" ? "Stop OBS source" : "Start OBS source";
button.disabled = next.state === "starting" || next.state === "stopping";
status.textContent = next.state === "starting" ? "Connecting to the Frame and waiting for video…"
: next.state === "running" ? `Sending ${next.alias} view and sound to udp://127.0.0.1:15099`
: next.state === "stopping" ? "Stopping the OBS source…"
: next.state === "failed" ? next.error : "";
}
obs.onState(showObs);
obs.status().then(showObs).catch(() => {});
button.onclick = async e => {
const stopping = state.state === "running";
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
if (result) showObs(result);
};
}
const clipsState = { list: [], gen: 0, exporting: new Set() };
async function loadClips() {
if (HOST.mobile) return;
@@ -5794,34 +5629,7 @@ document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async
// ---- Read-only PC wireless doctor ----
let wirelessSeq = 0;
let wirelessPrevious = null; // memory only; never sent or saved
const wirelessKnown = value => value === true ? 'Yes' : value === false ? 'No' : 'Unknown';
function compareWireless(previous, current) {
if (previous.sources?.pnp !== 'ok' || current.sources?.pnp !== 'ok')
return '<p class="hint">Exact device comparison unavailable: the PnP inventory was incomplete in one read. The current snapshot is shown below.</p>';
const keyed = snapshot => new Map((snapshot.devices || [])
.filter(d => typeof d.id === 'string' && d.id)
.map(d => [d.id.toLowerCase(), d]));
const before = keyed(previous), after = keyed(current), changes = [];
const describe = d => `${esc(d.name || 'Unnamed device')} (<code>${esc(d.id)}</code>)`;
for (const [id, device] of after) {
const old = before.get(id);
if (!old) { changes.push(`Newly listed: ${describe(device)}`); continue; }
for (const [field, label] of [['present','presence'], ['pnpStatus','PnP status'],
['problemCode','problem code'], ['operational','operation'], ['media','media'],
['defaultRoute','default route'], ['channel','reported channel'], ['band','reported band']]) {
if (old[field] == null || device[field] == null || old[field] === device[field]) continue;
const value = v => typeof v === 'boolean' ? wirelessKnown(v) : esc(v);
changes.push(`${describe(device)}: ${label} ${value(old[field])} → ${value(device[field])}`);
}
}
for (const [id, device] of before) if (!after.has(id)) changes.push(`No longer listed: ${describe(device)}`);
const shown = changes.slice(0, 25);
return `<div class="card"><h3>Changes since previous read</h3>
<p class="sub">${esc(previous.observedAt || 'Earlier read')} → ${esc(current.observedAt || 'Current read')}. These are reported changes, not proof of a new fault or an unused adapter. A newly listed numbered Wi-Fi Direct instance is not a safe disable target.</p>
${shown.length ? `<ul>${shown.map(line => `<li>${line}</li>`).join('')}</ul>` : '<p>No reported device changes in the available fields.</p>'}
${changes.length > shown.length ? `<p>${changes.length - shown.length} more changes omitted; inspect the current snapshot below.</p>` : ''}</div>`;
}
function renderWireless(r) {
const unknown = value => value == null ? 'Unknown' : esc(value);
const states = (value, known) => known[value] || (value == null ? 'Unknown' : `Other (${esc(value)})`);
@@ -5840,8 +5648,6 @@ function renderWireless(r) {
$('wirelessRead').onclick = async () => {
const seq = ++wirelessSeq;
$('wirelessRead').disabled = true;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessResult').textContent = '';
$('wirelessStatus').textContent = 'Reading this PC’s adapters…';
try {
@@ -5851,26 +5657,14 @@ $('wirelessRead').onclick = async () => {
$('wirelessStatus').textContent = r.message || 'PC wireless inventory unavailable.';
return;
}
$('wirelessComparison').innerHTML = wirelessPrevious ? compareWireless(wirelessPrevious, r) :
'<p class="hint">Read PC adapters again after a dropout to compare exact device reports. The previous read stays only in this open app.</p>';
wirelessPrevious = r;
$('wirelessResult').innerHTML = renderWireless(r);
$('wirelessStatus').textContent = r.state === 'partial' ? 'Partial PC snapshot. Some sources are unavailable. No settings changed.' : 'PC snapshot received. Unknown channel/band fields may remain. No settings changed.';
} catch (e) {
if (seq === wirelessSeq) $('wirelessStatus').textContent = `PC wireless inventory unavailable: ${e.message}`;
} finally {
if (seq === wirelessSeq) {
$('wirelessRead').disabled = false;
$('wirelessForget').disabled = !wirelessPrevious;
}
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
}
};
$('wirelessForget').onclick = () => {
wirelessPrevious = null;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessStatus').textContent = 'Previous adapter read forgotten. Read again to start a new comparison.';
};
// ---- Read-only game storage ----
let storageSeq = 0;
@@ -6190,7 +5984,6 @@ async function loadSteamPipewire() {
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
$('steamPipewireStatus').textContent = e.message;
}
}
async function steamPipewireAction(action) {
if (steamPipewireBusy) return;
@@ -6214,10 +6007,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", decky:"tools", headsetPanel:"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"};
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", decky:"Decky Loader", headsetPanel:"Frame Control in VR"};
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"};
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.
@@ -6324,9 +6117,6 @@ function showPage(e) {
if (tool === 'steamPipewire') loadSteamPipewire();
if (tool === 'macview') loadMacView();
if (tool === 'panels') loadPanelSwitcher();
if (tool === 'extensions') loadExtensions();
if (tool === 'decky') loadDecky();
if (tool === 'headsetPanel') loadHeadsetPanel();
if (tool === 'backup') loadBackups();
if (tool === 'storage') loadStorage();
if (tool === 'powerReport') loadPower();
+1 -39
View File
@@ -18,16 +18,10 @@
#note[hidden] { display: block; opacity: 0; }
#hud { position: fixed; left: 8px; top: 8px; font: 12px/1.35 ui-monospace, monospace; color: #dfe;
background: rgba(0,0,0,.72); padding: 6px 9px; border-radius: 6px; pointer-events: none; white-space: pre; }
#switchWindow { position: fixed; right: 12px; bottom: 12px; z-index: 2; min-height: 44px;
padding: 8px 14px; border: 1px solid #a8c8d9; border-radius: 7px;
background: rgba(19,35,48,.9); color: #fff; font: 15px system-ui, sans-serif; cursor: pointer; }
#switchWindow:focus-visible { outline: 3px solid #66c0f4; outline-offset: 3px; }
#switchWindow:disabled { opacity: .5; cursor: default; }
</style>
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<button id="switchWindow" type="button" hidden>Switch window</button>
<div id="note">Connecting to your computer…</div>
<div id="hud" hidden></div>
<script>
@@ -35,14 +29,10 @@
const q = new URLSearchParams(location.search);
// t: a single-use ticket from Frame Control. After the first connection the
// Mac sends a reconnect key, kept only in memory.
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "", host = q.get("host") || "";
document.title = host === "windows" ? "PC" : "Mac";
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
let reconnectKey = "";
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
const note = document.getElementById("note");
const switchButton = document.getElementById("switchWindow");
switchButton.hidden = !src.startsWith("display:") || !["mac", "windows"].includes(host);
if (!switchButton.hidden) switchButton.textContent = host === "mac" ? "Switch Mac app (⌘Tab)" : "Switch PC window (Alt+Tab)";
// Counters for Frame Control's tests (read over DevTools).
const stats = window.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
@@ -137,29 +127,6 @@ function show(text, ms) {
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function windowSwitchEvents(hostName) {
const code = hostName === "mac" ? "MetaLeft" : hostName === "windows" ? "AltLeft" : "";
if (!code) return [];
const key = hostName === "mac" ? "Meta" : "Alt", mod = hostName === "mac" ? "meta" : "alt";
return [
{ t: "k", e: "down", code, key, mods: [] },
{ t: "k", e: "down", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code, key, mods: [] },
];
}
switchButton.addEventListener("click", () => {
if (!ws || ws.readyState !== 1) return show("Connect to your computer before switching windows.", 4000);
const events = windowSwitchEvents(host);
if (!events.length) return;
switchButton.disabled = true;
send(stamp(events[0]));
setTimeout(() => {
send(stamp(events[1]));
send(events[2]);
setTimeout(() => { send(events[3]); switchButton.disabled = false; }, 60);
}, 40);
});
let lastKeyAsk = 0;
function askKeyFrame() {
const now = performance.now();
@@ -354,7 +321,6 @@ function flushMove() {
pendingMove = null;
}
addEventListener("pointermove", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside && !e.buttons) return;
pendingMove = { t: "m", e: "move", x: p.x, y: p.y };
@@ -365,7 +331,6 @@ addEventListener("pointermove", e => {
setTimeout(flushMove, wait);
});
addEventListener("pointerdown", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside) return;
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
@@ -374,7 +339,6 @@ addEventListener("pointerdown", e => {
e.preventDefault();
});
addEventListener("pointerup", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
send({ t: "m", e: "up", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y });
});
@@ -382,7 +346,6 @@ addEventListener("pointerup", e => {
addEventListener("pointercancel", () => send({ t: "release" }));
addEventListener("contextmenu", e => e.preventDefault());
addEventListener("wheel", e => {
if (e.target === switchButton) return;
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
send(stamp({ t: "wheel", dx: e.deltaX * unit, dy: e.deltaY * unit, x: p.x, y: p.y }, e));
e.preventDefault();
@@ -391,7 +354,6 @@ function mods(e) {
return ["shift", "ctrl", "alt", "meta"].filter(m => e[m + "Key"]);
}
function onKey(e, down) {
if (e.target === switchButton) return; // let Enter/Space activate the button
// Ctrl+Alt+Shift+S shows the numbers; it isn't sent to the Mac.
if (e.code === "KeyS" && e.ctrlKey && e.altKey && e.shiftKey) {
if (down) showHud(hud.hidden);
+30 -182
View File
@@ -76,11 +76,7 @@ import frame_wireless # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
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
import frame_headset # noqa: E402
frame_host.trust_bundled_cas()
@@ -89,7 +85,6 @@ HERE = Path(__file__).resolve().parent
# goes to local-bin/ssh, which runs COMMAND here instead, so one code path serves
# both. Nothing listens beyond 127.0.0.1; the phone reaches it through SSH.
LOCAL = os.environ.get("FRAME_LOCAL") == "1"
HEADSET_RUNTIME = LOCAL and os.environ.get("FRAME_HEADSET_RUNTIME") == "1"
if LOCAL:
os.environ["PATH"] = f"{HERE / 'local-bin'}{os.pathsep}{os.environ.get('PATH', '')}"
UI_KEY = os.environ.get("FRAME_UI_KEY") or "1"
@@ -552,26 +547,6 @@ 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))
def headset_action(body):
if LOCAL:
raise Failure("Manage the headset panel from Frame Control on your computer", 400)
action = body.get("action")
if action not in ("install", "update", "open", "close", "remove"):
raise Failure("Choose install, update, open, close, or remove", 400)
if action in ("install", "update", "remove") and body.get("acknowledged") is not True:
raise Failure("Confirm the opt-in headset installation or removal", 400)
return start_job("Headset panel " + action, lambda: frame_headset.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
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
@@ -659,7 +634,6 @@ def _headset_view():
SHOT_ROOT = ".local/share/Steam/userdata"
SHOT_ID = re.compile(r"(\d{1,12})/(\d{1,20})/(\d{14}_\d{1,4}\.(?:jpg|png))")
SHOTS_DIR = Path.home() / "Pictures" / "SteamFrame"
_shots_copy_lock = threading.Lock()
LIST_SHOTS = f"""cd ~/{SHOT_ROOT} 2>/dev/null || exit 0
find . -mindepth 6 -maxdepth 6 -path './*/760/remote/*/screenshots/*' -type f \\
\\( -name '*.jpg' -o -name '*.png' \\) -printf '%P\\t%s\\t%T@\\n'"""
@@ -672,19 +646,8 @@ def shot_path(shot_id, thumb=False):
return f"{SHOT_ROOT}/{m[1]}/760/remote/{m[2]}/screenshots/{'thumbnails/' if thumb else ''}{m[3]}"
def shot_local_path(shot_id, device_id=None):
"""Keep equal Steam filenames from different headsets/accounts/games separate."""
m = SHOT_ID.fullmatch(shot_id) if isinstance(shot_id, str) else None
if not m:
raise Failure("bad screenshot id", 400)
identity = device_id if device_id is not None else agent_target()["id"]
headset = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:12]
return SHOTS_DIR / f"headset-{headset}" / m[1] / m[2] / m[3]
def list_shots():
shots = []
device_id = agent_target()["id"]
for line in ssh(LIST_SHOTS, timeout=20).splitlines():
rel, _, rest = line.partition("\t")
parts = rel.split("/") # account/760/remote/appid/screenshots/file
@@ -696,7 +659,7 @@ def list_shots():
when = float(mtime)
except ValueError:
continue
local = shot_local_path(shot_id, device_id)
local = SHOTS_DIR / parts[-1]
shots.append({"id": shot_id, "appid": parts[3], "file": parts[-1], "size": int(size), "time": when,
"saved": local.exists() and local.stat().st_size == int(size)})
shots.sort(key=lambda s: s["time"], reverse=True)
@@ -718,51 +681,31 @@ def shot_image(query):
def save_shots(body):
"""Copy screenshots to per-headset/account/game folders without name collisions."""
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
ids = body.get("ids")
if not isinstance(ids, list) or not 0 < len(ids) <= 1000:
raise Failure("ids must be a list of 1-1000 screenshot ids", 400)
device_id = agent_target()["id"]
items = [(shot_path(i), shot_local_path(i, device_id)) for i in ids]
n = 0
with _shots_copy_lock:
todo = [(remote, local) for remote, local in items if not local.exists()]
if todo:
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
ensure_master()
# scp flattens source paths into one destination directory. Split equal
# basenames into separate transfers before assigning each file its ID path.
while todo:
names, group, rest = set(), [], []
for item in todo:
if item[1].name in names:
rest.append(item)
else:
names.add(item[1].name)
group.append(item)
todo = rest
# Stage under the destination filesystem, then move complete files in.
# -p keeps Steam's screenshot timestamp.
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
paths = [shot_path(i) for i in ids]
todo = [p for p in paths if not (SHOTS_DIR / p.rsplit("/", 1)[-1]).exists()]
if todo:
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
ensure_master()
# Copy into a hidden folder and move complete files in, so a cut-off
# copy never looks saved. -p keeps the time the shot was taken.
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
try:
r = frame_host.run_ssh(["scp", "-p", *SSH[1:],
*(f"{FRAME}:{remote}" for remote, _ in group), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
if any(not (incoming / local.name).is_file() for _, local in group):
raise Failure("Screenshot copy did not contain every requested file")
for _, local in group:
local.parent.mkdir(parents=True, exist_ok=True)
if not local.exists():
os.replace(incoming / local.name, local)
n += 1
finally:
shutil.rmtree(incoming, ignore_errors=True)
skipped = len(ids) - n
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
raise Failure(strip_ansi(r.stderr).strip() or f"scp exited {r.returncode}")
for f in incoming.iterdir():
os.replace(f, SHOTS_DIR / f.name)
finally:
shutil.rmtree(incoming, ignore_errors=True)
n, skipped = len(todo), len(ids) - len(todo)
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
@@ -840,32 +783,6 @@ recorder = frame_clips.Recorder(_record_channel, lambda cmd, timeout: ssh(cmd, t
lambda fn: busy_thread(fn).start(), release=lambda proc: _live_tunnels.discard(proc))
def _headset_record_run(command, timeout):
result = subprocess.run(['bash', '-c', command], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=timeout)
if result.returncode:
raise frame_clips.ClipError((result.stderr or result.stdout).strip()[-600:] or 'Recording command failed')
return result.stdout
def _headset_record_channel(command, stderr):
return subprocess.Popen(['bash', '-c', command], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=stderr, start_new_session=True)
def _headset_record_fetch(relative, target):
source = Path.home() / relative
if source.parent != Path.home() / '.cache/frame-control/headset-video' or not source.is_file() or source.is_symlink():
raise frame_clips.ClipError('The headset recording file is missing')
shutil.copyfile(source, target)
headset_recorder = frame_clips.Recorder(
_headset_record_channel, _headset_record_run, _headset_record_fetch,
lambda fn: busy_thread(fn).start(), folder=Path.home() / 'Videos/FrameControl',
work='.cache/frame-control/headset-video', destination_label='~/Videos/FrameControl') if HEADSET_RUNTIME else None
def _clips_here():
if LOCAL:
raise Failure("Recordings are saved to a computer: use Frame Control on your computer", 400)
@@ -873,42 +790,16 @@ def _clips_here():
def record(body):
"""{"action": "start"|"stop"}: record what the headset shows, with its sound."""
if not HEADSET_RUNTIME:
_clips_here()
_clips_here()
action = body.get("action")
if action not in ("start", "stop"):
raise Failure("action must be start or stop", 400)
try:
owned_recorder = headset_recorder if HEADSET_RUNTIME else recorder
return owned_recorder.start() if action == "start" else owned_recorder.stop()
return recorder.start() if action == "start" else recorder.stop()
except frame_clips.ClipError as e:
raise Failure(str(e), e.status)
def headset_quick(body):
"""Narrow actions for the local SteamVR panel, never exposed to the phone server."""
if not HEADSET_RUNTIME:
raise Failure('Headset quick actions require the local VR panel', 400)
action = body.get('action')
if action == 'game-mode':
try:
frame_panels.focus('valve.steam.gamepadui.main')
except frame_panels.PanelError as error:
raise Failure(str(error), 502) from error
return {'message':'Asked SteamVR to show the Game Mode dashboard.'}
if action == 'screenshot':
data = headset_view()
folder = Path.home() / 'Pictures/FrameControl'
folder.mkdir(parents=True, exist_ok=True, mode=0o700)
name = time.strftime('frame-%Y%m%d-%H%M%S-') + secrets.token_hex(3) + '.png'
path = folder / name
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), 'wb') as file:
file.write(data)
return {'message': f'Saved ~/Pictures/FrameControl/{name}. A sleeping headset may capture a dark frame.',
'file': str(path)}
raise Failure('Choose Game Mode or screenshot', 400)
def _clips_frame(args, timeout):
return steam_frame(*args, timeout=timeout, script="frame_clips.py")
@@ -2044,7 +1935,7 @@ def open_thing(body):
frame_host.open_path(frame_clips.VIDEOS_DIR)
return {"message": f"Opened {frame_clips.VIDEOS_DIR} in {frame_host.FILE_MANAGER}"}
if what == "shot":
saved = shot_local_path(body.get("id"))
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
if not saved.exists():
raise Failure("That screenshot isn't saved on this computer yet", 404)
frame_host.reveal_path(saved)
@@ -3396,9 +3287,6 @@ POST = {
"/api/steam-pipewire": steam_pipewire_action,
"/api/power": power_action,
"/api/android/backup": android_backup, "/api/backup": backup_action,
"/api/decky": decky,
"/api/headset": headset_action,
"/api/headset/quick": headset_quick,
"/api/vr": vr,
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
@@ -3545,7 +3433,7 @@ class Handler(BaseHTTPRequestHandler):
return False
return True
def send_bytes(self, data, ctype, status=200, headers=(), extension=False):
def send_bytes(self, data, ctype, status=200, headers=()):
self.send_response(status)
self.send_header("Content-Type", ctype)
for name, value in headers:
@@ -3553,12 +3441,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
# Nobody may frame the UI (clickjacking).
self.send_header("X-Content-Type-Options", "nosniff")
if extension:
self.send_header("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'self'; base-uri 'none'; form-action 'none'")
else:
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
try:
self.end_headers()
self.wfile.write(data)
@@ -3582,9 +3466,6 @@ class Handler(BaseHTTPRequestHandler):
try:
if path in ("/", "/index.html"):
self.send_bytes((HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/headset":
# Dedicated, large-control UI for the opt-in SteamVR panel.
self.send_bytes((HERE / "headset.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/store":
# The Android store as a headset panel (scripts/store-on-frame.sh).
self.send_bytes((HERE / "store.html").read_bytes(), "text/html; charset=utf-8")
@@ -3600,25 +3481,6 @@ class Handler(BaseHTTPRequestHandler):
raise Failure(str(exc), 409) from exc
self.send_bytes(data.encode('utf-8'), mime,
headers=[('Content-Disposition', f'attachment; filename="frame-power-{report_id}.{format}"')])
elif path == "/api/extensions":
self.send_json(frame_extensions.list_extensions())
elif path == "/api/decky":
self.send_json(frame_decky.status(ssh))
elif path == "/api/headset":
if LOCAL:
raise Failure("Manage this panel from Frame Control on your computer", 400)
self.send_json(frame_headset.status(ssh))
elif path.startswith("/extension/"):
parts = path.split("/")
if len(parts) != 4:
self.send_json({"error": "not found"}, 404)
else:
try:
data, ctype = frame_extensions.asset(parts[2], parts[3])
except (FileNotFoundError, ValueError, OSError):
self.send_json({"error": "extension asset not found"}, 404)
else:
self.send_bytes(data, ctype, extension=True)
elif path == "/api/settings/artwork":
self.send_json(frame_steamgriddb.settings())
elif path == "/artwork-settings.js":
@@ -3635,22 +3497,11 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(frame_agent.approvals.inspect(token))
elif path == "/wireless-doctor":
self.send_bytes((HERE / "wireless-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/wired-doctor":
if LOCAL:
raise Failure("Wired connection doctor runs on a computer, not the headset", 400)
self.send_bytes((HERE / "wired-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/wireless-doctor":
# PC-only and independent of LINK/working(): never start or wait for SSH.
args = parse_qs(url.query)
self.send_json(frame_wireless_doctor.diagnose(
(args.get("device") or [None])[0], local=LOCAL))
elif path == "/api/host/wired":
args = parse_qs(url.query)
try:
self.send_json(frame_wired_doctor.diagnose(
(args.get("alias") or ["frame"])[0], local=LOCAL))
except ValueError as error:
raise Failure(str(error), 400) from error
elif path == "/api/steam-pipewire":
self.send_json(steam_pipewire_state())
elif path == "/api/host":
@@ -3769,9 +3620,8 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/shots/image":
self.send_bytes(*shot_image(url.query))
elif path == "/api/record":
if not HEADSET_RUNTIME:
_clips_here()
self.send_json((headset_recorder if HEADSET_RUNTIME else recorder).status())
_clips_here()
self.send_json(recorder.status())
elif path == "/api/clips":
self.send_json(list_clips())
elif path == "/api/autosync":
@@ -4099,8 +3949,6 @@ def main():
if not frame_host.WINDOWS:
signal.signal(signal.SIGTERM, signal.SIG_IGN)
_power.shutdown()
if HEADSET_RUNTIME:
headset_recorder.shutdown(timeout=30)
_stop_sync.set()
webinstall_shutdown()
macview.shutdown() # close the headset's viewers before the agent goes
+1 -4
View File
@@ -552,10 +552,7 @@ $("kbd").onclick = e => {
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
// The catalogue does not need the headset's installed-app list. A slow or
// unavailable /api/android must not hold up browsing and search.
loadInstalled();
search();
loadInstalled().then(() => search());
</script>
</body>
</html>
-42
View File
@@ -1,42 +0,0 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wired connection doctor · Frame Control</title>
<style>
:root{color-scheme:dark;--bg:#101820;--card:#1b2838;--text:#e4edf5;--sub:#acbccb;--line:#3a5065;--blue:#66c0f4;--warn:#f0c67a}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.55 system-ui,sans-serif}main{max-width:850px;margin:auto;padding:32px 24px 64px}a{color:var(--blue)}h1{font-size:30px;line-height:1.2;margin:20px 0 12px}h2{font-size:18px;margin:0 0 8px}p{margin:8px 0 16px}.sub{color:var(--sub)}.badge{font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--blue)}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px;margin:18px 0}.row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}label{display:block}input,button{font:inherit;min-height:44px;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:#263f54;color:var(--text)}input{display:block;min-width:220px;margin-top:6px}button{cursor:pointer;background:#205b81;border-color:var(--blue)}button:disabled{opacity:.5;cursor:default}.status{font-size:12px;font-weight:700;text-transform:uppercase;margin-right:10px;color:var(--blue)}.warning .status{color:var(--warn)}.unknown .status{color:var(--sub)}#message{min-height:24px}:focus-visible{outline:2px solid var(--blue);outline-offset:4px}@media(max-width:540px){main{padding:20px 16px}h1{font-size:25px}section{padding:16px}}
</style></head><body><main>
<a id="back" href="/#devices">← Devices &amp; connection</a>
<header><h1>Wired connection doctor</h1><span class="badge">This computer · Read-only · On demand</span>
<p>Check whether this computer has the Frame's previously observed USB-C network route, whether SSH answers on it, and whether a strictly checked SSH identity works. This does not change network or headset settings.</p></header>
<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';
const $ = id => document.getElementById(id);
const key = new URLSearchParams(location.search).get('key') || '1';
$('back').href = '/?key=' + encodeURIComponent(key) + '#devices';
const make = (tag, value) => { const node = document.createElement(tag); node.textContent = value; return node; };
$('check').onclick = async () => {
$('check').disabled = true;
$('results').replaceChildren();
$('message').textContent = 'Checking this computer…';
try {
const response = await fetch('/api/host/wired?alias=' + encodeURIComponent($('alias').value.trim()),
{headers:{'X-Frame-UI':key}});
const report = await response.json();
if (!response.ok) throw new Error(report.error || 'Wired check failed');
for (const item of report.checks) {
const section = make('section', ''); section.className = item.status;
const title = make('h2', item.title); const status = make('span', item.status + ' · '); status.className = 'status';
title.prepend(status); section.append(title, make('p', item.detail)); $('results').append(section);
}
$('message').textContent = report.state === 'ready' ? 'The known USB path and pinned SSH identity worked.'
: 'Review each check and its limits below.';
} catch (error) { $('message').textContent = error.message; }
finally { $('check').disabled = false; }
};
</script></body></html>