mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 07:01:42 +01:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b8f42bf5f | ||
|
|
509ddbdc95 | ||
|
|
f2d293bd1a | ||
|
|
a5a554d0c2 | ||
|
|
af2dca8365 | ||
|
|
6a8e0093de | ||
|
|
53107fd91b | ||
|
|
548f5d8496 | ||
|
|
a03c94ae12 | ||
|
|
dd3df85fd6 | ||
|
|
e7bfa80e09 | ||
|
|
b26e34ce09 | ||
|
|
ab4b58b1c7 |
No files matched your search
@@ -173,3 +173,24 @@ jobs:
|
||||
app/dist/*.AppImage
|
||||
app/dist/*.deb
|
||||
if-no-files-found: ignore
|
||||
|
||||
checksums:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Attach SHA256SUMS after all installers upload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/$GH_REPO/releases?per_page=100" --jq '.[]' > releases.jsonl
|
||||
python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" < releases.jsonl > SHA256SUMS
|
||||
# Find the same draft using its exact tag, or its version title if a
|
||||
# draft edit made GitHub temporarily show an untagged-* tag name.
|
||||
id=$(python3 scripts/release-checksums.py --tag "$GITHUB_REF_NAME" --id < releases.jsonl)
|
||||
old=$(gh api "repos/$GH_REPO/releases/$id" --jq '.assets[] | select(.name == "SHA256SUMS") | .id')
|
||||
for asset in $old; do gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset" >/dev/null; done
|
||||
gh api -X POST "https://uploads.github.com/repos/$GH_REPO/releases/$id/assets?name=SHA256SUMS" \
|
||||
-H "Content-Type: text/plain" --input SHA256SUMS >/dev/null
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
[Copying guide](docs/recording.md).
|
||||
|
||||
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
|
||||
Highlight exact PnP instances and known state changes after a dropout;
|
||||
incomplete inventories stay unknown. A Forget button clears the comparison.
|
||||
|
||||
@@ -58,6 +58,9 @@ node --test site/test/*.test.mjs # website feedback function
|
||||
|
||||
Say what you tested, and whether you tried it on a real Steam Frame.
|
||||
|
||||
To add a local feature without changing the core app, see the
|
||||
[extension guide](docs/extensions.md) and its working example.
|
||||
|
||||
## Blocking
|
||||
|
||||
If you ignore this document twice, or spam the tracker with agent-generated
|
||||
|
||||
@@ -25,6 +25,8 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -130,6 +132,24 @@ Choose the operating system and processor of the computer you will use.
|
||||
| **Linux** (x64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-x86_64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-amd64.deb) | `ssh` (most desktops have it) |
|
||||
| **Linux** (arm64) | [AppImage](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.AppImage) · [.deb](https://github.com/saphid/frame-control/releases/latest/download/Frame-Control-linux-arm64.deb) | `ssh`, and `adb` for Android apps (`sudo apt install adb`) |
|
||||
|
||||
### Is it safe? Check your download
|
||||
|
||||
Starting with **v0.4.26**, get the installer and [SHA256SUMS](https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS) from the **same release**. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Use the command for your downloaded file, then compare all 64 characters with its line in `SHA256SUMS`:
|
||||
|
||||
```powershell
|
||||
# Windows PowerShell
|
||||
Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256
|
||||
```
|
||||
|
||||
```sh
|
||||
# macOS: shasum -a 256 Frame-Control-mac-arm64.dmg
|
||||
# Linux: sha256sum Frame-Control-linux-x86_64.AppImage
|
||||
```
|
||||
|
||||
The [source](https://github.com/saphid/frame-control) and [release build workflow](.github/workflows/release.yml) are public. The Windows installer is **not code-signed**, so SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.
|
||||
|
||||
日本語: 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
|
||||
@@ -186,8 +206,10 @@ or run it with `--appimage-extract-and-run`.
|
||||
|
||||
## Open the app
|
||||
|
||||
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the unsigned-app warning, use the macOS steps
|
||||
open it from Applications. For the Gatekeeper warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
- **Windows:** run the installer, then open **Frame Control** from Start. For
|
||||
the portable download, extract the entire `.zip` first and run
|
||||
@@ -268,6 +290,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [PC in the headset](docs/pc-in-headset.md) | Experimental, Windows only, off until you turn it on: a whole PC screen as a panel in the Frame. Tested in a Windows VM; individual windows need a WGC-enabled build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [Decky Loader on Frame](docs/decky-frame.md) · [Frame Control extensions](docs/extensions.md) | Experimental Decky support, removal, and the local extension API |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Opt-in OS login item. Linux owns only its marked autostart file; an existing
|
||||
// custom entry is never overwritten or removed.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const MARKER = "# Managed by Frame Control; use Frame > Open at Login to remove.\n";
|
||||
const FILE = "com.saphid.frame-control.desktop";
|
||||
|
||||
function quoteExec(value) {
|
||||
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`").replace(/%/g, "%%")}"`;
|
||||
}
|
||||
|
||||
function createLoginItem({ platform = process.platform, electronApp, env = process.env,
|
||||
executable = process.execPath, filesystem = fs,
|
||||
home = os.homedir() }) {
|
||||
const config = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME)
|
||||
? env.XDG_CONFIG_HOME : path.join(home, ".config");
|
||||
const file = path.join(config, "autostart", FILE);
|
||||
const command = platform === "linux" && env.APPIMAGE && path.isAbsolute(env.APPIMAGE)
|
||||
? env.APPIMAGE : executable;
|
||||
function owned() {
|
||||
try { return filesystem.readFileSync(file, "utf8").startsWith(MARKER); }
|
||||
catch (err) { if (err.code === "ENOENT") return false; throw err; }
|
||||
}
|
||||
function enabled() {
|
||||
if (platform === "linux") return owned();
|
||||
return !!electronApp.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
function set(enabledNow) {
|
||||
if (platform !== "linux") {
|
||||
electronApp.setLoginItemSettings({ openAtLogin: !!enabledNow, openAsHidden: !!enabledNow,
|
||||
args: enabledNow ? ["--hidden"] : [] });
|
||||
if (enabled() !== !!enabledNow) throw new Error("The operating system did not save the login item.");
|
||||
return enabled();
|
||||
}
|
||||
if (enabledNow) {
|
||||
if (filesystem.existsSync(file) && !owned()) throw new Error(`A different autostart entry already exists: ${file}`);
|
||||
filesystem.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = MARKER + "[Desktop Entry]\nType=Application\nName=Frame Control\n" +
|
||||
`Exec=${quoteExec(command)} --hidden\nTerminal=false\nX-GNOME-Autostart-enabled=true\n`;
|
||||
const tmp = file + `.tmp-${process.pid}`;
|
||||
try { filesystem.writeFileSync(tmp, body, { mode: 0o600 }); filesystem.renameSync(tmp, file); }
|
||||
finally { try { filesystem.unlinkSync(tmp); } catch (err) { if (err.code !== "ENOENT") throw err; } }
|
||||
} else if (owned()) {
|
||||
filesystem.unlinkSync(file);
|
||||
}
|
||||
return enabled();
|
||||
}
|
||||
return { enabled, set, file };
|
||||
}
|
||||
|
||||
module.exports = { createLoginItem, quoteExec };
|
||||
+67
-5
@@ -1,7 +1,7 @@
|
||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
||||
// a free loopback port and shows it in a native window. The server does all the
|
||||
// work over the `frame` SSH alias; this file only hosts it.
|
||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, Menu, Notification, Tray, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
|
||||
const { execFile, spawn } = require("child_process");
|
||||
const { promisify } = require("util");
|
||||
const fs = require("fs");
|
||||
@@ -12,6 +12,7 @@ const path = require("path");
|
||||
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
|
||||
const updater = require("./updater");
|
||||
const { preferenceStore } = require("./preferences");
|
||||
const { createLoginItem } = require("./login-item");
|
||||
const { createMetaSignIn } = require("./meta-signin");
|
||||
|
||||
const run = promisify(execFile);
|
||||
@@ -30,10 +31,12 @@ const LOG = path.join(LOG_DIR, "server.log");
|
||||
const BG = "#0d1117";
|
||||
const FRAME = process.env.FRAME_ALIAS || "frame";
|
||||
const preferences = preferenceStore(path.join(app.getPath("userData"), "client-preferences.json"));
|
||||
const loginItem = createLoginItem({ electronApp: app });
|
||||
|
||||
let server = null;
|
||||
let url = null;
|
||||
let win = null;
|
||||
let tray = null;
|
||||
let quitting = false;
|
||||
let python = null;
|
||||
|
||||
@@ -573,7 +576,60 @@ function registerScheme() {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
function showWindow() {
|
||||
if (!win) return createWindow(true);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
function ensureTray() {
|
||||
if (tray) return true;
|
||||
try {
|
||||
const icon = nativeImage.createFromPath(path.join(__dirname, "build", "icon.png"));
|
||||
if (icon.isEmpty()) return false;
|
||||
tray = new Tray(icon);
|
||||
// Some Linux desktops accept the Tray object but expose no status icon.
|
||||
// Keep a visible login window rather than hiding the only way back in.
|
||||
if (process.platform === "linux") {
|
||||
const bounds = tray.getBounds();
|
||||
if (!bounds.width || !bounds.height) { tray.destroy(); tray = null; return false; }
|
||||
}
|
||||
tray.setToolTip("Frame Control");
|
||||
tray.on("click", showWindow);
|
||||
updateTrayMenu();
|
||||
return true;
|
||||
} catch {
|
||||
if (tray) { try { tray.destroy(); } catch {} tray = null; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
tray.setContextMenu(Menu.buildFromTemplate([
|
||||
{ label: "Open Frame Control", click: showWindow },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(), click: toggleLoginItem },
|
||||
{ type: "separator" },
|
||||
{ label: "Quit Frame Control", click: () => app.quit() },
|
||||
]));
|
||||
}
|
||||
|
||||
function toggleLoginItem() {
|
||||
try {
|
||||
const on = loginItem.set(!loginItem.enabled());
|
||||
if (on) ensureTray();
|
||||
else if (tray) { tray.destroy(); tray = null; }
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox("Open at Login", String(err.message || err));
|
||||
buildMenu();
|
||||
updateTrayMenu();
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(showOnReady = !process.argv.includes("--hidden") || !tray) {
|
||||
win = new BrowserWindow({
|
||||
width: 1400, height: 950, minWidth: 760, minHeight: 560,
|
||||
title: "Frame Control", backgroundColor: BG, show: false,
|
||||
@@ -582,7 +638,10 @@ function createWindow() {
|
||||
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
|
||||
preload: path.join(__dirname, "preload.js") },
|
||||
});
|
||||
win.once("ready-to-show", () => win.show());
|
||||
win.once("ready-to-show", () => { if (showOnReady) win.show(); });
|
||||
win.on("close", (event) => {
|
||||
if (!quitting && tray && loginItem.enabled()) { event.preventDefault(); win.hide(); }
|
||||
});
|
||||
if (CHROME_CSS) win.webContents.on("did-finish-load", () => win.webContents.insertCSS(CHROME_CSS));
|
||||
// External links open in the default browser; the app never navigates away.
|
||||
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
||||
@@ -636,6 +695,8 @@ function buildMenu() {
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: "Open at Login", type: "checkbox", checked: loginItem.enabled(),
|
||||
enabled: app.isPackaged, click: toggleLoginItem },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
@@ -684,7 +745,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
app.on("open-url", (e, link) => { e.preventDefault(); openInstallLink(link); });
|
||||
// Windows and Linux start a second instance with the link as an argument.
|
||||
app.on("second-instance", (_e, argv) => {
|
||||
if (win) { if (win.isMinimized()) win.restore(); win.focus(); }
|
||||
if (!argv.includes("--hidden")) showWindow();
|
||||
const link = linkFromArgv(argv);
|
||||
if (link) openInstallLink(link);
|
||||
});
|
||||
@@ -692,11 +753,12 @@ if (!app.requestSingleInstanceLock()) {
|
||||
if (firstLink) openInstallLink(firstLink);
|
||||
app.whenReady().then(() => {
|
||||
registerScheme();
|
||||
if (loginItem.enabled()) ensureTray();
|
||||
buildMenu();
|
||||
createWindow();
|
||||
scheduleUpdateChecks();
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("activate", showWindow);
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
let obsQuitWaiting = false;
|
||||
let obsQuitReady = false;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.27",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+2
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.27",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -40,6 +40,7 @@
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"preferences.js",
|
||||
"login-item.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { createLoginItem } = require("../login-item");
|
||||
|
||||
test("Linux login toggle creates and removes only its own desktop entry", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-login-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root },
|
||||
executable: "/opt/Frame Control/frame-control" });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
const body = fs.readFileSync(login.file, "utf8");
|
||||
assert.match(body, /^# Managed by Frame Control;/);
|
||||
assert.match(body, /Exec="\/opt\/Frame Control\/frame-control" --hidden/);
|
||||
assert.equal(fs.statSync(login.file).mode & 0o777, 0o600);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.equal(fs.existsSync(login.file), false);
|
||||
fs.mkdirSync(path.dirname(login.file), { recursive: true });
|
||||
fs.writeFileSync(login.file, "[Desktop Entry]\nName=My custom launcher\n");
|
||||
assert.throws(() => login.set(true), /different autostart entry/);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /My custom launcher/);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("AppImage autostart launches the AppImage, with safe desktop escaping", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "frame-appimage-test-"));
|
||||
try {
|
||||
const login = createLoginItem({ platform: "linux", env: { XDG_CONFIG_HOME: root,
|
||||
APPIMAGE: '/home/user/Frame Control 100%.AppImage' },
|
||||
executable: "/tmp/.mount_extracted/AppRun" });
|
||||
login.set(true);
|
||||
assert.match(fs.readFileSync(login.file, "utf8"), /Exec="\/home\/user\/Frame Control 100%%\.AppImage" --hidden/);
|
||||
login.set(false);
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const platform of ["win32", "darwin"]) {
|
||||
test(`${platform} login toggle uses the OS API and starts hidden`, () => {
|
||||
let openAtLogin = false;
|
||||
const calls = [];
|
||||
const electronApp = {
|
||||
getLoginItemSettings: () => ({ openAtLogin }),
|
||||
setLoginItemSettings: opts => { calls.push(opts); openAtLogin = opts.openAtLogin; },
|
||||
};
|
||||
const login = createLoginItem({ platform, electronApp });
|
||||
assert.equal(login.enabled(), false);
|
||||
assert.equal(login.set(true), true);
|
||||
assert.deepEqual(calls.at(-1).args, ["--hidden"]);
|
||||
assert.equal(calls.at(-1).openAsHidden, true);
|
||||
assert.equal(login.set(false), false);
|
||||
assert.deepEqual(calls.at(-1).args, []);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.27**, 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) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; no packaged physical-PC extension check recorded | No | Third-party extensions and Windows/macOS extension folders were not tested on physical hosts | 25 Oct, v0.4.27: [PR #194](https://github.com/saphid/frame-control/pull/194), [extension guide](extensions.md) |
|
||||
| Experimental Decky Loader manager | Real Frame OS 0.5.6 ARM64 user-service install, status, update and remove completed | No | A usable Decky menu in the VR shell and individual plugin compatibility remain unverified | 25 Oct, v0.4.27: [PR #195](https://github.com/saphid/frame-control/pull/195), [headset findings](decky-frame.md) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Decky Loader on Steam Frame
|
||||
|
||||
**Status, 2026-10-25:** Decky Loader 3.3.0's ARM64 backend starts on Frame OS
|
||||
0.5.6 and injects into Steam's shared CEF context. Its Decky tab registers in
|
||||
JavaScript. A usable plugin menu in the Frame's VR shell has **not** been
|
||||
verified. Frame Control therefore labels its user-service manager experimental.
|
||||
No individual Decky plugin has been tested on the headset.
|
||||
|
||||
## What was checked on the real headset
|
||||
|
||||
The headset was SteamOS `0.5.6`, `VARIANT_ID=vr`, build `20261022.6153909`,
|
||||
`aarch64` (SteamOS 3.9.x family). SSH reached it through the existing `frame`
|
||||
alias. `/` was mounted read-write as Btrfs; no root filesystem change was
|
||||
needed. `systemctl --user` was running. The Steam client launched with
|
||||
`-cef-enable-debugging`, `-vrgamepadui`, and `-steamdeck`; its web helper served
|
||||
CEF remote debugging on `127.0.0.1:8080`. The usual
|
||||
`.cef-enable-remote-debugging` marker was absent, yet debugging was active.
|
||||
|
||||
CEF listed a `SharedJSContext` target at `steamloopback.host/routes/library/home`
|
||||
and a separate `SteamVR` target for the VR overlay. The Decky 3.3.0 ARM64
|
||||
binary started from `/tmp` as the normal user: port `127.0.0.1:1337` answered
|
||||
HTTP 200, the log reported `Loading Decky frontend!`, and CEF showed
|
||||
`window.deckyHasLoaded === true` and one registered Decky tab in
|
||||
`SharedJSContext`. The `SteamVR` target had neither that global nor a Decky tab;
|
||||
its current body text did not contain Decky. The Steam web helper had
|
||||
`--disable-quick-menu`. This is evidence of backend and shared-context
|
||||
injection, not proof of an accessible Decky plugin menu inside VR. A QAM
|
||||
interaction in the headset and a plugin UI test remain necessary.
|
||||
|
||||
Frame Control's per-user manager was then exercised on the real Frame:
|
||||
**install → status → update → status → remove → status**. The service became
|
||||
active, the binary matched Decky's published SHA-256, and removal left no
|
||||
service, binary or listening port 1337. Decky's already injected JavaScript
|
||||
remained in Steam's running web helper until that helper was restarted. After a
|
||||
web helper restart, both `deckyHasLoaded` and the tab hook were absent. All
|
||||
temporary trial files and the test's empty managed data folder were removed.
|
||||
|
||||
## Why the official Decky installer is not used
|
||||
|
||||
The current [Decky installer](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/install_release.sh)
|
||||
selects `PluginLoader-arm64` on `aarch64`, creates `~/homebrew/plugins`, writes
|
||||
the CEF marker, installs `/etc/systemd/system/plugin_loader.service`, and uses
|
||||
`sudo systemctl` to enable a **root** service. Its
|
||||
[uninstaller](https://github.com/SteamDeckHomebrew/decky-installer/blob/e42a3c828de90673959b4de26cf5c5d2c7830554/cli/uninstall.sh)
|
||||
also uses sudo. These scripts do not call `steamos-readonly`, but they do write
|
||||
under `/etc`; their root service has broad control over the headset. The
|
||||
current [Decky loader](https://github.com/SteamDeckHomebrew/decky-loader/blob/00cd202872b85868071b041c8f1b34e734147195/backend/decky_loader/injector.py)
|
||||
targets Steam's shared Gamepad UI CEF page, injects a frontend served on port
|
||||
1337, and patches the standard Quick Access Menu. On Frame, Steam supplies
|
||||
the CEF target, while visible VR shell integration is unresolved.
|
||||
|
||||
Frame Control downloads only Decky's official latest ARM64 release, requires
|
||||
its published SHA-256 digest, and checks the ELF architecture. It installs a
|
||||
**user** service named `frame-control-decky.service` in
|
||||
`~/.config/systemd/user`, with the binary and plugin folder under
|
||||
`~/.local/share/frame-control/decky`. It never asks for a sudo password, edits
|
||||
`/etc`, or changes CEF flags. Decky warns that running without root is not
|
||||
officially supported. Plugins that require root, assume `~/homebrew`, or rely
|
||||
on Steam Deck hardware may fail even if the loader runs. Frame Control lists
|
||||
plugins already present in its managed folder and the upstream `~/homebrew`
|
||||
folder; it does not install plugins while their Frame UI and compatibility
|
||||
are unverified.
|
||||
|
||||
The latest tested release was
|
||||
[Decky 3.3.0](https://github.com/SteamDeckHomebrew/decky-loader/releases/tag/v3.3.0),
|
||||
`PluginLoader-arm64` SHA-256
|
||||
`8ef5dcf36cbbbb6e97201a6baed5d79c4cc44ec27789fc9af1c5b6ae8f0b25b6`.
|
||||
This digest comes from that release's GitHub API asset metadata; it detects a
|
||||
download mismatch, but is not an independent signature.
|
||||
|
||||
## Opt in and remove
|
||||
|
||||
In Frame Control, open **Tools → Decky Loader on Frame**, read the experimental
|
||||
notice, check the acknowledgement box, and choose **Install**. **Update**
|
||||
downloads and verifies the latest ARM64 release again. **Remove managed
|
||||
Decky** disables and removes only Frame Control's user service and binary.
|
||||
It preserves plugin files and settings so unrelated user data is not deleted.
|
||||
Restart Steam or the headset to clear the frontend already injected into its
|
||||
current web helper process. No action wipes the headset.
|
||||
|
||||
Manual removal if Frame Control is unavailable:
|
||||
|
||||
```sh
|
||||
ssh frame 'systemctl --user disable --now frame-control-decky.service; rm -f ~/.config/systemd/user/frame-control-decky.service ~/.local/share/frame-control/decky/PluginLoader ~/.local/share/frame-control/decky/.frame-control/receipt.json; systemctl --user daemon-reload; systemctl --user reset-failed frame-control-decky.service'
|
||||
```
|
||||
|
||||
That command intentionally keeps `~/.local/share/frame-control/decky/plugins`
|
||||
and `settings`. Check those folders before deleting them yourself. An upstream
|
||||
Decky installation is separate; use its own installer to remove it. Frame
|
||||
Control refuses to install alongside an upstream system service.
|
||||
|
||||
## Next compatibility test
|
||||
|
||||
In the headset, open Steam's VR dashboard and look for Decky's plugin tab or
|
||||
Quick Access Menu after an opt-in install. If none appears, inspect whether
|
||||
the VR shell exposes a different menu component and whether Decky's frontend
|
||||
can target it without patching Steam globally. Then test one harmless plugin's
|
||||
install, visible UI, restart persistence and removal. Until those checks pass,
|
||||
Frame Control should not describe Decky plugins as supported on the Frame.
|
||||
@@ -0,0 +1,58 @@
|
||||
# OBS relay and MP4 follow-up, 25 October 2026 UTC
|
||||
|
||||
This check used a Linux x86-64 host (`lxso2`) and the real Frame running
|
||||
SteamOS 0.5.6, build `20261022.6153909`. The host reached `ssh frame`. Before
|
||||
device work, the task atomically acquired `/tmp/frame-test.lock`, wrote its
|
||||
owner marker, and after cleanup removed only that marker and its lock. Battery
|
||||
was 99–100%; SteamVR 2.18.2 reported `activity_level: 3` (standby). Steam and
|
||||
SteamVR were left running. No passthrough session or game was started.
|
||||
|
||||
The production `scripts/obs-headset-source.py` relay reached its first local
|
||||
UDP packet in 0.65–1.15 seconds across several runs. A 12-second wall-clock
|
||||
capture of its MPEG-TS packets contained 325 H.264 frames at 1280×720,
|
||||
spanning 10.83 seconds of video timestamps: 30.0 fps. All 324 adjacent frame
|
||||
intervals were 33.333–33.334 ms, with none over 50 ms. The 826 captured TS
|
||||
packets had zero sync-byte, transport-error-indicator, or per-PID continuity
|
||||
errors. Six final TS packets arrived after the receiver's 12-second cutoff and
|
||||
were not included in those counts. Static standby pixels cannot establish
|
||||
whether a moving game would drop frames.
|
||||
|
||||
After the fix, the `--height 1080 --fps 60 --no-audio` fallback reached its
|
||||
first packet in 0.597 seconds. An eight-second wall-clock capture contained
|
||||
447 H.264 frames at 1920×1080 over 7.45 seconds of video timestamps: 60.0 fps.
|
||||
All frame intervals were 16.666–16.667 ms, none over 25 ms, and its 756 TS
|
||||
packets had zero continuity or transport-error-indicator errors. It had no
|
||||
audio stream, as requested. The helper exited 0 on Stop, and the owned lock
|
||||
was released.
|
||||
|
||||
The stream also contained 48 kHz stereo AAC. A 440 Hz synthetic tone played
|
||||
through the Frame's default speaker output with real-time pacing appeared in
|
||||
the capture for about 0.89 seconds; decoded audio had a -21.0 dB peak. This
|
||||
proves an audio signal reached the output monitor and OBS transport. It does
|
||||
not prove audible monitoring, real game audio, or audio/video sync: there was
|
||||
no simultaneous visible marker to compare with the tone.
|
||||
|
||||
The production MP4 recording commands were run separately for six seconds,
|
||||
then Stop, remux, and copy to the host completed. Stop returned after 7.27
|
||||
seconds from Start, including six seconds of recording; the remote ffmpeg
|
||||
exited 0. The copied 36,929-byte MP4 contained 188 H.264 frames at 1920×1080
|
||||
and 30 fps (6.266 seconds), plus 281 AAC packets (5.994 seconds). One decoded
|
||||
frame at two seconds had a single color, RGB `(22, 27, 53)`, across all
|
||||
2,073,600 pixels. The test's remote files were removed; the host copy stayed
|
||||
under `/tmp` for inspection.
|
||||
|
||||
The relay previously returned an error when the app deliberately closed its
|
||||
control pipe, because its SSH child commonly exits 255 after termination.
|
||||
The same test reproduced this (`helper_exit: 1`) on the real Frame. The follow-up
|
||||
fix recognizes deliberate Stop and adds bounded startup and stall timeouts,
|
||||
with fake-SSH regression tests. A new locked real-Frame check after the fix
|
||||
received its first 1,316-byte UDP datagram in 0.697 seconds and returned
|
||||
`helper_exit_after_stop: 0` after closing the app control pipe. The longer
|
||||
transport and MP4 measurements above were taken before the code change; the
|
||||
fix does not alter codecs or capture commands.
|
||||
|
||||
Prior [OBS 30 Media Source evidence](obs-media-source-real-2026-10-25.md)
|
||||
records a real Frame stream in OBS on Linux. This follow-up measured the relay
|
||||
with an FFmpeg receiver; OBS itself was not relaunched. Windows and macOS
|
||||
application paths, wearer-visible image latency, real game sound, audible
|
||||
playback, perceptual A/V sync, and active passthrough pixels remain unverified.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Screenshot Save on a real Frame, 25 October 2026
|
||||
|
||||
After PR #192 merged, Frame Control's production `list_shots()` and
|
||||
`save_shots()` paths were run from lxso2 against the real Frame. The test held
|
||||
the shared `/tmp/frame-test.lock` with an owner marker and removed only its own
|
||||
lock. A separate locked check earlier in this round found SteamVR at activity
|
||||
level 3 (standby). No new screenshot was created.
|
||||
|
||||
- The Frame listed seven existing Steam screenshots.
|
||||
- One existing screenshot was copied to a temporary folder on the PC. Its
|
||||
local size matched the reported remote size: **294,885 bytes**. The new
|
||||
destination had four components below the temporary root: headset, Steam
|
||||
account, game ID and filename.
|
||||
- Saving the same screenshot a second time returned zero new copies. A fresh
|
||||
list then marked that screenshot as saved.
|
||||
- The temporary PC folder and its private image were removed at the end. The
|
||||
source screenshot on the Frame was only read. Battery was **100% Full**
|
||||
before and after.
|
||||
|
||||
This verifies one real screenshot transfer and the repeat-save behavior. It
|
||||
does **not** reproduce two different real screenshots with the same filename;
|
||||
the account/game/headset collision case is covered by the fake-source test in
|
||||
`tests/test_shot_copy.py`. Windows and macOS physical-PC saves were not tested.
|
||||
Active passthrough in the recording mirror remains unknown because the Frame
|
||||
was in standby.
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
extension is local to this computer; the iPhone app reads the folder on the
|
||||
Frame, where its server runs.
|
||||
|
||||
## Make an extension
|
||||
|
||||
Create `extensions/your-id/manifest.json` and `index.html`. IDs use lowercase
|
||||
letters, digits and hyphens, start with a letter, and are at most 40 characters.
|
||||
The manifest ID must match the folder name:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "your-id",
|
||||
"name": "Your feature",
|
||||
"version": "1.0.0",
|
||||
"description": "What the panel does.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
```
|
||||
|
||||
Put JavaScript in a separate `.js` file. Supported assets are HTML, JS, CSS,
|
||||
JSON, PNG, JPEG, GIF, WebP and SVG, all in the extension's top-level folder.
|
||||
Nested paths and symlinks are not served. No file may exceed 5 MiB. The panel
|
||||
uses an iframe with `sandbox="allow-scripts"`; its code has an opaque origin,
|
||||
cannot call Frame Control's HTTP API directly, and cannot navigate the app's
|
||||
top-level window. Content Security Policy restricts scripts, images, and
|
||||
`fetch` requests, but an extension can still navigate its own iframe to an
|
||||
external site. Treat third-party extensions as untrusted code and review them
|
||||
before installation.
|
||||
|
||||
The version 1 bridge uses `postMessage`. Send a request to `parent` and match
|
||||
the numeric `id` in the reply. The [example JavaScript](../examples/extensions/headset-glance/main.js)
|
||||
shows a complete client:
|
||||
|
||||
```js
|
||||
parent.postMessage({type: 'frame-control:request', id: 1, action: 'status'}, '*');
|
||||
// Reply: {type: 'frame-control:response', id: 1, value: {...}}
|
||||
```
|
||||
|
||||
Supported actions:
|
||||
|
||||
| Action | Value |
|
||||
| --- | --- |
|
||||
| `capabilities` | `{apiVersion: 1, actions: ['capabilities', 'status']}` |
|
||||
| `status` | `{connected, os: {version, build}, battery: {percent}, steamvr}` |
|
||||
|
||||
`status` is the app's latest successful status snapshot. When that reading fails,
|
||||
`connected` is false and its fields are empty. No write actions are currently exposed. Future APIs
|
||||
should use explicit, narrow actions and keep the iframe isolated. A third-party
|
||||
extension is code you choose to run locally: inspect its files before copying
|
||||
them into the folder. A Decky plugin is a separate Steam client plugin and does
|
||||
not use this API.
|
||||
@@ -27,6 +27,9 @@ Ctrl+C stops the checkout helper. The app closes a control pipe when you click
|
||||
Stop or quit; the helper then closes its SSH child, including on Windows. On
|
||||
Windows, install Python 3 and OpenSSH Client if Frame Control cannot find them.
|
||||
The Windows app Start/Stop path still needs a physical Windows/Frame test.
|
||||
If no first packet arrives within 20 seconds, or an active stream sends no
|
||||
packet for 10 seconds, the helper closes SSH and shows an error instead of
|
||||
leaving **Start OBS source** waiting or a frozen source running indefinitely.
|
||||
|
||||
To share the OBS view in a call, see the short
|
||||
[Discord setup guide](streaming-to-discord.md).
|
||||
@@ -49,3 +52,23 @@ default output was present in the saved audio. The headset was in standby, so
|
||||
the video showed the standby mirror. Nobody listened to OBS's monitoring output,
|
||||
and audio/video sync and active passthrough remain unverified. See the
|
||||
[test evidence](evidence/obs-media-source-real-2026-10-25.md).
|
||||
The [follow-up Linux/Frame transport check](evidence/obs-followup-2026-10-25.md)
|
||||
measured packet continuity and a short MP4 recording on the current SteamOS
|
||||
build. The headset was still in standby.
|
||||
|
||||
## 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.
|
||||
@@ -107,6 +107,18 @@ about once a minute and copies what's new with the same code as **Save** and
|
||||
**Export**: screenshots to `~/Pictures/SteamFrame`, clips to
|
||||
`~/Videos/SteamFrame`.
|
||||
|
||||
Each newly saved screenshot lives under
|
||||
`~/Pictures/SteamFrame/headset-<id>/<Steam account>/<game ID>/<filename>`.
|
||||
The headset folder uses a short hash of Frame Control's device identity.
|
||||
Steam can use the same screenshot filename for two games or accounts; these
|
||||
folders keep both originals. Existing screenshots in the old flat
|
||||
`~/Pictures/SteamFrame/<filename>` layout stay where they are. They cannot be
|
||||
matched reliably to one headset and game, so **Copy everything now** can create
|
||||
a new, correctly identified copy. It never overwrites the old file.
|
||||
One real Frame screenshot was copied through this path on 25 October 2026;
|
||||
see the [test record](evidence/screenshot-copy-2026-10-25.md). The collision
|
||||
case was tested with separate fake sources, not two real same-name screenshots.
|
||||
|
||||
- **Only what's new.** It copies what you take after turning it on, so the
|
||||
shots and clips already on the headset don't all arrive at once. **Copy
|
||||
everything now** copies everything that isn't on this computer yet, once.
|
||||
|
||||
+6
-2
@@ -13,7 +13,9 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
```
|
||||
|
||||
`.github/workflows/release.yml` builds macOS, Windows and Linux, and
|
||||
attaches everything to a **draft** release for that tag. Nobody is
|
||||
attaches everything to a **draft** release for that tag. After all eight
|
||||
installers upload, the workflow also attaches `SHA256SUMS` built from
|
||||
GitHub's SHA-256 digests of those uploaded bytes. Nobody is
|
||||
offered a draft. Editing a draft while installers are uploading can make
|
||||
GitHub temporarily show its tag as `untagged-…`. The workflow finds that
|
||||
draft by its exact version title, restores the tag association and retries
|
||||
@@ -32,7 +34,9 @@ count. So a build reaches people only when you publish it, after testing it.
|
||||
```
|
||||
|
||||
The script checks that the tag is on GitHub and that all eight installers
|
||||
are attached, each with the SHA-256 digest GitHub records. It attaches
|
||||
are attached, each with the SHA-256 digest GitHub records. It also checks
|
||||
that the uploaded `SHA256SUMS` digest matches a fresh list of those eight
|
||||
installer digests. It attaches
|
||||
`update.json` (the version, the notes, the release page and each
|
||||
installer's digest), then publishes the release and marks it latest. It
|
||||
uses only the REST API: `gh release view` can't find a draft whose
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p><strong>This is a September 2026 archive.</strong> For current coverage and hardware limits, read the <a href="current-support-matrix.md">v0.4.25 support matrix</a>.</p>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>Headset Glance</title>
|
||||
<main>
|
||||
<h1>Headset Glance</h1>
|
||||
<p id="summary">Reading the headset status…</p>
|
||||
<button id="refresh">Refresh</button>
|
||||
</main>
|
||||
<script src="main.js"></script>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
let nextId = 0;
|
||||
const pending = new Map();
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (event.source !== parent || event.data?.type !== 'frame-control:response') return;
|
||||
const resolve = pending.get(event.data.id);
|
||||
if (resolve) {
|
||||
pending.delete(event.data.id);
|
||||
resolve(event.data.value);
|
||||
}
|
||||
});
|
||||
|
||||
function request(action) {
|
||||
return new Promise(resolve => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, resolve);
|
||||
parent.postMessage({type: 'frame-control:request', id, action}, '*');
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const status = await request('status');
|
||||
document.getElementById('summary').textContent = status.connected
|
||||
? `Frame OS ${status.os.version || 'unknown'} · Battery ${status.battery.percent ?? '?'}% · SteamVR ${status.steamvr ? 'running' : 'stopped'}`
|
||||
: 'No headset status is available yet.';
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', refresh);
|
||||
refresh();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "headset-glance",
|
||||
"name": "Headset Glance",
|
||||
"version": "1.0.0",
|
||||
"description": "Shows a small read-only summary of the connected Steam Frame.",
|
||||
"entry": "index.html"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
body { margin: 0; background: #101820; color: #dcdedf; font: 16px system-ui, sans-serif; }
|
||||
main { padding: 24px; }
|
||||
h1 { font-size: 20px; }
|
||||
button { border: 0; border-radius: 4px; padding: 8px 14px; background: #1a9fff; color: #fff; cursor: pointer; }
|
||||
@@ -11,10 +11,13 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
PACKET = 188
|
||||
DATAGRAM = PACKET * 7
|
||||
STARTUP_TIMEOUT = 20
|
||||
STREAM_STALL = 10
|
||||
|
||||
|
||||
def remote_command(height=720, fps=30, audio=True):
|
||||
@@ -37,7 +40,7 @@ def remote_command(height=720, fps=30, audio=True):
|
||||
f'{maps} {encode} -f mpegts -flush_packets 1 -')
|
||||
|
||||
|
||||
def relay(source, destination):
|
||||
def relay(source, destination, on_packet=None):
|
||||
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
|
||||
pending = bytearray()
|
||||
total = 0
|
||||
@@ -46,6 +49,8 @@ def relay(source, destination):
|
||||
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:
|
||||
@@ -53,7 +58,7 @@ def relay(source, destination):
|
||||
return total
|
||||
|
||||
|
||||
def watch_control(stream, proc):
|
||||
def watch_control(stream, proc, stopped):
|
||||
"""A desktop app closes its pipe to stop SSH, including on Windows.
|
||||
|
||||
Windows TerminateProcess does not run Python's finally block. The pipe also
|
||||
@@ -63,10 +68,53 @@ def watch_control(stream, proc):
|
||||
stream.read(1)
|
||||
except OSError:
|
||||
pass
|
||||
stopped.set()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
|
||||
class StreamWatchdog:
|
||||
"""Close SSH if the first packet or a later packet takes too long."""
|
||||
|
||||
def __init__(self, proc, startup=None, stall=None):
|
||||
self.proc = proc
|
||||
self.startup = STARTUP_TIMEOUT if startup is None else startup
|
||||
self.stall = STREAM_STALL if stall is None else stall
|
||||
self.condition = threading.Condition()
|
||||
self.last_packet = time.monotonic()
|
||||
self.ready = False
|
||||
self.closed = False
|
||||
self.reason = ''
|
||||
self.thread = threading.Thread(target=self._watch, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def packet(self):
|
||||
with self.condition:
|
||||
self.ready = True
|
||||
self.last_packet = time.monotonic()
|
||||
self.condition.notify()
|
||||
|
||||
def close(self):
|
||||
with self.condition:
|
||||
self.closed = True
|
||||
self.condition.notify()
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
def _watch(self):
|
||||
with self.condition:
|
||||
while not self.closed:
|
||||
left = self.last_packet + (self.stall if self.ready else self.startup) - time.monotonic()
|
||||
if left <= 0:
|
||||
self.reason = (f'The Frame sent no OBS video for {self.startup:g} seconds' if not self.ready
|
||||
else f'The Frame OBS video stopped for {self.stall:g} seconds')
|
||||
break
|
||||
self.condition.wait(left)
|
||||
else:
|
||||
return
|
||||
if self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
|
||||
@@ -87,11 +135,17 @@ def main(argv=None):
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as udp:
|
||||
proc = subprocess.Popen(ssh, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
|
||||
stopped = threading.Event()
|
||||
watchdog = StreamWatchdog(proc)
|
||||
if args.control_stdin:
|
||||
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc), daemon=True).start()
|
||||
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc, stopped), daemon=True).start()
|
||||
try:
|
||||
amount = relay(proc.stdout, _Sender(udp, target))
|
||||
amount = relay(proc.stdout, _Sender(udp, target), watchdog.packet)
|
||||
code = proc.wait(timeout=10)
|
||||
if stopped.is_set():
|
||||
return 0
|
||||
if watchdog.reason:
|
||||
raise RuntimeError(watchdog.reason)
|
||||
if code or not amount:
|
||||
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
|
||||
'Check SteamVR and the audio monitor; try --no-audio if needed.')
|
||||
@@ -99,6 +153,7 @@ def main(argv=None):
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
watchdog.close()
|
||||
proc.stdout.close()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
@@ -70,6 +70,38 @@ print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent
|
||||
done
|
||||
[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
|
||||
|
||||
# The workflow uploads this only after all eight platform builds complete.
|
||||
# Regenerate it from GitHub's installer digests and compare its own GitHub
|
||||
# digest, so a stale or partial checksum file cannot pass the release gate.
|
||||
python3 "$(dirname "$0")/release-checksums.py" < "$tmp/release.json" > "$tmp/SHA256SUMS"
|
||||
expected_sum=$(sha256sum "$tmp/SHA256SUMS" | cut -d ' ' -f 1)
|
||||
checksum_digest=$(python3 -c 'import json,sys
|
||||
d=json.load(open(sys.argv[1])); matches=[a.get("digest") for a in d["assets"] if a["name"]=="SHA256SUMS"]
|
||||
print(matches[0] if len(matches)==1 else "absent")' "$tmp/release.json")
|
||||
[ "$checksum_digest" = "sha256:$expected_sum" ] || {
|
||||
echo "not publishing: SHA256SUMS is absent or does not match the eight installer digests" >&2; exit 1;
|
||||
}
|
||||
echo "ok SHA256SUMS"
|
||||
|
||||
# Every published release carries the same concrete verification instructions.
|
||||
# Keep the release's own notes, and append this section only once. The tag URL
|
||||
# is stable even while a draft temporarily has an untagged-* API name.
|
||||
python3 - "$tmp/release.json" "$(dirname "$0")/release-safety-note.md" "$tag" > "$tmp/release-with-note.json" <<'EOF'
|
||||
import json, pathlib, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
body = (d.get("body") or "").rstrip()
|
||||
if "## Is it safe?" not in body:
|
||||
note = pathlib.Path(sys.argv[2]).read_text(encoding="utf-8").replace("{tag}", sys.argv[3]).strip()
|
||||
body = (body + "\n\n" if body else "") + note
|
||||
d["body"] = body
|
||||
json.dump(d, sys.stdout)
|
||||
EOF
|
||||
mv "$tmp/release-with-note.json" "$tmp/release.json"
|
||||
python3 - "$tmp/release.json" > "$tmp/release-body.txt" <<'EOF'
|
||||
import json, sys
|
||||
sys.stdout.buffer.write(json.load(open(sys.argv[1]))["body"].encode("utf-8"))
|
||||
EOF
|
||||
|
||||
# update.json: what running copies read (app/updater.js), from github.com's
|
||||
# latest/download link rather than the rate-limited REST API.
|
||||
python3 - "$tmp/release.json" "$tag" "$page" "$expected" > "$tmp/update.json" <<'EOF'
|
||||
@@ -87,6 +119,7 @@ print(" ".join(str(a["id"]) for a in d["assets"] if a["name"]=="update.json"))'
|
||||
if [ -n "$dry" ]; then
|
||||
echo "dry run: would replace update.json (old asset ids: ${old:-none}) with:"
|
||||
cat "$tmp/update.json"
|
||||
echo "dry run: would include the Is it safe? section in the release body"
|
||||
echo "dry run: would PATCH release $id: tag_name=$tag draft=false prerelease=false make_latest=true"
|
||||
exit 0
|
||||
fi
|
||||
@@ -98,6 +131,6 @@ gh api -X POST "https://uploads.github.com/repos/$repo/releases/$id/assets?name=
|
||||
-H "Content-Type: application/json" --input "$tmp/update.json" >/dev/null
|
||||
echo "ok update.json"
|
||||
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -F draft=false -F prerelease=false \
|
||||
gh api -X PATCH "repos/$repo/releases/$id" -f tag_name="$tag" -f body="$(cat "$tmp/release-body.txt")" -F draft=false -F prerelease=false \
|
||||
-f make_latest=true --jq '"published " + .tag_name + " at " + .html_url'
|
||||
echo "running copies will offer $tag at their next check"
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print SHA256SUMS for the eight release installers from GitHub asset metadata.
|
||||
|
||||
GitHub computes each asset digest from its uploaded bytes. The workflow runs
|
||||
this only after all platform builds finish; publish-release.sh checks that the
|
||||
uploaded SHA256SUMS bytes still match these digests before publishing.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
|
||||
INSTALLERS = (
|
||||
"Frame-Control-mac-arm64.dmg",
|
||||
"Frame-Control-mac-arm64.zip",
|
||||
"Frame-Control-Setup-x64.exe",
|
||||
"Frame-Control-win-x64.zip",
|
||||
"Frame-Control-linux-x86_64.AppImage",
|
||||
"Frame-Control-linux-arm64.AppImage",
|
||||
"Frame-Control-linux-amd64.deb",
|
||||
"Frame-Control-linux-arm64.deb",
|
||||
)
|
||||
|
||||
|
||||
def find_release(lines, tag):
|
||||
releases = [json.loads(line) for line in lines if line.strip()]
|
||||
hits = [r for r in releases if r.get("tag_name") == tag]
|
||||
if not hits:
|
||||
title = re.compile(r"Frame Control " + re.escape(tag.removeprefix("v")) + r"(: .*)?")
|
||||
hits = [r for r in releases if r.get("draft")
|
||||
and str(r.get("tag_name") or "").startswith("untagged-")
|
||||
and title.fullmatch(r.get("name") or "")]
|
||||
if len(hits) != 1:
|
||||
raise ValueError(f"expected one release for {tag}; found {len(hits)}")
|
||||
return hits[0]
|
||||
|
||||
|
||||
def checksums(release):
|
||||
assets = release.get("assets") or []
|
||||
lines = []
|
||||
for name in INSTALLERS:
|
||||
matches = [a for a in assets if a.get("name") == name]
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"expected one asset named {name}; found {len(matches)}")
|
||||
digest = matches[0].get("digest") or ""
|
||||
if not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise ValueError(f"missing SHA-256 digest for {name}")
|
||||
lines.append(f"{digest[7:].lower()} {name}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--tag", help="find exact release in JSON-lines input")
|
||||
parser.add_argument("--id", action="store_true", help="print the selected release id")
|
||||
args = parser.parse_args()
|
||||
data = sys.stdin.read()
|
||||
release = find_release(data.splitlines(), args.tag) if args.tag else json.loads(data)
|
||||
if args.id:
|
||||
print(int(release["id"]))
|
||||
else:
|
||||
# Windows text stdout changes LF to CRLF. SHA256SUMS must have the same
|
||||
# bytes on every runner, including publish-release.sh's verification.
|
||||
sys.stdout.buffer.write(checksums(release).encode("ascii"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except ValueError as exc:
|
||||
sys.exit(str(exc))
|
||||
@@ -0,0 +1,11 @@
|
||||
## Is it safe?
|
||||
|
||||
Download only from this GitHub release. Check your installer against [SHA256SUMS](https://github.com/saphid/frame-control/releases/download/{tag}/SHA256SUMS). A matching hash proves that your file matches the file in this release. It does not prove that any program is free of malware.
|
||||
|
||||
- Windows PowerShell: `Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256`
|
||||
- macOS Terminal: `shasum -a 256 Frame-Control-mac-arm64.dmg`
|
||||
- Linux terminal: `sha256sum Frame-Control-linux-x86_64.AppImage`
|
||||
|
||||
Find the same filename in `SHA256SUMS` and compare all 64 hex characters. [Read the source](https://github.com/saphid/frame-control) and [see the build workflow](https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml). The Windows installer is **not code-signed**; SmartScreen may warn. The macOS app has only an **ad hoc signature**, not an Apple Developer ID signature; the DMG is **unsigned and not notarized**. Gatekeeper may warn or block it. Follow the opening steps in the README only if you trust this download.
|
||||
|
||||
日本語: 公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a> · <a href="https://github.com/saphid/frame-control/blob/main/docs/current-support-matrix.md">What we tested</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" id="download-mac" data-os="mac">
|
||||
@@ -257,6 +257,13 @@
|
||||
<h2>Good questions</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details id="verify-download">
|
||||
<summary>Is it safe? How do I check my download?</summary>
|
||||
<div><p>Starting with <b>v0.4.26</b>, download Frame Control and <a href="https://github.com/saphid/frame-control/releases/latest/download/SHA256SUMS">SHA256SUMS</a> from the same GitHub release. Earlier releases do not have this file. A matching SHA-256 means your file matches that release asset. It does not prove the app is free of malware. Compare all 64 characters with the line for your filename.</p>
|
||||
<p>Windows PowerShell: <code>Get-FileHash .\Frame-Control-Setup-x64.exe -Algorithm SHA256</code><br>macOS Terminal: <code>shasum -a 256 Frame-Control-mac-arm64.dmg</code><br>Linux terminal: <code>sha256sum Frame-Control-linux-x86_64.AppImage</code></p>
|
||||
<p>The <a href="https://github.com/saphid/frame-control">source</a> and <a href="https://github.com/saphid/frame-control/blob/main/.github/workflows/release.yml">build workflow</a> are public. The Windows installer is not code-signed, so SmartScreen may warn. The macOS app has only an ad hoc signature, not an Apple Developer ID signature; the DMG is unsigned and not notarized, so Gatekeeper may warn or block it. Only follow the opening steps below if you trust the download.</p>
|
||||
<p lang="ja">日本語: v0.4.26 以降は公式リリースの SHA256SUMS とファイルの SHA-256 値を照合してください。Windows は未署名、macOS は開発者 ID による署名・公証がありません。</p></div>
|
||||
</details>
|
||||
<details id="mac-opening">
|
||||
<summary>macOS says the app is damaged or can't be checked</summary>
|
||||
<div><p>The app isn't notarized, because there's no paid Apple developer account behind it. Only for a download you trust from the releases linked above: drag it to Applications, then clear the download quarantine once in Terminal:</p>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Decky release validation and the removable user-service transaction."""
|
||||
import sandbox # noqa: F401
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
|
||||
import frame_decky
|
||||
|
||||
|
||||
def arm_binary(fill=b'A'):
|
||||
data = bytearray(b'\x7fELF' + fill * 2044)
|
||||
data[18:20] = (183).to_bytes(2, 'little')
|
||||
return bytes(data)
|
||||
|
||||
|
||||
class DeckyRelease(unittest.TestCase):
|
||||
def test_official_arm_release_digest_is_checked(self):
|
||||
data = arm_binary()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
url = f'https://github.com/{frame_decky.REPO}/releases/download/v3.3.0/PluginLoader-arm64'
|
||||
info = {'tag_name':'v3.3.0', 'assets':[{'name':'PluginLoader-arm64', 'size':len(data),
|
||||
'digest':'sha256:'+digest, 'browser_download_url':url}]}
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
self.assertEqual(frame_decky.release_binary(), ('v3.3.0', digest, data))
|
||||
info['assets'][0]['digest'] = 'sha256:' + '0'*64
|
||||
with patch.object(frame_decky.urllib.request, 'urlopen', side_effect=[io.BytesIO(json.dumps(info).encode()), io.BytesIO(data)]):
|
||||
with self.assertRaisesRegex(ValueError, 'digest'):
|
||||
frame_decky.release_binary()
|
||||
|
||||
def test_install_rejects_incompatible_headset_before_download(self):
|
||||
state = {'arch':'x86_64','variant':'vr','cef':True,'managed':False,'upstreamInstall':False}
|
||||
with patch.object(frame_decky, 'release_binary') as download:
|
||||
with self.assertRaisesRegex(ValueError, 'Steam Frame'):
|
||||
frame_decky.change(lambda *a, **k: json.dumps(state), 'install')
|
||||
download.assert_not_called()
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == 'nt', 'remote service script runs on Linux')
|
||||
class DeckyServiceScript(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
home = Path(self.tmp.name)
|
||||
fake = home / 'systemctl'
|
||||
fake.write_text('#!/bin/sh\nif [ "$FRAME_DECKY_TEST_FAIL" = "1" ] && [ "$2" = "enable" ]; then echo blocked >&2; exit 1; fi\nexit 0\n')
|
||||
fake.chmod(0o700)
|
||||
self.env = {**os.environ, 'HOME': self.tmp.name, 'PATH': self.tmp.name + os.pathsep + os.environ['PATH']}
|
||||
self.base = home / '.local/share/frame-control/decky'
|
||||
|
||||
def apply(self, action, data=None, version='v3.3.0', fail=False):
|
||||
token = secrets.token_hex(12)
|
||||
stage = Path('/tmp/frame-control-decky-' + token)
|
||||
if data is not None:
|
||||
stage.write_bytes(data)
|
||||
digest = hashlib.sha256(data or b'').hexdigest()
|
||||
env = {**self.env, 'FRAME_DECKY_TEST_FAIL': '1' if fail else '0'}
|
||||
try:
|
||||
return subprocess.run([sys.executable, '-', action, token, digest, version],
|
||||
input=frame_decky.APPLY, capture_output=True, text=True, env=env, timeout=15)
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
|
||||
def test_install_update_rollback_and_remove(self):
|
||||
first = arm_binary(b'A')
|
||||
result = self.apply('install', first)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
receipt = self.base / '.frame-control/receipt.json'
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
self.assertTrue((Path(self.tmp.name) / '.config/systemd/user/frame-control-decky.service').is_file())
|
||||
|
||||
second = arm_binary(b'B')
|
||||
result = self.apply('update', second, version='v3.3.1', fail=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), first)
|
||||
self.assertEqual(json.loads(receipt.read_text())['version'], 'v3.3.0')
|
||||
|
||||
result = self.apply('update', second, version='v3.3.1')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual((self.base / 'PluginLoader').read_bytes(), second)
|
||||
result = self.apply('remove')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.base / 'PluginLoader').exists())
|
||||
self.assertFalse(receipt.exists())
|
||||
self.assertTrue((self.base / 'plugins').is_dir())
|
||||
@@ -9,8 +9,10 @@ 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'
|
||||
@@ -55,6 +57,29 @@ 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()
|
||||
|
||||
for ready in (False, True):
|
||||
with self.subTest(ready=ready):
|
||||
child = Child()
|
||||
watchdog = obs.StreamWatchdog(child, startup=0.1, stall=0.1)
|
||||
if ready:
|
||||
watchdog.packet()
|
||||
try:
|
||||
self.assertTrue(child.stopped.wait(1), 'a stalled SSH child was not terminated')
|
||||
self.assertIn('stopped' if ready else 'no OBS video', watchdog.reason)
|
||||
finally:
|
||||
watchdog.close()
|
||||
|
||||
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
|
||||
def test_relay_stops_its_ssh_child_on_app_signal_or_control_pipe(self):
|
||||
for mode in ('signal', 'control-pipe'):
|
||||
@@ -70,7 +95,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(0)\n"
|
||||
" sys.exit(255)\n"
|
||||
"signal.signal(signal.SIGTERM, stop)\n"
|
||||
"sys.stdout.buffer.write(b'G' * 1316)\n"
|
||||
"sys.stdout.buffer.flush()\n"
|
||||
@@ -112,6 +137,33 @@ 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', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'scroller', 'loadMacView', 'loadSteamPipewire', 'loadPanels', 'loadPanelSwitcher', 'loadExtensions', 'loadDecky', 'loadBackups', 'loadStorage', 'loadPower', 'loadTitles', 'showAndroidTab',
|
||||
'loadAndroid', 'loadDisplays', 'setGameSource', 'requestAnimationFrame']
|
||||
|
||||
RUN = r'''
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -26,6 +27,9 @@ 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}
|
||||
@@ -79,6 +83,16 @@ 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)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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,6 +94,38 @@ 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_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Experimental, removable per-user Decky Loader for Steam Frame (ARM64)."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.request
|
||||
|
||||
|
||||
REPO = "SteamDeckHomebrew/decky-loader"
|
||||
API = f"https://api.github.com/repos/{REPO}/releases/latest"
|
||||
MAX_BINARY = 80 * 1024 * 1024
|
||||
TAG = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:[-.][A-Za-z0-9.-]+)?\Z")
|
||||
|
||||
|
||||
PROBE = r'''
|
||||
import json, os, pathlib, platform, subprocess, urllib.request
|
||||
home = pathlib.Path.home()
|
||||
managed = home / '.local/share/frame-control/decky'
|
||||
service = home / '.config/systemd/user/frame-control-decky.service'
|
||||
release = managed / '.frame-control/receipt.json'
|
||||
legacy = managed / 'settings/release.json'
|
||||
def active(name, user=False):
|
||||
cmd = ['systemctl'] + (['--user'] if user else []) + ['is-active', name]
|
||||
return subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
def plugins(folder):
|
||||
found = []
|
||||
if folder.is_dir() and not folder.is_symlink():
|
||||
for child in sorted(folder.iterdir()):
|
||||
if child.is_symlink() or not child.is_dir(): continue
|
||||
info = child / 'plugin.json'
|
||||
if info.is_symlink() or not info.is_file() or info.stat().st_size > 16384: continue
|
||||
try:
|
||||
data = json.loads(info.read_text())
|
||||
found.append({'folder': child.name, 'name': str(data.get('name', child.name))[:80],
|
||||
'author': str(data.get('author', ''))[:80]})
|
||||
except (ValueError, OSError, UnicodeError): pass
|
||||
return found
|
||||
os_release = {}
|
||||
for line in pathlib.Path('/etc/os-release').read_text().splitlines():
|
||||
if '=' in line:
|
||||
k,v = line.split('=',1); os_release[k] = v.strip('"')
|
||||
try:
|
||||
with urllib.request.urlopen('http://127.0.0.1:8080/json/list', timeout=2) as reply:
|
||||
targets = json.load(reply)
|
||||
cef = any(x.get('title') == 'SharedJSContext' and 'steamloopback.host' in x.get('url','') for x in targets)
|
||||
except (OSError, ValueError): cef = False
|
||||
try:
|
||||
metadata = json.loads((release if release.exists() else legacy).read_text())
|
||||
version = metadata.get('version') if metadata.get('managedBy') == 'Frame Control' else None
|
||||
except (OSError, ValueError): version = None
|
||||
print(json.dumps({'arch':platform.machine(), 'frameOS':os_release.get('VERSION_ID'),
|
||||
'variant':os_release.get('VARIANT_ID'), 'build':os_release.get('BUILD_ID'), 'cef':cef,
|
||||
'managed':service.is_file() and version is not None, 'running':active('frame-control-decky.service', True),
|
||||
'version':version, 'upstreamInstall':pathlib.Path('/etc/systemd/system/plugin_loader.service').exists(),
|
||||
'plugins':plugins(managed/'plugins'), 'upstreamPlugins':plugins(home/'homebrew/plugins')}))
|
||||
'''
|
||||
|
||||
|
||||
APPLY = r'''
|
||||
import hashlib, json, os, pathlib, subprocess, sys
|
||||
action, token, digest, version = sys.argv[1:]
|
||||
home = pathlib.Path.home()
|
||||
base = home / '.local/share/frame-control/decky'
|
||||
service = home / '.config/systemd/user/frame-control-decky.service'
|
||||
binary = base / 'PluginLoader'
|
||||
receipt = base / '.frame-control/receipt.json'
|
||||
legacy = base / 'settings/release.json'
|
||||
stage = pathlib.Path('/tmp/frame-control-decky-' + token)
|
||||
def systemd(*args):
|
||||
p = subprocess.run(['systemctl','--user',*args], capture_output=True, text=True, timeout=20)
|
||||
if p.returncode: raise RuntimeError((p.stderr or p.stdout).strip() or 'systemd failed')
|
||||
def owned():
|
||||
try: return json.loads((receipt if receipt.exists() else legacy).read_text()).get('managedBy') == 'Frame Control' and service.is_file()
|
||||
except (OSError, ValueError): return False
|
||||
try:
|
||||
if action == 'remove':
|
||||
if not owned(): raise RuntimeError('No Frame Control managed Decky install to remove')
|
||||
systemd('disable','--now','frame-control-decky.service')
|
||||
service.unlink()
|
||||
systemd('daemon-reload')
|
||||
binary.unlink(missing_ok=True)
|
||||
receipt.unlink(missing_ok=True)
|
||||
if legacy.exists() and json.loads(legacy.read_text()).get('managedBy') == 'Frame Control': legacy.unlink()
|
||||
print(json.dumps({'message':'Removed Frame Control managed Decky service and binary. Plugin files were kept. Restart Steam or the headset to clear its menu.'}))
|
||||
else:
|
||||
if action not in ('install','update'): raise RuntimeError('Invalid Decky action')
|
||||
if (home/'homebrew/services/PluginLoader').exists() or pathlib.Path('/etc/systemd/system/plugin_loader.service').exists():
|
||||
raise RuntimeError('An upstream Decky installation exists; remove it with its own installer first')
|
||||
if action == 'update' and not owned(): raise RuntimeError('No Frame Control managed Decky install to update')
|
||||
if action == 'install' and (service.exists() or receipt.exists() or legacy.exists() or binary.exists()):
|
||||
raise RuntimeError('Decky files already exist at the managed path')
|
||||
data = stage.read_bytes()
|
||||
if len(data) > 80*1024*1024 or len(data) < 1024 or hashlib.sha256(data).hexdigest() != digest:
|
||||
raise RuntimeError('Decky binary digest or size did not match')
|
||||
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != 183:
|
||||
raise RuntimeError('Decky release is not an ARM64 Linux binary')
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
(base/'plugins').mkdir(exist_ok=True)
|
||||
receipt.parent.mkdir(parents=True, exist_ok=True)
|
||||
service.parent.mkdir(parents=True, exist_ok=True)
|
||||
old_binary = binary.read_bytes() if binary.exists() else None
|
||||
old_receipt = receipt.read_bytes() if receipt.exists() else legacy.read_bytes() if legacy.exists() else None
|
||||
old_service = service.read_bytes() if service.exists() else None
|
||||
if old_service: systemd('stop','frame-control-decky.service')
|
||||
try:
|
||||
next_binary = base / 'PluginLoader.next'
|
||||
next_binary.write_bytes(data)
|
||||
os.replace(next_binary, binary)
|
||||
binary.chmod(0o700)
|
||||
text = ('[Unit]\nDescription=Decky Loader (Frame Control experimental user install)\n'
|
||||
'After=default.target\n[Service]\nType=simple\nRestart=on-failure\n'
|
||||
'RestartSec=5\nExecStart='+str(binary)+'\n'
|
||||
'Environment=UNPRIVILEGED_PATH='+str(base)+'\n'
|
||||
'Environment=PRIVILEGED_PATH='+str(base)+'\n'
|
||||
'Environment=CHOWN_PLUGIN_PATH=0\nEnvironment=LIVE_RELOAD=0\n'
|
||||
'Environment=USE_CEF_CLOSE_WORKAROUND=0\nEnvironment=KEEP_SYSTEMD_SERVICE=1\n'
|
||||
'[Install]\nWantedBy=default.target\n')
|
||||
service.write_text(text)
|
||||
receipt.write_text(json.dumps({'managedBy':'Frame Control','version':version,'sha256':digest})+'\n')
|
||||
if legacy.exists() and json.loads(legacy.read_text()).get('managedBy') == 'Frame Control': legacy.unlink()
|
||||
systemd('daemon-reload')
|
||||
systemd('enable','--now','frame-control-decky.service')
|
||||
systemd('is-active','frame-control-decky.service')
|
||||
except Exception:
|
||||
(base/'PluginLoader.next').unlink(missing_ok=True)
|
||||
if old_binary is None: binary.unlink(missing_ok=True)
|
||||
else: binary.write_bytes(old_binary); binary.chmod(0o700)
|
||||
if old_receipt is None: receipt.unlink(missing_ok=True)
|
||||
else: receipt.write_bytes(old_receipt)
|
||||
if old_service is None: service.unlink(missing_ok=True)
|
||||
else: service.write_bytes(old_service)
|
||||
subprocess.run(['systemctl','--user','daemon-reload'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
if old_service: subprocess.run(['systemctl','--user','start','frame-control-decky.service'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
raise
|
||||
print(json.dumps({'message':'Decky Loader '+version+' is running as a user service. The VR menu is experimental.'}))
|
||||
finally:
|
||||
stage.unlink(missing_ok=True)
|
||||
'''
|
||||
|
||||
|
||||
def status(ssh):
|
||||
return json.loads(ssh('python3 -', stdin=PROBE, timeout=15))
|
||||
|
||||
|
||||
def release_binary():
|
||||
request = urllib.request.Request(API, headers={'User-Agent': 'Frame-Control'})
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
info = json.load(response)
|
||||
version = info.get('tag_name')
|
||||
if not isinstance(version, str) or not TAG.fullmatch(version):
|
||||
raise ValueError('Invalid Decky release version')
|
||||
asset = next((a for a in info.get('assets', []) if a.get('name') == 'PluginLoader-arm64'), None)
|
||||
expected_url = f'https://github.com/{REPO}/releases/download/{version}/PluginLoader-arm64'
|
||||
if not asset or asset.get('browser_download_url') != expected_url or not isinstance(asset.get('size'), int) or not 1024 < asset['size'] <= MAX_BINARY:
|
||||
raise ValueError('Decky release has no valid ARM64 asset')
|
||||
digest = asset.get('digest', '')
|
||||
if not isinstance(digest, str) or not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
|
||||
raise ValueError('Decky release has no published SHA-256 digest')
|
||||
with urllib.request.urlopen(urllib.request.Request(expected_url, headers={'User-Agent': 'Frame-Control'}), timeout=45) as response:
|
||||
data = response.read(MAX_BINARY + 1)
|
||||
if len(data) != asset['size'] or hashlib.sha256(data).hexdigest() != digest[7:]:
|
||||
raise ValueError('Decky download did not match its published digest')
|
||||
if data[:4] != b'\x7fELF' or int.from_bytes(data[18:20], 'little') != 183:
|
||||
raise ValueError('Decky download is not an ARM64 Linux binary')
|
||||
return version, digest[7:], data
|
||||
|
||||
|
||||
def change(ssh, action):
|
||||
if action not in ('install', 'update', 'remove'):
|
||||
raise ValueError('Invalid Decky action')
|
||||
current = status(ssh)
|
||||
if action == 'remove':
|
||||
if not current['managed']:
|
||||
raise ValueError('No Frame Control managed Decky install to remove')
|
||||
return json.loads(ssh('python3 - remove none none none', stdin=APPLY, timeout=45))
|
||||
if current['arch'] != 'aarch64' or current['variant'] != 'vr' or not current['cef']:
|
||||
raise ValueError('Decky needs a Steam Frame with an active Steam CEF debug page')
|
||||
if current['upstreamInstall']:
|
||||
raise ValueError('An upstream Decky installation exists; use its own installer')
|
||||
if action == 'install' and current['managed']:
|
||||
raise ValueError('Decky is already installed; use Update')
|
||||
if action == 'update' and not current['managed']:
|
||||
raise ValueError('Decky is not installed by Frame Control')
|
||||
version, digest, data = release_binary()
|
||||
token = secrets.token_hex(12)
|
||||
remote = '/tmp/frame-control-decky-' + token
|
||||
try:
|
||||
ssh('umask 077; cat > ' + remote, stdin=data, text=False, timeout=120)
|
||||
result = ssh(f'python3 - {action} {token} {digest} {version}', stdin=APPLY, timeout=50)
|
||||
return json.loads(result)
|
||||
finally:
|
||||
try: ssh('rm -f ' + remote, timeout=10)
|
||||
except Exception: pass
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Small, local Frame Control extensions. Code runs only in a sandboxed iframe."""
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import frame_host
|
||||
|
||||
|
||||
ID = re.compile(r"[a-z][a-z0-9-]{0,39}\Z")
|
||||
ASSET = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z")
|
||||
TYPES = {".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8", ".json": "application/json",
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml"}
|
||||
|
||||
|
||||
def root():
|
||||
return frame_host.data_dir("extensions")
|
||||
|
||||
|
||||
def _manifest(folder):
|
||||
if folder.is_symlink() or not ID.fullmatch(folder.name):
|
||||
raise ValueError("invalid extension directory")
|
||||
manifest = folder / "manifest.json"
|
||||
if manifest.is_symlink() or not manifest.is_file() or manifest.stat().st_size > 16_384:
|
||||
raise ValueError("missing or oversized manifest.json")
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict) or data.get("id") != folder.name:
|
||||
raise ValueError("manifest id must match its folder")
|
||||
for key, limit in (("name", 80), ("version", 40), ("description", 240)):
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
||||
raise ValueError(f"invalid {key}")
|
||||
entry = data.get("entry", "index.html")
|
||||
if entry != "index.html" or (folder / entry).is_symlink() or not (folder / entry).is_file():
|
||||
raise ValueError("entry must be a regular index.html")
|
||||
return {key: data[key] for key in ("id", "name", "version", "description")}
|
||||
|
||||
|
||||
def list_extensions():
|
||||
base = root()
|
||||
if not base.exists():
|
||||
return {"extensions": [], "errors": [], "folder": str(base)}
|
||||
found, errors = [], []
|
||||
for folder in sorted(base.iterdir()):
|
||||
if folder.name.startswith(".") or not folder.is_dir():
|
||||
continue
|
||||
try:
|
||||
found.append(_manifest(folder))
|
||||
except (ValueError, OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
errors.append({"folder": folder.name, "error": str(exc)})
|
||||
return {"extensions": found, "errors": errors, "folder": str(base)}
|
||||
|
||||
|
||||
def asset(extension_id, name):
|
||||
"""Return bytes and MIME; never follow symlinks or serve outside one valid folder."""
|
||||
if not ID.fullmatch(extension_id) or not ASSET.fullmatch(name) or name.startswith("."):
|
||||
raise FileNotFoundError("invalid extension path")
|
||||
folder = root() / extension_id
|
||||
_manifest(folder)
|
||||
file = folder / name
|
||||
if name == "manifest.json" or file.is_symlink() or not file.is_file():
|
||||
raise FileNotFoundError("extension asset not found")
|
||||
ctype = TYPES.get(file.suffix.lower())
|
||||
if not ctype or file.stat().st_size > 5 * 1024 * 1024:
|
||||
raise FileNotFoundError("unsupported extension asset")
|
||||
return file.read_bytes(), ctype
|
||||
+93
-3
@@ -1436,8 +1436,27 @@
|
||||
|
||||
<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>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div id="toolDirectory"><section class="tool-group"><h2>Content & files</h2><div class="quick-routes"><a href="#transfer"><span>Send to Frame</span><small>Add files, APKs or your own games</small></a><a href="#media"><span>Media player</span><small>Play your own videos and media</small></a><a href="#linuxApps"><span>Linux apps</span><small>Install and manage desktop apps</small></a></div></section><section class="tool-group"><h2>Play & control</h2><div class="quick-routes"><a href="#capture"><span>Capture & Live</span><small>See and control the headset view</small></a><a href="#pad" title="Type on the headset (T)"><span>Type on the headset</span><small>Keyboard, trackpad and clipboard</small></a><a href="#panels"><span>Panel switcher</span><small>Bring an open panel into view</small></a><a href="#vrTools"><span>VR tools</span><small>Performance HUD and VR utilities</small></a><a href="#comfort"><span>Family & comfort</span><small>Session reminders and breaks</small></a><a href="#macview" id="macToolLink" hidden><span>Mac in the headset</span><small>Share Mac windows and displays</small></a><a href="#steamPipewire" id="steamPipewireLink" hidden><span>Steam desktop sharing</span><small>Linux: launch Steam or re-pick a screen</small></a><a href="#macview" id="pcToolLink" hidden><span>PC in the headset</span><small>Experimental: share this PC's screen</small></a></div></section><section class="tool-group" id="wirelessToolGroup" hidden><h2>This PC</h2><div class="quick-routes"><a href="#wireless"><span>Wireless Adapter doctor</span><small>Read-only Windows adapter and interference checks</small></a></div></section><section class="tool-group"><h2>Your Frame</h2><div class="quick-routes"><a href="#powerReport"><span>Power & heat report</span><small>Read-only observation and offline downloads</small></a><a href="#storage"><span>Game storage</span><small>Free space and Steam install sizes</small></a><a href="#backup" id="backupToolLink"><span>Backup & recovery</span><small>Save games and settings, restore after a reset</small></a></div><a class="workspace-link" href="#settings">Connection, display & preferences →</a></section><section class="tool-group"><h2>Modding & extensions</h2><div class="quick-routes"><a href="#extensions"><span>Extensions</span><small>Add local tools to Frame Control</small></a><a href="#decky"><span>Decky Loader</span><small>Experimental Steam plugin loader</small></a></div></section><section class="tool-group"><h2>Assistant</h2><a class="workspace-link" href="/assistant">Open assistant & AI agents →</a><p class="sub">Use your own model endpoint or review a proposed MCP action.</p></section></div>
|
||||
<div class="grid-3" id="toolWorkspace">
|
||||
<section class="panel" id="extensions">
|
||||
<div class="shelf-head"><h2>Extensions</h2><span class="spacer"></span><button class="small" id="extensionsRefresh">Refresh</button></div>
|
||||
<p class="sub">Add local tools to Frame Control. An extension runs only when you open it, in an isolated panel with read-only headset status.</p>
|
||||
<div id="extensionsList" class="sub">Loading…</div>
|
||||
<div id="extensionsFolder" class="hint"></div>
|
||||
<div id="extensionPanel" hidden style="margin-top:16px">
|
||||
<div class="shelf-head"><h2 id="extensionTitle"></h2><span class="spacer"></span><button class="small" id="extensionClose">Close</button></div>
|
||||
<iframe id="extensionFrame" title="Frame Control extension" sandbox="allow-scripts" referrerpolicy="no-referrer" style="width:100%;height:440px;border:1px solid rgba(255,255,255,.12);border-radius:4px;background:#101820"></iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" id="decky">
|
||||
<div class="shelf-head"><h2>Decky Loader on Frame</h2><span class="spacer"></span><button class="small" id="deckyRefresh">Refresh</button></div>
|
||||
<p class="sub">Experimental. Decky's ARM64 backend can start on Frame OS, but its plugin menu has not been verified in the VR shell. Some Decky plugins need root and will not work with this user service.</p>
|
||||
<div id="deckyState" class="sub">Checking…</div>
|
||||
<div id="deckyPlugins" class="hint"></div>
|
||||
<label class="popt"><input type="checkbox" id="deckyAck"> I understand that the VR menu and individual plugins may not work. Install only if I want to test Decky.</label>
|
||||
<div class="row" style="margin-top:10px"><button class="small action" id="deckyInstall" disabled>Install</button><button class="small" id="deckyUpdate" disabled>Update</button><button class="small danger" id="deckyRemove" disabled>Remove managed Decky</button></div>
|
||||
<div class="hint">Install and update download Decky's official ARM64 release and verify its published SHA-256. Frame Control uses a removable user service and does not change the root filesystem. Remove keeps plugin files.</div>
|
||||
</section>
|
||||
<section class="panel" id="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>
|
||||
@@ -2122,6 +2141,74 @@
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let activeExtension = null;
|
||||
async function loadExtensions() {
|
||||
try {
|
||||
const data = await api('/api/extensions');
|
||||
$('extensionsFolder').textContent = `Put a folder with manifest.json and index.html in ${data.folder}`;
|
||||
$('extensionsList').innerHTML = data.extensions.length
|
||||
? data.extensions.map(x => `<div class="row" style="margin:7px 0"><button class="small" data-extension="${esc(x.id)}">Open ${esc(x.name)}</button><span class="sub">${esc(x.description)} · ${esc(x.version)}</span></div>`).join('')
|
||||
: 'No extensions installed.';
|
||||
if (data.errors.length) $('extensionsList').innerHTML += `<div class="hint">${data.errors.map(x => `${esc(x.folder)}: ${esc(x.error)}`).join('<br>')}</div>`;
|
||||
$('extensionsList').querySelectorAll('[data-extension]').forEach(button => button.onclick = () => {
|
||||
const ext = data.extensions.find(x => x.id === button.dataset.extension);
|
||||
if (!ext) return;
|
||||
activeExtension = ext.id;
|
||||
$('extensionTitle').textContent = ext.name;
|
||||
$('extensionFrame').src = `/extension/${encodeURIComponent(ext.id)}/index.html`;
|
||||
$('extensionPanel').hidden = false;
|
||||
});
|
||||
} catch (error) { $('extensionsList').textContent = error.message; }
|
||||
}
|
||||
$('extensionsRefresh').onclick = loadExtensions;
|
||||
$('extensionClose').onclick = () => {
|
||||
activeExtension = null;
|
||||
$('extensionFrame').removeAttribute('src');
|
||||
$('extensionPanel').hidden = true;
|
||||
};
|
||||
let deckyInfo = null;
|
||||
async function loadDecky() {
|
||||
try {
|
||||
const d = deckyInfo = await api('/api/decky');
|
||||
$('deckyState').textContent = `${d.frameOS || 'Unknown'} ${d.variant || ''} · ${d.arch} · CEF ${d.cef ? 'ready' : 'unavailable'} · ${d.managed ? `managed Decky ${d.version || ''} ${d.running ? 'running' : 'stopped'}` : d.upstreamInstall ? 'upstream Decky installation found' : 'Decky not installed'}`;
|
||||
const plugins = [...d.plugins.map(x => `${x.name} (managed)`), ...d.upstreamPlugins.map(x => `${x.name} (upstream)`)];
|
||||
$('deckyPlugins').textContent = plugins.length ? `Installed plugins: ${plugins.join(', ')}` : 'No Decky plugins found.';
|
||||
deckyButtons();
|
||||
} catch (error) { deckyInfo = null; $('deckyState').textContent = error.message; deckyButtons(); }
|
||||
}
|
||||
function deckyButtons() {
|
||||
const ready = !!deckyInfo && deckyInfo.arch === 'aarch64' && deckyInfo.variant === 'vr' && deckyInfo.cef && !deckyInfo.upstreamInstall;
|
||||
const ack = $('deckyAck').checked;
|
||||
$('deckyInstall').disabled = !ack || !ready || deckyInfo.managed;
|
||||
$('deckyUpdate').disabled = !ack || !ready || !deckyInfo.managed;
|
||||
$('deckyRemove').disabled = !ack || !deckyInfo?.managed;
|
||||
}
|
||||
$('deckyAck').onchange = deckyButtons;
|
||||
$('deckyRefresh').onclick = loadDecky;
|
||||
for (const action of ['Install', 'Update', 'Remove']) {
|
||||
$(`decky${action}`).onclick = async () => {
|
||||
if (!$('deckyAck').checked || !deckyInfo) return;
|
||||
const verb = action.toLowerCase();
|
||||
if (verb === 'remove' && !confirm('Remove the Frame Control managed Decky service and binary? Plugin files will stay.')) return;
|
||||
await runJob(`${action} Decky Loader`, 'decky', () => api('/api/decky', {action:verb, acknowledged:true}));
|
||||
loadDecky();
|
||||
};
|
||||
}
|
||||
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";
|
||||
@@ -6067,6 +6154,7 @@ async function loadSteamPipewire() {
|
||||
$('steamPipewireLaunch').disabled = $('steamPipewireReset').disabled = true;
|
||||
$('steamPipewireStatus').textContent = e.message;
|
||||
}
|
||||
|
||||
}
|
||||
async function steamPipewireAction(action) {
|
||||
if (steamPipewireBusy) return;
|
||||
@@ -6090,10 +6178,10 @@ const PAGES = ["apps", "games", "tools", "settings"];
|
||||
const SECONDARY_PAGES = ["home", "capture", "devices", "control"];
|
||||
const SECTION_PAGE = {library:"games", discover:"apps", android:"apps", view:"capture", shots:"capture", device:"home",
|
||||
steamPipewire:"tools", vrTools:"tools", comfort:"tools", assistant:"tools", pad:"tools", sideloaded:"games", getgames:"games", steamStore:"games", androidLibrary:"apps", appStore:"apps", linuxApps:"apps",
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools"};
|
||||
display:"settings", androidSettings:"settings", transfer:"tools", media:"tools", artworkSettings:"settings", autoSync:"settings", power:"settings", macview:"tools", privacy:"settings", panels:"tools", storage:"tools", wireless:"tools", powerReport:"tools", backup:"tools", extensions:"tools", decky:"tools"};
|
||||
const TOOL_TITLES = {steamPipewire:"Steam desktop sharing", transfer:"Send to Frame", media:"Media player", artworkSettings:"Library artwork", autoSync:"Copy to this computer",
|
||||
pad:"Type on the headset", panels:"Panel switcher", vrTools:"VR tools", comfort:"Family & comfort", androidSettings:"App display & reports",
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery"};
|
||||
power:"Remote & power", macview:"Mac in the headset", privacy:"Privacy & updates", storage:"Game storage", wireless:"Wireless Adapter doctor", powerReport:"Power & heat report", backup:"Backup & recovery", extensions:"Extensions", decky:"Decky Loader"};
|
||||
const workspace = {source:"all", scope:"installed", apps:"installed", scroll:new Map(), key:null, hash:null, y:0};
|
||||
let page = "apps";
|
||||
// Reuse the existing live lists and form handlers in their new destinations.
|
||||
@@ -6200,6 +6288,8 @@ 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 === 'backup') loadBackups();
|
||||
if (tool === 'storage') loadStorage();
|
||||
if (tool === 'powerReport') loadPower();
|
||||
|
||||
+89
-26
@@ -78,6 +78,8 @@ 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
|
||||
|
||||
frame_host.trust_bundled_cas()
|
||||
|
||||
@@ -548,6 +550,15 @@ def status(_body):
|
||||
return s
|
||||
|
||||
|
||||
def decky(body):
|
||||
action = body.get("action")
|
||||
if action not in ("install", "update", "remove"):
|
||||
raise Failure("Choose install, update, or remove", 400)
|
||||
if body.get("acknowledged") is not True:
|
||||
raise Failure("Confirm that Decky support on the Frame is experimental", 400)
|
||||
return start_job("Decky Loader " + action, lambda: frame_decky.change(ssh, action))
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
|
||||
@@ -635,6 +646,7 @@ 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'"""
|
||||
@@ -647,8 +659,19 @@ 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
|
||||
@@ -660,7 +683,7 @@ def list_shots():
|
||||
when = float(mtime)
|
||||
except ValueError:
|
||||
continue
|
||||
local = SHOTS_DIR / parts[-1]
|
||||
local = shot_local_path(shot_id, device_id)
|
||||
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)
|
||||
@@ -682,31 +705,51 @@ def shot_image(query):
|
||||
|
||||
|
||||
def save_shots(body):
|
||||
"""Copy screenshots to ~/Pictures/SteamFrame, skipping ones already there."""
|
||||
"""Copy screenshots to per-headset/account/game folders without name collisions."""
|
||||
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)
|
||||
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:
|
||||
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))
|
||||
try:
|
||||
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)
|
||||
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
|
||||
msg = f"Saved {n} screenshot{'s' * (n != 1)} to ~/Pictures/SteamFrame"
|
||||
return {"message": msg + (f" ({skipped} already there)" if skipped else ""), "saved": n}
|
||||
|
||||
@@ -1936,7 +1979,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 = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
|
||||
saved = shot_local_path(body.get("id"))
|
||||
if not saved.exists():
|
||||
raise Failure("That screenshot isn't saved on this computer yet", 404)
|
||||
frame_host.reveal_path(saved)
|
||||
@@ -3288,6 +3331,7 @@ POST = {
|
||||
"/api/steam-pipewire": steam_pipewire_action,
|
||||
"/api/power": power_action,
|
||||
"/api/android/backup": android_backup, "/api/backup": backup_action,
|
||||
"/api/decky": decky,
|
||||
"/api/vr": vr,
|
||||
"/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call,
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
@@ -3434,7 +3478,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return False
|
||||
return True
|
||||
|
||||
def send_bytes(self, data, ctype, status=200, headers=()):
|
||||
def send_bytes(self, data, ctype, status=200, headers=(), extension=False):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", ctype)
|
||||
for name, value in headers:
|
||||
@@ -3442,8 +3486,12 @@ 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-Frame-Options", "DENY")
|
||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||
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'")
|
||||
try:
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
@@ -3482,6 +3530,21 @@ 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.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":
|
||||
|
||||
Reference in new issue
Block a user