mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 09:01:43 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9266cde577 |
No files matched your search
@@ -46,7 +46,7 @@ jobs:
|
||||
python ui/frame_wireless_doctor.py --json > "$RUNNER_TEMP/linux-wireless-doctor.json"
|
||||
python -c 'import json, os; from pathlib import Path; d=json.loads((Path(os.environ["RUNNER_TEMP"]) / "linux-wireless-doctor.json").read_text()); assert d["supported"] and d["selected"] is None and d["schema"] == 1'
|
||||
- name: App syntax
|
||||
run: node --check app/main.js && node --check app/obs-source.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
|
||||
- name: App tests
|
||||
run: node --test app/test/*.test.js
|
||||
- name: Website
|
||||
@@ -71,11 +71,6 @@ jobs:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: OBS lifecycle tests
|
||||
run: node app/test/obs-source.test.js
|
||||
- name: Server tests
|
||||
run: python -m unittest discover -s tests -v
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -2,28 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fix smoke cleanup skipping orphan metadata and Steam shortcuts after a test
|
||||
title directory was already removed. Sweep and verify all six exact reserved
|
||||
smoke IDs; preserve similarly named titles. Make `scripts/frame-smoke.sh`
|
||||
work with POSIX sh and Python 3 without zsh, including from another working
|
||||
directory. Local regressions cover both fixes; no new headset run.
|
||||
|
||||
- Skip backup saves and settings reached through a symbolic link anywhere below
|
||||
the home folder, and recheck paths before packing each file. Local synthetic
|
||||
tests cover redirected Proton prefixes and links changed after discovery;
|
||||
concurrent filesystem changes can still race the checks.
|
||||
[Backup layout and limits](docs/backup-and-recovery.md).
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
This route has local fixture coverage, not a real split-game headset test.
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -58,9 +58,6 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -25,8 +25,6 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -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 plugin author guide](docs/frame-plugin-author.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, a removable headset panel, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
@@ -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 };
|
||||
+58
-91
@@ -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;
|
||||
|
||||
@@ -311,28 +308,57 @@ ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
||||
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
|
||||
// terminates Python without running its cleanup block.
|
||||
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
|
||||
const { createObsSource } = require("./obs-source");
|
||||
const obsSource = createObsSource({
|
||||
spawn,
|
||||
// Keep the button hidden until native Windows/Frame streaming is verified.
|
||||
supported: !IS_WIN,
|
||||
prepare: async alias => {
|
||||
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
|
||||
const env = { ...process.env, PATH: await loginPath() };
|
||||
const executable = python || await findPython(env);
|
||||
if (!executable) throw new Error(PYTHON_HELP);
|
||||
if (quitting || alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
|
||||
return { executable, args: [...PY_FLAGS, OBS_SCRIPT], env };
|
||||
},
|
||||
onState: state => {
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", state);
|
||||
},
|
||||
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();
|
||||
});
|
||||
const publicObs = () => obsSource.status();
|
||||
const stopObs = () => obsSource.stop();
|
||||
ipcMain.handle("obs:status", e => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", (e, port) => fromUi(e) ? obsSource.start(activeAlias(), port) : null);
|
||||
ipcMain.handle("obs:stop", e => fromUi(e) ? stopObs() : null);
|
||||
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.
|
||||
@@ -344,7 +370,7 @@ ipcMain.on("devices:changed", (e, list) => {
|
||||
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
|
||||
if (JSON.stringify(next) === JSON.stringify(devices)) return;
|
||||
devices = next;
|
||||
if (obsSource.status().alias && obsSource.status().alias !== activeAlias()) stopObs();
|
||||
if (obsChild && obsState.alias !== activeAlias()) stopObs();
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
@@ -547,60 +573,7 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function showWindow() {
|
||||
if (!win) return createWindow(true);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function ensureTray() {
|
||||
if (tray) return true;
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
|
||||
if (icon.isEmpty()) return false;
|
||||
tray = new Tray(icon);
|
||||
// Some Linux desktops accept the Tray object but expose no status icon.
|
||||
// Keep a visible login window rather than hiding the only way back in.
|
||||
if (process.platform === "linux") {
|
||||
const bounds = tray.getBounds();
|
||||
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
|
||||
}
|
||||
tray.setToolTip("Frame Control");
|
||||
tray.on("click", showWindow);
|
||||
updateTrayMenu();
|
||||
return true;
|
||||
} catch {
|
||||
if (tray) { try { tray.destroy(); } catch {} tray = null; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Open Frame Control", click: showWindow },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Frame Control", click: () => app.quit() },
|
||||
]));
|
||||
}
|
||||
|
||||
function toggleLoginItem() {
|
||||
try {
|
||||
const on = loginItem.set(!loginItem.enabled());
|
||||
if (on) ensureTray();
|
||||
else if (tray) { tray.destroy(); tray = null; }
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Open at Login", String(err.message || err));
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
@@ -609,10 +582,7 @@ function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray)
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
@@ -666,8 +636,6 @@ function buildMenu() {
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
|
||||
enabled: app.isPackaged, click: toggleLoginItem },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
@@ -716,7 +684,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (!argv.includes("--hidden")) showWindow();
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -724,23 +692,22 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", showWindow);
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
app.on("before-quit", (event) => {
|
||||
quitting = true;
|
||||
stopServer();
|
||||
if (!obsSource.child || obsQuitReady) { stopObs(); return; }
|
||||
if (!obsChild || obsQuitReady) return;
|
||||
event.preventDefault();
|
||||
if (obsQuitWaiting) return;
|
||||
obsQuitWaiting = true;
|
||||
const child = obsSource.child;
|
||||
const child = obsChild;
|
||||
let timer;
|
||||
child.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Own one Python relay. Async preparation can be cancelled before a process exists.
|
||||
const ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
function createObsSource({ prepare, spawn, onState = () => {}, supported = true }) {
|
||||
let child = null, attempt = 0;
|
||||
let state = { state: 'idle', alias: '', port: 15099, error: '', supported };
|
||||
const status = () => ({ ...state });
|
||||
function set(next, alias = state.alias, port = state.port, error = '') {
|
||||
state = { state: next, alias, port, error, supported };
|
||||
onState(status()); return status();
|
||||
}
|
||||
function stop() {
|
||||
++attempt;
|
||||
if (!child) return set('idle');
|
||||
set('stopping');
|
||||
if (child.stdin && !child.stdin.writableEnded) child.stdin.end();
|
||||
return status();
|
||||
}
|
||||
async function start(alias, port = 15099) {
|
||||
if (!supported) return status();
|
||||
if (!ALIAS.test(alias || '')) throw new Error('Select a valid headset first.');
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OBS port must be a whole number from 1024 to 65535.');
|
||||
if (child || state.state === 'starting') return status();
|
||||
const id = ++attempt;
|
||||
set('starting', alias, port);
|
||||
try {
|
||||
const { executable, args = [], env } = await prepare(alias);
|
||||
if (id !== attempt) return status();
|
||||
const proc = spawn(executable, [...args, '--frame', alias, '--port', String(port), '--control-stdin'],
|
||||
{ env, stdio: ['pipe', 'ignore', 'pipe'], windowsHide: true });
|
||||
child = proc;
|
||||
// The helper may exit between a click and closing its control pipe.
|
||||
proc.stdin.on('error', () => {});
|
||||
let output = '';
|
||||
proc.stderr.on('data', chunk => {
|
||||
output = (output + chunk.toString('utf8')).slice(-2048);
|
||||
if (child === proc && state.state === 'starting' && output.includes('Frame Control OBS stream is ready'))
|
||||
set('running', alias, port);
|
||||
});
|
||||
proc.once('error', error => {
|
||||
if (child !== proc) return;
|
||||
child = null; set('failed', alias, port, `Could not start OBS source: ${error.message}`);
|
||||
});
|
||||
// close follows stderr EOF, so the final failure message is available.
|
||||
proc.once('close', (code, signal) => {
|
||||
if (child !== proc) return;
|
||||
const stopping = state.state === 'stopping';
|
||||
child = null;
|
||||
set(stopping ? 'idle' : 'failed', alias, port,
|
||||
stopping ? '' : `OBS source stopped (${signal || code}). ${output.trim().split('\n').pop() || 'Check SteamVR and SSH.'}`);
|
||||
});
|
||||
return status();
|
||||
} catch (error) {
|
||||
if (id !== attempt) return status();
|
||||
return set('failed', alias, port, error.message);
|
||||
}
|
||||
}
|
||||
return { start, stop, status, get child() { return child; } };
|
||||
}
|
||||
module.exports = { createObsSource };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.24",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.30",
|
||||
"version": "0.4.24",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -36,12 +36,10 @@
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"obs-source.js",
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||
obs: {
|
||||
status: () => ipcRenderer.invoke("obs:status"),
|
||||
start: (port) => ipcRenderer.invoke("obs:start", port),
|
||||
start: () => ipcRenderer.invoke("obs:start"),
|
||||
stop: () => ipcRenderer.invoke("obs:stop"),
|
||||
onState: (cb) => {
|
||||
ipcRenderer.removeAllListeners("obs:state");
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createLoginItem } = require("../login-item");
|
||||
|
||||
test("Linux login toggle creates and removes only its own desktop entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
|
||||
executable: "/opt/Frame Control/frame-control" });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
const body = fs.readFileSync(login.file, "utf8");
|
||||
assert.match(body, /^# Managed by Frame Control;/);
|
||||
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
|
||||
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.equal(fs.existsSync(login.file), false);
|
||||
fs.mkdirSync(path.dirname(login.file), { recursive: true });
|
||||
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
|
||||
assert.throws(() => login.set(true), /different autostart entry/);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
|
||||
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
|
||||
executable: "/tmp/.mount_extracted/AppRun" });
|
||||
login.set(true);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
|
||||
login.set(false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const platform of ["win32", "darwin"]) {
|
||||
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
|
||||
let openAtLogin = false;
|
||||
const calls = [];
|
||||
const electronApp = {
|
||||
getLoginItemSettings: () => ({ openAtLogin }),
|
||||
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
|
||||
};
|
||||
const login = createLoginItem({ platform, electronApp });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
|
||||
assert.equal(calls.at(-1).openAsHidden, true);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.deepEqual(calls.at(-1).args, []);
|
||||
});
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createObsSource } = require('../obs-source');
|
||||
|
||||
function fixture(prepare) {
|
||||
const states = [], children = [];
|
||||
const source = createObsSource({
|
||||
prepare: prepare || (async () => ({ executable: 'python', args: ['helper.py'], env: {} })),
|
||||
spawn(executable, args, options) {
|
||||
const child = new EventEmitter();
|
||||
child.stderr = new EventEmitter(); child.stdin = new EventEmitter();
|
||||
child.stdin.end = () => { child.stdin.writableEnded = true; };
|
||||
children.push({ child, executable, args, options }); return child;
|
||||
},
|
||||
onState: s => states.push(s),
|
||||
});
|
||||
return { source, states, children };
|
||||
}
|
||||
|
||||
test('Stop during Python discovery cancels the pending start', async () => {
|
||||
let resolve;
|
||||
const f = fixture(() => new Promise(r => { resolve = r; }));
|
||||
const pending = f.source.start('frame', 15100);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
f.source.stop();
|
||||
resolve({ executable: 'python', args: ['helper.py'], env: {} });
|
||||
await pending;
|
||||
assert.equal(f.children.length, 0);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
});
|
||||
|
||||
test('late stderr cannot undo Stop; closing allows a fresh start on another port', async () => {
|
||||
const f = fixture(); await f.source.start('frame', 15100);
|
||||
const { child, args } = f.children[0];
|
||||
assert.deepEqual(args.slice(-5), ['--frame', 'frame', '--port', '15100', '--control-stdin']);
|
||||
child.stderr.emit('data', Buffer.from('Frame Control OBS stream is '));
|
||||
child.stderr.emit('data', Buffer.from('ready\n'));
|
||||
assert.equal(f.source.status().state, 'running');
|
||||
f.source.stop();
|
||||
child.stderr.emit('data', Buffer.from('last ffmpeg warning\n'));
|
||||
assert.equal(f.source.status().state, 'stopping');
|
||||
child.emit('close', 0, null);
|
||||
assert.equal(f.source.status().state, 'idle');
|
||||
await f.source.start('other-frame', 15101);
|
||||
assert.equal(f.source.status().port, 15101);
|
||||
// An old child cannot overwrite the replacement's state.
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'starting');
|
||||
});
|
||||
|
||||
test('concurrent starts spawn once and preparation failures are retryable', async () => {
|
||||
let attempts = 0;
|
||||
const f = fixture(async () => {
|
||||
if (++attempts === 1) throw new Error('Python missing');
|
||||
return { executable: 'python', args: [], env: {} };
|
||||
});
|
||||
assert.equal((await f.source.start('frame')).state, 'failed');
|
||||
assert.match(f.source.status().error, /Python missing/);
|
||||
await Promise.all([f.source.start('frame'), f.source.start('frame')]);
|
||||
assert.equal(f.children.length, 1);
|
||||
});
|
||||
|
||||
test('invalid ports and aliases cannot spawn; errors and closed pipes remain visible', async () => {
|
||||
const f = fixture();
|
||||
for (const port of [0, 1023, 65536, 15099.5, '15100']) {
|
||||
await assert.rejects(f.source.start('frame', port), /port/i);
|
||||
}
|
||||
await assert.rejects(f.source.start('-bad'), /headset/i);
|
||||
assert.equal(f.children.length, 0);
|
||||
await f.source.start('frame');
|
||||
const { child } = f.children[0];
|
||||
child.stdin.emit('error', Object.assign(new Error('closed pipe'), { code: 'EPIPE' }));
|
||||
child.stderr.emit('data', Buffer.from('network lost\n'));
|
||||
child.emit('close', 255, null);
|
||||
assert.equal(f.source.status().state, 'failed');
|
||||
assert.match(f.source.status().error, /network lost/);
|
||||
});
|
||||
|
||||
test('an unverified host hides the controls and cannot spawn a relay', async () => {
|
||||
let spawned = false;
|
||||
const source = createObsSource({ supported: false, prepare: async () => ({}), spawn: () => { spawned = true; } });
|
||||
assert.equal(source.status().supported, false);
|
||||
await source.start('frame');
|
||||
assert.equal(spawned, false);
|
||||
});
|
||||
+4
-10
@@ -4,7 +4,7 @@ A SideQuest backup can contain a small APK and much larger `main.…obb` and
|
||||
`patch.…obb` files. All of them belong to the same game. Installing only the APK
|
||||
can leave the game unable to start because its assets are missing.
|
||||
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, and its OBB files together.
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
You can also drop them anywhere in the window. In the desktop app, dropping the
|
||||
APK alone finds matching OBB files beside it; dropping a folder containing the
|
||||
APK and OBBs also works. Browsers can read only the files you explicitly choose.
|
||||
@@ -37,13 +37,9 @@ are discarded after use or cancellation; abandoned imports expire after an hour
|
||||
when the importer is next used, and dead-server staging folders are swept at
|
||||
startup.
|
||||
|
||||
This flow handles one base APK, supported native-library or asset splits, and
|
||||
standard OBB files for one game at a time. It checks the package and version in
|
||||
each split manifest before touching the headset. The existing Quest merger
|
||||
rejects resource, code and feature splits and sets with multiple device
|
||||
variants. A merged APK is re-signed; that can affect a game's licence or login.
|
||||
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
|
||||
private save archives remain unsupported. PC ZIP/folder/EXE imports keep their
|
||||
This flow handles one ordinary APK and standard OBB files for one game at a time.
|
||||
Split APKs, XAPK/APKS archives, nested Android/data folders and private save
|
||||
archives need their own import handling. PC ZIP/folder/EXE imports keep their
|
||||
existing route. Quest USB and Meta library imports keep their existing workflow.
|
||||
|
||||
Validation covers actual APK parsing, the three-file backup layout, package
|
||||
@@ -59,5 +55,3 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
|
||||
The headset was unworn; this checks transfer and persistence, not rendering,
|
||||
Resident Evil 4 gameplay, or a game that exits before data can be copied. This
|
||||
flow does not change APK compatibility or remove licence checks.
|
||||
The supported-split backup route was checked with generated local fixtures and
|
||||
existing merger tests, not a real split game on the Frame.
|
||||
+9
-11
@@ -436,13 +436,13 @@ permissions remain unverified. They do not change APK install or launch behavior
|
||||
## Local APK/OBB backups and Android bundles
|
||||
|
||||
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
|
||||
base APK, any supported splits, and its matching `main.<version>.<package>.obb` and/or
|
||||
standalone APK and its matching `main.<version>.<package>.obb` and/or
|
||||
`patch.<version>.<package>.obb` files together. The desktop app also discovers
|
||||
matching OBB files beside a lone selected APK. Browser uploads use only the
|
||||
files you selected. Installed apps can receive OBB-only imports.
|
||||
|
||||
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
|
||||
one base APK, optional supported splits and matching OBBs. These extensions describe
|
||||
one standalone APK and optional matching OBBs. These extensions describe
|
||||
containers, not a guarantee that their contents are supported. OBBs may be
|
||||
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
|
||||
one enclosing backup folder is allowed. Known manifest/icon/signature
|
||||
@@ -450,7 +450,7 @@ sidecars are ignored; the APK manifest determines the package. Unknown files,
|
||||
unsafe paths, links, encrypted entries, duplicate paths (including case
|
||||
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
|
||||
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
|
||||
100-file/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
|
||||
extract an Android ZIP yourself and select its APK/OBBs.
|
||||
|
||||
The importer previews the app and files before **Import game & data**.
|
||||
@@ -463,14 +463,12 @@ data setup. Uploaded and extracted staging is removed on cancellation,
|
||||
inspection failure, or job completion/failure; abandoned imports expire
|
||||
through the existing one-hour staging lifecycle. Original local files stay intact.
|
||||
|
||||
**Limits:** this backup importer requires exactly one base APK. For split sets,
|
||||
each split must identify the same package and version, and pass the existing
|
||||
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
|
||||
feature splits and does not choose among device variants. Merging re-signs the
|
||||
APK, which can affect licences. It does not restore `Android/data`, private app
|
||||
data, saves or entitlements. These split imports have local fixture and merger
|
||||
tests, but no real split game has been installed with this backup route on the
|
||||
Frame.
|
||||
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
|
||||
bases marked as requiring splits. It does not select device variants or restore
|
||||
`Android/data`, private app data, saves, or entitlements. For the separate
|
||||
restricted native-library/asset split workflow see
|
||||
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
|
||||
code and feature splits are not supported by that merger.
|
||||
|
||||
For issue #102, the RE4 report only establishes that an APK attempt failed.
|
||||
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
|
||||
|
||||
@@ -100,21 +100,7 @@ copy one by hand if you need it.
|
||||
- Steam Cloud's own folders (`userdata/<account>/<appid>`), shader caches and
|
||||
screenshots (**Tools → Capture & Live** saves those to Pictures).
|
||||
- Android apps themselves, their OBB files and shared storage (`/sdcard`).
|
||||
- Symbolic links, including any ancestor below the home folder of a selected
|
||||
save or settings path, and single files over 2 GiB. Discovery checks each
|
||||
component, and packing checks it again before opening each file.
|
||||
|
||||
Steam backups use the normal `~/.local/share/Steam` directory tree. A separate
|
||||
`~/.steam/steam` alias does not affect this route. A library, Proton prefix or
|
||||
Wine save folder redirected by a symlink below the home folder is skipped,
|
||||
even if its target is inside home; it is not resolved and backed up elsewhere.
|
||||
The home folder itself is trusted and may use the host's normal path aliases.
|
||||
|
||||
These are path checks, not a filesystem snapshot: a concurrent rename or link
|
||||
replacement between a check and a read can still race it. Close games and
|
||||
avoid moving or relinking these folders during backup. This fix rejects links
|
||||
already present when checked, including ones introduced after discovery; it
|
||||
does not provide atomic protection against concurrent filesystem changes.
|
||||
- Symbolic links (never followed), and single files over 2 GiB.
|
||||
|
||||
Backups aren't encrypted. Game folders can still hold a launcher's login, and
|
||||
Android app data can hold an app's, so keep backups private.
|
||||
@@ -206,18 +192,11 @@ If the button can't help, or the headset isn't reachable:
|
||||
`tests/test_backup.py` runs the headset-side script (`ui/frame_backup_remote.py`)
|
||||
for real against a folder laid out like the Frame's home, with only SSH faked:
|
||||
which paths are picked, that sign-in files and keys never are (also inside game
|
||||
folders), links in save/settings roots and their ancestors refused, the manifest
|
||||
and its checksums, restore checks
|
||||
folders), links never followed, the manifest and its checksums, restore checks
|
||||
after a reset (game not installed, no Proton folders yet, not signed in), that a
|
||||
restore replaces only its own files and keeps the old ones, unsafe archives
|
||||
(`..`, absolute paths, links, other games' folders) writing nothing, backup
|
||||
names that try to leave the backups folder, and the exact Game Mode command.
|
||||
Synthetic symlink fixtures cover every component of a Proton save root below
|
||||
home, including `compatdata/<appid>` and `steamuser/AppData`, plus directory
|
||||
and file links introduced between discovery and packing. Ordinary Proton and
|
||||
non-Steam saves, Linux saves, controller layouts, reference settings and home
|
||||
aliases still pack with their original bytes. These are local fixture checks;
|
||||
this path fix has not been tested on a headset.
|
||||
`tests/e2e/test_recovery_kit.py` backs up a Proton save from the fake Frame through
|
||||
the server, deletes it and restores it. Neither involves a real headset.
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Connection and streaming lag FAQ
|
||||
|
||||
## Does Frame Control lower my streaming lag?
|
||||
|
||||
No. Frame Control uses SSH to manage the Frame. SteamVR or Steam Link sends the
|
||||
game stream. Frame Control does not carry the game stream. It cannot lower your
|
||||
game-stream ping or fix a stream that stops on the PC. A 40–75 ms ping in a VR
|
||||
game is not a Frame Control measurement.
|
||||
|
||||
You do **not** need the Steam Wireless Adapter to use Frame Control. The app can
|
||||
connect over Wi-Fi, the Wireless Adapter, or USB if the computer has an SSH
|
||||
route to the Frame. Use the route that lets the app connect. To improve game
|
||||
streaming, check the SteamVR or Steam Link connection on the PC and the Frame.
|
||||
The [wired connection doctor](wired-connection.md) checks only Frame Control's
|
||||
SSH route. A successful doctor check does not prove that the game stream uses
|
||||
the cable or has low lag.
|
||||
|
||||
## Can I use a USB-A-to-C cable?
|
||||
|
||||
A Mac reached the Frame over a USB-C connection on the
|
||||
`10.86.200.232/29` USB network. That test used a USB-C host port. We have
|
||||
**not tested a USB-A-to-C cable** with the Frame. A data-capable USB-A-to-C
|
||||
cable might show a USB network interface on your PC, but this is unknown on
|
||||
the tested Frame OS and PC hosts. Check whether your PC shows that interface
|
||||
and whether Frame Control's wired doctor can reach the Frame at
|
||||
`10.86.200.233`. A charge-only cable cannot carry USB data. You can use Wi-Fi
|
||||
for Frame Control if the USB route does not work.
|
||||
|
||||
## 日本語: 5 ステップで接続
|
||||
|
||||
1. [公式リリース](https://github.com/saphid/frame-control/releases/latest)から PC の OS 用ファイルをダウンロードします。Windows は `.exe`、macOS は `.dmg`、Linux は `.AppImage` または `.deb` です。同じリリースの `SHA256SUMS` でファイルを確認してください。
|
||||
2. PC に Frame Control をインストールして起動します。Windows はスタートメニュー、macOS はアプリケーション、Linux はアプリ一覧から開きます。AppImage の場合は実行権限を付けて開きます。
|
||||
3. Frame の Steam 設定 → システムで開発者モードを有効にし、開発者設定でユーザーパスワードを設定します。Frame と PC を同じ信頼できるネットワークに接続し、Frame を起こしておきます。
|
||||
4. PC の Frame Control で **Set Up Connection…** を選びます。Frame が見つからない場合は、Frame の設定で IP アドレスを確認して入力します。macOS では **Devices & connection → + Add a headset…** からアドレスを設定できます。
|
||||
5. Frame に **Pair new host** が出たら、自分が開始した要求だけを承認します。ペアリングに失敗した場合は、PC のターミナルで開発者モードのパスワードを入力します。接続後、Frame Control に戻って状態を確認します。
|
||||
|
||||
Frame Control は管理用の SSH 接続を使います。ゲーム映像は SteamVR / Steam Link が送ります。Frame Control を入れてもゲームの ping は下がりません。無線アダプターは必須ではありません。USB-A-to-C ケーブルで Frame の USB ネットワークが使えるかは、まだ確認できていません。
|
||||
@@ -1,27 +0,0 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.26 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on a SteamOS / Steam Machine PC | No SteamOS PC or Flatpak OBS runtime test | No SteamOS host VM test | v0.4.27 AppImage launched on Ubuntu x86_64 under Xvfb; the helper sent H.264/AAC from a real Frame to a local UDP receiver. SteamOS read-only root, desktop UI and Flatpak OBS remain unverified | 25 Oct, v0.4.27: [host test](evidence/obs-steamos-host-2026-10-25.md), [OBS guide](obs-headset-source.md#steamos--steam-machine-pc-host) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | Real room pixels in OBS and a production MP4 after safe SteamVR wake | No | **Yes** for the checked 0.5.6 mirror. Raw cameras are not opened. Other games/modes and Steam clips may differ | 25 Oct: [validation](evidence/obs-validation-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
| Experimental Frame Control SteamVR panel | 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.
|
||||
@@ -1,188 +0,0 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
For the modding-path survey, licence review and update limits, see
|
||||
[plugin and modding research](plugins-research.md).
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
ProtonDB Badges loaded in the backend and Gamepad UI plugin registry, but its
|
||||
VR user interface has not been seen or used.
|
||||
|
||||
## Official store plugin audit, 2026-10-25
|
||||
|
||||
On a real Frame with Frame OS 0.5.6 build `20261022.6153909`, SteamVR 2.18.2
|
||||
reported `activity_level` 3 (standby). The battery was 99% before and after.
|
||||
The test downloaded the official store's current archives, checked each SHA-256
|
||||
against store metadata, and inspected their manifests and binaries. For seven
|
||||
non-root candidates, the removable user-service loader started each plugin in
|
||||
turn. It stopped between plugins. Logs showed `Loaded <name>` for all seven.
|
||||
The service and test folders were removed after the run; the owned shared lock
|
||||
was released. **A load message is not proof that a plugin's page, settings, or
|
||||
action works in VR.** No plugin was tried by a wearer.
|
||||
|
||||
| Official store plugin (tested version) | Result | Evidence and limit |
|
||||
| --- | --- | --- |
|
||||
| ProtonDB Badges 1.3.4 | Partial | ARM64 backend loaded. Earlier 1.1.0 also registered in the shared Gamepad UI; badges in the VR shell were not seen. |
|
||||
| CSS Loader 2.1.3 | Partial | Backend loaded; one `SyntaxWarning`. Theme controls and CSS application in VR were not checked. |
|
||||
| SteamGridDB 1.8.1 | Partial | Backend loaded. Artwork selection and its UI were not checked. |
|
||||
| Animation Changer 1.3.2 | Partial | Backend loaded. Boot animation replacement was not attempted. |
|
||||
| Storage Cleaner 1.4.0 | Partial | Backend loaded. No files were cleaned; no UI check. |
|
||||
| HLTB for Deck 2.0.12 | Partial | Passive plugin loaded. Game page content in VR was not checked. |
|
||||
| Bluetooth 3.1.1 | Partial | Backend logged `Bluetooth plugin loaded`. Pairing and VR controls were not checked. |
|
||||
| PowerTools 2.0.3 | Not applicable | Manifest asks for root; archive includes x86_64 Ryzen binaries and targets Steam Deck hardware. The Frame user service blocks it. |
|
||||
| Audio Loader 1.6.1 | Not applicable | Manifest asks for `_root`; the Frame user service blocks it. |
|
||||
| Wine Cellar 0.2.1 | Broken on ARM64 | Archive includes an x86_64 backend binary. The store installer blocks it before execution. |
|
||||
| MagicPods 2.0.21 | Broken on ARM64 | Archive includes an x86_64 `magicpodscore` binary. The store installer blocks it before execution. |
|
||||
| Decky Recorder | Not in current official store | No matching plugin appeared in the official store list on this date; no archive was installed. Frame Control's own headset panel has a separate recorder. |
|
||||
|
||||
The table reflects the tested versions only. Other releases can change their
|
||||
files or behavior. The official store's digest checks the archive in transit;
|
||||
it does not certify plugin code. Plugins run with the headset user's access.
|
||||
For a new plugin, follow the [Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
The new store transaction was also exercised on the same real Frame:
|
||||
**managed loader install → ProtonDB Badges 1.3.4 store install → status →
|
||||
store update → store remove → managed loader remove**. Status reported the
|
||||
managed store ID and version while the plugin was installed. The service
|
||||
remained active after each store change. The battery was 99% before and after.
|
||||
The test removed its plugin folder, loader, and owned lock. This validates the
|
||||
store file and service path over SSH, not a visible VR plugin page.
|
||||
|
||||
Frame Control's desktop app and opt-in headset panel now show official store
|
||||
entries. Install, update, and remove need an explicit confirmation. They only
|
||||
change Frame Control owned plugin folders under
|
||||
`~/.local/share/frame-control/decky/plugins`. The installer checks the store
|
||||
hash and archive paths, blocks root plugins and non-ARM64 binaries, and rolls
|
||||
back an update if the service restart fails. The current VR shell does not
|
||||
provide Decky's Steam Deck Quick Access Menu. Frame Control's headset panel
|
||||
shows installed plugins and store actions, but plugin pages, settings, routes,
|
||||
and Decky toasts are **not** available there yet. The panel itself still needs
|
||||
a worn-headset visual and controller check.
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
|
||||
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
|
||||
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
|
||||
needed. `systemctl --user` was running. The Steam client launched with
|
||||
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
|
||||
CEF remote debugging on `127.0.0.1:8080`. The usual
|
||||
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
|
||||
|
||||
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
|
||||
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
|
||||
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
|
||||
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
|
||||
`window.deckyHasLoaded === true` and one registered Decky tab in
|
||||
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
|
||||
its current body text did not contain Decky. The Steam web helper had
|
||||
`--disable-quick-menu`. This is evidence of backend and shared-context
|
||||
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
|
||||
interaction in the headset and a plugin UI test remain necessary.
|
||||
|
||||
Frame Control's per-user manager was then exercised on the real Frame:
|
||||
**install → status → update → status → remove → status**. The service became
|
||||
active, the binary matched Decky's published SHA-256, and removal left no
|
||||
service, binary or listening port 1337. Decky's already injected JavaScript
|
||||
remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## ProtonDB Badges compatibility test
|
||||
|
||||
The [ProtonDB Badges v1.1.0 release](https://github.com/OMGDuke/protondb-decky/releases/tag/v1.1.0)
|
||||
was tested on the same Frame OS build. It is a popular, passive Decky plugin
|
||||
(133 GitHub stars at test time) that adds badges to game pages. The published
|
||||
archive SHA-256 was
|
||||
`b4c72ff0ffe60b1a14abafe75a62f644cbb6689f398a753a1905c281151d9173`.
|
||||
Its six archive entries had no links or path traversal, and its manifest declared
|
||||
`flags: []` and `api_version: 2`. The release contains a frontend bundle and
|
||||
no backend `main.py`; its source backend only manages its own settings.
|
||||
|
||||
The test copied the archive into Frame Control's managed Decky plugin folder,
|
||||
then installed the official Decky 3.3.0 ARM64 binary as a user service. Decky's
|
||||
log said `Plugin ProtonDB Badges is passive` and `Loaded ProtonDB Badges`.
|
||||
`DeckyPluginLoader.plugins` in Steam's `SharedJSContext` contained
|
||||
`ProtonDB Badges`; `deckyHasLoaded` was true there. The separate `SteamVR`
|
||||
CEF target had no Decky globals or plugin text, and SteamVR listed no Decky
|
||||
overlay. The headset was in standby, so no in-headset plugin page or badge
|
||||
interaction was verified. The backend's unauthenticated root URL returned
|
||||
HTTP 403, as expected.
|
||||
|
||||
The test removed the managed Decky service and binary, the test plugin folder,
|
||||
and its shared-device lock. The service was no longer running, the plugin list
|
||||
was empty, the binary and unit were gone, and port 1337 was closed. Decky's
|
||||
already injected frontend may remain in the running Steam web helper until
|
||||
Steam normally restarts; the test did not restart Steam or SteamVR.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
|
||||
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
|
||||
`sudo systemctl` to enable a **root** service. Its
|
||||
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
|
||||
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
|
||||
under `/etc`; their root service has broad control over the headset. The
|
||||
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
|
||||
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
|
||||
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
|
||||
the CEF target, while visible VR shell integration is unresolved.
|
||||
|
||||
Frame Control downloads only Decky's official latest ARM64 release, requires
|
||||
its published SHA-256 digest, and checks the ELF architecture. It installs a
|
||||
**user** service named `frame-control-decky.service` in
|
||||
`~/.config/systemd/user`, with the binary and plugin folder under
|
||||
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
|
||||
`/etc`, or changes CEF flags. Decky warns that running without root is not
|
||||
officially supported. Plugins that require root, assume `~/homebrew`, or rely
|
||||
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder. The opt-in store manager installs compatible archives only into its
|
||||
own folder. It cannot make Steam Deck QAM plugin pages visible in Frame VR.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
`PluginLoader-arm64` SHA-256
|
||||
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
|
||||
This digest comes from that release's GitHub API asset metadata; it detects a
|
||||
download mismatch, but is not an independent signature.
|
||||
|
||||
## Opt in and remove
|
||||
|
||||
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
|
||||
notice, check the acknowledgement box, and choose **Install**. **Update**
|
||||
downloads and verifies the latest ARM64 release again. **Remove managed
|
||||
Decky** disables and removes only Frame Control's user service and binary.
|
||||
It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Open **Official plugin store** to review a plugin, then confirm each install,
|
||||
update, or remove. Root and x86-only plugins are blocked. The manager only
|
||||
removes plugin folders that it installed; it leaves other Decky folders alone.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
Manual removal if Frame Control is unavailable:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
|
||||
```
|
||||
|
||||
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
|
||||
and `settings`. Check those folders before deleting them yourself. An upstream
|
||||
Decky installation is separate; use its own installer to remove it. Frame
|
||||
Control refuses to install alongside an upstream system service.
|
||||
|
||||
## Next compatibility test
|
||||
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Confirm the plugin's visible
|
||||
UI, controller interaction, and persistence across a normal Steam restart.
|
||||
Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as usable in VR yet. A worn
|
||||
headset check is needed for both the Decky menu and the Frame Control SteamVR
|
||||
panel; only then can their controller interaction and plugin presentation be
|
||||
confirmed.
|
||||
@@ -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,32 +0,0 @@
|
||||
# SteamOS PC OBS host feasibility check, 2026-10-25 UTC
|
||||
|
||||
The available x86_64 host, lxso2, runs Ubuntu 24.04.3. It has no `flatpak`
|
||||
command, OBS installation, SteamOS 3.9.5 VM or physical Steam Machine. This
|
||||
check therefore does **not** verify that Frame Control or Flatpak OBS runs on
|
||||
a SteamOS desktop or a read-only-root PC host.
|
||||
|
||||
The downloaded v0.4.27 Linux x86_64 AppImage matched GitHub's SHA-256 digest
|
||||
`42d774653c69b193c3d577743c32b8fe8dfeee7ea1f8cdf7369902962d647b83`.
|
||||
With `APPIMAGE_EXTRACT_AND_RUN=1`, it launched under Xvfb using an isolated
|
||||
profile and its bundled server returned HTTP 200. FUSE mounting, physical
|
||||
display rendering and SteamOS-specific dependencies were not tested.
|
||||
|
||||
The v0.4.27 tagged OBS helper then ran against the real Frame under the shared
|
||||
`/tmp/frame-test.lock`. Frame OS was 0.5.6 and SteamVR activity level was 3
|
||||
(standby). A receiver bound to `127.0.0.1:15099` on lxso2 accepted 497
|
||||
MPEG-TS packets, 93,436 bytes. Every datagram had complete 188-byte packets
|
||||
with sync bytes. `ffprobe` found H.264 video and AAC audio. Closing the
|
||||
helper's control pipe made it exit 0; no owned remote stream process remained.
|
||||
Battery read 100% Full before and after, and the owned lock was released. The
|
||||
temporary `.ts` file was removed. This proves the local UDP sender path on an
|
||||
Ubuntu host; it does not prove Flatpak OBS could receive it on SteamOS.
|
||||
|
||||
The public
|
||||
[OBS Flatpak manifest](https://github.com/flathub/com.obsproject.Studio/blob/master/com.obsproject.Studio.json)
|
||||
lists `--share=network`, which permits use of the host network. We read that
|
||||
manifest but did not install or run the Flatpak. A future check needs a real
|
||||
SteamOS 3.9.5 desktop host or VM with Flatpak OBS: launch the AppImage without
|
||||
root changes, start the source, record from the Media Source at
|
||||
`udp://127.0.0.1:15099`, verify video and audible game audio, then stop and
|
||||
confirm cleanup. The standby test cannot establish real game audio, A/V sync
|
||||
or active passthrough in the mirror.
|
||||
@@ -1,125 +0,0 @@
|
||||
# OBS end-to-end validation — 25 October 2026
|
||||
|
||||
**Passthrough: yes.** Real room pixels behind SteamVR panels appeared in the
|
||||
live `/dev/video99` mirror, the saved OBS recording, and a decoded frame at
|
||||
four seconds in a production MP4. The Frame was safely woken with
|
||||
`vrcmd --handlewakeup`; no camera, power or compositor settings were changed.
|
||||
This establishes the checked SteamVR mirror, not every game/mode or Steam's
|
||||
own clips. Treat recordings as potentially showing the room.
|
||||
|
||||
## Environment and provenance
|
||||
|
||||
- Host: lxso2, Linux x86_64. Real OBS 30.0.2 in disposable Ubuntu 24.04/Xvfb,
|
||||
using software llvmpipe rendering and the Media Source `ffmpeg_source`.
|
||||
- Frame: SteamOS 0.5.6, build `20261022.6153909`, SteamVR 2.18.2, reachable
|
||||
with the existing `ssh frame` connection. Hardware use was serialized with
|
||||
coordinator mppkd9qczt through `/tmp/frame-test.lock` and a unique owner.
|
||||
- Released v0.4.30 AppImage SHA-256:
|
||||
`b1335bb06130f2cce64a0dd40a8cb60a52c68effc258dc4bc35f8c40c822d5bb`.
|
||||
The long recording started through this app's actual Electron IPC, Python
|
||||
helper and SSH stream. Robustness checks then used that released runtime
|
||||
with the final changed resources and repacked `app.asar`; this was not a
|
||||
newly built AppImage. No OBS plugin or host FFmpeg is required by the app.
|
||||
- Windows release ZIP SHA-256:
|
||||
`a393cc1653f14f162b4a3e56803f7fe9bea7b04904d18577b0b2d7d7c2d95b03`.
|
||||
Checked against release SHA256SUMS; bundled Python 3.12.14.
|
||||
|
||||
Machine-readable measurements and the manual stimulus/analysis programs are
|
||||
in [obs-validation-2026-10-25/](obs-validation-2026-10-25/).
|
||||
Room images and videos remain private under `/tmp/obs-aacapv3evh` on lxso2;
|
||||
only their hashes are committed in `private-artifacts.json`.
|
||||
|
||||
## Recording, sound, sync and latency
|
||||
|
||||
| Check | Measured result | What it establishes |
|
||||
| --- | --- | --- |
|
||||
| Real OBS recording | 187.367 s; 15,784,412 bytes; H.264 1280×720 at 30 fps; 5,621 decoded video frames; AAC 48 kHz stereo | More than three minutes through the packaged Linux path |
|
||||
| OBS dropped frames | 0 encoder skips; 90 render lag/stall frames, approximately 1.6% of recording frames | The software-rendered host was not lossless; this is not a hardware-rendered performance benchmark |
|
||||
| Audio | 1 kHz stimulus; FFT peak 996.094 Hz at 11.719 Hz bin resolution; peak −13.207 dBFS; 0 clipped samples; measured harmonic energy −81.32 dB relative to fundamental | Non-silent, no numerical clipping or large harmonic distortion in the checked tone; no listening or real-game quality verdict |
|
||||
| Baseline A/V | Audio leads video by median 426.67 ms; range 360–426.67 ms across 36 pulses | Default OBS setup needs sync checking; not suitable for a claim of automatic accurate sync |
|
||||
| Live image latency | 1,431.4 ms, n=1; clock exchange RTT 3.409 ms; screenshot polling about 20–35 ms | Overlay submission to OBS source screenshot, not physical headset motion-to-photon latency |
|
||||
| Production MP4 | 5.121 s; 1920×1080, 30 fps, 153 video frames; AAC stereo; request to saved result 7.435 s | A separate production recorder saved room passthrough pixels too |
|
||||
|
||||
The manual stimulus places an owned black/white OpenVR marker over the mirror
|
||||
and plays a 200 ms, 1 kHz tone every five seconds through the Frame's default
|
||||
output. `pa_simple_get_latency` estimates when the submitted samples play;
|
||||
marker submission is aligned to that estimate. This measures software output,
|
||||
not an externally measured loudspeaker/physical-display event. Most submission
|
||||
jitter was below 0.2 ms; compositor timing and the 30 fps/10 ms analysis grids
|
||||
still limit precision. FFmpeg decodes both tracks; the analysis detects marker
|
||||
edges and groups AAC pre-echo onsets within 100 ms.
|
||||
|
||||
A separate initial +400 ms OBS Sync Offset trial left 83.33 ms of audio lead.
|
||||
Changing the offset during an already active source from 0 to +367 ms did not
|
||||
improve that run (366.67 ms before, 370 ms after). Do not assume a universal
|
||||
offset or that a live setting change has taken effect: reopen the source and
|
||||
verify another recording. Three subsequent fresh-source trials, setting the offset **before activating**
|
||||
the Media Source, measured 480 ms audio lead at 0 ms, 70 ms lead at +400 ms,
|
||||
and **0 ms at +480 ms** across three pulses (30 fps video and 10 ms audio
|
||||
analysis resolution). This proves a setup-specific workaround, not a universal
|
||||
default. See `calibration-initial.json`. Full trial measurements are retained. Zero OBS
|
||||
buffering caused missing/distorted sound and was rejected. Zero mux delay
|
||||
and alternate timestamp experiments did not improve latency and were not
|
||||
merged. The default capture/encoding settings remain unchanged.
|
||||
|
||||
## Robustness and fixes
|
||||
|
||||
| Failure or scenario | Fix / result | Proof |
|
||||
| --- | --- | --- |
|
||||
| Stop during asynchronous Python discovery could still launch later | Cancel the pending generation; expose Stop while starting | Controller regression test |
|
||||
| Late stderr containing an earlier ready message changed stopping back to running | Only accept ready while starting; ignore stale child events | Original released block reproduced in `race-before.json`; controller regression |
|
||||
| A non-responsive SSH process defeated timeout termination | Bounded terminate/wait then kill; SSH connection/keepalive bounds | Real SSH child deliberately stopped with SIGSTOP; clear UI failure in 15.009 s including 5 s forced cleanup; both processes gone |
|
||||
| Timeout with the app's stdin still open aborted Python at shutdown | Read the control descriptor directly, avoiding a blocked buffered-stdin daemon lock | Previously reproduced fatal shutdown; final real stall shows `The Frame OBS video stopped for 10 seconds`; subprocess regression holds stdin open |
|
||||
| Three Start/Stop cycles | First packets 0.890 / 0.701 / 0.709 s; Stop to idle 61.7 / 52.7 / 53.0 ms | Final patched packaged runtime, ports 15101–15103, `robustness.json` |
|
||||
| Restart after stall | First packet 0.691 s | Same runtime and real Frame |
|
||||
| Quit while streaming | No surviving helper at 2.021 s | Same runtime; final audit found no test FFmpeg/stimulus process |
|
||||
| Port 15099 occupied | App now offers a validated alternate port and matching copyable URL | OBS occupied 15099 while real packets arrived on alternate ports; UDP cannot identify or guarantee the receiver |
|
||||
| OBS before/after Start; receiver restarts | Live decoding verified, including recreated sources while relay stays active; an already-open stalled receiver may need hide/show or properties OK | Documented recovery, not automatic OBS control |
|
||||
| Standby/wake | Safe wake changed a blank mirror to visible room/panels | Private mirror and saved recordings |
|
||||
|
||||
A forced SSH stall tests the transport failure and error/cleanup behavior.
|
||||
It does **not** prove real Wi-Fi loss or full headset suspend/resume. No physical
|
||||
network interface was disabled and no headset reset was performed. There is
|
||||
no automatic reconnect: restore connectivity and press Start. Valid blank
|
||||
standby frames do not trigger a packet timeout.
|
||||
|
||||
## Windows and macOS
|
||||
|
||||
**Windows controls remain hidden.** A disposable Windows 11 VM ran the final
|
||||
helper using the released app's bundled Python and a native executable SSH
|
||||
fixture. It stayed alive for three seconds without a UDP receiver, then sent
|
||||
1,316-byte packets when a receiver opened. Closing its control pipe returned
|
||||
exit 0 with no surviving SSH child (`windows-pipe.json`; stop measured below
|
||||
the VM timer's resolution in the final run). The helper hides the SSH console
|
||||
and tolerates Winsock's delayed UDP receiver-unreachable error.
|
||||
|
||||
The real-headset Windows attempt delivered zero packets through a temporary
|
||||
host SSH bridge. Native Windows OBS, direct Windows-to-Frame SSH, firewall
|
||||
behavior and packaged app Start/Stop have therefore not passed end to end.
|
||||
No local FFmpeg was installed; encoding belongs on the Frame. No firewall
|
||||
exception, headset authorized key, or Windows scheduled task was installed.
|
||||
|
||||
**macOS:** no physical Mac or macOS VM was available. Python tests and the
|
||||
five controller lifecycle tests run in the macOS CI matrix; these cannot
|
||||
establish capture performance, audio quality, packaged launch or OBS behavior
|
||||
on a physical Mac.
|
||||
|
||||
## Validation and remaining limits
|
||||
|
||||
Local suite: **1,514 Python tests passed, 16 skipped**, 46 app tests and 18
|
||||
site tests passed. The latest helper additionally passed all 11 focused OBS
|
||||
tests; the controller passed all five focused lifecycle tests. Cross-platform
|
||||
CI results belong to the linked PR, not these host measurements.
|
||||
|
||||
Still unverified: perceptual listening, real-game audio routing, accurate
|
||||
default A/V sync, broad latency/performance across hosts, hours-long sessions,
|
||||
physical Wi-Fi drop/full suspend recovery, physical macOS and native Windows
|
||||
OBS. The app/docs now explain copyable URL/format, receiver recovery, port
|
||||
selection, passthrough privacy and OBS Virtual Camera's video-only limit.
|
||||
The Discord guide provides the corresponding audio-sharing choices.
|
||||
|
||||
Cleanup completed: Steam and vrcompositor remained running, battery 98%,
|
||||
no test FFmpeg/stimulus process remained, UDP ports 15099/15101/15102/15103/15105
|
||||
were bindable, task guest SSH config and the temporary bridge were removed,
|
||||
and the owned headset lock was released. The disposable Windows VM was
|
||||
stopped with its disk retained. See `cleanup.json`.
|
||||
@@ -1,23 +0,0 @@
|
||||
import json,subprocess,sys,statistics
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from scipy import signal
|
||||
video=Path(sys.argv[1]); root=video.parent
|
||||
v=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:v','-vf','scale=64:36','-pix_fmt','rgb24','-f','rawvideo','-']),dtype=np.uint8).reshape(-1,36,64,3)
|
||||
a=np.frombuffer(subprocess.check_output(['ffmpeg','-v','error','-i',str(video),'-map','0:a','-ac','1','-ar','48000','-f','f32le','-']),dtype='<f4')
|
||||
# Locate the high contrast square marker, excluding the background's low variations.
|
||||
gray=v.astype(float).mean(axis=3); spread=np.percentile(gray,98,axis=0)-np.percentile(gray,2,axis=0)
|
||||
y,x=np.unravel_index(spread.argmax(),spread.shape); track=gray[:,y,x]; lo,hi=np.percentile(track,[2,98]); white=track>(lo+hi)/2;onsets=np.where(np.diff(white.astype(int))==1)[0]+1
|
||||
rms=np.sqrt(np.mean(a[:len(a)//480*480].reshape(-1,480)**2,axis=1)); audible=rms>.003;beeps=(np.where(np.diff(audible.astype(int))==1)[0]+1)/100
|
||||
beeps=beeps[np.r_[True,np.diff(beeps)>.1]]
|
||||
visual=onsets/30; offsets=[]
|
||||
for t in visual:
|
||||
if len(beeps):
|
||||
b=beeps[np.argmin(abs(beeps-t))]
|
||||
if abs(b-t)<2:offsets.append(float((b-t)*1000))
|
||||
active=a[abs(a)>.003]; peaks,props=signal.find_peaks(rms,height=.003,distance=100)
|
||||
# Spectrum of one well inside a tone, with Hann window; harmonic distortion estimate.
|
||||
start=int((beeps[2]+.05)*48000) if len(beeps)>2 else 0;sample=a[start:start+4096];freq=np.fft.rfftfreq(len(sample),1/48000);power=abs(np.fft.rfft(sample*np.hanning(len(sample))))**2
|
||||
base=power[(freq>960)&(freq<1040)].sum();harm=sum(power[(freq>h*1000-40)&(freq<h*1000+40)].sum() for h in range(2,10))
|
||||
result={'duration_s':len(v)/30,'video_frames':len(v),'marker_pixel_xy':[int(x),int(y)],'marker_levels':[float(lo),float(hi)],'visual_markers_s':visual.tolist(),'audio_onsets_s':beeps.tolist(),'audio_minus_video_ms':offsets,'offset_median_ms':statistics.median(offsets) if offsets else None,'offset_min_max_ms':[min(offsets),max(offsets)] if offsets else None,'peak_dbfs':float(20*np.log10(abs(a).max())),'clipped_samples':int((abs(a)>=.999).sum()),'tone_frequency_hz':float(freq[power.argmax()]),'harmonics_to_fundamental_db':float(10*np.log10(harm/base)),'rms_peak':float(rms.max())}
|
||||
(root/(video.stem+'-analysis.json')).write_text(json.dumps(result,indent=2));np.save(root/(video.stem+'-marker-spread.npy'),spread);print(json.dumps(result,indent=2))
|
||||
@@ -1,136 +0,0 @@
|
||||
{
|
||||
"duration_s": 187.36666666666667,
|
||||
"video_frames": 5621,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
9.766666666666667,
|
||||
14.766666666666667,
|
||||
19.766666666666666,
|
||||
24.766666666666666,
|
||||
29.766666666666666,
|
||||
34.766666666666666,
|
||||
39.766666666666666,
|
||||
44.766666666666666,
|
||||
49.766666666666666,
|
||||
54.766666666666666,
|
||||
59.766666666666666,
|
||||
64.76666666666667,
|
||||
69.76666666666667,
|
||||
74.76666666666667,
|
||||
79.76666666666667,
|
||||
84.76666666666667,
|
||||
89.76666666666667,
|
||||
94.76666666666667,
|
||||
99.76666666666667,
|
||||
104.76666666666667,
|
||||
109.76666666666667,
|
||||
114.76666666666667,
|
||||
119.76666666666667,
|
||||
124.76666666666667,
|
||||
129.76666666666668,
|
||||
134.76666666666668,
|
||||
139.76666666666668,
|
||||
144.7,
|
||||
149.7,
|
||||
154.7,
|
||||
159.7,
|
||||
164.7,
|
||||
169.7,
|
||||
174.7,
|
||||
179.7,
|
||||
184.7
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
9.34,
|
||||
14.34,
|
||||
19.34,
|
||||
24.34,
|
||||
29.34,
|
||||
34.34,
|
||||
39.34,
|
||||
44.34,
|
||||
49.34,
|
||||
54.34,
|
||||
59.34,
|
||||
64.34,
|
||||
69.34,
|
||||
74.34,
|
||||
79.34,
|
||||
84.34,
|
||||
89.34,
|
||||
94.34,
|
||||
99.34,
|
||||
104.34,
|
||||
109.34,
|
||||
114.34,
|
||||
119.34,
|
||||
124.34,
|
||||
129.34,
|
||||
134.34,
|
||||
139.34,
|
||||
144.34,
|
||||
149.34,
|
||||
154.34,
|
||||
159.34,
|
||||
164.34,
|
||||
169.34,
|
||||
174.34,
|
||||
179.34,
|
||||
184.34
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-426.66666666666765,
|
||||
-426.66666666666765,
|
||||
-426.66666666666583,
|
||||
-426.66666666666583,
|
||||
-426.66666666666583,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666623,
|
||||
-426.6666666666765,
|
||||
-426.6666666666765,
|
||||
-426.6666666666765,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852,
|
||||
-359.9999999999852
|
||||
],
|
||||
"offset_median_ms": -426.6666666666623,
|
||||
"offset_min_max_ms": [
|
||||
-426.6666666666765,
|
||||
-359.9999999999852
|
||||
],
|
||||
"peak_dbfs": -13.2072114944458,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -81.32048537168609,
|
||||
"rms_peak": 0.15280573070049286
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
[
|
||||
{
|
||||
"duration_s": 21.033333333333335,
|
||||
"video_frames": 631,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
4.9,
|
||||
9.9,
|
||||
14.9,
|
||||
19.9
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
4.42,
|
||||
9.42,
|
||||
14.42,
|
||||
19.42
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-480.00000000000045,
|
||||
-480.00000000000045,
|
||||
-480.00000000000045,
|
||||
-479.9999999999969
|
||||
],
|
||||
"offset_median_ms": -480.00000000000045,
|
||||
"offset_min_max_ms": [
|
||||
-480.00000000000045,
|
||||
-479.9999999999969
|
||||
],
|
||||
"peak_dbfs": -13.202757835388184,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.94567523863627,
|
||||
"rms_peak": 0.15256504714488983,
|
||||
"offset_ms": 0,
|
||||
"recording": "2026-10-25 06-31-15.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 21.066666666666666,
|
||||
"video_frames": 632,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
10.6,
|
||||
15.6,
|
||||
20.6
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
5.62,
|
||||
10.53,
|
||||
15.53,
|
||||
20.53
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-70.00000000000028,
|
||||
-70.00000000000028,
|
||||
-70.00000000000028
|
||||
],
|
||||
"offset_median_ms": -70.00000000000028,
|
||||
"offset_min_max_ms": [
|
||||
-70.00000000000028,
|
||||
-70.00000000000028
|
||||
],
|
||||
"peak_dbfs": -13.215741157531738,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.99685616364809,
|
||||
"rms_peak": 0.15322808921337128,
|
||||
"offset_ms": 400,
|
||||
"recording": "2026-10-25 06-31-46.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 21.0,
|
||||
"video_frames": 630,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
7.6,
|
||||
12.6,
|
||||
17.6
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
7.6,
|
||||
12.6,
|
||||
17.6
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"offset_median_ms": 0.0,
|
||||
"offset_min_max_ms": [
|
||||
0.0,
|
||||
0.0
|
||||
],
|
||||
"peak_dbfs": -13.219660758972168,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -83.85259564406981,
|
||||
"rms_peak": 0.15266509354114532,
|
||||
"offset_ms": 480,
|
||||
"recording": "2026-10-25 06-32-18.mkv"
|
||||
}
|
||||
]
|
||||
@@ -1,22 +0,0 @@
|
||||
import json,sys,time,subprocess,os
|
||||
from pathlib import Path
|
||||
sys.path.insert(0,'/tmp/obs-aacapv3evh');from control import OBS
|
||||
r=Path('/tmp/obs-aacapv3evh');o=OBS();
|
||||
try:o.request('RemoveInput',{'inputName':'Frame mirror'})
|
||||
except RuntimeError:pass
|
||||
time.sleep(1)
|
||||
h=subprocess.Popen([sys.executable,'scripts/obs-headset-source.py','--control-stdin'],stdin=subprocess.PIPE,stderr=(r/'calibration-initial-helper.log').open('w'))
|
||||
p=subprocess.Popen(['ssh','frame','XDG_RUNTIME_DIR=/run/user/1000 timeout 100 python3 /tmp/obs-aacapv3evh/stimulus.py'],stdout=(r/'calibration-initial-stimulus.jsonl').open('w'),stderr=(r/'calibration-initial-stimulus.log').open('w'))
|
||||
results=[]
|
||||
try:
|
||||
for offset in (0,400,480):
|
||||
name='Frame calibration '+str(offset)
|
||||
data=o.request('CreateInput',{'sceneName':'Scene','inputName':name,'inputKind':'ffmpeg_source','inputSettings':{'is_local_file':False,'input':'udp://127.0.0.1:15099','input_format':'mpegts','buffering_mb':2,'restart_on_activate':True},'sceneItemEnabled':False})
|
||||
o.request('SetInputAudioSyncOffset',{'inputName':name,'inputAudioSyncOffset':offset})
|
||||
print('setting',o.request('GetInputAudioSyncOffset',{'inputName':name}),flush=True)
|
||||
o.request('SetSceneItemEnabled',{'sceneName':'Scene','sceneItemId':data['sceneItemId'],'sceneItemEnabled':True})
|
||||
time.sleep(7);o.request('StartRecord');time.sleep(21);rec=o.request('StopRecord');video=r/Path(rec['outputPath']).name
|
||||
env=dict(os.environ,PYTHONPATH=str(r/'deps'));a=json.loads(subprocess.check_output([sys.executable,str(r/'analyse.py'),str(video)],env=env));a.update(offset_ms=offset,recording=video.name);results.append(a);print(json.dumps(a),flush=True)
|
||||
o.request('RemoveInput',{'inputName':name})
|
||||
finally:
|
||||
h.stdin.close();h.wait(timeout=10);p.terminate();p.wait(timeout=10);(r/'calibration-initial-result.json').write_text(json.dumps(results,indent=2))
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"offset_ms": 367,
|
||||
"records": [
|
||||
{
|
||||
"duration_s": 19.966666666666665,
|
||||
"video_frames": 599,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
6.133333333333334,
|
||||
7.266666666666667,
|
||||
12.266666666666667,
|
||||
17.266666666666666
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
6.9,
|
||||
11.9,
|
||||
16.9
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
766.6666666666666,
|
||||
-366.66666666666623,
|
||||
-366.66666666666714,
|
||||
-366.66666666666714
|
||||
],
|
||||
"offset_median_ms": -366.6666666666667,
|
||||
"offset_min_max_ms": [
|
||||
-366.66666666666714,
|
||||
766.6666666666666
|
||||
],
|
||||
"peak_dbfs": -13.215461730957031,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -84.12980132719696,
|
||||
"rms_peak": 0.15284518897533417,
|
||||
"phase": "before",
|
||||
"recording": "2026-10-25 06-28-11.mkv"
|
||||
},
|
||||
{
|
||||
"duration_s": 20.066666666666666,
|
||||
"video_frames": 602,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
4.4,
|
||||
9.4,
|
||||
14.4,
|
||||
19.4
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
4.03,
|
||||
9.03,
|
||||
14.03,
|
||||
19.03
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-370.0000000000001,
|
||||
-370.000000000001,
|
||||
-370.000000000001,
|
||||
-369.99999999999744
|
||||
],
|
||||
"offset_median_ms": -370.00000000000057,
|
||||
"offset_min_max_ms": [
|
||||
-370.000000000001,
|
||||
-369.99999999999744
|
||||
],
|
||||
"peak_dbfs": -13.167834281921387,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -82.16381051643725,
|
||||
"rms_peak": 0.15364883840084076,
|
||||
"phase": "after",
|
||||
"recording": "2026-10-25 06-28-34.mkv"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"bindable_udp_ports": [
|
||||
15099,
|
||||
15101,
|
||||
15102,
|
||||
15103,
|
||||
15105
|
||||
],
|
||||
"remote_task_ffmpeg_and_stimulus_processes": 0,
|
||||
"steam_and_vrcompositor_running": true,
|
||||
"battery_percent": 98,
|
||||
"headset_lock_released": true,
|
||||
"guest_task_ssh_host_block_removed": true,
|
||||
"temporary_ssh_bridge_stopped": true,
|
||||
"persistent_headset_authentication_changes": false
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"duration_s": 36.06666666666667,
|
||||
"video_frames": 1082,
|
||||
"marker_pixel_xy": [
|
||||
50,
|
||||
0
|
||||
],
|
||||
"marker_levels": [
|
||||
0.0,
|
||||
255.0
|
||||
],
|
||||
"visual_markers_s": [
|
||||
9.433333333333334,
|
||||
14.433333333333334,
|
||||
19.433333333333334,
|
||||
24.433333333333334,
|
||||
29.433333333333334,
|
||||
34.43333333333333
|
||||
],
|
||||
"audio_onsets_s": [
|
||||
9.35,
|
||||
14.35,
|
||||
19.35,
|
||||
24.35,
|
||||
29.35,
|
||||
34.35
|
||||
],
|
||||
"audio_minus_video_ms": [
|
||||
-83.33333333333393,
|
||||
-83.33333333333393,
|
||||
-83.33333333333215,
|
||||
-83.33333333333215,
|
||||
-83.33333333333215,
|
||||
-83.3333333333286
|
||||
],
|
||||
"offset_median_ms": -83.33333333333215,
|
||||
"offset_min_max_ms": [
|
||||
-83.33333333333393,
|
||||
-83.3333333333286
|
||||
],
|
||||
"peak_dbfs": -13.15299129486084,
|
||||
"clipped_samples": 0,
|
||||
"tone_frequency_hz": 996.09375,
|
||||
"harmonics_to_fundamental_db": -82.4335935832891,
|
||||
"rms_peak": 0.15312190353870392
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"clock": {
|
||||
"rtt": 0.0034094941802322865,
|
||||
"offset_remote_minus_host": -2894789.9195547644
|
||||
},
|
||||
"hits": [
|
||||
{
|
||||
"source_index": 1000,
|
||||
"delay_ms": 1431.418131571263,
|
||||
"query_ms": 11.743285227566957
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"programs": [
|
||||
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"codec_name": "h264",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"r_frame_rate": "30/1",
|
||||
"nb_read_frames": "153"
|
||||
},
|
||||
{
|
||||
"codec_name": "aac",
|
||||
"sample_rate": "48000",
|
||||
"channels": 2,
|
||||
"r_frame_rate": "0/0",
|
||||
"nb_read_frames": "233"
|
||||
}
|
||||
],
|
||||
"format": {
|
||||
"duration": "5.121000",
|
||||
"size": "3932019"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"elapsed_s": 7.434504003729671,
|
||||
"result": {
|
||||
"state": "saved",
|
||||
"file": "/tmp/obs-aacapv3evh/mp4/rec-20261025-062251.mp4",
|
||||
"audio": true,
|
||||
"seconds": 5,
|
||||
"message": "Saved rec-20261025-062251.mp4 to /tmp OBS evidence"
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
{
|
||||
"result": {
|
||||
"outputPath": "/evidence/2026-10-25 06-08-22.mkv"
|
||||
},
|
||||
"samples": [
|
||||
{
|
||||
"elapsed": 30.000104404054582,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 9068.64453125,
|
||||
"averageFrameRenderTime": 18.458413,
|
||||
"cpuUsage": 20.24916849878798,
|
||||
"memoryUsage": 543.7109375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 959,
|
||||
"renderSkippedFrames": 4,
|
||||
"renderTotalFrames": 1783,
|
||||
"webSocketSessionIncomingMessages": 14,
|
||||
"webSocketSessionOutgoingMessages": 16
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 2243010,
|
||||
"outputDuration": 30866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:00:30.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 60.002103955019265,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8942.09375,
|
||||
"averageFrameRenderTime": 13.660508,
|
||||
"cpuUsage": 23.863888888888887,
|
||||
"memoryUsage": 544.56640625,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 1859,
|
||||
"renderSkippedFrames": 5,
|
||||
"renderTotalFrames": 2683,
|
||||
"webSocketSessionIncomingMessages": 16,
|
||||
"webSocketSessionOutgoingMessages": 18
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 4951606,
|
||||
"outputDuration": 60866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:01:00.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 90.0040782331489,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8900.6015625,
|
||||
"averageFrameRenderTime": 12.110281,
|
||||
"cpuUsage": 23.680555555555557,
|
||||
"memoryUsage": 545.74609375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 2759,
|
||||
"renderSkippedFrames": 13,
|
||||
"renderTotalFrames": 3583,
|
||||
"webSocketSessionIncomingMessages": 18,
|
||||
"webSocketSessionOutgoingMessages": 20
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 7552329,
|
||||
"outputDuration": 90866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:01:30.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 120.0062853959389,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8603.08984375,
|
||||
"averageFrameRenderTime": 14.892247,
|
||||
"cpuUsage": 24.66111111111111,
|
||||
"memoryUsage": 546.52734375,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 3659,
|
||||
"renderSkippedFrames": 13,
|
||||
"renderTotalFrames": 4483,
|
||||
"webSocketSessionIncomingMessages": 20,
|
||||
"webSocketSessionOutgoingMessages": 22
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 10124530,
|
||||
"outputDuration": 120866,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:02:00.866"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 150.00849257130176,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8484.58203125,
|
||||
"averageFrameRenderTime": 15.3434,
|
||||
"cpuUsage": 23.169444444444444,
|
||||
"memoryUsage": 548.5546875,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 4559,
|
||||
"renderSkippedFrames": 78,
|
||||
"renderTotalFrames": 5383,
|
||||
"webSocketSessionIncomingMessages": 22,
|
||||
"webSocketSessionOutgoingMessages": 24
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 12643085,
|
||||
"outputDuration": 150833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:02:30.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 180.01099411491305,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8382.75,
|
||||
"averageFrameRenderTime": 31.402812,
|
||||
"cpuUsage": 23.717094301899365,
|
||||
"memoryUsage": 548.51953125,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 5459,
|
||||
"renderSkippedFrames": 90,
|
||||
"renderTotalFrames": 6283,
|
||||
"webSocketSessionIncomingMessages": 24,
|
||||
"webSocketSessionOutgoingMessages": 26
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 15130232,
|
||||
"outputDuration": 180833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:03:00.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"elapsed": 185.01674677524716,
|
||||
"obs": {
|
||||
"activeFps": 30.000000300000007,
|
||||
"availableDiskSpace": 8366.79296875,
|
||||
"averageFrameRenderTime": 22.859219,
|
||||
"cpuUsage": 23.766666666666666,
|
||||
"memoryUsage": 510.28515625,
|
||||
"outputSkippedFrames": 0,
|
||||
"outputTotalFrames": 5609,
|
||||
"renderSkippedFrames": 90,
|
||||
"renderTotalFrames": 6433,
|
||||
"webSocketSessionIncomingMessages": 26,
|
||||
"webSocketSessionOutgoingMessages": 28
|
||||
},
|
||||
"record": {
|
||||
"outputActive": true,
|
||||
"outputBytes": 15580161,
|
||||
"outputDuration": 185833,
|
||||
"outputPaused": false,
|
||||
"outputTimecode": "00:03:05.833"
|
||||
},
|
||||
"app": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
[
|
||||
{
|
||||
"name": "2026-10-25 06-08-22.mkv",
|
||||
"bytes": 15784412,
|
||||
"sha256": "f2760ed28bf48274cc8c936428edbc389cd3a9f4b9cd2a61e699a04bf4487b50",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "rec-20261025-062251.mp4",
|
||||
"bytes": 3932019,
|
||||
"sha256": "c71de1a49b75159c587fddd012361b9e6626a4d5cc41b79696343a17b73c151a",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "mirror-wake.png",
|
||||
"bytes": 1741004,
|
||||
"sha256": "0754466948d5b9e8448f9ed67c8582e19802367df6ed4002afc64f42e1763f53",
|
||||
"published": false
|
||||
},
|
||||
{
|
||||
"name": "private-mp4-frame4.png",
|
||||
"bytes": 1002068,
|
||||
"sha256": "08d87bc9ab0e62115b42cca7dfcf7e4db11848a0ede273034a92a69297c6775b",
|
||||
"published": false
|
||||
}
|
||||
]
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"baseline": "d79efb4",
|
||||
"after_stop": {
|
||||
"state": "stopping",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
},
|
||||
"after_late_stderr": {
|
||||
"state": "running",
|
||||
"alias": "frame",
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
[
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 1,
|
||||
"port": 15101,
|
||||
"first_packet_s": 0.890392117202282,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.06173023069277406,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15101,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 2,
|
||||
"port": 15102,
|
||||
"first_packet_s": 0.7008698149584234,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.0526565620675683,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15102,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "start-stop",
|
||||
"cycle": 3,
|
||||
"port": 15103,
|
||||
"first_packet_s": 0.7092029112391174,
|
||||
"packet_bytes": 1316,
|
||||
"stop_s": 0.052990387193858624,
|
||||
"state": {
|
||||
"state": "idle",
|
||||
"alias": "frame",
|
||||
"port": 15103,
|
||||
"error": "",
|
||||
"supported": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"test": "stalled-ssh",
|
||||
"ssh_pid": 3025339,
|
||||
"elapsed_s": 15.008651106152683,
|
||||
"state": {
|
||||
"state": "failed",
|
||||
"alias": "frame",
|
||||
"port": 15105,
|
||||
"error": "OBS source stopped (1). The Frame OBS video stopped for 10 seconds",
|
||||
"supported": true
|
||||
},
|
||||
"helper_gone": true,
|
||||
"ssh_gone": true
|
||||
},
|
||||
{
|
||||
"test": "restart-after-stall",
|
||||
"first_packet_s": 0.6910299891605973
|
||||
},
|
||||
{
|
||||
"test": "quit-streaming",
|
||||
"checked_after_s": 2.020789209753275,
|
||||
"surviving_helpers": []
|
||||
}
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import ctypes as C, json, math, os, signal, struct, subprocess, sys, threading, time
|
||||
# Put frame_media_player.py and its two imports beside this helper on the Frame.
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from frame_media_player import Overlay, OverlayBusy
|
||||
os.environ['XDG_RUNTIME_DIR']='/run/user/1000'
|
||||
class Spec(C.Structure):
|
||||
_fields_=[('format',C.c_int),('rate',C.c_uint32),('channels',C.c_uint8)]
|
||||
class Attr(C.Structure):
|
||||
_fields_=[(k,C.c_uint32) for k in ('maxlength','tlength','prebuf','minreq','fragsize')]
|
||||
p=C.CDLL('libpulse-simple.so.0'); p.pa_simple_new.restype=C.c_void_p
|
||||
p.pa_simple_new.argtypes=[C.c_char_p,C.c_char_p,C.c_int,C.c_char_p,C.c_char_p,C.POINTER(Spec),C.c_void_p,C.POINTER(Attr),C.POINTER(C.c_int)]
|
||||
p.pa_simple_write.argtypes=[C.c_void_p,C.c_void_p,C.c_size_t,C.POINTER(C.c_int)]
|
||||
p.pa_simple_get_latency.argtypes=[C.c_void_p,C.POINTER(C.c_int)];p.pa_simple_get_latency.restype=C.c_uint64
|
||||
p.pa_simple_free.argtypes=[C.c_void_p]
|
||||
err=C.c_int(); spec=Spec(3,48000,2); attr=Attr(0xffffffff,9600,0,1920,0xffffffff)
|
||||
stream=p.pa_simple_new(None,b'OBS validation',1,None,b'aligned marker',C.byref(spec),None,C.byref(attr),C.byref(err))
|
||||
if not stream: raise RuntimeError('pulse '+str(err.value))
|
||||
vr=Overlay(); handle=vr.create('framecontrol.obs.validation.aacapv3evh',2,1.5,order=100)
|
||||
quit=threading.Event(); events=[]
|
||||
signal.signal(signal.SIGTERM,lambda *_:quit.set())
|
||||
def wake():
|
||||
while not quit.is_set():
|
||||
subprocess.run(['/opt/steamvr/bin/linuxarm64/vrcmd','--handlewakeup'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
quit.wait(1)
|
||||
threading.Thread(target=wake,daemon=True).start()
|
||||
try:
|
||||
previous=False
|
||||
for i in range(19000):
|
||||
if quit.is_set():break
|
||||
tone= i>=500 and (i-500)%500<20
|
||||
block=b''.join(struct.pack('<hh',int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0,int(5000*math.sin(2*math.pi*1000*(i*480+j)/48000)) if tone else 0) for j in range(480))
|
||||
b=C.create_string_buffer(block)
|
||||
if p.pa_simple_write(stream,b,len(block),C.byref(err)):raise RuntimeError('write '+str(err.value))
|
||||
if tone != previous or i==0:
|
||||
latency=p.pa_simple_get_latency(stream,C.byref(err))/1e6
|
||||
expected=time.monotonic()+latency-.01
|
||||
time.sleep(max(0,expected-time.monotonic()))
|
||||
before=time.monotonic()
|
||||
try:vr.pixels(handle,(b'\xff\xff\xff\xff' if tone else b'\x00\x00\x00\xff')*64*64,64,64)
|
||||
except OverlayBusy:continue
|
||||
after=time.monotonic()
|
||||
event={'tone':tone,'index':i,'expected_audio':expected,'visual_submit':before,'visual_return':after,'submit_minus_audio_ms':(before-expected)*1000,'pulse_latency_ms':latency*1000}
|
||||
print(json.dumps(event),flush=True); events.append(event)
|
||||
previous=tone
|
||||
finally:
|
||||
quit.set();vr.close();p.pa_simple_free(stream)
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"alive_without_receiver": true,
|
||||
"platform": "win32",
|
||||
"has_udp_connreset": false,
|
||||
"packet_bytes": 1316,
|
||||
"ssh_pid": 2668,
|
||||
"stop_seconds": 0.0,
|
||||
"helper_exit": 0,
|
||||
"ssh_child_alive": false
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"checkedAt": "2026-10-25T06:03:45.387452+00:00",
|
||||
"method": "Read-only Python probe over the existing frame SSH alias; no device changes",
|
||||
"baselineCommit": "d79efb4730becc781f9a641cc36cb27f6481667c",
|
||||
"deckyStatus": {
|
||||
"arch": "aarch64",
|
||||
"frameOS": "0.5.6",
|
||||
"variant": "vr",
|
||||
"build": "20261022.6153909",
|
||||
"cef": true,
|
||||
"managed": false,
|
||||
"running": false,
|
||||
"version": null,
|
||||
"upstreamInstall": false,
|
||||
"plugins": [],
|
||||
"upstreamPlugins": []
|
||||
},
|
||||
"platform": {
|
||||
"userServiceManager": true,
|
||||
"rootMountOptions": "rw,relatime,ssd,discard=async,space_cache=v2,subvolid=5,subvol=/",
|
||||
"homeMountOptions": "rw,relatime",
|
||||
"homebrewExists": false,
|
||||
"cefMarkerExists": false,
|
||||
"cefTargetTitles": [
|
||||
"SteamVR - valve.steam.gamepadui.frame.menu.53930005 (53930005)",
|
||||
"SteamVR - valve.steam.gamepadui.bar (53930000)",
|
||||
"SteamVR",
|
||||
"SteamVR - Notifications",
|
||||
"SteamVR - valve.steam.gamepadui.floatingfooter (53930004)",
|
||||
"SteamVR - valve.steam.gamepadui.volumelevel (53930003)",
|
||||
"SteamVR - valve.steam.gamepadui.tooltip.53930002 (53930002)",
|
||||
"SteamVR - valve.steam.gamepadui.barpopup.53930001 (53930001)",
|
||||
"SteamVR - Keyboard",
|
||||
"data:text/html,<body><%2Fbody>",
|
||||
"SharedJSContext"
|
||||
]
|
||||
},
|
||||
"limits": [
|
||||
"No Decky install/update/uninstall repeated in this retry",
|
||||
"No OS update or reboot performed",
|
||||
"No wearer-visible menu or controller input verified"
|
||||
]
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
# Screenshot Save on a real Frame, 25 October 2026
|
||||
|
||||
After PR #192 merged, Frame Control's production `list_shots()` and
|
||||
`save_shots()` paths were run from lxso2 against the real Frame. The test held
|
||||
the shared `/tmp/frame-test.lock` with an owner marker and removed only its own
|
||||
lock. A separate locked check earlier in this round found SteamVR at activity
|
||||
level 3 (standby). No new screenshot was created.
|
||||
|
||||
- The Frame listed seven existing Steam screenshots.
|
||||
- One existing screenshot was copied to a temporary folder on the PC. Its
|
||||
local size matched the reported remote size: **294,885 bytes**. The new
|
||||
destination had four components below the temporary root: headset, Steam
|
||||
account, game ID and filename.
|
||||
- Saving the same screenshot a second time returned zero new copies. A fresh
|
||||
list then marked that screenshot as saved.
|
||||
- The temporary PC folder and its private image were removed at the end. The
|
||||
source screenshot on the Frame was only read. Battery was **100% Full**
|
||||
before and after.
|
||||
|
||||
This verifies one real screenshot transfer and the repeat-save behavior. It
|
||||
does **not** reproduce two different real screenshots with the same filename;
|
||||
the account/game/headset collision case is covered by the fake-source test in
|
||||
`tests/test_shot_copy.py`. Windows and macOS physical-PC saves were not tested.
|
||||
Active passthrough in the recording mirror remains unknown because the Frame
|
||||
was in standby.
|
||||
@@ -1,81 +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",
|
||||
"apiVersion": 1,
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
`apiVersion` selects the Frame Control bridge contract, independently of your
|
||||
extension's own `version`. This app supports integer `1`; a missing field means
|
||||
`1` for existing extensions. Unsupported versions appear as errors on Refresh,
|
||||
and their assets are not served. Declare it in new extensions so a future API
|
||||
change can fail clearly instead of running incompatible code. The runtime
|
||||
`capabilities` reply below reports the bridge version too.
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
|
||||
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
|
||||
top-level window. Content Security Policy restricts scripts, images, and
|
||||
`fetch` requests, but an extension can still navigate its own iframe to an
|
||||
external site. Treat third-party extensions as untrusted code and review them
|
||||
before installation.
|
||||
|
||||
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
|
||||
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
|
||||
shows a complete client:
|
||||
|
||||
```js
|
||||
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
|
||||
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
|
||||
```
|
||||
|
||||
Supported actions:
|
||||
|
||||
| Action | Value |
|
||||
| --- | --- |
|
||||
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
|
||||
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
|
||||
|
||||
`status` is the app's latest successful status snapshot. When that reading fails,
|
||||
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
|
||||
should use explicit, narrow actions and keep the iframe isolated. A third-party
|
||||
extension is code you choose to run locally: inspect its files before copying
|
||||
them into the folder. A Decky plugin is a separate Steam client plugin and does
|
||||
not use this API.
|
||||
@@ -1,34 +0,0 @@
|
||||
# Write a plugin for Frame
|
||||
|
||||
There are two paths. Choose the one that matches your feature.
|
||||
|
||||
## Frame Control extension
|
||||
|
||||
Use a Frame Control extension for a panel, status view, or guide that does not
|
||||
need to change the headset. Put `extension.json` and `index.html` in one folder
|
||||
under `~/.local/share/frame-control/extensions`. The opt-in headset panel lists
|
||||
the folder and opens it in a sandboxed iframe. The version 1 bridge has only
|
||||
`capabilities` and read-only `status`. It cannot call Steam, change settings,
|
||||
or run privileged code. See [the extension API](extensions.md) and its example.
|
||||
|
||||
## Decky plugin
|
||||
|
||||
Use a Decky plugin only if you need Decky's backend or Gamepad UI integration.
|
||||
Build an official-format ZIP with one top-level folder, `plugin.json`, and a
|
||||
frontend bundle in `dist/index.js`. A Python `main.py` backend is optional.
|
||||
Test on ARM64. If you ship an ELF helper, build it for AArch64 (ELF machine
|
||||
183). Avoid root, `sudo`, Steam Deck TDP controls, fixed x86 binaries, and
|
||||
paths under `/etc`. Frame Control's managed loader is a normal user service;
|
||||
it blocks root plugins and x86 archives. Keep data under your plugin folder or
|
||||
the user's home directory, and provide a removal path.
|
||||
|
||||
The current Frame SteamVR shell does not expose Decky's Steam Deck Quick Access
|
||||
Menu. A backend `Loaded` log only proves backend startup. Test any frontend
|
||||
page, setting, notification, route, and controller input while wearing a Frame
|
||||
before describing it as working in VR. Until a QAM bridge is verified, a Frame
|
||||
Control extension is the practical route for a visible read-only VR panel.
|
||||
|
||||
To test safely, use a separate Frame Control managed Decky install, verify the
|
||||
ZIP digest from the official store, install one plugin at a time, and inspect
|
||||
`journalctl --user -u frame-control-decky.service`. Remove only your own
|
||||
plugin folder when you finish. See the current [compatibility table](decky-frame.md).
|
||||
@@ -1,92 +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 panel also lists Decky plugins and can install, update, or remove compatible
|
||||
plugins from the official store when the opt-in managed loader is running.
|
||||
Each change asks for confirmation. The Frame VR shell does not show Decky's
|
||||
Steam Deck Quick Access Menu, so Decky plugin pages, settings, routes, and
|
||||
toasts are not available in this panel yet. The [compatibility table](decky-frame.md)
|
||||
separates backend loading from VR use. Authors can use the
|
||||
[Frame plugin and extension guide](frame-plugin-author.md).
|
||||
|
||||
The per-run UI key is passed in the URL fragment, removed from the address bar after load,
|
||||
and sent in the `X-Frame-UI` header. The key is absent from the HTTP request
|
||||
line and server log. The usual host/origin and anti-framing checks still apply.
|
||||
|
||||
## Real Frame test, 2026-10-25
|
||||
|
||||
On Frame OS 0.5.6, build `20261022.6153909`, the opt-in install started the
|
||||
user service, served `/headset`, read live `/api/status` from the browser, and
|
||||
registered a mapped Chromium window with SteamVR under the dedicated overlay
|
||||
key. The server rejected a request with the wrong UI key. A read-only status
|
||||
probe reported the user service running and the overlay registered.
|
||||
|
||||
The unworn headset was in standby (`activity_level` 3). SteamVR reported the
|
||||
new overlay as `not_visible` even after `ShowDashboard`. An OpenVR stereo
|
||||
screenshot was a uniform dark frame. The documented temporary wake attempt
|
||||
(`power.turnOffScreensTimeout` 5 → 3600, `--handlewakeup`, `ShowDashboard`)
|
||||
still left the overlay `not_visible`; the setting was restored to 5 and the
|
||||
dashboard hidden. The X window's `xwd` image was black too. These observations
|
||||
prove panel creation and page/API execution, **not wearer-visible pixels or
|
||||
laser/controller input**. A worn-headset check is still required before
|
||||
describing the panel as visually verified.
|
||||
|
||||
The test then removed the managed service and app bundle. Its unit became
|
||||
inactive, port 47813 closed, the SteamVR overlay disappeared, and the Frame's
|
||||
battery was 100%. Steam and SteamVR were left running. The browser profile and
|
||||
extensions folder were deliberately kept.
|
||||
|
||||
A second test on the same build installed the expanded panel with the
|
||||
`headset-glance` example extension. The headset's authenticated loopback API
|
||||
returned live status (99% battery), discovered and served the extension, and
|
||||
listed no running games. The Game Mode dashboard request succeeded; an invalid
|
||||
force-quit app ID was rejected with HTTP 400. Screenshot and recording actions
|
||||
saved a PNG and a 3.6 MB MP4 in the headset user's folders. The recording
|
||||
reported an audio source. The per-run UI key was absent from the server log.
|
||||
After another wake and dashboard request, SteamVR still reported the panel
|
||||
`not_visible`. No wearer-visible screenshot or controller interaction was
|
||||
obtained. The test removed its example extension and captured media, then
|
||||
uninstalled the service. The unit, port, bundle and overlay were absent after
|
||||
removal; Steam and SteamVR remained running.
|
||||
|
||||
## Remove
|
||||
|
||||
Use **Tools → Frame Control in VR → Remove**. If the companion is unavailable,
|
||||
run:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user stop frame-control-headset.service; rm -f ~/.config/systemd/user/frame-control-headset.service; systemctl --user daemon-reload; rm -rf ~/.local/share/frame-control/headset/bundle; rm -f ~/.local/share/frame-control/headset/receipt.json ~/.local/share/frame-control/headset/server.log'
|
||||
```
|
||||
|
||||
That command removes only Frame Control's managed panel code. The Chromium
|
||||
profile remains in `~/.local/share/frame-control/headset/profile` so local
|
||||
extension data is not discarded. Inspect and delete that folder separately if
|
||||
you no longer want it. Third-party extensions are in
|
||||
`~/.local/share/frame-control/extensions` and are never removed by this tool.
|
||||
@@ -31,7 +31,6 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| `gamescopectl screenshot <file>` (with `WAYLAND_DISPLAY=gamescope-0`) captures gamescope's flat layer. | Frame Control's capture |
|
||||
| The **headset view** (both eyes, fully composited: room, panels, dashboard, controllers) comes from OpenVR `IVRScreenshots::RequestScreenshot(VRScreenshotType_Stereo)`. It's callable from `python3` with `ctypes` against `/opt/steamvr/bin/linuxarm64/libopenvr_api.so` as an overlay app. The compositor appends `.png`, writing a 1920×1080 side-by-side image (960×1080 per eye) plus a left-eye preview, in about 0.3s. In standby the frame is blank. `vrcmd --screenshot` and `vrcmd --compositorcmd screenshot_request` wrote nothing, even with `steamvr/rawCapturePath` set. | `ui/frame_vrshot.py` |
|
||||
| SteamVR's `steamvr-v4l2cam.service` (`/opt/steamvr/bin/linuxarm64/v4l2cam --output=99`) copies the headset view (the `system.HeadsetView` mirror, one undistorted image) into the v4l2loopback device `/dev/video99` ("SteamVR"), 1920×1080 RGB24. `ffmpeg -f v4l2 -i /dev/video99` reads it at about 70 new frames/s; the first frame read can be black. The Frame's hardware encoder (`iris_encoder`, `/dev/video-enc0`) crashes ffmpeg's `h264_v4l2m2m`, so encode with `libx264 -preset ultrafast -tune zerolatency`: 720p30 takes about 0.7 of a core and 1080p60 about 1.7 (of 8). gamescope also publishes a PipeWire `gamescope` video source, but the Frame's GStreamer has no `pipewiresrc`. **Verified 2026-09-26.** | Frame Control's live video (`/api/stream`) |
|
||||
| **Passthrough in the mirror: yes on SteamOS 0.5.6**, build `20261022.6153909`, SteamVR 2.18.2. After `vrcmd --handlewakeup`, `/dev/video99` showed real room pixels behind panels. They also appeared in OBS's saved recording and a five-second production MP4. No camera setting was changed, and the private room images were not published. Other games/modes and Steam clips were not established. **Verified 2026-10-25.** | [OBS validation](evidence/obs-validation-2026-10-25.md) |
|
||||
| Battery: `/sys/class/power_supply/max1720x_bat_7-36` gives µV/µA (current is positive while charging), `time_to_full_now`/`time_to_empty_now` in seconds, and `temp` in tenths of °C. The charger shows up as `tcpm-source-psy-…` (`type=USB`, `usb_type=C PD [PD_PPS]`), for example 12 V × 1.67 A. | Frame Control's battery card |
|
||||
| `vrcmd --stats` reports `activity_level` (3 = standby). | Telling whether the headset is being worn |
|
||||
| **Testing VR apps without wearing the headset.** In standby SteamVR keeps OpenXR sessions hidden, so they render one frame and stop. `vrcmd` (in `/opt/steamvr/bin/linuxarm64`) settings use `section.key`: `vrcmd --set-settings-bool power.pauseCompositorOnStandby 0` and `vrcmd --set-settings-float power.turnOffScreensTimeout 3600`, then `vrcmd --handlewakeup`, keep the compositor running, and the scene app becomes visible. If it stays `visible-blurred`, the Steam dashboard is open: `SteamClient.OpenVR.VROverlay.HideDashboard()` in Steam's `SharedJSContext` (CDP on 8080) closes it. The headset view then captures with `ui/frame_vrshot.py`. Restore afterwards with `--set-settings-bool power.pauseCompositorOnStandby 1` and `--set-settings-float power.turnOffScreensTimeout 5`. The bool setter reads `true` as false, so use 1/0. A Steam launch that stalls in standby at `ShowInterstitials` or `CreatingProcess` (see `console_log.txt`) continues with `SteamClient.Apps.ContinueGameAction(<action id>, "<appid>", "<task>")`. **Verified 2026-09-27.** | Proving VR output remotely, [webxr-chromium.md](webxr-chromium.md) |
|
||||
|
||||
+8
-101
@@ -1,7 +1,7 @@
|
||||
# Headset view in OBS
|
||||
|
||||
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
|
||||
OBS on a Linux or macOS computer. A checkout helper is also available for Windows tests. The helper uses the same `/dev/video99` video
|
||||
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.
|
||||
|
||||
@@ -11,10 +11,7 @@ local MPEG-TS stream; no port is opened to the LAN or internet.
|
||||
**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. Expand **OBS setup** in the app to copy the Input URL and format. The port
|
||||
can be changed before Start if another program uses 15099. Use that same
|
||||
port in OBS. The sender cannot identify which program is listening.
|
||||
4. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
|
||||
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.
|
||||
@@ -27,15 +24,9 @@ run:
|
||||
```
|
||||
|
||||
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. Encoding runs on the Frame; the computer does not need FFmpeg. The desktop
|
||||
app bundles Python. A source checkout needs Python 3.8+ and OpenSSH Client.
|
||||
The Windows button is hidden: native Windows/Frame streaming and OBS have
|
||||
not passed an end-to-end test. A Windows 11 VM passed the helper control-pipe,
|
||||
late UDP receiver and child-cleanup fixture; its real-headset bridge delivered
|
||||
zero packets. These are separate results, not native Windows support.
|
||||
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.
|
||||
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.
|
||||
|
||||
To share the OBS view in a call, see the short
|
||||
[Discord setup guide](streaming-to-discord.md).
|
||||
@@ -49,96 +40,12 @@ 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. **Room passthrough is verified** in this mirror and the saved OBS/MP4 outputs
|
||||
on SteamOS 0.5.6 after a safe SteamVR wake. Check the picture before sharing;
|
||||
other games and modes may differ.
|
||||
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. That initial check did not assess listening quality, A/V sync or active
|
||||
passthrough. The full validation below supersedes those gaps with measurements
|
||||
and a positive passthrough result, while listening remains unverified. See the
|
||||
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 newer [full OBS validation](evidence/obs-validation-2026-10-25.md)
|
||||
records more than three minutes and measures audio, sync, latency and cleanup.
|
||||
|
||||
## 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.
|
||||
|
||||
## Check sync and recover a blank source
|
||||
|
||||
Make a short recording of a visible click, clap or other simultaneous marker
|
||||
and sound before streaming. Open **Advanced Audio Properties** in OBS. If audio
|
||||
arrives early, hide/deactivate this Media Source, add a positive **Sync Offset
|
||||
(ms)** in Advanced Audio Properties, then show/reactivate it, record again,
|
||||
and check the result. Changing the offset during playback did not correct
|
||||
the tested OBS 30 source. Setting it before activation did: a +480 ms offset
|
||||
reduced a 480 ms lead to 0 ms within the test resolution in three pulses. The 2026-10-25 Linux software-rendering test measured
|
||||
about 0.4 seconds of audio lead with default OBS settings. This is a measured
|
||||
setup result, not a universal offset. Do not apply it blindly to another host.
|
||||
|
||||
Start Frame Control's source before adding or activating the OBS Media Source.
|
||||
After Stop/Start or a connection failure, hide and show the Media Source, or
|
||||
reopen its properties and click OK, to reopen its UDP receiver. Keep only one
|
||||
receiver on a port. If another application owns 15099, stop the relay, choose
|
||||
another port in **OBS setup**, and change the OBS Input to match. Sending UDP
|
||||
packets does not confirm that OBS has received or decoded them.
|
||||
|
||||
A stalled stream ends with an error. Wake or reconnect the Frame and press
|
||||
Start again; automatic reconnection is not implemented. Standby can produce
|
||||
valid but blank frames, so it does not necessarily trigger a packet timeout.
|
||||
A fully suspended Frame cannot be woken over an unavailable SSH connection.
|
||||
+9
-30
@@ -1,29 +1,10 @@
|
||||
# Open questions and on-device checks
|
||||
|
||||
The September sections below record early checks after the Frame's retail
|
||||
release (2026-09-18). They are a historical test log, not a current setup
|
||||
guide. For current connection steps, see [SteamOS October 2026
|
||||
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
|
||||
[charge-limit.md](charge-limit.md).
|
||||
|
||||
## Current Frame OS 0.5.6 status (2026-10-25)
|
||||
|
||||
- `frame.local` no longer resolves on the checked 0.5.6 beta build
|
||||
(`20261022.6153909`). Avahi was active but did not publish addresses. The
|
||||
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
|
||||
the same network after verifying the pinned SSH key, or a manual IP/SSH
|
||||
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
|
||||
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
|
||||
- Read-only checks on that build found no SteamOS Manager
|
||||
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
|
||||
charging switches are root-owned and managed by Valve's charger service.
|
||||
No charging control was written. An 80% limit is **not supported** by Frame
|
||||
Control; see [charge-limit.md](charge-limit.md) and the
|
||||
[headset check](evidence/round3-headset-2026-10-25.md).
|
||||
- Passthrough in the MP4 and OBS mirror is **verified on 0.5.6**: after a safe
|
||||
SteamVR wake, room pixels appeared behind panels in the actual mirror and
|
||||
saved outputs. Per-game behavior and Steam's own clips remain unverified;
|
||||
see the [OBS validation](evidence/obs-validation-2026-10-25.md).
|
||||
Research as of 2026-09-25, eight days after the Frame's retail release
|
||||
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
|
||||
pages. Searches of Reddit and the Steam forums turned up **almost no
|
||||
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
|
||||
"not documented yet", not "doesn't work".
|
||||
|
||||
## Verified on device (2026-09-25)
|
||||
|
||||
@@ -33,9 +14,7 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
|
||||
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
|
||||
the hostname is `frame`.
|
||||
- **3.** `frame.local` resolved from the Mac on the September build;
|
||||
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
|
||||
beta build; see the current status above.
|
||||
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
|
||||
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
|
||||
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
|
||||
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
|
||||
@@ -169,9 +148,9 @@ check in the headset:
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
|
||||
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
|
||||
See [charge-limit.md](charge-limit.md).
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Plugin and modding research
|
||||
|
||||
Checked **2026-10-25**. Decision: keep the experimental, reversible Decky manager
|
||||
and ship Frame Control's own sandboxed panel API as the supported way to add
|
||||
local features. Both implementations already landed in the replacement job
|
||||
while the original job was unavailable. This report consolidates their evidence
|
||||
and the original research questions; it does not claim another loader was built.
|
||||
|
||||
## Can Decky run on Frame?
|
||||
|
||||
**Its ARM64 backend and Steam shared-context injection work on the tested build.
|
||||
A usable Decky menu inside VR remains unverified.** Do not equate an active
|
||||
service or a JavaScript plugin registration with a working headset interface.
|
||||
|
||||
| Requirement | Evidence | Consequence |
|
||||
| --- | --- | --- |
|
||||
| ARM64 Linux binary | Upstream [3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0) has `PluginLoader-arm64`; Frame reports `aarch64` | ARM is not itself a blocker. Individual plugins may still ship x86 binaries or assume Deck hardware. |
|
||||
| Steam Gamepad UI and CEF | Loopback `127.0.0.1:8080/json/list` exposes `SharedJSContext` plus distinct SteamVR pages | The shared context exists; the VR shell is a separate integration target. |
|
||||
| CEF enable marker | `~/.steam/steam/.cef-enable-remote-debugging` is absent, but CEF already answers | No marker or Steam launch flag change is needed on this build. |
|
||||
| User service manager | `systemctl --user show-environment` succeeds | A per-user service works without installing an upstream root service. The manager is degraded by a stale failed Decky unit, although its unit file is gone. |
|
||||
| Filesystem | `/` currently mounts `rw` on Btrfs, `/home` mounts `rw` | This is an observation of this device, not a stock-image guarantee. Do not disable SteamOS read-only protection or install packages in `/usr`. |
|
||||
| Homebrew folder | `~/homebrew` is absent | It is an installer convention, not a preinstalled SteamOS plugin API. Frame Control uses a separate managed directory. |
|
||||
| Visible menu | Earlier tests registered Decky's tab only in shared JavaScript; SteamVR had no Decky overlay; headset was in standby | QAM visibility, controller input and a real plugin's visible result remain unverified. |
|
||||
|
||||
The fresh [read-only probe](evidence/plugins-2026-10-25.json) records SteamOS
|
||||
`0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`. It confirms CEF, architecture,
|
||||
mounts, user-service availability, and absence of a running managed or upstream
|
||||
Decky installation. This retry did not install anything or restart Steam.
|
||||
|
||||
The replacement job's initial [test record](decky-frame.md) reports:
|
||||
|
||||
- Decky 3.3.0 started as the normal `steamos` user and injected into
|
||||
`SharedJSContext`.
|
||||
- Install → status → update → status → remove → status succeeded on the Frame.
|
||||
Removal left the unit, binary and port 1337 absent, while preserving user data.
|
||||
- ProtonDB Badges 1.1.0 loaded as a passive plugin and appeared in Decky's
|
||||
shared-context registry. Its badge or menu was not seen inside VR.
|
||||
- Injected frontend code can outlive backend removal until Steam's web helper
|
||||
restarts. An earlier helper-restart test cleared the globals; this retry did
|
||||
not repeat it.
|
||||
|
||||
These are prior documented results, separate from the fresh read-only probe.
|
||||
The coordinator then ran a separate, locked
|
||||
[official-store compatibility audit](decky-frame.md#official-store-plugin-audit-2026-10-25)
|
||||
on the same Frame OS build. Seven plugins logged backend loads. A managed
|
||||
ProtonDB Badges 1.3.4 store install, update and remove also succeeded. The
|
||||
headset stayed unworn, so these results still do not establish VR UI use.
|
||||
|
||||
## Installer and update choice
|
||||
|
||||
The upstream [installer at e42a3c8](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
escalates through sudo, creates `~/homebrew/plugins` and `services`, writes the
|
||||
CEF marker and a system unit under `/etc/systemd/system`, and starts Decky as
|
||||
root. Its architecture switch supports `aarch64|arm64`. Running this installer
|
||||
is unnecessary for a compatibility check and grants more access than a panel
|
||||
extension needs.
|
||||
|
||||
Frame Control's [manager](../ui/frame_decky.py) instead downloads the official
|
||||
ARM64 asset, checks GitHub's published SHA-256 and the ELF machine, and writes
|
||||
only under `~/.local/share/frame-control/decky` and `~/.config/systemd/user`.
|
||||
It refuses an upstream system-service installation, requires active CEF before
|
||||
install/update, and restores the previous binary, receipt and unit if applying
|
||||
an update fails. Existing tests exercise that rollback with a failed service
|
||||
start. These checks do not prove every possible OS or Steam update is safe.
|
||||
|
||||
The binary is downloaded on demand, not bundled. Plugin discovery lists the
|
||||
managed directory and upstream `~/homebrew/plugins`. The later
|
||||
[store manager](../ui/frame_decky_store.py) exposes official entries in the PC
|
||||
app and opt-in headset panel. It verifies archive digests, blocks root and
|
||||
non-ARM64 binaries, and changes only its own plugin folders. That is file and
|
||||
backend support, not a working VR plugin page. Decky plugins can execute
|
||||
backend code and are not isolated by Frame Control's HTML extension sandbox.
|
||||
|
||||
Home-directory state is expected to survive SteamOS image updates, based on
|
||||
SteamOS's layout; **an actual OS update and reboot persistence test has not been
|
||||
performed**. Steam frontend changes, CEF availability, libc requirements and
|
||||
plugin hardware assumptions can still break Decky. On failure, remove the user
|
||||
service using [the documented removal command](decky-frame.md#opt-in-and-remove);
|
||||
its removal path does not require a working CEF target. Test the sequence again
|
||||
on each supported OS build before expanding support claims.
|
||||
|
||||
## Other paths
|
||||
|
||||
| Path | What it enables | Current limits and choice |
|
||||
| --- | --- | --- |
|
||||
| Decky plugins | Steam frontend hooks, optional Python backends | Seven official-store plugins logged backend loads on ARM64. Store install/update/remove worked for ProtonDB Badges. VR pages and controller input remain unverified. Keep experimental. |
|
||||
| [CSS Loader](https://github.com/suchmememanyskill/SDH-CssLoader) / [DeckThemes](https://docs.deckthemes.com/) | Stylesheets applied to Steam UI | CSS Loader 2.1.3 backend loaded on Frame. No theme was applied or visually tested. Deck themes do not establish compatibility with Frame's separate VR shell. |
|
||||
| User home / SteamOS homebrew | User services, user Flatpaks, files and helpers | Use owned folders and explicit uninstall. A folder named `homebrew` supplies no discovery or security boundary by itself. No rootfs changes needed for the chosen path. |
|
||||
| Android / Lepton | Sideloaded apps, game-specific content and per-app settings | Frame Control already manages APKs and ADB. Lepton runs Android containers, not Steam's CEF UI; an Android mod does not add a Steam dashboard tab. No new Android mod was tested here. See [APKs](apks.md), [VR APKs](vr-apks.md), and [game mods](mods.md). |
|
||||
| Frame Control extensions | Third-party HTML/CSS/JS panels with a small read-only bridge | Shipped in [#194](https://github.com/saphid/frame-control/pull/194), with a manifest, local folder, sandbox, example and [author guide](extensions.md). No Python imports, arbitrary SSH commands or write actions are exposed. |
|
||||
| Frame Control's headset panel | Runs the local tools and extensions as a SteamVR panel | Shipped in [#198](https://github.com/saphid/frame-control/pull/198), expanded in [#201](https://github.com/saphid/frame-control/pull/201). Page/API execution and uninstall were tested; wearer-visible pixels and controller interaction remain unverified. See [test record](headset-app.md). |
|
||||
|
||||
The extension iframe has `sandbox="allow-scripts"`, an opaque origin, restrictive
|
||||
CSP and a `postMessage` bridge accepting only `capabilities` and a reduced
|
||||
`status` snapshot from the active frame. It is useful for local dashboards and
|
||||
small tools today. It cannot replace the full Decky API. Third-party code still
|
||||
needs review: an iframe may navigate itself externally, and local installation
|
||||
does not establish publisher trust.
|
||||
|
||||
Extensions are loaded only when opened and are removed by closing their panel
|
||||
and deleting their own directory. They modify no SteamOS files. The
|
||||
[compatibility follow-up](https://github.com/saphid/frame-control/pull/209) adds
|
||||
manifest `apiVersion` validation during discovery and asset serving, with
|
||||
existing manifests defaulting to version 1. It rejects incompatible panels
|
||||
even when requested through a direct asset URL.
|
||||
|
||||
## Licences: separate download versus bundling
|
||||
|
||||
Decky's [licence](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/LICENSE)
|
||||
is GPL version 2. Frame Control is MIT-licensed. The integration contains its
|
||||
own service-management code and downloads the upstream executable as a separate
|
||||
program. It does not incorporate Decky source or ship its binary in Frame
|
||||
Control installers. Keep the upstream licence/source links visible to users
|
||||
who opt in; installing a separate GPL program does not by itself relicense
|
||||
Frame Control's independent code.
|
||||
|
||||
If a future release bundles or mirrors Decky binaries, the distributor must
|
||||
preserve copyright/licence notices and satisfy GPLv2's corresponding-source
|
||||
requirements for the exact binary, including modifications and build scripts
|
||||
as applicable. A link to an evolving default branch is not a substitute for
|
||||
meeting those distribution obligations. Mere aggregation and deriving or
|
||||
linking a combined work are different questions; reassess the boundary if
|
||||
Decky code is incorporated. Each plugin, theme and bundled dependency has its
|
||||
own licence and assets to check independently. The existing
|
||||
[third-party notices](../THIRD_PARTY_NOTICES.md) describe software already
|
||||
distributed with Frame Control.
|
||||
|
||||
## Next checks
|
||||
|
||||
1. On a worn Frame, establish an accessible Decky menu or implement an adapter
|
||||
for the VR shell. Verify a plugin's visible result and controller navigation
|
||||
before describing store-installed plugins as usable inside VR.
|
||||
2. Verify the Frame Control panel and example extension visually, including
|
||||
opening, refreshing and closing with the controllers.
|
||||
3. Test a normal Steam restart, reboot and SteamOS update with a disposable
|
||||
managed install; confirm removal still works with CEF unavailable. Retain
|
||||
the experimental label until those checks pass.
|
||||
|
||||
The release coordinator owns those ongoing headset-integration checks. This
|
||||
retry leaves the headset untouched and hands over the research and compatibility
|
||||
follow-up instead of running concurrent loader experiments.
|
||||
+11
-24
@@ -52,19 +52,18 @@ Recording and **Live** can run together; both read the same device.
|
||||
|
||||
### Does Record include passthrough?
|
||||
|
||||
**Yes, the MP4 recording and OBS mirror can include room passthrough.** On
|
||||
25 October 2026, SteamOS 0.5.6 build `20261022.6153909`, a safe SteamVR wake
|
||||
produced real room pixels behind panels in `/dev/video99`. Room pixels also
|
||||
appeared in decoded frames of both the OBS recording and a five-second MP4
|
||||
made with the packaged production recorder. The private room images are not
|
||||
published. See the [test record](evidence/obs-validation-2026-10-25.md).
|
||||
**Record captures SteamVR's `system.HeadsetView` mirror from `/dev/video99`.**
|
||||
Frame Control does not open the headset's passthrough cameras or add a separate
|
||||
camera layer to the MP4. If SteamVR puts passthrough into that mirror, it will
|
||||
appear in the recording; Frame Control cannot force or promise that result.
|
||||
Steam's own **Steam clips** use a separate game recording path and may differ.
|
||||
|
||||
Both paths read SteamVR's `system.HeadsetView` mirror. Frame Control does not
|
||||
open raw passthrough cameras or add a camera layer. The observed mirror includes
|
||||
SteamVR's composited room and panels; it is not limited to a game's image.
|
||||
This does not promise that every game or passthrough mode appears the same way.
|
||||
The mirror can be blank in standby. Steam's own **Steam clips** use a separate
|
||||
game recording path and were not checked for passthrough in this test.
|
||||
On 25 October 2026, the Frame was in standby. A frame captured from
|
||||
`/dev/video99` was black, as expected in standby, so this check **did not
|
||||
establish whether active passthrough appears in the mirror**. A wearer-visible
|
||||
passthrough session and its simultaneous mirror frame are still needed for a
|
||||
yes-or-no answer about passthrough pixels. See the
|
||||
[live compatibility evidence](evidence/steamos-compat-live-2026-10-25.md).
|
||||
|
||||
## Steam clips
|
||||
|
||||
@@ -108,18 +107,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
@@ -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
|
||||
|
||||
@@ -17,4 +17,4 @@ 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
|
||||
the mirror can include real room passthrough behind panels. Check the scene before sharing.
|
||||
active passthrough in that mirror remains unverified.
|
||||
@@ -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
|
||||
|
||||
+4
-21
@@ -188,11 +188,6 @@ scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
||||
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||
```
|
||||
|
||||
The wrapper uses POSIX `sh` and Python 3 on macOS or Linux; zsh is not required.
|
||||
It resolves the checkout path independently of the current working directory.
|
||||
`/path/to/checkout/scripts/frame-smoke.sh --help` is safe to run offline and
|
||||
does not contact the headset.
|
||||
|
||||
It checks `properties.json` and the status, then installs, launches and
|
||||
removes three tiny titles built from bytes by `tests/smoke/tiny_programs.py`
|
||||
(an ARM64 and an x86-64 static Linux program that sleep for ten seconds, and
|
||||
@@ -213,14 +208,6 @@ isn't reachable.
|
||||
Smoke process cleanup runs before and after title removal and during the
|
||||
initial/final leftover sweeps. It targets only the reserved `fc_smoke_arm64`,
|
||||
`fc_smoke_x86_64`, `fc_smoke_exe` IDs and their legacy `fc-smoke-` equivalents.
|
||||
Each leftover sweep checks all six IDs, even if no title directory or install
|
||||
record remains. It removes their exact `-argv.json`, `-env.json`,
|
||||
`-settings.json` and `-framecontrol.json` files, syncs Steam's shortcuts, then
|
||||
syncs again to verify that none of those IDs is still registered. Missing
|
||||
devkit utilities are copied before syncing and removed at the end if they
|
||||
were absent before the run. Metadata or shortcuts that remain, or a reported
|
||||
sync failure, fail cleanup. Similarly named titles and their metadata are
|
||||
outside the deletion boundary.
|
||||
On the target, `tests/smoke/stop_processes.py` requires the SSH user's uid and
|
||||
an exact title directory in `/proc` cwd/exe or an individual absolute command
|
||||
argument; similarly named folders and shell command substrings do not count.
|
||||
@@ -244,16 +231,12 @@ The `fc_smoke_exe` launch ran under Proton Experimental (ARM64), and its
|
||||
remove step stopped five test-owned wrapper/Proton PIDs before removing the
|
||||
title; the following process sweep found none. The final cleanup found no
|
||||
test titles or processes, the test desktop was closed, and the battery
|
||||
remained at 99%. That historical run used the Python entry point directly;
|
||||
the current POSIX wrapper has local offline coverage on a PATH without zsh.
|
||||
The orphan-metadata and shortcut follow-up has not been rerun on a headset.
|
||||
remained at 99%. The `scripts/frame-smoke.sh` zsh wrapper could not run
|
||||
on lxso2 because zsh is absent; `python3 tests/smoke/frame_smoke.py` ran
|
||||
the same entry point instead.
|
||||
The local regression suite runs real Linux processes in temporary fake title
|
||||
directories (including deleted folders and a TERM-resistant wrapper); it checks
|
||||
exit, escalation and survival of unrelated processes. Local filesystem commands
|
||||
with mocked SSH/Steam also cover orphan metadata, shortcut-only leftovers,
|
||||
sync failures and exact ID boundaries. Wrapper tests check offline help,
|
||||
argument forwarding and exit status from another working directory. None of
|
||||
these local tests contacts a headset:
|
||||
exit, escalation and survival of unrelated processes without SSH or a headset:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_smoke_cleanup.py -v
|
||||
|
||||
@@ -25,19 +25,3 @@ 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,8 +0,0 @@
|
||||
{
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -1,9 +1,8 @@
|
||||
#!/bin/sh
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: the headset smoke test. Installs, launches and removes tiny
|
||||
# test titles on the Frame (the `frame` alias) and records the results with
|
||||
# its BUILD_ID under tests/smoke/results/. See docs/testing.md.
|
||||
#
|
||||
# Usage: scripts/frame-smoke.sh [--pair] (--pair needs you in the headset to approve)
|
||||
set -eu
|
||||
smoke_root=$(CDPATH= cd "$(dirname "$0")/.." && pwd -P)
|
||||
exec python3 "$smoke_root/tests/smoke/frame_smoke.py" "$@"
|
||||
set -euo pipefail
|
||||
exec python3 "${0:A:h:h}/tests/smoke/frame_smoke.py" "$@"
|
||||
@@ -5,21 +5,16 @@ 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 os
|
||||
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
|
||||
CHILD_STOP_TIMEOUT = 5
|
||||
|
||||
|
||||
def remote_command(height=720, fps=30, audio=True):
|
||||
@@ -42,7 +37,7 @@ def remote_command(height=720, fps=30, audio=True):
|
||||
f'{maps} {encode} -f mpegts -flush_packets 1 -')
|
||||
|
||||
|
||||
def relay(source, destination, on_packet=None):
|
||||
def relay(source, destination):
|
||||
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
|
||||
pending = bytearray()
|
||||
total = 0
|
||||
@@ -51,8 +46,6 @@ def relay(source, destination, on_packet=None):
|
||||
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:
|
||||
@@ -60,75 +53,18 @@ def relay(source, destination, on_packet=None):
|
||||
return total
|
||||
|
||||
|
||||
def stop_child(proc):
|
||||
"""Bound cleanup even if a stalled SSH process does not handle termination."""
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=CHILD_STOP_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
except ProcessLookupError:
|
||||
pass # another shutdown path already reaped it
|
||||
|
||||
|
||||
def watch_control(fd, proc, stopped):
|
||||
def watch_control(stream, proc):
|
||||
"""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.
|
||||
Read the descriptor directly: a blocked daemon must not hold sys.stdin's
|
||||
buffered-I/O lock when a timeout makes the main thread exit.
|
||||
"""
|
||||
try:
|
||||
os.read(fd, 1)
|
||||
stream.read(1)
|
||||
except OSError:
|
||||
pass
|
||||
stopped.set()
|
||||
stop_child(proc)
|
||||
|
||||
|
||||
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
|
||||
stop_child(self.proc)
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
@@ -145,26 +81,17 @@ def main(argv=None):
|
||||
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', '-o', 'ConnectTimeout=10',
|
||||
'-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2',
|
||||
args.frame, 'bash', '-lc', shlex.quote(command)]
|
||||
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:
|
||||
options = {'creationflags': subprocess.CREATE_NO_WINDOW} if sys.platform == 'win32' else {}
|
||||
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, **options)
|
||||
stopped = threading.Event()
|
||||
watchdog = StreamWatchdog(proc)
|
||||
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
||||
if args.control_stdin:
|
||||
threading.Thread(target=watch_control, args=(sys.stdin.fileno(), proc, stopped), daemon=True).start()
|
||||
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc), daemon=True).start()
|
||||
try:
|
||||
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
|
||||
amount = relay(proc.stdout, _Sender(udp, target))
|
||||
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.')
|
||||
@@ -172,9 +99,14 @@ def main(argv=None):
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
watchdog.close()
|
||||
proc.stdout.close()
|
||||
stop_child(proc)
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class _Sender:
|
||||
@@ -183,14 +115,7 @@ class _Sender:
|
||||
self.ready = False
|
||||
|
||||
def send(self, data):
|
||||
try:
|
||||
self.sock.sendto(data, self.target)
|
||||
except ConnectionResetError:
|
||||
# Winsock can report a previous ICMP "port unreachable" here while
|
||||
# OBS is closed. Keep sending so opening the receiver can recover.
|
||||
if sys.platform != 'win32':
|
||||
raise
|
||||
return
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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 による署名・公証がありません。
|
||||
@@ -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>
|
||||
|
||||
@@ -203,23 +203,23 @@ def cleanup(smoke):
|
||||
never removed. Raises if any owned process, file or shortcut stays.
|
||||
"""
|
||||
problems = []
|
||||
# Directory listings and this run's install tracker cannot reveal orphan
|
||||
# metadata or shortcuts. Always sweep the exact, reserved IDs; never let
|
||||
# arbitrary entries in smoke.installed expand the deletion boundary.
|
||||
ids = stop_processes.IDS
|
||||
try:
|
||||
ids = {t['id'] for t in frame_titles.list_titles() if t['id'] in stop_processes.IDS}
|
||||
except frame_android.FrameError as e:
|
||||
ids = set()
|
||||
problems.append(f'could not list titles: {e}')
|
||||
ids |= smoke.installed
|
||||
# Scan every reserved ID, even when a prior run removed its folder and
|
||||
# shortcut but left Proton running. Stop before removing any more files.
|
||||
stopped = stop_titles(stop_processes.IDS)
|
||||
gone = set()
|
||||
for gid in sorted(ids):
|
||||
try:
|
||||
metadata = ' '.join(f'~/devkit-game/{gid}-{k}.json'
|
||||
for k in ('argv', 'env', 'settings', 'framecontrol'))
|
||||
if ssh(f'test -d ~/devkit-game/{gid} && echo yes || true').strip() == 'yes':
|
||||
frame_titles.remove(gid)
|
||||
# Also when remove() stopped part-way, or only the json files are left.
|
||||
ssh(f'rm -f {metadata}')
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} {metadata} 2>/dev/null || true').strip():
|
||||
ssh(f"rm -f {' '.join(f'~/devkit-game/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))}")
|
||||
if ssh(f'ls -d ~/devkit-game/{gid} ~/devkit-game/{gid}-*.json 2>/dev/null || true').strip():
|
||||
problems.append(f'{gid} is still on the Frame')
|
||||
else:
|
||||
gone.add(gid)
|
||||
@@ -230,8 +230,6 @@ def cleanup(smoke):
|
||||
# It logs a failed sync and exits 0, so read its log; a second run lists
|
||||
# what Steam still has registered, which must not include ours.
|
||||
try:
|
||||
# A shortcut-only leftover (or a first run) may have no utilities.
|
||||
frame_titles.ensure_utils()
|
||||
command = frame_titles.devkit_script('delete') + ' 2>&1'
|
||||
first = ssh(command, timeout=120)
|
||||
second = ssh(command, timeout=120)
|
||||
|
||||
@@ -16,7 +16,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_android_import as backup
|
||||
import server
|
||||
from apk_fixtures import apk, manifest
|
||||
from test_quest import apk_bytes
|
||||
|
||||
PKG = 'com.Armature.VR4'
|
||||
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
|
||||
@@ -67,41 +66,6 @@ class BackupTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
|
||||
|
||||
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
|
||||
base = self.root / 'not-named-base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'arm64.apk'
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
plan = backup.inspect([str(split), str(base)])
|
||||
self.assertEqual(plan['apk'], base)
|
||||
self.assertEqual(plan['splits'], [split])
|
||||
self.assertEqual(backup.public(plan)['splits'], ['arm64.apk'])
|
||||
with patch.object(backup.quest, '_running', return_value=False), \
|
||||
patch.object(backup.android, 'read_meta', return_value=None), \
|
||||
patch.object(backup.android, 'install', return_value=META) as install:
|
||||
backup.install(plan)
|
||||
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
|
||||
|
||||
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
|
||||
base = self.root / 'base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'split.apk'
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
|
||||
files={'res/drawable/icon.png': b'png'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
|
||||
files={'lib/x86_64/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self):
|
||||
with patch.object(backup.android, 'read_meta', return_value=None):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'):
|
||||
@@ -240,21 +204,6 @@ class BundleTests(unittest.TestCase):
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
|
||||
source = self.bundle({
|
||||
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
|
||||
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}),
|
||||
}, suffix='.apks')
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
|
||||
ssh.assert_not_called()
|
||||
self.assertEqual(read['plan']['apk'], 'base.apk')
|
||||
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
|
||||
folders = list(server._android_backups[read['token']]['dirs'])
|
||||
server.android_backup({'action': 'discard', 'token': read['token']})
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
|
||||
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
|
||||
source = self.bundle()
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
@@ -272,7 +221,7 @@ class BundleTests(unittest.TestCase):
|
||||
|
||||
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
|
||||
cases = [
|
||||
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
|
||||
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
|
||||
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
|
||||
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
|
||||
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
|
||||
@@ -280,7 +229,7 @@ class BundleTests(unittest.TestCase):
|
||||
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
|
||||
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
|
||||
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
|
||||
({'manifest.json': b'{}'}, 'base APK'),
|
||||
({'manifest.json': b'{}'}, 'standalone APK'),
|
||||
]
|
||||
for entries, message in cases:
|
||||
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
|
||||
@@ -328,8 +277,7 @@ class BundleTests(unittest.TestCase):
|
||||
('x' * len('isSplitRequired')).encode('utf-16-le'))
|
||||
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
|
||||
with self.subTest(attrs=attrs):
|
||||
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
|
||||
backup.inspect([str(self.apk)])
|
||||
|
||||
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
|
||||
|
||||
@@ -113,108 +113,6 @@ class Remote(unittest.TestCase):
|
||||
os.symlink(str(outside), str(base / 'linked-file'))
|
||||
self.assertFalse([f for f in self.files('saves:1234') if 'linked' in f])
|
||||
|
||||
def packed(self, part):
|
||||
out = io.BytesIO()
|
||||
remote.pack(part, out)
|
||||
out.seek(0)
|
||||
with tarfile.open(fileobj=out, mode='r:gz') as archive:
|
||||
return {m.name: archive.extractfile(m).read() for m in archive}
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_backup_rejects_every_ancestor_of_a_save_root(self):
|
||||
save = USER % 1234 + '/AppData/Roaming/Game/fixture.dat'
|
||||
# Each component below HOME, including the selected root itself.
|
||||
components = (USER % 1234 + '/AppData/Roaming').split('/')
|
||||
for end in range(1, len(components) + 1):
|
||||
ancestor = '/'.join(components[:end])
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
manifest(home, 1234, 'Synthetic game')
|
||||
path = write(home, save, b'synthetic fixture')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=True)
|
||||
# Only synthetic bytes: the lexical archive path hides .ssh.
|
||||
self.assertEqual(path.read_bytes(), b'synthetic fixture')
|
||||
with patch.object(remote, 'HOME', home):
|
||||
self.assertEqual(self.files('saves:1234'), [])
|
||||
self.assertNotIn('saves:1234', [p['id'] for p in remote.plan()['parts']])
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_pack_rechecks_links_changed_after_discovery(self):
|
||||
save = USER % 1234 + '/Documents/Game/fixture.dat'
|
||||
for ancestor in (remote.COMPAT + '/1234', USER % 1234 + '/Documents', save):
|
||||
with self.subTest(ancestor=ancestor), tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp) / 'home'
|
||||
write(home, save, b'original save')
|
||||
linked = home / ancestor
|
||||
outside = Path(tmp) / 'outside/.ssh'
|
||||
outside.parent.mkdir()
|
||||
part_files = remote.part_files
|
||||
|
||||
def discover_then_link(part):
|
||||
result = part_files(part)
|
||||
self.assertEqual(result[0], [(save, len(b'original save'))])
|
||||
linked.rename(outside)
|
||||
linked.symlink_to(outside, target_is_directory=outside.is_dir())
|
||||
(home / save).write_bytes(b'synthetic redirected bytes')
|
||||
return result
|
||||
|
||||
with patch.object(remote, 'HOME', home), \
|
||||
patch.object(remote, 'part_files', side_effect=discover_then_link):
|
||||
self.assertEqual(self.packed('saves:1234'), {})
|
||||
|
||||
def test_legitimate_steam_layout_and_reference_files_still_pack(self):
|
||||
frame_home(self.home)
|
||||
manifest(self.home, 2800000002, 'Synthetic non-Steam game')
|
||||
save = USER % 2800000002 + '/Saved Games/fixture.dat'
|
||||
write(self.home, save, b'non-Steam save')
|
||||
controller = STEAM + '/steamapps/common/Steam Controller Configs/555/config/layout.vdf'
|
||||
write(self.home, controller, b'controller layout')
|
||||
for part in ('saves:1234', 'saves:2800000002', 'saves:linux',
|
||||
'settings:steam', 'settings:system'):
|
||||
with self.subTest(part=part):
|
||||
expected = {rel: (self.home / rel).read_bytes() for rel in self.files(part)}
|
||||
self.assertTrue(expected)
|
||||
self.assertEqual(self.packed(part), expected)
|
||||
self.assertEqual(self.packed('saves:2800000002'), {save: b'non-Steam save'})
|
||||
self.assertIn(controller, self.packed('settings:steam'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_settings_discovery_and_manifest_reads_reject_links(self):
|
||||
frame_home(self.home)
|
||||
for ancestor, part in (('.config', 'settings:system'),
|
||||
(STEAM + '/userdata', 'settings:steam')):
|
||||
with self.subTest(ancestor=ancestor):
|
||||
linked = self.home / ancestor
|
||||
moved = linked.with_name(linked.name + '-original')
|
||||
linked.rename(moved)
|
||||
linked.symlink_to(moved, target_is_directory=True)
|
||||
self.assertEqual(self.files(part), [])
|
||||
self.assertEqual(self.packed(part), {})
|
||||
if part == 'settings:steam':
|
||||
self.assertEqual(remote.accounts(), [])
|
||||
appmanifest = self.home / STEAM / 'steamapps/appmanifest_1234.acf'
|
||||
moved = appmanifest.with_suffix('.original')
|
||||
appmanifest.rename(moved)
|
||||
appmanifest.symlink_to(moved)
|
||||
with patch.object(Path, 'read_text', side_effect=AssertionError('must not read a link')):
|
||||
self.assertIsNone(remote.game_name('1234'))
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'symlinks need privileges on Windows')
|
||||
def test_home_alias_and_unused_steam_alias_preserve_normal_backups(self):
|
||||
frame_home(self.home)
|
||||
alias = self.home / 'home-alias'
|
||||
alias.symlink_to(self.home, target_is_directory=True)
|
||||
(self.home / '.steam/steam').symlink_to(self.home / STEAM, target_is_directory=True)
|
||||
expected = self.packed('saves:1234')
|
||||
with patch.object(remote, 'HOME', alias):
|
||||
self.assertEqual(self.packed('saves:1234'), expected)
|
||||
self.assertEqual(remote.game_name('1234'), 'Test Game')
|
||||
|
||||
def test_big_files_are_listed_as_skipped(self):
|
||||
frame_home(self.home)
|
||||
with patch.object(remote, 'MAX_FILE', 3):
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
"""Extension compatibility and filesystem boundaries, without running plugin code."""
|
||||
import sandbox # noqa: F401
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
|
||||
import frame_extensions
|
||||
|
||||
|
||||
class ExtensionFiles(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.root = Path(self.tmp.name) / 'extensions'
|
||||
self.root.mkdir()
|
||||
self.patch = patch.object(frame_extensions, 'root', return_value=self.root)
|
||||
self.patch.start()
|
||||
self.addCleanup(self.patch.stop)
|
||||
|
||||
def extension(self, name='test-panel', **fields):
|
||||
folder = self.root / name
|
||||
folder.mkdir(exist_ok=True)
|
||||
manifest = {'id': name, 'name': 'Test panel', 'version': '1.0.0',
|
||||
'description': 'A read-only panel', 'entry': 'index.html', **fields}
|
||||
(folder / 'manifest.json').write_text(json.dumps(manifest), encoding='utf-8')
|
||||
(folder / 'index.html').write_text('<script src="main.js"></script>', encoding='utf-8')
|
||||
(folder / 'main.js').write_text('/* extension code */', encoding='utf-8')
|
||||
return folder
|
||||
|
||||
def test_existing_and_explicit_v1_manifests_are_compatible(self):
|
||||
self.extension('legacy')
|
||||
self.extension('explicit', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual([(x['id'], x['apiVersion']) for x in result['extensions']],
|
||||
[('explicit', 1), ('legacy', 1)])
|
||||
self.assertEqual(frame_extensions.asset('explicit', 'main.js'),
|
||||
(b'/* extension code */', 'text/javascript; charset=utf-8'))
|
||||
|
||||
def test_unsupported_or_malformed_api_cannot_be_loaded_directly(self):
|
||||
for version in (2, 0, -1, '1', None, True, 1.0, [], {}):
|
||||
with self.subTest(apiVersion=version):
|
||||
self.extension(apiVersion=version)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['extensions'], [])
|
||||
self.assertIn('unsupported apiVersion', result['errors'][0]['error'])
|
||||
with self.assertRaisesRegex(ValueError, 'unsupported apiVersion'):
|
||||
frame_extensions.asset('test-panel', 'index.html')
|
||||
|
||||
def test_one_incompatible_extension_does_not_hide_compatible_panels(self):
|
||||
self.extension('future', apiVersion=2)
|
||||
self.extension('working', apiVersion=1)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual([x['id'] for x in result['extensions']], ['working'])
|
||||
self.assertEqual(result['errors'][0]['folder'], 'future')
|
||||
|
||||
def test_sample_is_discoverable_and_its_assets_are_served(self):
|
||||
source = Path(__file__).resolve().parents[1] / 'examples/extensions/headset-glance'
|
||||
shutil.copytree(source, self.root / source.name)
|
||||
result = frame_extensions.list_extensions()
|
||||
self.assertEqual(result['errors'], [])
|
||||
self.assertEqual(result['extensions'][0]['id'], source.name)
|
||||
for asset in ('index.html', 'main.js', 'style.css'):
|
||||
self.assertEqual(frame_extensions.asset(source.name, asset)[0],
|
||||
(source / asset).read_bytes())
|
||||
|
||||
def test_private_nested_and_traversal_paths_are_rejected(self):
|
||||
self.extension()
|
||||
for name in ('manifest.json', '../outside.js', 'sub/main.js', '.hidden.js'):
|
||||
with self.subTest(name=name), self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', name)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('../test-panel', 'main.js')
|
||||
|
||||
def test_oversized_assets_are_not_read(self):
|
||||
folder = self.extension()
|
||||
with (folder / 'large.js').open('wb') as output:
|
||||
output.truncate(5 * 1024 * 1024 + 1)
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'large.js')
|
||||
|
||||
def test_symlinks_cannot_expose_files_outside_a_panel(self):
|
||||
folder = self.extension()
|
||||
outside = Path(self.tmp.name) / 'outside.js'
|
||||
outside.write_text('private file', encoding='utf-8')
|
||||
try:
|
||||
(folder / 'escape.js').symlink_to(outside)
|
||||
(self.root / 'alias').symlink_to(folder, target_is_directory=True)
|
||||
except OSError:
|
||||
self.skipTest('symlinks unavailable for this user')
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
frame_extensions.asset('test-panel', 'escape.js')
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('alias', 'main.js')
|
||||
(folder / 'manifest.json').unlink()
|
||||
(folder / 'manifest.json').symlink_to(outside)
|
||||
with self.assertRaises(ValueError):
|
||||
frame_extensions.asset('test-panel', 'main.js')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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())
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Official Decky store transport and owned plugin transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_decky_store
|
||||
|
||||
|
||||
def archive(flags=(), extra=None):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as bundle:
|
||||
bundle.writestr('Example/plugin.json', json.dumps({'name': 'Example', 'flags': list(flags)}))
|
||||
bundle.writestr('Example/dist/index.js', 'export default {};' + ' ' * 1200)
|
||||
if extra:
|
||||
bundle.writestr(*extra)
|
||||
data = stream.getvalue()
|
||||
return data, hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_catalog_selects_valid_visible_latest_versions(self):
|
||||
records = [{'id': 42, 'name': 'Example', 'visible': True, 'versions':
|
||||
[{'name': '1.0', 'hash': 'a' * 64}], 'tags': []},
|
||||
{'id': 43, 'name': 'Hidden', 'visible': False, 'versions':
|
||||
[{'name': '1.0', 'hash': 'b' * 64}]},
|
||||
{'id': 44, 'name': 'Unsafe', 'visible': True, 'versions':
|
||||
[{'name': '1.0; touch /tmp/bad', 'hash': 'c' * 64}]}]
|
||||
with patch.object(frame_decky_store.urllib.request, 'urlopen', return_value=io.BytesIO(json.dumps(records).encode())):
|
||||
self.assertEqual([p['id'] for p in frame_decky_store.catalog()], [42])
|
||||
|
||||
def test_accepts_hashed_non_root_archive(self):
|
||||
data, digest = archive()
|
||||
self.assertEqual(frame_decky_store.archive_info(data, digest, 'Example'), 'Example')
|
||||
with self.assertRaisesRegex(ValueError, 'SHA-256'):
|
||||
frame_decky_store.archive_info(data, '0' * 64, 'Example')
|
||||
|
||||
def test_rejects_root_x86_and_escape(self):
|
||||
for data in (archive(flags=['root'])[0],
|
||||
archive(extra=('Example/bin/helper', b'\x7fELF' + b'\x00' * 14 + (62).to_bytes(2, 'little')))[0],
|
||||
archive(extra=('../outside', b'oops'))[0]):
|
||||
with self.subTest(data=hashlib.sha256(data).hexdigest()[:8]):
|
||||
with self.assertRaises(ValueError):
|
||||
frame_decky_store.archive_info(data, hashlib.sha256(data).hexdigest(), 'Example')
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote plugin transaction runs on Linux')
|
||||
class StoreTransaction(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
home = Path(self.tmp.name)
|
||||
unit = home / '.config/systemd/user/frame-control-decky.service'
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text('[Service]\n')
|
||||
fake = home / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$2" = is-active ]; then exit 0; fi\nexit 0\n')
|
||||
fake.chmod(0o700)
|
||||
self.env = {**os.environ, 'HOME': self.tmp.name,
|
||||
'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
||||
self.folder = home / '.local/share/frame-control/decky/plugins/Example'
|
||||
self.folder.parent.mkdir(parents=True)
|
||||
|
||||
def apply(self, action, data=None):
|
||||
token = secrets.token_hex(12)
|
||||
source = Path('/tmp/frame-control-decky-plugin-' + token)
|
||||
if data is not None:
|
||||
source.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, '42', digest, 'Example', '1.0'],
|
||||
input=frame_decky_store.APPLY, text=True, capture_output=True,
|
||||
env=self.env, timeout=15)
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
|
||||
def test_install_update_remove_only_owned_folder(self):
|
||||
first, _ = archive()
|
||||
result = self.apply('install', first)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads((self.folder / '.frame-control-store.json').read_text())['storeId'], 42)
|
||||
second, _ = archive(extra=('Example/data.txt', 'new'))
|
||||
result = self.apply('update', second)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.folder / 'data.txt').read_text(), 'new')
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(self.folder.exists())
|
||||
self.folder.mkdir()
|
||||
(self.folder / 'plugin.json').write_text('{}')
|
||||
result = self.apply('remove')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(self.folder.exists())
|
||||
external = Path(self.tmp.name) / 'other-plugin'
|
||||
external.mkdir()
|
||||
(external / '.frame-control-store.json').write_text('{"storeId":42}')
|
||||
for item in self.folder.iterdir(): item.unlink()
|
||||
self.folder.rmdir()
|
||||
self.folder.symlink_to(external, target_is_directory=True)
|
||||
result = self.apply('remove')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertTrue(external.exists())
|
||||
|
||||
|
||||
class StoreHost(unittest.TestCase):
|
||||
def test_owned_plugin_can_be_removed_without_store_network(self):
|
||||
state = {'managed': True, 'running': True, 'plugins':
|
||||
[{'storeId': 42, 'folder': 'Example', 'name': 'Example', 'version': '1.0'}]}
|
||||
def ssh(command, **kwargs):
|
||||
return '{}'
|
||||
with patch.object(frame_decky_store.frame_decky, 'status', return_value=state), \
|
||||
patch.object(frame_decky_store, 'catalog') as network:
|
||||
frame_decky_store.change(ssh, 'remove', 42)
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -9,10 +9,8 @@ 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'
|
||||
@@ -57,97 +55,7 @@ class ObsSourceTests(unittest.TestCase):
|
||||
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()
|
||||
|
||||
def wait(self, timeout=None):
|
||||
return 255
|
||||
|
||||
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()
|
||||
|
||||
def test_shutdown_kills_an_unresponsive_ssh_child(self):
|
||||
proc = mock.Mock()
|
||||
proc.poll.return_value = None
|
||||
proc.wait.side_effect = [subprocess.TimeoutExpired('ssh', 5), 137]
|
||||
obs.stop_child(proc)
|
||||
proc.terminate.assert_called_once()
|
||||
proc.kill.assert_called_once()
|
||||
self.assertEqual(proc.wait.call_count, 2)
|
||||
|
||||
def test_windows_sender_ignores_absent_receiver_and_hides_ssh_console(self):
|
||||
proc = mock.Mock()
|
||||
proc.stdout = io.BytesIO(bytes(range(188)) * 7)
|
||||
proc.poll.return_value = proc.wait.return_value = 0
|
||||
# These are the actual Python Winsock control and subprocess constants.
|
||||
with mock.patch.object(obs.sys, 'platform', 'win32'), \
|
||||
mock.patch.object(obs.subprocess, 'CREATE_NO_WINDOW', 0x08000000, create=True), \
|
||||
mock.patch.object(obs.socket, 'socket') as socket_factory, \
|
||||
mock.patch.object(obs.subprocess, 'Popen', return_value=proc) as popen:
|
||||
self.assertEqual(obs.main(['--frame', 'test-frame']), 0)
|
||||
udp = socket_factory.return_value.__enter__.return_value
|
||||
self.assertEqual(popen.call_args.kwargs['creationflags'], 0x08000000)
|
||||
udp.sendto.assert_called_once()
|
||||
|
||||
def test_windows_recovers_from_an_unreachable_udp_receiver(self):
|
||||
udp = mock.Mock()
|
||||
udp.sendto.side_effect = [ConnectionResetError(10054, 'receiver closed'), 188]
|
||||
sender = obs._Sender(udp, ('127.0.0.1', 15099))
|
||||
with mock.patch.object(obs.sys, 'platform', 'win32'):
|
||||
sender.send(b'G' * 188)
|
||||
self.assertFalse(sender.ready)
|
||||
sender.send(b'G' * 188)
|
||||
self.assertTrue(sender.ready)
|
||||
self.assertEqual(udp.sendto.call_count, 2)
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
|
||||
def test_stall_exits_cleanly_even_while_app_control_pipe_stays_open(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
directory = Path(folder)
|
||||
fake = directory / 'ssh'
|
||||
fake.write_text("#!/usr/bin/env python3\n"
|
||||
"import signal,sys,time\n"
|
||||
"signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
|
||||
"sys.stdout.buffer.write(b'G'*1316);sys.stdout.buffer.flush()\n"
|
||||
"while True: time.sleep(1)\n")
|
||||
fake.chmod(0o755)
|
||||
wrapper = ("import importlib.util; "
|
||||
f"s=importlib.util.spec_from_file_location('obs', {str(SCRIPT)!r}); "
|
||||
"o=importlib.util.module_from_spec(s);s.loader.exec_module(o); "
|
||||
"o.STARTUP_TIMEOUT=o.STREAM_STALL=o.CHILD_STOP_TIMEOUT=0.15; "
|
||||
"o.main(['--frame','fake-frame','--control-stdin'])")
|
||||
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', '')}
|
||||
process = subprocess.Popen([sys.executable, '-c', wrapper], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
|
||||
try:
|
||||
# Keep stdin OPEN: real Electron does this until the helper exits.
|
||||
self.assertEqual(process.wait(timeout=4), 1)
|
||||
error = process.stderr.read().decode(errors='replace')
|
||||
self.assertIn('video stopped', error)
|
||||
self.assertNotIn('Fatal Python error', error)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill(); process.wait(timeout=5)
|
||||
process.stdin.close(); process.stderr.close()
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32', 'the fake SSH executable uses a POSIX shebang')
|
||||
@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):
|
||||
@@ -162,7 +70,7 @@ class ObsSourceTests(unittest.TestCase):
|
||||
"import os, signal, sys, time\n"
|
||||
"def stop(*_):\n"
|
||||
" open(os.environ['FAKE_SSH_STOPPED'], 'w').close()\n"
|
||||
" sys.exit(255)\n"
|
||||
" sys.exit(0)\n"
|
||||
"signal.signal(signal.SIGTERM, stop)\n"
|
||||
"sys.stdout.buffer.write(b'G' * 1316)\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
@@ -204,33 +112,6 @@ class ObsSourceTests(unittest.TestCase):
|
||||
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()
|
||||
@@ -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'''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -94,64 +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_incompatible_extension_is_reported_and_assets_are_unavailable(self):
|
||||
folder = Path(os.environ['FRAME_CONTROL_DATA_DIR']) / 'extensions' / 'future-panel'
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / 'manifest.json').write_text(json.dumps({'id': 'future-panel', 'name': 'Future',
|
||||
'version': '1.0', 'description': 'Requires a newer API', 'apiVersion': 2}))
|
||||
(folder / 'index.html').write_text('<p>Incompatible panel</p>')
|
||||
try:
|
||||
self.assertEqual(self.request('GET', '/api/extensions')[0], 403)
|
||||
code, _, body = self.request('GET', '/api/extensions', headers={'X-Frame-UI': '1'})
|
||||
self.assertEqual(code, 200)
|
||||
data = json.loads(body)
|
||||
self.assertNotIn('future-panel', [x['id'] for x in data['extensions']])
|
||||
self.assertTrue(any(x['folder'] == 'future-panel' and 'apiVersion' in x['error']
|
||||
for x in data['errors']))
|
||||
self.assertEqual(self.request('GET', '/extension/future-panel/index.html')[0], 404)
|
||||
finally:
|
||||
shutil.rmtree(folder)
|
||||
|
||||
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"):
|
||||
|
||||
@@ -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()
|
||||
+9
-161
@@ -119,6 +119,15 @@ class Safety(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, 'survived cleanup'):
|
||||
stop_processes.stop(['fc_smoke_exe'], grace=0)
|
||||
|
||||
def test_cleanup_scans_even_when_title_already_removed(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
mock.patch.object(frame_smoke, 'stop_titles', return_value={'stopped_pids': [42]}) as stop:
|
||||
result = frame_smoke.cleanup(frame_smoke.Smoke())
|
||||
self.assertEqual(stop.call_args_list, [mock.call(stop_processes.IDS)] * 2)
|
||||
self.assertEqual(result['process_cleanup'][0]['stopped_pids'], [42])
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_stop_failure_prevents_file_removal(self):
|
||||
with mock.patch.object(frame_smoke.frame_titles, 'list_titles', return_value=[{'id': 'fc_smoke_exe'}]), \
|
||||
mock.patch.object(frame_smoke, 'ssh') as ssh, \
|
||||
@@ -142,164 +151,3 @@ class Safety(unittest.TestCase):
|
||||
self.assertEqual(frame_smoke.main(), 1)
|
||||
cycle.assert_not_called()
|
||||
self.assertEqual(len(list(Path(folder).glob('*-fake.json'))), 1)
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'local POSIX shell required')
|
||||
class OrphanCleanup(unittest.TestCase):
|
||||
"""Run the real cleanup shell commands in a throwaway home; mock Steam/SSH."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.home = Path(self.tmp.name)
|
||||
self.games = self.home / 'devkit-game'
|
||||
self.games.mkdir()
|
||||
self.shortcuts = set()
|
||||
self.sync_outputs = None
|
||||
self.events = []
|
||||
self.smoke = frame_smoke.Smoke()
|
||||
for target, name, kwargs in (
|
||||
(frame_smoke, 'ssh', {'side_effect': self.local_ssh}),
|
||||
(frame_smoke, 'stop_titles', {'side_effect': self.stop}),
|
||||
(frame_smoke.frame_titles, 'list_titles', {'return_value': []}),
|
||||
(frame_smoke.frame_titles, 'remove', {'side_effect': self.remove}),
|
||||
(frame_smoke.frame_titles, 'ensure_utils', {}),
|
||||
(frame_smoke.frame_titles, 'devkit_script', {'return_value': 'smoke-sync'})):
|
||||
patcher = mock.patch.object(target, name, **kwargs)
|
||||
setattr(self, name + '_mock', patcher.start())
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def stop(self, ids):
|
||||
self.assertEqual(ids, stop_processes.IDS)
|
||||
self.events.append('stop')
|
||||
return {'stopped_pids': [42] if len(self.events) == 1 else []}
|
||||
|
||||
def remove(self, gid):
|
||||
self.assertIn(gid, stop_processes.IDS)
|
||||
self.events.append('remove')
|
||||
shutil.rmtree(self.games / gid)
|
||||
|
||||
def local_ssh(self, command, timeout=60):
|
||||
self.events.append(command)
|
||||
self.assertEqual(self.events[0], 'stop', 'processes must stop before file cleanup')
|
||||
if command == 'smoke-sync 2>&1':
|
||||
if self.sync_outputs is not None:
|
||||
return self.sync_outputs.pop(0)
|
||||
# Valve logs the registered IDs before reconciling with disk.
|
||||
out = '\n'.join(f'Found Devkit Game registered with Steam Client: {gid!r}'
|
||||
for gid in sorted(self.shortcuts))
|
||||
self.shortcuts = {gid for gid in self.shortcuts if (self.games / gid).is_dir()}
|
||||
return out
|
||||
return subprocess.run(command, shell=True, check=True, capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
env={**os.environ, 'HOME': str(self.home)}).stdout
|
||||
|
||||
def metadata(self, gid):
|
||||
paths = [self.games / f'{gid}-{kind}.json'
|
||||
for kind in ('argv', 'env', 'settings', 'framecontrol')]
|
||||
for path in paths:
|
||||
path.write_text('{}')
|
||||
return paths
|
||||
|
||||
def test_orphan_metadata_for_every_reserved_id_is_removed_and_shortcuts_rechecked(self):
|
||||
paths = [path for gid in stop_processes.IDS for path in self.metadata(gid)]
|
||||
self.shortcuts = set(stop_processes.IDS)
|
||||
result = frame_smoke.cleanup(self.smoke)
|
||||
self.assertTrue(all(not path.exists() for path in paths))
|
||||
self.assertFalse(self.shortcuts)
|
||||
self.assertEqual(result['removed'], sorted(stop_processes.IDS))
|
||||
self.assertEqual(result['process_cleanup'], [{'stopped_pids': [42]}, {'stopped_pids': []}])
|
||||
self.assertEqual(self.events[-1], 'stop')
|
||||
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
|
||||
self.ensure_utils_mock.assert_called_once_with()
|
||||
self.remove_mock.assert_not_called()
|
||||
|
||||
def test_shortcut_only_leftover_is_synced_without_title_or_install_record(self):
|
||||
self.shortcuts = {'fc_smoke_exe'}
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertFalse(self.shortcuts)
|
||||
self.assertEqual(self.events.count('smoke-sync 2>&1'), 2)
|
||||
|
||||
def test_unrelated_titles_and_metadata_survive_even_if_in_install_tracker(self):
|
||||
others = ('portal2', 'fc_smoke_custom', 'fc_smoke_exe-other', 'fc_smoke_exe_other')
|
||||
paths = []
|
||||
for gid in ('fc_smoke_exe', *others):
|
||||
(self.games / gid).mkdir()
|
||||
paths.extend(self.metadata(gid) if gid in others else [])
|
||||
self.shortcuts = {'fc_smoke_exe', *others}
|
||||
self.smoke.installed = {'fc_smoke_exe', *others, 'fc_smoke_exe/../portal2'}
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertFalse((self.games / 'fc_smoke_exe').exists())
|
||||
self.assertTrue(all((self.games / gid).is_dir() for gid in others))
|
||||
self.assertTrue(all(path.exists() for path in paths))
|
||||
self.assertEqual(self.shortcuts, set(others))
|
||||
self.assertNotIn('fc_smoke_exe', self.smoke.installed)
|
||||
self.remove_mock.assert_called_once_with('fc_smoke_exe')
|
||||
|
||||
def test_still_registered_current_or_legacy_shortcut_fails_cleanup(self):
|
||||
for gid in sorted(stop_processes.IDS):
|
||||
with self.subTest(gid=gid):
|
||||
self.sync_outputs = ['', f'Found Devkit Game registered with Steam Client: {gid!r}']
|
||||
self.smoke.installed = {gid}
|
||||
with self.assertRaisesRegex(AssertionError, f'{gid} is still registered with Steam'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertIn(gid, self.smoke.installed)
|
||||
|
||||
def test_sync_failure_in_either_pass_fails_cleanup(self):
|
||||
for failed_pass in (0, 1):
|
||||
with self.subTest(failed_pass=failed_pass):
|
||||
self.sync_outputs = ['', '']
|
||||
self.sync_outputs[failed_pass] = 'Steam client sync of devkit games failed: offline'
|
||||
self.smoke.installed = {'fc_smoke_exe'}
|
||||
with self.assertRaisesRegex(AssertionError, 'sync of devkit games failed'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertIn('fc_smoke_exe', self.smoke.installed)
|
||||
|
||||
def test_metadata_remaining_after_delete_fails_cleanup(self):
|
||||
self.metadata('fc_smoke_exe')
|
||||
real_ssh = self.local_ssh
|
||||
with mock.patch.object(frame_smoke, 'ssh', side_effect=lambda command, **kwargs:
|
||||
'' if command.startswith('rm -f ') else real_ssh(command, **kwargs)):
|
||||
with self.assertRaisesRegex(AssertionError, 'fc_smoke_exe is still on the Frame'):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
|
||||
def test_missing_utils_cannot_report_success_without_shortcut_verification(self):
|
||||
self.ensure_utils_mock.side_effect = frame_smoke.frame_android.FrameError('copy failed')
|
||||
with self.assertRaisesRegex(AssertionError, "syncing Steam's shortcuts: copy failed"):
|
||||
frame_smoke.cleanup(self.smoke)
|
||||
self.assertEqual(self.events[-1], 'stop')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'posix', 'POSIX wrapper')
|
||||
class PortableWrapper(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory(prefix='smoke wrapper ')
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
# macOS aliases /var to /private/var; relative commands need the physical cwd.
|
||||
self.folder = Path(self.tmp.name).resolve()
|
||||
self.bin = self.folder / 'bin'
|
||||
self.bin.mkdir()
|
||||
# No zsh or SSH on PATH: --help must exit before any headset access.
|
||||
(self.bin / 'dirname').symlink_to(shutil.which('dirname'))
|
||||
self.env = {**os.environ, 'PATH': str(self.bin), 'CDPATH': str(self.folder)}
|
||||
self.wrapper = SMOKE.parent.parent / 'scripts' / 'frame-smoke.sh'
|
||||
|
||||
def test_help_runs_directly_and_with_posix_sh_from_another_cwd_without_zsh(self):
|
||||
(self.bin / 'python3').symlink_to(sys.executable)
|
||||
for launcher in ([], ['/bin/sh']):
|
||||
with self.subTest(launcher=launcher):
|
||||
result = subprocess.run([*launcher, str(self.wrapper), '--help'], cwd=self.folder,
|
||||
env=self.env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn('--pair', result.stdout)
|
||||
self.assertIn('Headset smoke test', result.stdout)
|
||||
|
||||
def test_relative_invocation_preserves_arguments_and_python_exit_status(self):
|
||||
python = self.bin / 'python3'
|
||||
python.write_text('#!/bin/sh\nprintf "<%s>\\n" "$@"\nexit 7\n')
|
||||
python.chmod(0o755)
|
||||
result = subprocess.run([os.path.relpath(self.wrapper, self.folder), '--pair', 'two words', ''],
|
||||
cwd=self.folder, env=self.env, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 7, result.stderr)
|
||||
self.assertEqual(result.stdout.splitlines(),
|
||||
[f'<{SMOKE / "frame_smoke.py"}>', '<--pair>', '<two words>', '<>'])
|
||||
+14
-58
@@ -131,30 +131,10 @@ def unpack_bundle(path, root):
|
||||
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
|
||||
paths = [str(local) for local, _, _, _ in entries]
|
||||
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
|
||||
raise android.FrameError('Android bundles must contain a base APK and optional supported splits or OBB files.')
|
||||
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
|
||||
return paths
|
||||
|
||||
|
||||
def _apk_identity(path):
|
||||
"""Use the manifest, not the filename, to identify a base or split APK."""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
elements = frame_apk.manifest_elements(
|
||||
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
|
||||
attrs = next((attrs for tag, attrs in elements if tag == 'manifest'), {})
|
||||
package = (attrs.get('package') or (None, None, None))[2]
|
||||
split = (attrs.get('split') or (None, None, None))[2]
|
||||
code = (attrs.get('versionCode') or (None, None, None))[1]
|
||||
major = (attrs.get('versionCodeMajor') or (None, 0, None))[1]
|
||||
if not package or not android.PKG_RE.fullmatch(package):
|
||||
raise android.FrameError(f'{path.name}: no valid package in APK manifest')
|
||||
return package, split, (major, code) if code is not None else None
|
||||
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, UnicodeError,
|
||||
ValueError, RuntimeError, EOFError, zlib.error, struct.error,
|
||||
IndexError) as error:
|
||||
raise android.FrameError(f'Could not read APK manifest in {path.name}: {error}') from error
|
||||
|
||||
|
||||
def _check_standalone(path):
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
@@ -188,41 +168,19 @@ def inspect(paths, discover=False):
|
||||
if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0:
|
||||
raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.')
|
||||
apks = [p for p in files if p.suffix.lower() == '.apk']
|
||||
apk, splits = None, []
|
||||
if apks:
|
||||
identities = [(p, *_apk_identity(p)) for p in apks]
|
||||
bases = [p for p, _, split, _ in identities if not split]
|
||||
if len(bases) != 1:
|
||||
raise android.FrameError('Choose one game at a time: exactly one base APK is required.')
|
||||
apk = bases[0]
|
||||
package, version = next((pkg, code) for p, pkg, _, code in identities if p == apk)
|
||||
split_names = set()
|
||||
for path, pkg, split, code in identities:
|
||||
if path == apk:
|
||||
continue
|
||||
if pkg != package or split in split_names:
|
||||
raise android.FrameError('Split APKs must have the same package and distinct split names.')
|
||||
if version is None or code is None or code != version:
|
||||
raise android.FrameError('Split APK version cannot be verified against the base APK.')
|
||||
split_names.add(split)
|
||||
splits.append(path)
|
||||
if len(apks) > 1:
|
||||
raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.')
|
||||
apk = apks[0] if apks else None
|
||||
if apk:
|
||||
if not splits:
|
||||
_check_standalone(apk)
|
||||
_check_standalone(apk)
|
||||
info = android.apk_info(str(apk))
|
||||
if not splits:
|
||||
try:
|
||||
android.check_installable(info)
|
||||
except android.FrameError as error:
|
||||
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
|
||||
'blocker': str(error)}
|
||||
raise
|
||||
if splits:
|
||||
added = android.split_entries(str(apk), [str(p) for p in splits])
|
||||
if any(name.startswith('lib/') and not name.startswith('lib/arm64-v8a/') for name in added):
|
||||
raise android.FrameError('This split set includes native libraries for another device. '
|
||||
'Choose only the arm64-v8a split for the Frame.')
|
||||
checked = quest.inspect(info['package'], str(apk), [str(p) for p in splits])
|
||||
try:
|
||||
android.check_installable(info)
|
||||
except android.FrameError as error:
|
||||
error.apk = {'package': info['package'], 'version_code': info.get('version_code'),
|
||||
'blocker': str(error)}
|
||||
raise
|
||||
checked = quest.inspect(info['package'], str(apk))
|
||||
pkg = checked['package']
|
||||
if discover and len(files) == 1:
|
||||
files.extend(p for p in sorted(apk.parent.iterdir())
|
||||
@@ -255,14 +213,13 @@ def inspect(paths, discover=False):
|
||||
if not path.is_file() or not path.stat().st_size or path.name in names:
|
||||
raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.')
|
||||
names.add(path.name)
|
||||
return {'apk': apk, 'splits': splits, 'obb': obb, 'checked': checked,
|
||||
return {'apk': apk, 'obb': obb, 'checked': checked,
|
||||
'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}}
|
||||
|
||||
|
||||
def public(plan):
|
||||
checked = plan['checked']
|
||||
return {**checked, 'apk': plan['apk'].name if plan['apk'] else None,
|
||||
'splits': [p.name for p in plan['splits']],
|
||||
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
|
||||
'size': sum(size for size, _ in plan['signatures'].values())}
|
||||
|
||||
@@ -286,8 +243,7 @@ def install(plan, display='auto', progress=None):
|
||||
quest._check_downgrade(android.read_meta(pkg), checked, False)
|
||||
report('Installing the game app', 15)
|
||||
meta = android.install(str(plan['apk']), source=plan['apk'].name,
|
||||
flatscreen=None if display == 'auto' else display == 'flat',
|
||||
splits=[str(p) for p in plan['splits']])
|
||||
flatscreen=None if display == 'auto' else display == 'flat')
|
||||
else:
|
||||
meta = android._meta_or_fail(pkg)
|
||||
if not plan['obb']:
|
||||
|
||||
+17
-60
@@ -19,7 +19,6 @@ import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
@@ -89,57 +88,28 @@ def _under(rel, root):
|
||||
return rel == root or rel.startswith(root + '/')
|
||||
|
||||
|
||||
def _backup_stat(rel):
|
||||
"""lstat a backup path only if every component below trusted HOME is link-free.
|
||||
|
||||
Rechecked at discovery and before reads; not atomic against concurrent renames.
|
||||
HOME itself may use the host's normal home/temp-directory aliases.
|
||||
"""
|
||||
if not safe_rel(rel):
|
||||
return None
|
||||
path = HOME
|
||||
try:
|
||||
for component in rel.split('/'):
|
||||
path = path / component
|
||||
st = path.lstat()
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
return None
|
||||
return st
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _backup_dir(rel):
|
||||
st = _backup_stat(rel)
|
||||
return st is not None and stat.S_ISDIR(st.st_mode)
|
||||
|
||||
|
||||
def _walk(rel_root, skip=()):
|
||||
"""Regular files under HOME/rel_root, never following links: [(rel, size)], [skipped]."""
|
||||
files, skipped = [], []
|
||||
base = HOME / rel_root
|
||||
st = _backup_stat(rel_root)
|
||||
if st is None or is_secret(rel_root):
|
||||
return files, skipped
|
||||
if stat.S_ISREG(st.st_mode):
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel_root, 'reason': 'over 2 GiB'})
|
||||
return files, skipped
|
||||
return [(rel_root, st.st_size)], skipped
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
if base.is_symlink() or not base.is_dir():
|
||||
if base.is_file() and not base.is_symlink():
|
||||
return [(rel_root, base.stat().st_size)], skipped
|
||||
return files, skipped
|
||||
for top, dirs, names in os.walk(str(base)):
|
||||
rel_top = PurePosixPath(rel_root, *Path(top).relative_to(base).parts).as_posix()
|
||||
if not _backup_dir(rel_top):
|
||||
dirs[:] = []
|
||||
continue
|
||||
dirs[:] = sorted(d for d in dirs if _backup_dir(rel_top + '/' + d) and
|
||||
dirs[:] = sorted(d for d in dirs if not os.path.islink(os.path.join(top, d)) and
|
||||
not any(_under(rel_top + '/' + d, s) for s in skip) and
|
||||
not is_secret(rel_top + '/' + d + '/x'))
|
||||
for name in sorted(names):
|
||||
rel = rel_top + '/' + name
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
|
||||
path, rel = os.path.join(top, name), rel_top + '/' + name
|
||||
if os.path.islink(path) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except OSError:
|
||||
continue
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if st.st_size > MAX_FILE:
|
||||
skipped.append({'path': rel, 'reason': 'over 2 GiB'})
|
||||
@@ -154,34 +124,26 @@ def _manifest_field(text, key):
|
||||
|
||||
|
||||
def game_name(appid):
|
||||
rel = '%s/steamapps/appmanifest_%s.acf' % (STEAM, appid)
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
try:
|
||||
text = (HOME / rel).read_text(errors='replace')
|
||||
text = (HOME / STEAM / 'steamapps' / ('appmanifest_%s.acf' % appid)).read_text(errors='replace')
|
||||
except OSError:
|
||||
return None
|
||||
return _manifest_field(text, 'name')
|
||||
|
||||
|
||||
def accounts():
|
||||
rel = STEAM + '/userdata'
|
||||
if not _backup_dir(rel):
|
||||
return []
|
||||
root = HOME / STEAM / 'userdata'
|
||||
try:
|
||||
return sorted(p.name for p in (HOME / rel).iterdir() if p.name.isdigit() and
|
||||
p.name != '0' and _backup_dir(rel + '/' + p.name))
|
||||
return sorted(p.name for p in root.iterdir() if p.name.isdigit() and p.is_dir()
|
||||
and not p.is_symlink() and p.name != '0')
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _prefixes():
|
||||
if not _backup_dir(COMPAT):
|
||||
return []
|
||||
try:
|
||||
return sorted((p.name for p in (HOME / COMPAT).iterdir()
|
||||
if p.name.isdigit() and _backup_dir(COMPAT + '/' + p.name + '/' + PREFIX_USER)), key=int)
|
||||
if p.name.isdigit() and (p / PREFIX_USER).is_dir()), key=int)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
@@ -282,14 +244,9 @@ def pack(part, output):
|
||||
with tarfile.open(fileobj=output, mode='w|gz') as archive:
|
||||
for rel, _ in files:
|
||||
path = HOME / rel
|
||||
st = _backup_stat(rel)
|
||||
if st is None or not stat.S_ISREG(st.st_mode) or is_secret(rel):
|
||||
continue
|
||||
try:
|
||||
with open(str(path), 'rb') as src:
|
||||
st = os.fstat(src.fileno())
|
||||
if not stat.S_ISREG(st.st_mode) or st.st_size > MAX_FILE:
|
||||
continue
|
||||
member = tarfile.TarInfo(rel)
|
||||
member.size, member.mtime, member.mode = st.st_size, int(st.st_mtime), st.st_mode & 0o755
|
||||
archive.addfile(member, src)
|
||||
|
||||
+15
-30
@@ -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)
|
||||
|
||||
@@ -1,199 +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())
|
||||
marker = child / '.frame-control-store.json'
|
||||
try:
|
||||
owned = json.loads(marker.read_text()) if marker.is_file() and not marker.is_symlink() else {}
|
||||
except (OSError, ValueError): owned = {}
|
||||
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
|
||||
'author': str(data.get('author', ''))[:80],
|
||||
'storeId': owned.get('storeId') if isinstance(owned.get('storeId'), int) else None,
|
||||
'version': str(owned.get('version', ''))[:40]})
|
||||
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
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Verified official-store archives for the opt-in Frame Decky user service.
|
||||
|
||||
Only Frame Control owned plugin folders can be updated or removed. The store
|
||||
digest validates transport, not plugin trust; store plugins run as the user.
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
import frame_decky
|
||||
|
||||
|
||||
STORE_URL = 'https://plugins.deckbrew.xyz/plugins'
|
||||
CDN = 'https://cdn.tzatzikiweeb.moe/file/steam-deck-homebrew/versions/'
|
||||
MAX_STORE = 4 * 1024 * 1024
|
||||
MAX_ZIP = 50 * 1024 * 1024
|
||||
MAX_UNPACKED = 150 * 1024 * 1024
|
||||
SAFE_FOLDER = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\Z')
|
||||
SHA = re.compile(r'[0-9a-f]{64}\Z')
|
||||
VERSION = re.compile(r'[A-Za-z0-9][A-Za-z0-9._+\-]{0,39}\Z')
|
||||
|
||||
|
||||
def catalog():
|
||||
request = urllib.request.Request(STORE_URL, headers={'User-Agent': 'Frame-Control'})
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
raw = response.read(MAX_STORE + 1)
|
||||
if len(raw) > MAX_STORE:
|
||||
raise ValueError('Decky store response is too large')
|
||||
records = json.loads(raw)
|
||||
if not isinstance(records, list):
|
||||
raise ValueError('Invalid Decky store response')
|
||||
result = []
|
||||
for item in records:
|
||||
if not isinstance(item, dict) or item.get('visible') is False:
|
||||
continue
|
||||
versions = item.get('versions')
|
||||
if type(item.get('id')) is not int or item['id'] <= 0 or not isinstance(item.get('name'), str) or not isinstance(versions, list):
|
||||
continue
|
||||
latest = next((v for v in versions if isinstance(v, dict) and isinstance(v.get('name'), str)
|
||||
and VERSION.fullmatch(v['name']) and isinstance(v.get('hash'), str) and SHA.fullmatch(v['hash'])), None)
|
||||
if not latest:
|
||||
continue
|
||||
tags = item.get('tags') if isinstance(item.get('tags'), list) else []
|
||||
result.append({'id': item['id'], 'name': item['name'][:100],
|
||||
'description': str(item.get('description') or '')[:300],
|
||||
'version': latest['name'][:40], 'sha256': latest['hash'],
|
||||
'root': 'root' in tags})
|
||||
return result
|
||||
|
||||
|
||||
def archive_info(data, expected_hash, expected_name):
|
||||
if not 1024 < len(data) <= MAX_ZIP or hashlib.sha256(data).hexdigest() != expected_hash:
|
||||
raise ValueError('Plugin archive size or SHA-256 did not match the store')
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
files = archive.infolist()
|
||||
if not files or len(files) > 3000 or sum(f.file_size for f in files) > MAX_UNPACKED:
|
||||
raise ValueError('Plugin archive exceeds the file or size limit')
|
||||
names = [f.filename for f in files]
|
||||
manifests = [n for n in names if n.count('/') == 1 and n.endswith('/plugin.json')]
|
||||
if len(manifests) != 1:
|
||||
raise ValueError('Plugin archive needs one top-level manifest')
|
||||
folder = manifests[0].split('/')[0]
|
||||
if not SAFE_FOLDER.fullmatch(folder):
|
||||
raise ValueError('Invalid plugin folder')
|
||||
for entry in files:
|
||||
name = entry.filename
|
||||
mode = entry.external_attr >> 16
|
||||
if ('\\' in name or name.startswith('/') or '..' in name.split('/') or
|
||||
name.split('/')[0] != folder or entry.file_size > 30 * 1024 * 1024 or
|
||||
mode & 0o170000 == 0o120000):
|
||||
raise ValueError('Unsafe plugin archive member')
|
||||
if entry.is_dir():
|
||||
continue
|
||||
with archive.open(entry) as source:
|
||||
header = source.read(20)
|
||||
if header[:4] == b'\x7fELF' and int.from_bytes(header[18:20], 'little') != 183:
|
||||
raise ValueError('Plugin contains a non-ARM64 Linux binary: ' + name)
|
||||
if header[:2] == b'MZ':
|
||||
raise ValueError('Plugin contains a Windows binary: ' + name)
|
||||
manifest = json.loads(archive.read(manifests[0]))
|
||||
if manifest.get('name') != expected_name:
|
||||
raise ValueError('Plugin name differs from the official store')
|
||||
flags = manifest.get('flags', [])
|
||||
if not isinstance(flags, list) or not all(isinstance(flag, str) for flag in flags):
|
||||
raise ValueError('Invalid plugin flags')
|
||||
if 'root' in flags or '_root' in flags:
|
||||
raise ValueError('This plugin needs root and is not supported by the Frame user service')
|
||||
return folder
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib,json,pathlib,shutil,subprocess,sys,zipfile
|
||||
action,token,plugin_id,digest,folder,version = sys.argv[1:]
|
||||
base=pathlib.Path.home()/'.local/share/frame-control/decky/plugins'
|
||||
unit=pathlib.Path.home()/'.config/systemd/user/frame-control-decky.service'
|
||||
target=base/folder
|
||||
archive=pathlib.Path('/tmp/frame-control-decky-plugin-'+token)
|
||||
stage=base/('.frame-control-stage-'+token)
|
||||
backup=base/('.frame-control-old-'+token)
|
||||
marker='.frame-control-store.json'
|
||||
def owned():
|
||||
if target.is_symlink() or not target.is_dir(): return False
|
||||
try: return json.loads((target/marker).read_text()).get('storeId') == int(plugin_id)
|
||||
except (OSError,ValueError): return False
|
||||
def service(*args):
|
||||
p=subprocess.run(['systemctl','--user',*args],capture_output=True,text=True,timeout=25)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip()[-400:] or 'Decky service failed')
|
||||
if not unit.is_file(): raise RuntimeError('Install Frame Control managed Decky first')
|
||||
if action not in ('install','update','remove'): raise RuntimeError('Invalid plugin action')
|
||||
if action=='install' and (target.exists() or target.is_symlink()): raise RuntimeError('Plugin folder already exists')
|
||||
if action in ('update','remove') and not owned(): raise RuntimeError('Only a Frame Control store plugin can be changed')
|
||||
running=subprocess.run(['systemctl','--user','is-active','--quiet','frame-control-decky.service']).returncode==0
|
||||
try:
|
||||
if action!='remove':
|
||||
data=archive.read_bytes()
|
||||
if not 1024<len(data)<=50*1024*1024 or hashlib.sha256(data).hexdigest()!=digest:
|
||||
raise RuntimeError('Plugin archive digest mismatch on Frame')
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
files=z.infolist()
|
||||
if not files or len(files)>3000 or sum(f.file_size for f in files)>150*1024*1024:
|
||||
raise RuntimeError('Plugin archive too large')
|
||||
if any(f.filename.split('/')[0]!=folder or f.filename.startswith('/') or
|
||||
'..' in f.filename.split('/') or '\\' in f.filename or
|
||||
f.file_size>30*1024*1024 or f.external_attr>>16&0o170000==0o120000 for f in files):
|
||||
raise RuntimeError('Unsafe plugin archive')
|
||||
stage.mkdir(mode=0o700)
|
||||
z.extractall(stage)
|
||||
unpacked=stage/folder
|
||||
if not unpacked.is_dir(): raise RuntimeError('Missing plugin folder')
|
||||
(unpacked/marker).write_text(json.dumps({'storeId':int(plugin_id),'sha256':digest,'version':version})+'\n')
|
||||
if running: service('stop','frame-control-decky.service')
|
||||
try:
|
||||
if action!='install': target.rename(backup)
|
||||
if action!='remove': (stage/folder).rename(target)
|
||||
if running: service('start','frame-control-decky.service')
|
||||
except Exception:
|
||||
if target.exists(): shutil.rmtree(target)
|
||||
if backup.exists(): backup.rename(target)
|
||||
if running: subprocess.run(['systemctl','--user','start','frame-control-decky.service'],timeout=25)
|
||||
raise
|
||||
if backup.exists(): shutil.rmtree(backup)
|
||||
past={'install':'Installed','update':'Updated','remove':'Removed'}[action]
|
||||
print(json.dumps({'message':f'{past} {folder}. Plugin UI in VR is not yet verified.'}))
|
||||
finally:
|
||||
archive.unlink(missing_ok=True)
|
||||
if stage.exists(): shutil.rmtree(stage)
|
||||
'''
|
||||
|
||||
|
||||
def change(ssh, action, plugin_id):
|
||||
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int or plugin_id <= 0:
|
||||
raise ValueError('Choose a valid store plugin action')
|
||||
state = frame_decky.status(ssh)
|
||||
if not state['managed'] or not state['running']:
|
||||
raise ValueError('Start Frame Control managed Decky before changing plugins')
|
||||
installed = [p for p in state['plugins'] if p.get('storeId') == plugin_id]
|
||||
if action == 'remove':
|
||||
if len(installed) != 1:
|
||||
raise ValueError('Only an installed Frame Control store plugin can be removed')
|
||||
folder = installed[0]['folder']
|
||||
plugin = {'sha256': '0' * 64, 'version': 'removed'}
|
||||
else:
|
||||
plugin = next((item for item in catalog() if item['id'] == plugin_id), None)
|
||||
if not plugin:
|
||||
raise ValueError('Plugin is not in the official Decky store')
|
||||
if plugin['root']:
|
||||
raise ValueError('Root plugins are not supported by the Frame user service')
|
||||
existing_name = [p for p in state['plugins'] if p.get('name') == plugin['name']]
|
||||
if action == 'install' and existing_name:
|
||||
raise ValueError('A plugin with this name is already installed')
|
||||
if action == 'update' and len(installed) != 1:
|
||||
raise ValueError('Only an installed Frame Control store plugin can be updated')
|
||||
data = None
|
||||
if action != 'remove':
|
||||
url = CDN + plugin['sha256'] + '.zip'
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers={'User-Agent': 'Frame-Control'}), timeout=35) as response:
|
||||
data = response.read(MAX_ZIP + 1)
|
||||
folder = archive_info(data, plugin['sha256'], plugin['name'])
|
||||
if action == 'update' and installed[0]['folder'] != folder:
|
||||
raise ValueError('The store changed its plugin folder; remove the old version before installing')
|
||||
if not SAFE_FOLDER.fullmatch(folder):
|
||||
raise ValueError('Unsafe installed plugin folder')
|
||||
token = secrets.token_hex(12)
|
||||
remote = '/tmp/frame-control-decky-plugin-' + 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} {plugin_id} {plugin["sha256"]} {folder} {plugin["version"]}',
|
||||
stdin=APPLY, timeout=90))
|
||||
finally:
|
||||
if data is not None:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
except Exception: pass
|
||||
@@ -1,74 +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")
|
||||
API_VERSION = 1
|
||||
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")
|
||||
# Older manifests predate this field and use bridge v1. Check again when
|
||||
# serving assets, so an incompatible panel cannot bypass discovery.
|
||||
api_version = data.get("apiVersion", 1)
|
||||
if type(api_version) is not int or api_version != API_VERSION:
|
||||
raise ValueError(f"unsupported apiVersion; this app supports {API_VERSION}")
|
||||
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")},
|
||||
"apiVersion": api_version}
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
-130
@@ -1,130 +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>Decky plugins</h2><p>Manage official store plugins here. Their backend can run on Frame, but Decky's Steam Deck Quick Access Menu is absent from this VR shell. Plugin pages, settings, routes, and toasts are not yet available in this panel.</p>
|
||||
<div class="row"><button id="deckyRefresh" class="secondary">Refresh plugins</button><button id="deckyStoreLoad" class="secondary">Open official store</button></div>
|
||||
<p id="deckyState" role="status"></p><div id="deckyInstalled" class="stack"></div>
|
||||
<div id="deckyStorePanel" hidden><label>Find a plugin <input id="deckySearch" type="search" placeholder="Search Decky store"></label><div id="deckyStoreList" class="stack"></div></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;}}
|
||||
let deckyState=null, deckyStore=[];
|
||||
async function refreshDecky(){try{deckyState=await api('/api/decky');
|
||||
byId('deckyState').textContent=deckyState.running?'Decky loader is running. Backend loading is not a VR menu check.':'Decky is not running. Install it from Frame Control on your computer.';
|
||||
const list=byId('deckyInstalled');list.replaceChildren();
|
||||
for(const plugin of deckyState.plugins||[]){const row=document.createElement('div');row.className='block';
|
||||
const name=document.createElement('strong');name.textContent=`${plugin.name} ${plugin.version||''}`;row.append(name);
|
||||
if(plugin.storeId){const button=document.createElement('button');button.className='danger';button.textContent='Remove';
|
||||
button.onclick=()=>deckyChange('remove',{id:plugin.storeId,name:plugin.name});row.append(button);}
|
||||
list.append(row);}if(!deckyState.plugins?.length)list.textContent='No managed plugins installed.';
|
||||
showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
async function loadDeckyStore(){try{const data=await api('/api/decky/store');deckyStore=data.plugins;
|
||||
byId('deckyStorePanel').hidden=false;showDeckyStore();}catch(error){byId('deckyState').textContent=error.message;}}
|
||||
function showDeckyStore(){const list=byId('deckyStoreList');list.replaceChildren();
|
||||
const q=byId('deckySearch').value.trim().toLowerCase();
|
||||
for(const plugin of deckyStore.filter(p=>!q||`${p.name} ${p.description}`.toLowerCase().includes(q)).slice(0,30)){
|
||||
const row=document.createElement('div');row.className='block game';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version} · ${plugin.description}`;
|
||||
const button=document.createElement('button');button.textContent='Install';
|
||||
const installed=(deckyState?.plugins||[]).find(p=>p.storeId===plugin.id);
|
||||
if(installed)button.textContent=installed.version===plugin.version?'Reinstall':'Update';
|
||||
button.disabled=!deckyState?.running||plugin.root;
|
||||
if(plugin.root)label.textContent+=' · Needs root; not supported on Frame';
|
||||
button.onclick=()=>deckyChange(installed?'update':'install',plugin);row.append(label,button);list.append(row);}}
|
||||
async function deckyChange(action,plugin){if(!confirm(`${action} ${plugin.name}? Store plugins run code as your Frame user.`))return;
|
||||
byId('deckyState').textContent=`${action} ${plugin.name}…`;
|
||||
try{const job=await api('/api/decky/plugin',{action,id:plugin.id,acknowledged:true});
|
||||
byId('deckyState').textContent=job.message||'Request queued. Refresh after it finishes.';
|
||||
setTimeout(refreshDecky,5000);}catch(error){byId('deckyState').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('deckyRefresh').onclick=refreshDecky;byId('deckyStoreLoad').onclick=loadDeckyStore;byId('deckySearch').oninput=showDeckyStore;
|
||||
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();refreshDecky();
|
||||
setInterval(()=>{if(!document.hidden){refresh();refreshRecord();}},15000);
|
||||
</script></body></html>
|
||||
+11
-213
@@ -1243,15 +1243,6 @@
|
||||
<div id="obsControl" class="hint" hidden>
|
||||
<button class="small" id="obsBtn">Start OBS source</button>
|
||||
<span id="obsStatus" role="status"></span>
|
||||
<details style="margin-top:8px">
|
||||
<summary>OBS setup</summary>
|
||||
<p>Start the source here, then add a Media Source in OBS and clear <strong>Local File</strong>.</p>
|
||||
<label>Local UDP port <input id="obsPort" type="number" min="1024" max="65535" step="1" value="15099" style="width:100px"></label>
|
||||
<p>Input: <input id="obsUrl" readonly value="udp://127.0.0.1:15099" aria-label="OBS input URL" style="width:260px"> <button class="small" id="obsCopyUrl">Copy URL</button></p>
|
||||
<p>Input Format: <code>mpegts</code> <button class="small" id="obsCopyFormat">Copy format</button></p>
|
||||
<p>Use one receiver. If another program uses this port, choose a different port here and in OBS, then restart the source. Sending does not confirm OBS is receiving.</p>
|
||||
<p>If OBS stays blank after Start or a restart, hide and show its Media Source, or reopen its properties and click OK. The stream includes output sound and may show your room through passthrough. If sound arrives before the picture, hide the Media Source, add a positive Sync Offset in OBS Advanced Audio Properties, then show it and check a test recording. OBS Virtual Camera carries video only.</p>
|
||||
</details>
|
||||
</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>
|
||||
@@ -1382,7 +1373,7 @@
|
||||
<div class="store-owned" id="storeOwned">
|
||||
<section class="panel" id="androidBackup">
|
||||
<div class="shelf-head"><h2>Import Android game</h2></div>
|
||||
<p class="sub">Have a backup? Choose the game's base APK, supported native-library or asset splits, and all its .obb files together, or one XAPK/APKS/APKM bundle. Resource, code and feature splits and Android/data restore are not supported here.
|
||||
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
|
||||
Frame Control installs the app and puts its data in the right place.</p>
|
||||
<button class="action small" id="androidBackupChoose">Choose game files…</button>
|
||||
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" multiple hidden>
|
||||
@@ -1445,41 +1436,8 @@
|
||||
|
||||
<div class="page" data-page="tools">
|
||||
<div class="workspace-heading"><div><a href="#tools" id="toolBack" class="workspace-link" hidden>← All tools</a><h1 id="toolTitle">Tools</h1><p id="toolSubtitle">Choose a task for your Frame.</p></div></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><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 & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
<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>
|
||||
<div id="deckyManagedList" class="stack"></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>
|
||||
<h3>Official plugin store</h3>
|
||||
<p class="sub">Plugins run code as your Frame user. Store installs are opt-in and only work with the managed loader. Root plugins and x86-only archives are blocked. A loaded backend does not prove its VR page works.</p>
|
||||
<label>Find a plugin <input id="deckyStoreSearch" type="search" placeholder="Search store"></label>
|
||||
<div class="row"><button class="small" id="deckyStoreRefresh">Load store</button></div>
|
||||
<div id="deckyStoreState" class="hint"></div><div id="deckyStoreList" class="stack"></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 & 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>
|
||||
@@ -1931,7 +1889,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Android backups: drop a base APK with supported splits and OBB files, or one XAPK/APKS/APKM bundle.
|
||||
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
|
||||
Other files go to <code>~/Downloads</code>;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||
|
||||
@@ -2164,153 +2122,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;
|
||||
let deckyCatalog = [];
|
||||
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.';
|
||||
const managedList=$('deckyManagedList');managedList.replaceChildren();
|
||||
for(const plugin of d.plugins||[]){if(!plugin.storeId)continue;
|
||||
const row=document.createElement('div');row.className='hint';
|
||||
const label=document.createElement('span');label.textContent=`${plugin.name} ${plugin.version||''}`;
|
||||
const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove plugin';
|
||||
remove.disabled=!d.running||!$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',{id:plugin.storeId,name:plugin.name});
|
||||
row.append(label,remove);managedList.append(row);}
|
||||
deckyButtons();
|
||||
showDeckyStore();
|
||||
} catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); showDeckyStore(); }
|
||||
}
|
||||
async function loadDeckyStore() {
|
||||
$('deckyStoreState').textContent = 'Loading the official store…';
|
||||
try { deckyCatalog = (await api('/api/decky/store')).plugins;
|
||||
$('deckyStoreState').textContent = `${deckyCatalog.length} plugins in the official store. Select a plugin to install it.`;
|
||||
showDeckyStore();
|
||||
} catch(error) { $('deckyStoreState').textContent = error.message; }
|
||||
}
|
||||
function showDeckyStore() {
|
||||
const list = $('deckyStoreList'); list.replaceChildren();
|
||||
const query = $('deckyStoreSearch').value.trim().toLowerCase();
|
||||
const installed = [...(deckyInfo?.plugins||[]), ...(deckyInfo?.upstreamPlugins||[])];
|
||||
for (const plugin of deckyCatalog.filter(p => !query || `${p.name} ${p.description}`.toLowerCase().includes(query)).slice(0, 40)) {
|
||||
const row = document.createElement('div'); row.className = 'hint';
|
||||
const title = document.createElement('strong'); title.textContent = `${plugin.name} · ${plugin.version}`;
|
||||
const description = document.createElement('span'); description.textContent = ` ${plugin.description}`;
|
||||
const entry = installed.find(p => p.storeId === plugin.id);
|
||||
const unmanaged = installed.some(p => p.name === plugin.name && p.storeId !== plugin.id);
|
||||
const reason = plugin.root ? 'Needs root; not supported by the Frame user service.' : unmanaged ? 'Existing plugin is not managed by Frame Control.' : '';
|
||||
row.append(title, description);
|
||||
if (reason) { const note=document.createElement('small'); note.textContent=` ${reason}`; row.append(note); }
|
||||
else {
|
||||
const button=document.createElement('button'); button.className='small';
|
||||
button.textContent=entry ? (entry.version===plugin.version?'Reinstall':'Update') : 'Install';
|
||||
button.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
|
||||
button.onclick=()=>changeDeckyPlugin(entry?'update':'install',plugin);
|
||||
row.append(button);
|
||||
if(entry){const remove=document.createElement('button');remove.className='small danger';remove.textContent='Remove';
|
||||
remove.disabled=!deckyInfo?.managed || !deckyInfo?.running || !$('deckyAck').checked;
|
||||
remove.onclick=()=>changeDeckyPlugin('remove',plugin);row.append(remove);}
|
||||
}
|
||||
list.append(row);
|
||||
}
|
||||
}
|
||||
async function changeDeckyPlugin(action, plugin) {
|
||||
if (!$('deckyAck').checked || !confirm(`${action} ${plugin.name}? This official store plugin runs code as your Frame user.`)) return;
|
||||
try { await runJob(`${action} ${plugin.name}`, 'decky-plugin', () => api('/api/decky/plugin', {action,id:plugin.id,acknowledged:true}));
|
||||
await loadDecky(); showDeckyStore(); } catch(error) { $('deckyStoreState').textContent=error.message; }
|
||||
}
|
||||
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(); showDeckyStore();
|
||||
for(const button of $('deckyManagedList').querySelectorAll('button'))button.disabled=!$('deckyAck').checked||!deckyInfo?.running; };
|
||||
$('deckyRefresh').onclick = loadDecky;
|
||||
$('deckyStoreRefresh').onclick = loadDeckyStore;
|
||||
$('deckyStoreSearch').oninput = showDeckyStore;
|
||||
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";
|
||||
@@ -4046,8 +3857,7 @@ async function importAndroidFiles(files, dev, probe = false) {
|
||||
if (!dlg.open) return true;
|
||||
const plan = read.plan;
|
||||
$("androidImportName").textContent = `${plan.label}${plan.version ? " · " + plan.version : ""}`;
|
||||
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>` +
|
||||
(plan.splits || []).map(f => `<div>${esc(f)} <span class="sub">split APK</span></div>`).join("")
|
||||
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>`
|
||||
: `<div class="sub">Game app already installed</div>`) +
|
||||
`<div class="sub" style="margin-top:12px">Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}</div>` +
|
||||
plan.obb.map(f => `<div>${esc(f.name)} <span class="sub">${gb(f.size)}</span></div>`).join("");
|
||||
@@ -5700,31 +5510,23 @@ if (window.frameApp?.obs) {
|
||||
const obs = window.frameApp.obs;
|
||||
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
|
||||
let state = { state: "idle" };
|
||||
const port = $("obsPort"), input = $("obsUrl");
|
||||
port.oninput = () => { input.value = `udp://127.0.0.1:${port.value}`; };
|
||||
$("obsCopyUrl").onclick = e => act("Copy OBS URL", () => copyText(input.value), e.currentTarget);
|
||||
$("obsCopyFormat").onclick = e => act("Copy OBS format", () => copyText("mpegts"), e.currentTarget);
|
||||
function showObs(next) {
|
||||
if (!next) return;
|
||||
state = next;
|
||||
wrap.hidden = !next.supported;
|
||||
const active = next.state === "starting" || next.state === "running";
|
||||
button.textContent = active ? "Stop OBS source" : "Start OBS source";
|
||||
button.disabled = next.state === "stopping";
|
||||
port.disabled = active || next.state === "stopping";
|
||||
if (port.disabled && next.port) { port.value = next.port; port.oninput(); }
|
||||
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:${next.port || 15099}`
|
||||
: 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 === "starting" || state.state === "running";
|
||||
if (!stopping && !port.reportValidity()) return;
|
||||
const stopping = state.state === "running";
|
||||
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
|
||||
() => stopping ? obs.stop() : obs.start(Number(port.value)), e.currentTarget);
|
||||
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
|
||||
if (result) showObs(result);
|
||||
};
|
||||
}
|
||||
@@ -6265,7 +6067,6 @@ async function loadSteamPipewire() {
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
$('steamPipewireStatus').textContent = e.message;
|
||||
}
|
||||
|
||||
}
|
||||
async function steamPipewireAction(action) {
|
||||
if (steamPipewireBusy) return;
|
||||
@@ -6289,10 +6090,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.
|
||||
@@ -6399,9 +6200,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();
|
||||
|
||||
+30
-185
@@ -78,10 +78,6 @@ 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_decky_store # noqa: E402
|
||||
import frame_headset # noqa: E402
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -90,7 +86,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"
|
||||
@@ -553,37 +548,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 decky_plugin(body):
|
||||
action = body.get('action')
|
||||
plugin_id = body.get('id')
|
||||
if action not in ('install', 'update', 'remove') or type(plugin_id) is not int:
|
||||
raise Failure('Choose an official store plugin and action', 400)
|
||||
if body.get('acknowledged') is not True:
|
||||
raise Failure('Confirm that store plugins run code as your Frame user', 400)
|
||||
return start_job('Decky plugin ' + action,
|
||||
lambda: frame_decky_store.change(ssh, action, plugin_id))
|
||||
|
||||
|
||||
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
|
||||
@@ -671,7 +635,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'"""
|
||||
@@ -684,19 +647,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
|
||||
@@ -708,7 +660,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)
|
||||
@@ -730,51 +682,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}
|
||||
|
||||
@@ -852,32 +784,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)
|
||||
@@ -885,42 +791,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")
|
||||
|
||||
@@ -2056,7 +1936,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)
|
||||
@@ -3408,10 +3288,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/decky/plugin": decky_plugin,
|
||||
"/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,
|
||||
@@ -3558,7 +3434,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:
|
||||
@@ -3566,12 +3442,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)
|
||||
@@ -3595,9 +3467,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")
|
||||
@@ -3613,27 +3482,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/decky/store":
|
||||
self.send_json({'plugins': frame_decky_store.catalog()})
|
||||
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":
|
||||
@@ -3784,9 +3632,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":
|
||||
@@ -4114,8 +3961,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
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
<section><div class="row"><label for="alias">Your configured headset SSH alias<input id="alias" value="frame" maxlength="64" autocomplete="off"></label>
|
||||
<button id="check">Check cable connection</button></div><p id="message" role="status" aria-live="polite">No checks have run.</p></section>
|
||||
<div id="results" aria-live="polite"></div>
|
||||
<section><h2>SSH works, but the PC stream still fails?</h2>
|
||||
<p>The doctor checks Frame Control's SSH route only. If the SteamVR stream stops on USB, Wi-Fi and the Wireless Adapter, the problem may be on the PC. On a Windows or Linux PC, first save your work and close the VR game. Exit SteamVR from Steam on the PC, then start SteamVR again. Frame Control reconnecting to <code>frame.local</code> will not restart PC SteamVR. This is a manual test, not a fix for the cause of repeated drops. Frame Control does not restart PC SteamVR automatically because it cannot safely know which game and session it would stop.</p></section>
|
||||
<p class="sub">The observed USB address is 10.86.200.233 on Frame OS tested in 2026. A different OS build may use another subnet. An unknown result does not prove the cable is broken. The doctor never changes IP settings, accepts a new host key, or bypasses SSH warnings.</p>
|
||||
</main><script>
|
||||
'use strict';
|
||||
|
||||
Reference in new issue
Block a user