Compare commits

...
Author SHA1 Message Date
SAPHID 61f576ebab Clarify checksum rollout and macOS signing status 2026-10-25 01:26:38 +00:00
SAPHID c9852f5762 Read release safety text as UTF-8 on Windows 2026-10-25 01:14:27 +00:00
SAPHID f6ecf9b1f4 Write release checksums as exact LF bytes on Windows 2026-10-25 01:07:38 +00:00
SAPHID 630cdd7b34 Attach and verify release SHA256SUMS with download guidance 2026-10-25 01:00:08 +00:00
Alex SouthwellandSAPHID 74089fb2f1 Bump Frame Control to 0.4.25 (#185)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:43:52 +00:00
Alex SouthwellandSAPHID 7f828cbcf8 Compare Windows wireless snapshots after dropouts (#184)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:35:48 +00:00
Alex SouthwellandSAPHID 42665d14db Guide recovery for Windows Wireless Adapter dropouts (#183)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:23:39 +00:00
Alex SouthwellandSAPHID 8dd6eb3fe9 Bump Frame Control to 0.4.24 (#182)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-25 00:06:07 +00:00
Alex SouthwellandSAPHID ef309c9931 Add window switch control to desktop panels (#181)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:57:35 +00:00
Alex SouthwellandSAPHID de0f0eb0e3 Bump Frame Control to 0.4.23 (#180)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:39:29 +00:00
Alex SouthwellandSAPHID bdef125f68 Enable OBS source control on Windows (#179)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:31:23 +00:00
Alex SouthwellandSAPHID f64cbe5bac Add read-only wired connection doctor (#178)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:23:29 +00:00
Alex SouthwellandSAPHID d4714eadf7 Open Android catalogue without waiting for installed apps (#177)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 23:04:06 +00:00
Alex SouthwellandSAPHID bcdd69f6d8 Bump Frame Control to 0.4.22 (#176)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:45:11 +00:00
Alex SouthwellandSAPHID f5becdd2c0 Document real OBS Media Source verification (#175)
* Document real OBS Media Source video and audio verification

* Record packaged OBS Start and Stop headset check

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:33:11 +00:00
Alex SouthwellandSAPHID e185cb3b29 Bundle OBS source with desktop Start and Stop control (#174)
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 22:05:20 +00:00
39 changed files with 1223 additions and 40 deletions

No files matched your search

+21
View File
@@ -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
+39
View File
@@ -2,6 +2,45 @@
## 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).
- Add a read-only wired connection doctor for a PC using the Frame's observed
USB-C network: show the local route, SSH port and pinned-key login result
without changing IP, host keys or headset settings.
[Usage and limits](docs/wired-connection.md).
- Start the Android app catalogue without waiting for the headset's installed-app
list. Browsing remains available if that status request is slow or fails;
installation still waits for a confirmed list to avoid duplicate installs.
- Verify the packaged OBS Start and Stop control with a real Frame, and record
37.600 seconds of headset mirror video and non-silent output audio in OBS.
Listening, sync and active passthrough remain unverified.
[Test record](docs/evidence/obs-media-source-real-2026-10-25.md).
- Bundle the OBS source helper in Linux and macOS installers and add a
**Capture & Live** Start/Stop control. The app selects the active Frame and
closes its SSH stream on a headset switch or app exit. A real Frame stream
reached OBS with video and non-silent audio; listening and sync remain
unverified. [Setup and limits](docs/obs-headset-source.md).
- Add a source-checkout helper that sends the Frame's SteamVR mirror and
default-output audio to a local OBS Media Source. A real Frame supplied a
4.021-second H.264/AAC stream to a Linux receiver; OBS playback and sync
+19 -1
View File
@@ -130,6 +130,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
@@ -187,7 +205,7 @@ or run it with `--appimage-extract-and-run`.
## Open the app
- **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
+1
View File
@@ -17,6 +17,7 @@ const REQUIRED = [
"ui/frame_status.py",
"scripts/frame_power_sample.py",
"scripts/frame_power_report.py",
"scripts/obs-headset-source.py",
"ui/frame_android.py",
"frame/android/lepton-app.sh",
"frame/openxr-compat/XrApiLayer_FRAME_compat.json",
+87 -2
View File
@@ -304,6 +304,62 @@ ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
// The OBS source is local to this computer. Closing the Python relay's control
// pipe lets it stop SSH on every platform, including Windows where SIGTERM
// terminates Python without running its cleanup block.
const OBS_SCRIPT = path.join(SCRIPTS, "obs-headset-source.py");
let obsChild = null;
let obsState = { state: "idle", alias: "", error: "" };
function publicObs() { return { ...obsState, supported: true }; }
function setObs(state, alias = "", error = "") {
obsState = { state, alias, error };
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
return publicObs();
}
function stopObs() {
if (!obsChild) return publicObs();
setObs("stopping", obsState.alias);
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
return publicObs();
}
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
ipcMain.handle("obs:start", async (e) => {
if (!fromUi(e)) return null;
if (obsChild) return publicObs();
const alias = activeAlias();
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
if (!fs.existsSync(OBS_SCRIPT)) throw new Error("The OBS helper is missing; reinstall Frame Control.");
const env = { ...process.env, PATH: await loginPath() };
const executable = python || await findPython(env);
if (!executable) throw new Error(PYTHON_HELP);
// A concurrent click or a headset switch may have happened while finding Python.
if (obsChild) return publicObs();
if (alias !== activeAlias()) throw new Error("The selected headset changed; try again.");
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias, "--control-stdin"],
{ env, stdio: ["pipe", "ignore", "pipe"], windowsHide: true });
obsChild = child;
setObs("starting", alias);
let output = "";
child.stderr.on("data", (chunk) => {
output = (output + chunk.toString("utf8")).slice(-2048);
if (obsChild === child && output.includes("Frame Control OBS stream is ready")) setObs("running", alias);
});
child.once("error", (err) => {
if (obsChild !== child) return;
obsChild = null;
setObs("failed", alias, `Could not start OBS source: ${err.message}`);
});
child.once("exit", (code, signal) => {
if (obsChild !== child) return;
const stopping = obsState.state === "stopping" || quitting;
obsChild = null;
setObs(stopping ? "idle" : "failed", alias,
stopping ? "" : `OBS source stopped (${signal || code}). ${output.trim().split("\n").pop() || "Check SteamVR and SSH."}`);
});
return publicObs();
});
ipcMain.handle("obs:stop", (e) => fromUi(e) ? stopObs() : null);
// The page reports the headsets it knows (the server's Devices tab), so the Frame
// menu can switch between them. Only plain names and ids go into the menu.
const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
@@ -314,6 +370,7 @@ ipcMain.on("devices:changed", (e, list) => {
.map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
if (JSON.stringify(next) === JSON.stringify(devices)) return;
devices = next;
if (obsChild && obsState.alias !== activeAlias()) stopObs();
buildMenu();
});
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
@@ -641,6 +698,34 @@ if (!app.requestSingleInstanceLock()) {
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
app.on("before-quit", () => { quitting = true; stopServer(); });
process.on("exit", stopServer);
let obsQuitWaiting = false;
let obsQuitReady = false;
app.on("before-quit", (event) => {
quitting = true;
stopServer();
if (!obsChild || obsQuitReady) return;
event.preventDefault();
if (obsQuitWaiting) return;
obsQuitWaiting = true;
const child = obsChild;
let timer;
child.once("close", () => {
clearTimeout(timer);
obsQuitReady = true;
app.quit();
});
stopObs();
timer = setTimeout(() => {
if (child.exitCode === null) {
if (IS_WIN) {
// Only the helper and its SSH child: never enumerate unrelated SSH jobs.
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"],
{ stdio: "ignore", windowsHide: true, detached: true }).unref();
} else child.kill("SIGKILL");
}
obsQuitReady = true;
app.quit();
}, 8000);
});
process.on("exit", () => { stopObs(); stopServer(); });
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.21",
"version": "0.4.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.21",
"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.21",
"version": "0.4.25",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -69,6 +69,7 @@
"to": "scripts",
"filter": [
"*.sh",
"obs-headset-source.py",
"frame_power_sample.py",
"frame_power_report.py"
]
+9
View File
@@ -21,6 +21,15 @@ contextBridge.exposeInMainWorld("frameApp", {
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
obs: {
status: () => ipcRenderer.invoke("obs:status"),
start: () => ipcRenderer.invoke("obs:start"),
stop: () => ipcRenderer.invoke("obs:stop"),
onState: (cb) => {
ipcRenderer.removeAllListeners("obs:state");
ipcRenderer.on("obs:state", (_e, state) => cb(state));
},
},
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
@@ -0,0 +1,48 @@
# OBS Media Source on a real Frame, 2026-10-25 AEDT
The Linux x86_64 AppImage from PR #174's exact head `69fff152` had SHA-256
`4902ac80b77fbf98ec918db29df9cf9260b8abd6763a5b5055572335869f038a`.
Its extracted resources passed `app/build/check-resources.js`, including the
OBS helper, and the app launched under Xvfb with an isolated profile and a
dummy SSH alias. Its bundled server served the page with HTTP 200.
For the device test, the PC was lxso2. The real Frame was reached as `ssh frame`
under an atomically acquired `/tmp/frame-test.lock`. Battery read 100% Full
before and after. SteamVR 2.18.2 reported `activity_level: 3` (standby). The
mirror service and `/dev/video99` were active. The test cleaned its temporary
remote WAV and released only its own lock.
OBS 30.0.2 ran in a disposable Ubuntu 24.04 container on lxso2, with Xvfb and
host loopback networking. A Media Source opened `udp://127.0.0.1:15099` with
input format `mpegts`. A synthetic pattern and tone first verified the OBS
scene: its saved 43.733-second MKV had H.264 video and AAC audio with a
`-17.9 dB` audio peak. The real Frame test then ran the helper from the
extracted AppImage resources **before** OBS opened the source, so OBS joined
an ongoing stream. OBS's saved 37.600-second MKV had H.264 video and AAC audio.
The standby mirror appeared in decoded frames by five seconds. A 440 Hz tone
played briefly to the Frame's default PulseAudio output appeared in the OBS
audio recording at seconds 20–21; decoded audio peaked at `-34.4 dB`. A
separate direct recording of `@DEFAULT_MONITOR@` during the same type of tone
peaked at `-34.5 dB`. These measurements verify non-silent headset output
audio passed through the helper and OBS into the saved recording.
The first OBS attempt opened its Media Source well before the Frame sent
packets. Its output had a silent AAC track until the mirror appeared late.
Starting the helper before OBS fixed this in the second run. The setup guide
now recommends starting the source in Frame Control before adding or activating
the OBS Media Source.
The exact-head AppImage was then launched on an isolated Xvfb display and
profile with the real `frame` alias. In **Tools → Capture & Live**, clicking
**Start OBS source** changed the UI to **Stop OBS source** and showed
`udp://127.0.0.1:15099` after the first stream packet. The packaged Python
helper was running with `--frame frame`. Clicking **Stop OBS source** returned
the UI to **Start OBS source**; the helper and its remote `/dev/video99` ffmpeg
process were gone. The app exited through **File → Quit**. Battery again read
100% Full, and the owned lock was released.
**Limits:** Nobody listened to OBS audio monitoring or the Frame speakers.
Audio/video sync was not measured. The headset was unworn and in standby, so
this test does not answer whether active passthrough pixels appear in the
mirror. The OBS MKVs were temporary local test artifacts and are not included
in the repository.
+5 -1
View File
@@ -84,7 +84,11 @@ counts them while they run.
- **Volume** and mute (`wpctl`).
- **Android apps**: search about 4,500 F-Droid apps rated for the Frame, install
one with a click as its own Lepton instance (it keeps its data and shows in the
Steam library), then launch, stop, test or remove it. **Report an APK** records
Steam library), then launch, stop, test or remove it.
The catalogue can load while the installed-app list is still being read. If
that read fails, browsing remains available, but installation waits for a
confirmed list so Frame Control does not make a duplicate app instance.
**Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
you see. With **Share compatibility results** on (Privacy & updates), they also
+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
+28 -15
View File
@@ -1,23 +1,35 @@
# Headset view in OBS
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
OBS on a Linux or macOS computer. The helper uses the same `/dev/video99` video
OBS on a Windows, Linux or macOS computer. The helper uses the same `/dev/video99` video
source and default-output monitor as **Capture & Live → Record**. It sends a
local MPEG-TS stream; no port is opened to the LAN or internet.
1. Connect to the Frame with `ssh frame` and start SteamVR. Install `python3`
on the computer; the Frame already has `ffmpeg`. OBS runs on the computer.
2. From this repository, run:
1. Connect to the Frame and start SteamVR. OBS runs on the computer; the Frame
already has `ffmpeg`.
2. In the **Frame Control desktop app**, open **Capture & Live** and click
**Start OBS source**. Keep Frame Control open; **Stop OBS source**, switching
headsets, or quitting the app closes its SSH stream. It uses the selected
headset and the app's bundled helper.
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
recording levels.
From a source checkout, you can instead install `python3` on the computer and
run:
```sh
python3 scripts/obs-headset-source.py
```
3. In OBS, add a **Media Source**, clear **Local File**, set **Input** to
`udp://127.0.0.1:15099`, and set **Input Format** to `mpegts`. The source
carries video and AAC audio. Use OBS's audio mixer to choose monitoring and
recording levels. Leave the helper running while the source is in use;
Ctrl+C stops it.
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.
To share the OBS view in a call, see the short
[Discord setup guide](streaming-to-discord.md).
`--height 1080 --fps 60` increases quality and Frame CPU use. The default is
720p30. `--port 15100` selects another loopback port; use the same port in
@@ -30,9 +42,10 @@ This is the composited, undistorted one-eye **SteamVR mirror**, not a raw
passthrough camera feed. The mirror may be blank when the headset is idle or
unworn. Whether active passthrough pixels appear in it is still unverified.
The helper does not send a microphone. It is designed for one local OBS
receiver. **Verified 2026-10-24:** against a real Frame running SteamOS 0.5.6,
build 20261022.6153909, the receiver saved a 4.021-second MPEG-TS sample on
the Linux PC; `ffprobe` found H.264 video and AAC audio. The temporary sample
was removed. The headset was unworn, so visible content and audible sound were
not checked. OBS itself was not installed on that PC; its Media Source setup,
monitoring and sync are still unverified.
receiver. **Verified 2026-10-25:** the helper bundled in the Linux AppImage
sent a real Frame stream to OBS 30's Media Source. OBS recorded 37.6 seconds
with H.264 video and AAC audio; a brief 440 Hz tone played to the Frame's
default output was present in the saved audio. The headset was in standby, so
the video showed the standby mirror. 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).
+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
+6 -2
View File
@@ -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
+20
View File
@@ -0,0 +1,20 @@
# Share the Frame view in Discord
1. Start the **OBS source** in Frame Control and add it to OBS as described in
[Headset view in OBS](obs-headset-source.md). Confirm that OBS shows the
headset mirror and that its audio meter moves before joining a call.
2. In OBS, click **Start Virtual Camera**. In Discord's **Voice & Video**
settings, select **OBS Virtual Camera** as the camera, then turn it on in the
call. This sends the OBS scene as video.
3. If the call needs the Frame's game sound, route OBS's monitored Media Source
audio to a virtual audio input installed on your computer, then select that
input as Discord's microphone. Keep headphones on to avoid feedback. Test
this in a private call first; a virtual camera alone carries no audio.
4. Stop the OBS source in Frame Control when you finish. It closes the SSH
stream; OBS may keep showing its last frame until the source is hidden.
Frame Control sends the Frame's **output audio**, not its microphone. Discord
audio routing differs by computer and requires a separate virtual audio device
if you want game sound in the call. This guide has not been verified in a real
Discord call. The SteamVR mirror can be blank while the headset is idle, and
active passthrough in that mirror remains unverified.
+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)
+27
View File
@@ -0,0 +1,27 @@
# Wired connection doctor
If you use a USB-C cable because the Wireless Adapter is unreliable, open
**Devices & connection → Wired connection doctor** in the desktop app. Enter
the headset's configured SSH alias and click **Check cable connection**. From
a source checkout, run `python3 scripts/frame-wired-doctor.py --alias frame`.
The on-demand check reads the computer's selected route to the Frame USB
address, checks whether port 22 answers, then runs remote `true` over SSH with
the existing pinned host key. It does not change network settings or the
headset, accept a new key, or attempt a password. A failure is shown as unknown
or warning; the doctor never claims a cable works from an IP address alone.
The known address is `10.86.200.233` on the Frame's `10.86.200.232/29` USB
network. A Mac with the Frame attached used `10.86.200.234/29` and completed a
strict SSH login on 2026-09-28; see [the observed link](how-the-frame-works.md).
If the route check says unknown, first confirm a data-capable USB-C cable, a
visible PC network interface and a current Frame OS build. Other OS builds may
use another subnet. If the port answers but SSH identity fails, connect using
the normal pinned headset alias and resolve the pairing or key warning there;
do not disable host-key checking.
This checks **Frame Control's SSH route**, not whether SteamVR's game stream
uses the cable. The Wireless Adapter and Steam streaming can follow a different
path. No cable is attached to the Linux test host, so this exact UI has only
synthetic route/SSH tests. Windows and Linux physical USB-C behavior, and a
fresh wired readback on the current Frame OS, remain unverified.
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
"""Source-checkout entry point for the PC's read-only USB-C route checks."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
from frame_wired_doctor import main # noqa: E402
if __name__ == '__main__':
raise SystemExit(main())
+22
View File
@@ -10,6 +10,7 @@ import signal
import socket
import subprocess
import sys
import threading
PACKET = 188
@@ -52,6 +53,20 @@ def relay(source, destination):
return total
def watch_control(stream, proc):
"""A desktop app closes its pipe to stop SSH, including on Windows.
Windows TerminateProcess does not run Python's finally block. The pipe also
closes if the app crashes, so the SSH connection is closed in either case.
"""
try:
stream.read(1)
except OSError:
pass
if proc.poll() is None:
proc.terminate()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--frame', default='frame', help='SSH host or alias (default: frame)')
@@ -59,6 +74,7 @@ def main(argv=None):
parser.add_argument('--height', type=int, choices=(720, 1080), default=720)
parser.add_argument('--fps', type=int, choices=(30, 60), default=30)
parser.add_argument('--no-audio', action='store_true', help='video only when the Frame audio monitor is unavailable')
parser.add_argument('--control-stdin', action='store_true', help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if not 1024 <= args.port <= 65535:
parser.error('port must be between 1024 and 65535')
@@ -71,6 +87,8 @@ 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)
if args.control_stdin:
threading.Thread(target=watch_control, args=(sys.stdin.buffer, proc), daemon=True).start()
try:
amount = relay(proc.stdout, _Sender(udp, target))
code = proc.wait(timeout=10)
@@ -94,9 +112,13 @@ def main(argv=None):
class _Sender:
def __init__(self, sock, target):
self.sock, self.target = sock, target
self.ready = False
def send(self, data):
self.sock.sendto(data, self.target)
if not self.ready:
self.ready = True
print('Frame Control OBS stream is ready', file=sys.stderr, flush=True)
if __name__ == '__main__':
+34 -1
View File
@@ -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"
+71
View File
@@ -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))
+11
View File
@@ -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 による署名・公証がありません。
+7
View File
@@ -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>
+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');
+41
View File
@@ -0,0 +1,41 @@
// Run the actual wired-doctor page script without browser or device I/O.
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import vm from 'node:vm';
class Element {
constructor() { this.value = ''; this.disabled = false; this.textContent = ''; this.children = []; }
append(...nodes) { this.children.push(...nodes); }
prepend(...nodes) { this.children.unshift(...nodes); }
replaceChildren(...nodes) { this.children = nodes; }
}
const elements = Object.fromEntries(['back','alias','check','message','results'].map(id => [id, new Element()]));
elements.alias.value = 'frame';
const requests = [];
let respond;
const context = vm.createContext({
document: {getElementById: id => elements[id], createElement: () => new Element()},
URLSearchParams, location: {search: '?key=test-key'},
fetch: (url, options) => { requests.push({url, options}); return new Promise(resolve => {respond = resolve;}); },
});
const source = readFileSync(new URL('../../ui/wired-doctor.html', import.meta.url), 'utf8');
vm.runInContext(source.match(/<script>([\s\S]*)<\/script>/)[1], context);
assert.equal(requests.length, 0, 'page load must not probe the PC or Frame');
assert.equal(elements.back.href, '/?key=test-key#devices');
let promise = elements.check.onclick();
assert.equal(elements.check.disabled, true);
assert.equal(requests[0].url, '/api/host/wired?alias=frame');
assert.equal(requests[0].options.headers['X-Frame-UI'], 'test-key');
respond({ok: true, json: async () => ({state: 'unknown', checks: [{title: '<script>bad</script>',status: 'unknown',detail: 'No known route'}]})});
await promise;
assert.equal(elements.results.children.length, 1);
assert.equal(elements.results.children[0].children[0].textContent, '<script>bad</script>', 'device text must stay plain text');
assert.equal(elements.check.disabled, false);
promise = elements.check.onclick();
assert.equal(elements.results.children.length, 0, 'stale result cleared before retry');
respond({ok: false, json: async () => ({error: 'PC route unavailable'})});
await promise;
assert.match(elements.message.textContent, /route unavailable/);
assert.equal(elements.results.children.length, 0);
assert.equal(elements.check.disabled, false);
console.log('wired doctor UI: on-demand, escaped data, stale-result clearing and retry passed');
+21 -9
View File
@@ -95,8 +95,20 @@ test('Assistant Back encodes the injected key for the same-origin workspace only
assert.match(html, /referrerpolicy="no-referrer"/);
});
test('catalogue browsing starts while installed-app discovery is still pending', async () => {
const f = fixture(read('store.html'));
assert.deepEqual(f.requests.map(r => r.url.split('?')[0]), ['/api/android', '/api/search']);
await f.answer(1, {apps: [{name: 'Example Game', category: 'games', package: 'example.game',
offers: [{source: 'test', id: 'example'}]}], sources: []});
assert.equal(f.run('st.installedState'), 'loading');
assert.match(f.element('content').innerHTML, /Example Game/);
await f.answer(0, {apps: [{package: 'example.game', label: 'Example Game'}]});
assert.match(f.element('content').innerHTML, /Installed/);
});
test('installed read failure is persistent, retry recovers, and cached entries are marked stale', async () => {
const f = fixture(read('store.html'));
await f.answer(1, {apps: [], sources: []});
f.run('st.view = "installed"; render()');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
f.requests[0].reject(Error('offline')); await tick();
@@ -105,16 +117,16 @@ test('installed read failure is persistent, retry recovers, and cached entries a
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const retry = f.element('content').querySelector('[data-retry-installed]');
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.requests[1].url, '/api/android');
await f.answer(1, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.requests[2].url, '/api/android');
await f.answer(2, {apps: [{package: 'cached.app', label: 'Cached app'}]});
assert.equal(f.run('st.installedState'), 'ready');
const failed = f.run('loadInstalled()');
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
f.requests[2].reject(Error('refresh failed')); await failed;
f.requests[3].reject(Error('refresh failed')); await failed;
assert.match(f.element('content').innerHTML, /Cached app/);
assert.match(f.element('content').innerHTML, /last (?:known|read)|stale/i);
assert.match(f.element('content').innerHTML, /data-retry-installed/);
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
const empty = f.run('loadInstalled()'); await f.answer(4, {apps: []}); await empty;
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
});
@@ -122,12 +134,12 @@ test('installed read failure is persistent, retry recovers, and cached entries a
test('newest installed read wins over older success and failure without pruning cached data', async () => {
const f = fixture(read('store.html'));
f.run('st.needsData.add("new.app"); st.jobs.set("data:new.app", {done: false})');
const newer = f.run('loadInstalled()'); await f.answer(1, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
const newer = f.run('loadInstalled()'); await f.answer(2, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installed.has("new.app")'), true);
assert.equal(f.run('st.needsData.has("new.app")'), true);
const old = f.run('loadInstalled()'), newest = f.run('loadInstalled()');
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
await f.answer(4, {apps: []}); await newest; // request 1 is the independent bootstrap catalogue search
f.requests[3].reject(Error('late failure')); await old;
assert.equal(f.run('st.installedState'), 'ready');
assert.equal(f.run('st.installedError'), '');
@@ -137,12 +149,12 @@ test('newest installed read wins over older success and failure without pruning
test('an old success cannot mask the newest failure, and malformed data is not successful empty', async () => {
const f = fixture(read('store.html'));
f.run('st.view = "installed"');
const newest = f.run('loadInstalled()'); f.requests[1].reject(Error('latest offline')); await newest;
const newest = f.run('loadInstalled()'); f.requests[2].reject(Error('latest offline')); await newest;
await f.answer(0, {apps: []});
assert.equal(f.run('st.installedState'), 'error');
assert.equal(f.run('st.installedError'), 'latest offline');
assert.doesNotMatch(f.element('content').innerHTML, /Nothing installed yet/);
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
const malformed = f.run('loadInstalled()'); await f.answer(3, {}); await malformed;
assert.equal(f.run('st.installedState'), 'error');
assert.match(f.element('content').innerHTML, /data-retry-installed/);
});
@@ -197,12 +209,12 @@ test('named native Store dialog enters, contains and restores focus across async
test('dialog Retry recovers installed knowledge without losing focus, and cancel restores its opener', async () => {
const f = fixture(read('store.html'));
await f.answer(1, {apps: [], sources: []});
f.context.app = {name: 'Example', detailed: true, offers: [{source: 'test', id: 'example'}]};
f.run('st.view = "games"; st.apps = [app]; render()');
const opener = f.element('content').querySelector('[data-i="0"]'); opener.focus();
f.run('openDetail(app)');
f.requests[0].reject(Error('offline')); await tick();
await f.answer(1, {apps: [f.context.app], sources: []});
const retry = f.element('sheet').querySelector('[data-retry-installed]'); retry.focus();
for (const listener of f.events.get('click')) listener({target: retry});
assert.equal(f.document.activeElement.dataset.act, 'close');
+72
View File
@@ -1,7 +1,15 @@
"""The OBS relay must preserve transport packets and keep its output local."""
import importlib.util
import io
import json
import os
from pathlib import Path
import signal
import socket
import subprocess
import sys
import tempfile
import time
import unittest
@@ -20,6 +28,13 @@ class Receiver:
class ObsSourceTests(unittest.TestCase):
def test_desktop_packages_the_helper_as_a_required_resource(self):
root = SCRIPT.parents[1]
build = json.loads((root / 'app' / 'package.json').read_text())['build']
scripts = next(item for item in build['extraResources'] if item['to'] == 'scripts')
self.assertIn(SCRIPT.name, scripts['filter'])
self.assertIn('scripts/' + SCRIPT.name, (root / 'app' / 'build' / 'check-resources.js').read_text())
def test_relay_keeps_whole_transport_packets(self):
stream = bytes(range(188)) * 101
receiver = Receiver()
@@ -40,6 +55,63 @@ class ObsSourceTests(unittest.TestCase):
self.assertIn('-f mpegts', with_audio)
self.assertNotIn('@DEFAULT_MONITOR@', obs.remote_command(audio=False))
@unittest.skipIf(sys.platform == 'win32', 'the packaged Start/Stop control is Linux/macOS only')
def test_relay_stops_its_ssh_child_on_app_signal_or_control_pipe(self):
for mode in ('signal', 'control-pipe'):
with self.subTest(mode=mode):
self._check_stop_mode(mode)
def _check_stop_mode(self, mode):
with tempfile.TemporaryDirectory() as folder, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
directory = Path(folder)
stopped = directory / 'stopped'
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(0)\n"
"signal.signal(signal.SIGTERM, stop)\n"
"sys.stdout.buffer.write(b'G' * 1316)\n"
"sys.stdout.buffer.flush()\n"
"while True: time.sleep(1)\n")
fake.chmod(0o755)
receiver.bind(('127.0.0.1', 0))
receiver.settimeout(8)
env = {**os.environ, 'PATH': str(directory) + os.pathsep + os.environ.get('PATH', ''),
'FAKE_SSH_STOPPED': str(stopped)}
arguments = [sys.executable, str(SCRIPT), '--frame', 'fake-frame',
'--port', str(receiver.getsockname()[1])]
if mode == 'control-pipe':
arguments.append('--control-stdin')
process = subprocess.Popen(arguments,
stdin=subprocess.PIPE if mode == 'control-pipe' else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, env=env)
try:
packet, _ = receiver.recvfrom(1500)
self.assertEqual(packet, b'G' * 1316)
if mode == 'control-pipe':
process.stdin.close()
process.wait(timeout=8)
errors = process.stderr.read()
else:
process.send_signal(signal.SIGTERM)
_, errors = process.communicate(timeout=8)
self.assertEqual(process.returncode, 0, errors.decode())
self.assertIn(b'Frame Control OBS stream is ready', errors)
for _ in range(30):
if stopped.exists():
break
time.sleep(0.1)
self.assertTrue(stopped.exists(), 'the SSH child survived relay shutdown')
finally:
if process.poll() is None:
process.kill()
process.communicate(timeout=5)
process.stdout.close()
process.stderr.close()
if __name__ == '__main__':
unittest.main()
+14
View File
@@ -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)
+38
View File
@@ -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()
+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()
+138
View File
@@ -0,0 +1,138 @@
"""The wired doctor never requires a cable or contacts the real Frame in tests."""
import sandbox # noqa: F401
from pathlib import Path
import http.client
import json
import subprocess
import sys
import threading
import unittest
from unittest.mock import patch
import shutil
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
import frame_wired_doctor as wired
class FakeProbe:
def __init__(self, source='10.86.200.234', port=True, ssh_code=0, stderr=''):
self.source, self.port, self.ssh_code, self.stderr = source, port, ssh_code, stderr
self.calls = []
def route_source(self):
self.calls.append('route')
return self.source
def port_open(self):
self.calls.append('port')
return self.port
def host_key_name(self, alias):
self.calls.append('config')
return alias + '.example.ts.net'
def ssh_check(self, alias, name):
self.calls.append(('ssh', alias, name))
return subprocess.CompletedProcess([], self.ssh_code, '', self.stderr)
class WiredDoctor(unittest.TestCase):
@unittest.skipUnless(shutil.which('node'), 'Node is needed for the page script')
def test_page_is_on_demand_and_clears_stale_results(self):
result = subprocess.run(['node', str(Path(__file__).parent / 'page/wired_doctor.mjs')],
capture_output=True, text=True, timeout=15)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_known_usb_route_and_pinned_login(self):
probe = FakeProbe()
report = wired.diagnose('frame', probe=probe)
self.assertEqual(report['state'], 'ready')
self.assertEqual([c['status'] for c in report['checks']], ['ok', 'ok', 'ok'])
self.assertEqual(probe.calls, ['route', 'port', 'config', ('ssh', 'frame', 'frame.example.ts.net')])
def test_other_route_never_contacts_target(self):
for source in ('100.91.1.2', '192.168.1.10', '127.0.0.1'):
probe = FakeProbe(source=source)
report = wired.diagnose(probe=probe)
self.assertEqual(report['state'], 'unknown')
self.assertEqual(probe.calls, ['route'])
self.assertIn('no headset was contacted', report['checks'][-1]['detail'])
def test_closed_port_skips_ssh_and_key_mismatch_is_not_bypassed(self):
closed = FakeProbe(port=False)
self.assertEqual(wired.diagnose(probe=closed)['state'], 'partial')
self.assertEqual(closed.calls, ['route', 'port'])
mismatch = FakeProbe(ssh_code=255, stderr='Host key verification failed.')
report = wired.diagnose(probe=mismatch)
self.assertEqual(report['checks'][-1]['status'], 'warning')
self.assertIn('do not bypass', report['checks'][-1]['detail'])
def test_invalid_alias_and_headset_mode_do_not_probe(self):
probe = FakeProbe()
with self.assertRaises(ValueError):
wired.diagnose('-oProxyCommand=bad', probe=probe)
self.assertFalse(wired.diagnose(local=True, probe=probe)['supported'])
self.assertEqual(probe.calls, [])
def test_real_command_uses_strict_identity_and_disables_proxy(self):
with patch.object(wired.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 0, '', '')) as run:
wired.Probe().ssh_check('frame', 'frame.example.ts.net')
argv = run.call_args.args[0]
self.assertIn('StrictHostKeyChecking=yes', argv)
self.assertIn('HostName=10.86.200.233', argv)
self.assertIn('HostKeyAlias=frame.example.ts.net', argv)
self.assertIn('ProxyCommand=none', argv)
self.assertIn('ControlMaster=no', argv)
self.assertEqual(argv[-2:], ['frame', 'true'])
self.assertEqual(run.call_args.kwargs['stdin'], subprocess.DEVNULL)
class HttpBoundary(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server_module = server
cls.httpd = server.LoopbackServer(('127.0.0.1', 0), server.Handler)
cls.thread = threading.Thread(target=cls.httpd.serve_forever, daemon=True)
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.thread.join()
def request(self, path, authorized=True):
conn = http.client.HTTPConnection('127.0.0.1', self.httpd.server_address[1], timeout=5)
headers = {'X-Frame-UI': self.server_module.UI_KEY} if authorized else {}
conn.request('GET', path, headers=headers)
response = conn.getresponse()
result = response.status, response.read()
conn.close()
return result
def test_auth_alias_and_page_without_headset_link(self):
with patch.object(self.server_module, 'ssh', side_effect=AssertionError('no generic SSH')), \
patch.object(self.server_module, 'working', side_effect=AssertionError('no headset lock')), \
patch.object(wired, 'diagnose', return_value={'supported': True, 'checks': []}) as probe:
self.assertEqual(self.request('/api/host/wired', authorized=False)[0], 403)
probe.assert_not_called()
code, data = self.request('/api/host/wired?alias=bedroom')
self.assertEqual(code, 200)
self.assertTrue(json.loads(data)['supported'])
probe.assert_called_once_with('bedroom', local=self.server_module.LOCAL)
code, page = self.request('/wired-doctor')
self.assertEqual(code, 200)
self.assertIn(b'No checks have run', page)
def test_invalid_alias_and_headset_page_guard(self):
self.assertEqual(self.request('/api/host/wired?alias=-bad')[0], 400)
with patch.object(self.server_module, 'LOCAL', True):
self.assertEqual(self.request('/wired-doctor')[0], 400)
code, body = self.request('/api/host/wired')
self.assertEqual(code, 200)
self.assertFalse(json.loads(body)['supported'])
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)
+129
View File
@@ -0,0 +1,129 @@
"""Read-only PC checks for the Frame's observed USB-C network path.
The known Frame USB address was observed on a Mac in 2026-09. It is a clue,
not a promise that every Frame OS or host uses the same address. Never set an
interface address, accept a new SSH host key, or change a network route here.
"""
import argparse
import ipaddress
import json
import re
import socket
import subprocess
import frame_host
TARGET = '10.86.200.233'
SUBNET = ipaddress.ip_network('10.86.200.232/29')
ALIAS = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
class Probe:
def route_source(self):
# UDP connect chooses a route but sends no packet.
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.connect((TARGET, 22))
return sock.getsockname()[0]
def port_open(self):
with socket.create_connection((TARGET, 22), timeout=2):
return True
def host_key_name(self, alias):
result = frame_host.run_ssh(['ssh', '-G', alias], stdin=subprocess.DEVNULL,
capture_output=True, text=True, timeout=5)
if result.returncode:
raise OSError('SSH configuration unavailable')
settings = dict(line.split(None, 1) for line in result.stdout.splitlines() if ' ' in line)
name = settings.get('hostkeyalias') or settings.get('hostname') or alias
if not name or len(name) > 253 or name.startswith('-') or any(c.isspace() for c in name):
raise ValueError('Invalid SSH host-key name')
return name
def ssh_check(self, alias, host_key_name):
return frame_host.run_ssh([
'ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'NumberOfPasswordPrompts=0', '-o', 'ConnectTimeout=5',
'-o', 'ConnectionAttempts=1', '-o', 'ProxyCommand=none',
'-o', 'ProxyJump=none', '-o', 'PermitLocalCommand=no',
'-o', 'RemoteCommand=none', '-o', 'ClearAllForwardings=yes',
'-o', 'ControlMaster=no', '-o', 'ControlPath=none',
'-o', f'HostName={TARGET}',
'-o', f'HostKeyAlias={host_key_name}', alias, 'true',
], stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=9)
def check(identifier, title, status, detail):
return {'id': identifier, 'title': title, 'status': status, 'detail': detail}
def diagnose(alias='frame', *, probe=None, local=False):
"""No headset request unless the PC chooses the known local USB subnet."""
if local:
return {'supported': False, 'state': 'unsupported', 'checks': [],
'message': 'Run this doctor on your computer, not the headset.'}
if not ALIAS.fullmatch(alias):
raise ValueError('Choose one configured headset alias.')
probe = probe or Probe()
checks = []
try:
source = probe.route_source()
wired = ipaddress.ip_address(source) in SUBNET and source != TARGET
except (OSError, ValueError):
source, wired = None, False
if wired:
checks.append(check('route', 'PC route to Frame USB address', 'ok',
f'This PC chooses {source} on the previously observed Frame USB subnet. This alone does not prove the cable or headset identity.'))
else:
checks.append(check('route', 'PC route to Frame USB address', 'unknown',
'No route using the previously observed 10.86.200.232/29 USB subnet. Check a data-capable USB-C cable, port and the PC network interface. This OS may use a different subnet.'))
checks += [check('port', 'USB path SSH port', 'unknown', 'Skipped: the known local USB route was not observed.'),
check('ssh', 'Pinned headset SSH identity', 'unknown', 'Skipped: no known USB route; no headset was contacted.')]
return {'supported': True, 'state': 'unknown', 'alias': alias, 'checks': checks}
try:
open_port = probe.port_open()
except OSError:
open_port = False
if not open_port:
checks += [check('port', 'USB path SSH port', 'warning',
'Port 22 did not answer on the observed USB route. Check the cable and whether Developer Mode SSH is enabled on the Frame.'),
check('ssh', 'Pinned headset SSH identity', 'unknown', 'Skipped: port 22 did not answer.')]
return {'supported': True, 'state': 'partial', 'alias': alias, 'checks': checks}
checks.append(check('port', 'USB path SSH port', 'ok', 'Port 22 answered on the observed USB route. This does not authenticate the headset.'))
try:
host_key_name = probe.host_key_name(alias)
result = probe.ssh_check(alias, host_key_name)
except (OSError, ValueError, subprocess.TimeoutExpired):
result = None
if result and result.returncode == 0:
checks.append(check('ssh', 'Pinned headset SSH identity', 'ok',
'A read-only SSH command succeeded over the USB address using strict host-key checking.'))
else:
error = (result.stderr or '') if result else ''
reason = ('The saved host key was not accepted. Verify and pair through the normal headset alias first; do not bypass host-key checking.'
if 'Host key verification failed' in error or 'No ' in error and 'host key is known' in error
else 'The USB SSH login did not complete. Check the saved headset key and Developer Mode SSH; no key or security setting was changed.')
checks.append(check('ssh', 'Pinned headset SSH identity', 'warning', reason))
return {'supported': True, 'state': 'ready' if checks[-1]['status'] == 'ok' else 'partial',
'alias': alias, 'checks': checks}
def main(argv=None):
parser = argparse.ArgumentParser(description='Read-only wired Frame connection doctor')
parser.add_argument('--alias', default='frame', help='configured SSH alias (default: frame)')
parser.add_argument('--json', action='store_true', help='print a JSON report')
args = parser.parse_args(argv)
try:
report = diagnose(args.alias)
except ValueError as error:
parser.error(str(error))
if args.json:
print(json.dumps(report, indent=2))
else:
for item in report['checks']:
print(f"{item['status'].upper():7} {item['title']}: {item['detail']}")
return 0
if __name__ == '__main__':
raise SystemExit(main())
+85 -3
View File
@@ -1240,7 +1240,11 @@
</div>
<div class="hint" id="recStatus" role="status" hidden></div>
<div class="hint" id="viewHint"></div>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Use the headset view and sound in OBS →</a></p>
<div id="obsControl" class="hint" hidden>
<button class="small" id="obsBtn">Start OBS source</button>
<span id="obsStatus" role="status"></span>
</div>
<p class="sub"><a href="https://github.com/saphid/frame-control/blob/main/docs/obs-headset-source.md" target="_blank" rel="noopener noreferrer">Set up the headset view and sound in OBS →</a></p>
</section>
@@ -1437,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>
@@ -1448,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">
@@ -1861,6 +1874,7 @@
<div class="hint">Frame Control talks to one headset at a time. Each can be reached at several addresses;
it tries them all at once and uses the best one that answers on the network you're on.</div>
<p id="wirelessDoctorLink" hidden><a href="/wireless-doctor">Linux Wireless Adapter doctor ↗</a><br><span class="sub">Read-only PC checks; works without a connected Frame.</span></p>
<p><a href="/wired-doctor">Wired connection doctor ↗</a><br><span class="sub">Check the USB-C network route and pinned SSH login from this computer.</span></p>
<h3 style="margin-top:22px">This computer's network</h3>
<div id="netNow" class="sub">Checking…</div>
<div class="row" style="margin-top:8px; flex-wrap:nowrap">
@@ -5490,6 +5504,33 @@ $("recBtn").onclick = async e => {
if (res) showRec(res); else pollRec();
};
// The desktop app owns the local OBS relay, so it can close SSH when the app
// exits. The browser and mobile views keep the command-line guide instead.
if (window.frameApp?.obs) {
const obs = window.frameApp.obs;
const wrap = $("obsControl"), button = $("obsBtn"), status = $("obsStatus");
let state = { state: "idle" };
function showObs(next) {
if (!next) return;
state = next;
wrap.hidden = !next.supported;
button.textContent = next.state === "running" ? "Stop OBS source" : "Start OBS source";
button.disabled = next.state === "starting" || next.state === "stopping";
status.textContent = next.state === "starting" ? "Connecting to the Frame and waiting for video…"
: next.state === "running" ? `Sending ${next.alias} view and sound to udp://127.0.0.1:15099`
: next.state === "stopping" ? "Stopping the OBS source…"
: next.state === "failed" ? next.error : "";
}
obs.onState(showObs);
obs.status().then(showObs).catch(() => {});
button.onclick = async e => {
const stopping = state.state === "running";
const result = await act(stopping ? "Stop OBS source" : "Start OBS source",
() => stopping ? obs.stop() : obs.start(), e.currentTarget);
if (result) showObs(result);
};
}
const clipsState = { list: [], gen: 0, exporting: new Set() };
async function loadClips() {
if (HOST.mobile) return;
@@ -5630,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)})`);
@@ -5649,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 {
@@ -5658,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);
+12
View File
@@ -76,6 +76,7 @@ import frame_wireless # noqa: E402
import frame_vr # noqa: E402
import frame_utilities # noqa: E402
import frame_wireless_doctor # noqa: E402
import frame_wired_doctor # noqa: E402
import frame_compat_db # noqa: E402
frame_host.trust_bundled_cas()
@@ -3497,11 +3498,22 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(frame_agent.approvals.inspect(token))
elif path == "/wireless-doctor":
self.send_bytes((HERE / "wireless-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/wired-doctor":
if LOCAL:
raise Failure("Wired connection doctor runs on a computer, not the headset", 400)
self.send_bytes((HERE / "wired-doctor.html").read_bytes(), "text/html; charset=utf-8")
elif path == "/api/wireless-doctor":
# PC-only and independent of LINK/working(): never start or wait for SSH.
args = parse_qs(url.query)
self.send_json(frame_wireless_doctor.diagnose(
(args.get("device") or [None])[0], local=LOCAL))
elif path == "/api/host/wired":
args = parse_qs(url.query)
try:
self.send_json(frame_wired_doctor.diagnose(
(args.get("alias") or ["frame"])[0], local=LOCAL))
except ValueError as error:
raise Failure(str(error), 400) from error
elif path == "/api/steam-pipewire":
self.send_json(steam_pipewire_state())
elif path == "/api/host":
+4 -1
View File
@@ -552,7 +552,10 @@ $("kbd").onclick = e => {
};
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
loadInstalled().then(() => search());
// The catalogue does not need the headset's installed-app list. A slow or
// unavailable /api/android must not hold up browsing and search.
loadInstalled();
search();
</script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Wired connection doctor · Frame Control</title>
<style>
:root{color-scheme:dark;--bg:#101820;--card:#1b2838;--text:#e4edf5;--sub:#acbccb;--line:#3a5065;--blue:#66c0f4;--warn:#f0c67a}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:16px/1.55 system-ui,sans-serif}main{max-width:850px;margin:auto;padding:32px 24px 64px}a{color:var(--blue)}h1{font-size:30px;line-height:1.2;margin:20px 0 12px}h2{font-size:18px;margin:0 0 8px}p{margin:8px 0 16px}.sub{color:var(--sub)}.badge{font-size:12px;letter-spacing:.05em;text-transform:uppercase;color:var(--blue)}section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px;margin:18px 0}.row{display:flex;gap:12px;align-items:end;flex-wrap:wrap}label{display:block}input,button{font:inherit;min-height:44px;border:1px solid var(--line);border-radius:5px;padding:9px 14px;background:#263f54;color:var(--text)}input{display:block;min-width:220px;margin-top:6px}button{cursor:pointer;background:#205b81;border-color:var(--blue)}button:disabled{opacity:.5;cursor:default}.status{font-size:12px;font-weight:700;text-transform:uppercase;margin-right:10px;color:var(--blue)}.warning .status{color:var(--warn)}.unknown .status{color:var(--sub)}#message{min-height:24px}:focus-visible{outline:2px solid var(--blue);outline-offset:4px}@media(max-width:540px){main{padding:20px 16px}h1{font-size:25px}section{padding:16px}}
</style></head><body><main>
<a id="back" href="/#devices">← Devices &amp; connection</a>
<header><h1>Wired connection doctor</h1><span class="badge">This computer · Read-only · On demand</span>
<p>Check whether this computer has the Frame's previously observed USB-C network route, whether SSH answers on it, and whether a strictly checked SSH identity works. This does not change network or headset settings.</p></header>
<section><div class="row"><label for="alias">Your configured headset SSH alias<input id="alias" value="frame" maxlength="64" autocomplete="off"></label>
<button id="check">Check cable connection</button></div><p id="message" role="status" aria-live="polite">No checks have run.</p></section>
<div id="results" aria-live="polite"></div>
<p class="sub">The observed USB address is 10.86.200.233 on Frame OS tested in 2026. A different OS build may use another subnet. An unknown result does not prove the cable is broken. The doctor never changes IP settings, accepts a new host key, or bypasses SSH warnings.</p>
</main><script>
'use strict';
const $ = id => document.getElementById(id);
const key = new URLSearchParams(location.search).get('key') || '1';
$('back').href = '/?key=' + encodeURIComponent(key) + '#devices';
const make = (tag, value) => { const node = document.createElement(tag); node.textContent = value; return node; };
$('check').onclick = async () => {
$('check').disabled = true;
$('results').replaceChildren();
$('message').textContent = 'Checking this computer…';
try {
const response = await fetch('/api/host/wired?alias=' + encodeURIComponent($('alias').value.trim()),
{headers:{'X-Frame-UI':key}});
const report = await response.json();
if (!response.ok) throw new Error(report.error || 'Wired check failed');
for (const item of report.checks) {
const section = make('section', ''); section.className = item.status;
const title = make('h2', item.title); const status = make('span', item.status + ' · '); status.className = 'status';
title.prepend(status); section.append(title, make('p', item.detail)); $('results').append(section);
}
$('message').textContent = report.state === 'ready' ? 'The known USB path and pinned SSH identity worked.'
: 'Review each check and its limits below.';
} catch (error) { $('message').textContent = error.message; }
finally { $('check').disabled = false; }
};
</script></body></html>