mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 09:01:43 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e1eac02dc | ||
|
|
04f737e7a7 |
No files matched your search
@@ -25,8 +25,6 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
@@ -188,6 +186,8 @@ or run it with `--appimage-extract-and-run`.
|
||||
|
||||
## Open the app
|
||||
|
||||
**Optional: open at login.** In the installed desktop app, choose **Frame → Open at Login**. It is off by default. When on, a login launch starts in the tray or menu bar if that control is available; click its icon to open the window, or use its **Quit Frame Control** menu item. Closing the window keeps the app in the tray while this option is on. Turn the same menu option off to remove its OS login item. On Linux this creates or removes only Frame Control's own file in `~/.config/autostart/` (or `$XDG_CONFIG_HOME/autostart/`). If the desktop has no working tray, the window opens at login instead. A source checkout does not offer this setting.
|
||||
|
||||
- **macOS:** open the `.dmg`, drag **Frame Control** to **Applications**, then
|
||||
open it from Applications. For the unsigned-app warning, use the macOS steps
|
||||
above. Allow local network access and control of Terminal when prompted.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.25**, 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.25 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) |
|
||||
|
||||
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.
|
||||
@@ -52,7 +52,6 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p><strong>This is a September 2026 archive.</strong> For current coverage and hardware limits, read the <a href="current-support-matrix.md">v0.4.25 support matrix</a>.</p>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a> · <a href="https://github.com/saphid/frame-control/blob/main/docs/current-support-matrix.md">What we tested</a></p>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" id="download-mac" data-os="mac">
|
||||
|
||||
Reference in new issue
Block a user