Compare commits

...
10 changed files with 269 additions and 10 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Compare two explicit Windows Wireless Adapter doctor reads in local memory.
Highlight exact PnP instances and known state changes after a dropout;
incomplete inventories stay unknown. A Forget button clears the comparison.
No adapter setting changes or report uploads are made.
- Add recovery steps to the Windows Wireless Adapter doctor for streams that
drop as they start and dongles that will not reconnect. Compare exact PnP IDs
and test one connection condition at a time. In-app disable/re-enable remains
+2
View File
@@ -186,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.
+53
View File
@@ -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
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, 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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.24",
"version": "0.4.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.24",
"version": "0.4.25",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.24",
"version": "0.4.25",
"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"
+57
View File
@@ -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, []);
});
}
+10
View File
@@ -56,6 +56,16 @@ whether the PC's own Wi-Fi drops too. A new `#2` name is not proof that this
instance caused the failure. Keep the time of the drop and the two snapshots
for Steam Support; the doctor does not save or upload them.
The app shows **Changes since previous read** after the second click. It
compares exact PnP IDs, known status fields and reported channels/bands; an
unknown value is never treated as a state change. If the PnP source was
unavailable in either read, it declines to compare devices. Each successful
read becomes the baseline for the next one. **Forget previous read** clears the
baseline and comparison; closing the app clears them too. The before/after
comparison stays in local memory and is not uploaded or added to telemetry.
Reported changes can reflect sequential query timing or a service recovering,
not a fault or permission to disable any instance.
To narrow a drop at stream start, try one condition at a time: wired PC
networking instead of PC Wi-Fi, wired headphones instead of 2.4 GHz audio, and
spacing the USB radio dongles apart. Recheck the stream after each change. The
+27
View File
@@ -37,6 +37,33 @@ test('click reads once, disables repeated clicks and renders partial inventory',
assert.match(f.el('wirelessResult').innerHTML,/distinct Wi-Fi Direct/);
assert.match(f.el('wirelessStatus').textContent,/Partial PC snapshot/);
});
test('two explicit reads compare exact identities and known state changes without claiming a repair',async()=>{
const f=fixture();
const first={...sample,sources:{pnp:'ok'},devices:[{...sample.devices[0],id:'SWD\\WIFIDIRECT\\one',
name:'Adapter <one>',present:true,problemCode:0}]};
const second={...first,observedAt:'2026-10-24T12:01:00Z',devices:[
{...first.devices[0],present:false,problemCode:22},
{...first.devices[0],id:'SWD\\WIFIDIRECT\\two',name:'Adapter <two>'}]};
let calls=0; f.context.api=async()=> ++calls===1 ? first : second;
await f.el('wirelessRead').onclick();
assert.match(f.el('wirelessComparison').innerHTML,/Read PC adapters again/);
assert.equal(f.el('wirelessForget').disabled,false);
await f.el('wirelessRead').onclick();
const change=f.el('wirelessComparison').innerHTML;
for(const phrase of ['Changes since previous read','Newly listed','presence Yes → No',
'problem code 0 → 22','not a safe disable target','&lt;one&gt;','&lt;two&gt;']) assert.ok(change.includes(phrase),phrase);
assert.doesNotMatch(change,/<one>|<two>/);
f.el('wirelessForget').onclick();
assert.equal(f.el('wirelessComparison').textContent,'');
assert.equal(f.el('wirelessForget').disabled,true);
});
test('incomplete PnP reads cannot imply a device appeared or vanished',()=>{
const f=fixture(); f.context.before={...sample,sources:{pnp:'unavailable'}};
f.context.after={...sample,sources:{pnp:'ok'},devices:[]};
const out=f.run('compareWireless(before,after)');
assert.match(out,/comparison unavailable/);
assert.doesNotMatch(out,/No longer listed|Newly listed/);
});
test('unsupported, busy and failed probes leave no stale inventory and allow retry',async()=>{
for(const result of [{state:'unsupported',message:'Windows only'},{state:'busy',message:'Try again'},new Error('Denied')]) {
const f=fixture();f.el('wirelessResult').textContent='stale inventory';
+44 -2
View File
@@ -1441,8 +1441,9 @@
<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>
<button class="action" id="wirelessRead">Read PC adapters</button>
<div class="row"><button class="action" id="wirelessRead">Read PC adapters</button><button id="wirelessForget" disabled>Forget previous read</button></div>
<p id="wirelessStatus" role="status" aria-live="polite">Choose Read PC adapters to start. No automatic polling.</p>
<div id="wirelessComparison" aria-live="polite"></div>
<div id="wirelessResult"></div>
<div class="card" style="margin-top:16px"><h3>Before changing an adapter</h3>
<p>Multiple Microsoft Wi-Fi Direct Virtual Adapter instances can be normal. A number such as #2, a disconnected link or a non-present device does not establish which instance is extra or safe to disable. This tool cannot reliably identify the Steam dongle's primary or active instance, so it offers no disable action.</p>
@@ -5670,7 +5671,34 @@ document.querySelectorAll('[data-power-format]').forEach(b => b.onclick = async
// ---- Read-only PC wireless doctor ----
let wirelessSeq = 0;
let wirelessPrevious = null; // memory only; never sent or saved
const wirelessKnown = value => value === true ? 'Yes' : value === false ? 'No' : 'Unknown';
function compareWireless(previous, current) {
if (previous.sources?.pnp !== 'ok' || current.sources?.pnp !== 'ok')
return '<p class="hint">Exact device comparison unavailable: the PnP inventory was incomplete in one read. The current snapshot is shown below.</p>';
const keyed = snapshot => new Map((snapshot.devices || [])
.filter(d => typeof d.id === 'string' && d.id)
.map(d => [d.id.toLowerCase(), d]));
const before = keyed(previous), after = keyed(current), changes = [];
const describe = d => `${esc(d.name || 'Unnamed device')} (<code>${esc(d.id)}</code>)`;
for (const [id, device] of after) {
const old = before.get(id);
if (!old) { changes.push(`Newly listed: ${describe(device)}`); continue; }
for (const [field, label] of [['present','presence'], ['pnpStatus','PnP status'],
['problemCode','problem code'], ['operational','operation'], ['media','media'],
['defaultRoute','default route'], ['channel','reported channel'], ['band','reported band']]) {
if (old[field] == null || device[field] == null || old[field] === device[field]) continue;
const value = v => typeof v === 'boolean' ? wirelessKnown(v) : esc(v);
changes.push(`${describe(device)}: ${label} ${value(old[field])} → ${value(device[field])}`);
}
}
for (const [id, device] of before) if (!after.has(id)) changes.push(`No longer listed: ${describe(device)}`);
const shown = changes.slice(0, 25);
return `<div class="card"><h3>Changes since previous read</h3>
<p class="sub">${esc(previous.observedAt || 'Earlier read')} → ${esc(current.observedAt || 'Current read')}. These are reported changes, not proof of a new fault or an unused adapter. A newly listed numbered Wi-Fi Direct instance is not a safe disable target.</p>
${shown.length ? `<ul>${shown.map(line => `<li>${line}</li>`).join('')}</ul>` : '<p>No reported device changes in the available fields.</p>'}
${changes.length > shown.length ? `<p>${changes.length - shown.length} more changes omitted; inspect the current snapshot below.</p>` : ''}</div>`;
}
function renderWireless(r) {
const unknown = value => value == null ? 'Unknown' : esc(value);
const states = (value, known) => known[value] || (value == null ? 'Unknown' : `Other (${esc(value)})`);
@@ -5689,6 +5717,8 @@ function renderWireless(r) {
$('wirelessRead').onclick = async () => {
const seq = ++wirelessSeq;
$('wirelessRead').disabled = true;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessResult').textContent = '';
$('wirelessStatus').textContent = 'Reading this PC’s adapters…';
try {
@@ -5698,14 +5728,26 @@ $('wirelessRead').onclick = async () => {
$('wirelessStatus').textContent = r.message || 'PC wireless inventory unavailable.';
return;
}
$('wirelessComparison').innerHTML = wirelessPrevious ? compareWireless(wirelessPrevious, r) :
'<p class="hint">Read PC adapters again after a dropout to compare exact device reports. The previous read stays only in this open app.</p>';
wirelessPrevious = r;
$('wirelessResult').innerHTML = renderWireless(r);
$('wirelessStatus').textContent = r.state === 'partial' ? 'Partial PC snapshot. Some sources are unavailable. No settings changed.' : 'PC snapshot received. Unknown channel/band fields may remain. No settings changed.';
} catch (e) {
if (seq === wirelessSeq) $('wirelessStatus').textContent = `PC wireless inventory unavailable: ${e.message}`;
} finally {
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
if (seq === wirelessSeq) {
$('wirelessRead').disabled = false;
$('wirelessForget').disabled = !wirelessPrevious;
}
}
};
$('wirelessForget').onclick = () => {
wirelessPrevious = null;
$('wirelessForget').disabled = true;
$('wirelessComparison').textContent = '';
$('wirelessStatus').textContent = 'Previous adapter read forgotten. Read again to start a new comparison.';
};
// ---- Read-only game storage ----
let storageSeq = 0;