Compare commits

...
5 Commits
12 changed files with 230 additions and 7 deletions

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
## 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
deferred until an active Steam adapter instance can be identified safely.
[Guide and limits](docs/windows-wireless-doctor.md).
- Add an explicit host window-switch button to whole-display panels: Alt-Tab
on Windows and Command-Tab on macOS. Single-window panels keep their own
source, so the button is hidden there.
- Enable the in-app OBS source Start/Stop control on Windows. A control pipe
lets the Python relay close its SSH child on Stop, app quit or parent exit;
add a short [Discord guide](docs/streaming-to-discord.md).
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.23",
"version": "0.4.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.23",
"version": "0.4.25",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.23",
"version": "0.4.25",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+6
View File
@@ -7,6 +7,12 @@ the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
only). On Windows there's an experimental port,
[PC in the headset](pc-in-headset.md).
On a **whole Mac display** panel, click **Switch Mac app (⌘Tab)** to send one
Command-Tab chord to the Mac. The button is absent from single-window panels;
those already keep their own source. It requires the same Mac Accessibility
permission as other viewer keyboard input. The chord and button have automated
checks, but a worn-headset click has not been verified.
The confidence labels are the same as in [ssh.md](ssh.md).
## Why this design
+6
View File
@@ -13,6 +13,12 @@ whole screens. Place the panel with the dashboard's Float
in World, Move and Size, point and click with the laser, scroll with the
thumbstick, and type on the PC's keyboard.
On a whole-screen panel, click **Switch PC window (Alt+Tab)** to switch the
foreground Windows window. It sends one explicit Alt-Tab chord through the
viewer's existing input channel. The button is absent from single-window panels
when a WGC-enabled build supports them. A real Windows/Frame button click has
not been verified; use the PC keyboard if focus does not move as expected.
**It's experimental.** A Windows 11 VM has captured its real desktop with
Desktop Duplication and encoded it with Media Foundation and JPEG. Physical PCs,
hardware encoding and physical laser input remain unverified. See
+40
View File
@@ -48,6 +48,46 @@ this report. A future disable feature needs a verified Steam adapter/session
mapping, fresh protection for primary/active instances, explicit confirmation
for one exact instance, and a tested recovery path that re-enables that same ID.
## When a stream drops immediately or the adapter will not reconnect
Read PC adapters **before** starting a stream, then refresh immediately after a
drop. Compare the *exact* PnP instance IDs, presence and problem codes. Note
whether the PC's own Wi-Fi drops too. A new `#2` name is not proof that this
instance caused the failure. Keep the time of the drop and the two snapshots
for Steam Support; the doctor does not save or upload them.
The app shows **Changes since previous read** after the second click. It
compares exact PnP IDs, known status fields and reported channels/bands; an
unknown value is never treated as a state change. If the PnP source was
unavailable in either read, it declines to compare devices. Each successful
read becomes the baseline for the next one. **Forget previous read** clears the
baseline and comparison; closing the app clears them too. The before/after
comparison stays in local memory and is not uploaded or added to telemetry.
Reported changes can reflect sequential query timing or a service recovering,
not a fault or permission to disable any instance.
To narrow a drop at stream start, try one condition at a time: wired PC
networking instead of PC Wi-Fi, wired headphones instead of 2.4 GHz audio, and
spacing the USB radio dongles apart. Recheck the stream after each change. The
PC Wi-Fi channel in this report is not the Steam dongle's channel, so a matching
or different number does not establish interference.
If the adapter will not reconnect, stop the failed stream and close Steam's
streaming session before touching its USB connection. Refresh the inventory. If
the dongle is absent, reconnect it once at the PC, understanding that this
interrupts any connection using it, then refresh again. If the **same exact**
PnP instance now reports problem code 22 (disabled), review that instance in
Windows Device Manager and its normal enable action. Do not enable or disable a
different numbered instance by guesswork. For a missing device or another
problem code, record the before/after ID and code for Steam Support; do not
guess a driver or change security settings.
These are user-controlled checks, not an automatic repair. We have no physical
Steam Wireless Adapter dropout to reproduce and no verified mapping from a
Wi-Fi Direct virtual instance to Steam's active session. An in-app disable and
re-enable control remains deferred until that mapping and a recovery path are
tested on real hardware.
## Checking a 2.4 GHz audio conflict
[Deko's report](https://x.com/DekoDrak/status/2113412044644897023)
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import vm from 'node:vm';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const page = readFileSync(join(root, 'ui', 'mac-view.html'), 'utf8');
const match = page.match(/function windowSwitchEvents\(hostName\) \{[\s\S]*?\n\}/);
assert.ok(match, 'viewer exposes a bounded host-specific switch sequence');
const events = vm.runInNewContext(`(${match[0]})`);
for (const [host, modifier, mod] of [
['mac', 'MetaLeft', 'meta'], ['windows', 'AltLeft', 'alt'],
]) {
const sequence = JSON.parse(JSON.stringify(events(host)));
assert.deepEqual(sequence.map(x => [x.e, x.code]),
[['down', modifier], ['down', 'Tab'], ['up', 'Tab'], ['up', modifier]]);
assert.deepEqual(sequence[1].mods, [mod]);
assert.deepEqual(sequence[2].mods, [mod]);
}
assert.deepEqual(JSON.parse(JSON.stringify(events('linux'))), []);
assert.match(page, /switchButton\.hidden = !src\.startsWith\("display:"\)/);
console.log('window switch sequences passed');
+17
View File
@@ -0,0 +1,17 @@
"""The viewer sends one explicit native window-switch chord per click."""
from pathlib import Path
import shutil
import subprocess
import unittest
class WindowSwitch(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is required for the viewer script check')
def test_host_chords_and_display_only_guard(self):
script = Path(__file__).parent / 'page' / 'window_switch.mjs'
result = subprocess.run(['node', str(script)], capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
if __name__ == '__main__':
unittest.main()
+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';
+2 -1
View File
@@ -411,7 +411,8 @@ class MacView:
# A single-use ticket for this source, not Frame Control's key: the URL
# is visible in the Frame's process list.
ticket = self.call("/ticket", method="POST", src=src)["ticket"]
params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
params = {"src": src, "t": ticket, "tag": tag, "host": self.host,
"codec": q["codec"], "max": q["max"], "fps": q["fps"],
"bpp": q["bpp"]}
url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
w, h = fit(width or 1280, height or 720)
+52 -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>
@@ -1452,6 +1453,14 @@
<p>A Razer or other 2.4 GHz audio dongle can share spectrum with Wi-Fi. This inventory cannot measure interference, detect the audio dongle's channel or prove what caused a dropout or PC shutdown.</p>
<p>Try wired headphones briefly and compare. Space USB radio dongles apart with an extension lead; if your PC uses 2.4 GHz Wi-Fi, compare Ethernet or an existing 5/6 GHz network. Change one thing at a time and keep notes. No network or security settings are changed here.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If streaming drops as it starts</h3>
<p>Read PC adapters before starting, then refresh immediately after a drop. Compare exact PnP instance IDs, presence and problem codes; a new numbered name alone does not identify a fault. Note whether the PC's own Wi-Fi also drops.</p>
<p>Retry once with wired PC networking or wired audio if available, then with the radio dongles spaced apart. Change one condition at a time. The PC Wi-Fi channel shown above is not the Steam dongle channel, and this check cannot prove RF interference.</p>
</div>
<div class="card" style="margin-top:16px"><h3>If the adapter will not reconnect</h3>
<p>Stop the failed stream, close Steam's streaming session, and check that the adapter still appears in the refreshed PC snapshot. If it is absent, reconnect the USB dongle once at the PC and read again; this will interrupt any connection using it.</p>
<p>If the same exact PnP instance shows problem code 22 (disabled), use Windows Device Manager to review that instance and its enable action. Do not enable or disable a different numbered instance by guesswork. If it stays missing or reports another problem code, keep the before/after IDs and error for Steam Support. Frame Control does not change the driver, radio or network settings.</p>
</div>
</section>
<section class="panel" id="powerReport">
@@ -5662,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)})`);
@@ -5681,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 {
@@ -5690,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;
+39 -1
View File
@@ -18,10 +18,16 @@
#note[hidden] { display: block; opacity: 0; }
#hud { position: fixed; left: 8px; top: 8px; font: 12px/1.35 ui-monospace, monospace; color: #dfe;
background: rgba(0,0,0,.72); padding: 6px 9px; border-radius: 6px; pointer-events: none; white-space: pre; }
#switchWindow { position: fixed; right: 12px; bottom: 12px; z-index: 2; min-height: 44px;
padding: 8px 14px; border: 1px solid #a8c8d9; border-radius: 7px;
background: rgba(19,35,48,.9); color: #fff; font: 15px system-ui, sans-serif; cursor: pointer; }
#switchWindow:focus-visible { outline: 3px solid #66c0f4; outline-offset: 3px; }
#switchWindow:disabled { opacity: .5; cursor: default; }
</style>
</head>
<body>
<canvas id="c" width="16" height="9"></canvas>
<button id="switchWindow" type="button" hidden>Switch window</button>
<div id="note">Connecting to your computer…</div>
<div id="hud" hidden></div>
<script>
@@ -29,10 +35,14 @@
const q = new URLSearchParams(location.search);
// t: a single-use ticket from Frame Control. After the first connection the
// Mac sends a reconnect key, kept only in memory.
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "", host = q.get("host") || "";
document.title = host === "windows" ? "PC" : "Mac";
let reconnectKey = "";
const c = document.getElementById("c"), ctx = c.getContext("2d", { alpha: false, desynchronized: true });
const note = document.getElementById("note");
const switchButton = document.getElementById("switchWindow");
switchButton.hidden = !src.startsWith("display:") || !["mac", "windows"].includes(host);
if (!switchButton.hidden) switchButton.textContent = host === "mac" ? "Switch Mac app (⌘Tab)" : "Switch PC window (Alt+Tab)";
// Counters for Frame Control's tests (read over DevTools).
const stats = window.stats = { frames: 0, keyFrames: 0, bytes: 0, dropped: 0, codec: "", errors: [], info: null };
@@ -127,6 +137,29 @@ function show(text, ms) {
}
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
function windowSwitchEvents(hostName) {
const code = hostName === "mac" ? "MetaLeft" : hostName === "windows" ? "AltLeft" : "";
if (!code) return [];
const key = hostName === "mac" ? "Meta" : "Alt", mod = hostName === "mac" ? "meta" : "alt";
return [
{ t: "k", e: "down", code, key, mods: [] },
{ t: "k", e: "down", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code: "Tab", key: "Tab", mods: [mod] },
{ t: "k", e: "up", code, key, mods: [] },
];
}
switchButton.addEventListener("click", () => {
if (!ws || ws.readyState !== 1) return show("Connect to your computer before switching windows.", 4000);
const events = windowSwitchEvents(host);
if (!events.length) return;
switchButton.disabled = true;
send(stamp(events[0]));
setTimeout(() => {
send(stamp(events[1]));
send(events[2]);
setTimeout(() => { send(events[3]); switchButton.disabled = false; }, 60);
}, 40);
});
let lastKeyAsk = 0;
function askKeyFrame() {
const now = performance.now();
@@ -321,6 +354,7 @@ function flushMove() {
pendingMove = null;
}
addEventListener("pointermove", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside && !e.buttons) return;
pendingMove = { t: "m", e: "move", x: p.x, y: p.y };
@@ -331,6 +365,7 @@ addEventListener("pointermove", e => {
setTimeout(flushMove, wait);
});
addEventListener("pointerdown", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
if (!p.inside) return;
if (e.pointerId !== undefined) try { c.setPointerCapture(e.pointerId); } catch (err) {}
@@ -339,6 +374,7 @@ addEventListener("pointerdown", e => {
e.preventDefault();
});
addEventListener("pointerup", e => {
if (e.target === switchButton) return;
const p = toPicture(e);
send({ t: "m", e: "up", b: e.button < 0 ? 0 : e.button, x: p.x, y: p.y });
});
@@ -346,6 +382,7 @@ addEventListener("pointerup", e => {
addEventListener("pointercancel", () => send({ t: "release" }));
addEventListener("contextmenu", e => e.preventDefault());
addEventListener("wheel", e => {
if (e.target === switchButton) return;
const p = toPicture(e), unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? innerHeight : 1;
send(stamp({ t: "wheel", dx: e.deltaX * unit, dy: e.deltaY * unit, x: p.x, y: p.y }, e));
e.preventDefault();
@@ -354,6 +391,7 @@ function mods(e) {
return ["shift", "ctrl", "alt", "meta"].filter(m => e[m + "Key"]);
}
function onKey(e, down) {
if (e.target === switchButton) return; // let Enter/Space activate the button
// Ctrl+Alt+Shift+S shows the numbers; it isn't sent to the Mac.
if (e.code === "KeyS" && e.ctrlKey && e.altKey && e.shiftKey) {
if (down) showHud(hud.hidden);