mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 02:01:39 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b706cf8dd |
No files matched your search
@@ -2,49 +2,6 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 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
|
||||
remain unverified. [Setup and limits](docs/obs-headset-source.md).
|
||||
|
||||
- Link emulator setup guidance from Store → Emulators. Existing Steam library
|
||||
shortcuts and artwork cover Android apps and sideloaded titles; Android ROM
|
||||
transfer still needs a verified per-instance workflow.
|
||||
[Guide](docs/emulators.md).
|
||||
|
||||
- Record the current Frame OS mirror and charge-limit checks. Active
|
||||
passthrough in the mirror is still unknown; this build has no supported
|
||||
user-writable 80% charge-limit control.
|
||||
[Headset evidence](docs/evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
- Add Windows Tools → Wireless Adapter doctor: an explicit read-only PC snapshot
|
||||
of distinct Wi-Fi Direct PnP instances, presence/link state, PC Wi-Fi and
|
||||
reported channels/bands, with possible 2.4 GHz overlap guidance. Unknowns stay
|
||||
|
||||
@@ -17,7 +17,6 @@ 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",
|
||||
|
||||
+2
-87
@@ -304,62 +304,6 @@ 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}$/;
|
||||
@@ -370,7 +314,6 @@ 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;
|
||||
@@ -698,34 +641,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
});
|
||||
app.on("activate", () => { if (!win) createWindow(); });
|
||||
app.on("window-all-closed", () => app.quit());
|
||||
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(); });
|
||||
app.on("before-quit", () => { quitting = true; stopServer(); });
|
||||
process.on("exit", stopServer);
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.20",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.24",
|
||||
"version": "0.4.20",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -69,7 +69,6 @@
|
||||
"to": "scripts",
|
||||
"filter": [
|
||||
"*.sh",
|
||||
"obs-headset-source.py",
|
||||
"frame_power_sample.py",
|
||||
"frame_power_report.py"
|
||||
]
|
||||
|
||||
@@ -21,15 +21,6 @@ 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) => {
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Emulators and your own games
|
||||
|
||||
Frame Control's **Store → Emulators** lists Android emulators from its open
|
||||
app sources. Pick an app, read its compatibility result, and install it. An
|
||||
installed Android app gets its own Steam library shortcut and artwork; **Add
|
||||
artwork** in its library menu can fill missing images later. The artwork
|
||||
workflow also covers native Linux and Windows titles added through **Send to
|
||||
Frame**. An optional SteamGridDB key under **Settings → Library artwork**
|
||||
can supply exact-title community images; source images and generated art are
|
||||
fallbacks. See [VR APKs → In the Steam library](vr-apks.md#in-the-steam-library)
|
||||
for the limits and tested behavior.
|
||||
|
||||
## Set up an emulator
|
||||
|
||||
1. In **Store → Emulators**, choose an emulator that supports the console and
|
||||
file format you want. Check its compatibility result before installing;
|
||||
an app listed in the store is not automatically verified on the Frame.
|
||||
2. Launch the new shortcut from Steam. Open the emulator's own settings to
|
||||
choose a gamepad layout, core or system, and game folder. These controls
|
||||
differ by emulator; Frame Control does not change them.
|
||||
3. Use game files you own and any firmware that emulator's instructions
|
||||
require. Frame Control does not provide ROMs or firmware.
|
||||
|
||||
**Android apps run in separate Lepton instances.** Copying a ROM to the
|
||||
Frame's Linux `~/Downloads` through **Send to Frame** does not automatically
|
||||
put it in an Android app's `/sdcard/Download`. A one-click ROM transfer is
|
||||
deferred until an emulator and its file picker can be tested end to end on a
|
||||
real Frame. An app's own import or sync feature may be enough. Advanced users
|
||||
can use [Lepton's per-instance ADB port](apks.md)
|
||||
to copy into that instance, after confirming the correct container and port.
|
||||
Do not assume that port 5555 is the emulator: it is Lepton Development, while
|
||||
installed apps receive their own ports.
|
||||
|
||||
For a native Linux emulator that can read the Frame's home directory, use
|
||||
**Send to Frame → Copy to ~/Downloads instead** for your files, then open them
|
||||
from the emulator. Whether a particular emulator has a Frame-compatible ARM64
|
||||
build, correct controller mapping and readable storage must be checked for
|
||||
that app. **Linux apps** in Frame Control lists Flatpak options; a Windows or
|
||||
native game archive can instead be added through
|
||||
[Send to Frame](sideloading.md#using-it), which registers a Steam title and
|
||||
artwork. Each ROM is usually opened *inside* its emulator; Frame Control does
|
||||
not create one Steam shortcut per ROM.
|
||||
|
||||
## If something does not appear
|
||||
|
||||
- Check that the emulator is running and is the same app instance to which
|
||||
you sent the file. Android instances do not share `/sdcard` storage.
|
||||
- Check the emulator's supported extension and whether it expects an archive
|
||||
or an extracted file. Some systems need firmware before games appear.
|
||||
- For an empty Steam tile, use **Add artwork** in the installed app or
|
||||
sideloaded title menu. This changes artwork, not emulator data or ROMs.
|
||||
|
||||
The Frame's microSD/USB path into Android apps remains unverified without an
|
||||
inserted medium and an app readback test; see [issue #169](https://github.com/saphid/frame-control/issues/169).
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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.
|
||||
@@ -84,11 +84,7 @@ 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.
|
||||
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
|
||||
Steam library), then launch, stop, test or remove it. **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
|
||||
|
||||
@@ -7,12 +7,6 @@ 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
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Headset view in OBS
|
||||
|
||||
Frame Control can send SteamVR's headset mirror and the Frame's output audio to
|
||||
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 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
|
||||
```
|
||||
|
||||
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
|
||||
OBS. If the Frame's PulseAudio monitor cannot be read, the helper exits with
|
||||
an error. Use `--no-audio` only when a silent view is useful. To inspect the
|
||||
stream without OBS, run `ffprobe -v error -show_streams
|
||||
udp://127.0.0.1:15099` in a second terminal while the helper is running.
|
||||
|
||||
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-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).
|
||||
@@ -13,12 +13,6 @@ 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
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Recording and Steam clips
|
||||
|
||||
For a live OBS Media Source with headset output audio, see
|
||||
[Headset view in OBS](obs-headset-source.md).
|
||||
|
||||
Frame Control can record SteamVR's mirrored headset view, with its sound, and save it as
|
||||
an `.mp4` on your computer. It can also export the clips and background
|
||||
recordings from Steam's own game recording, at any length, as one `.mp4` each.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/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())
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Send the Frame's SteamVR mirror and output audio to a local OBS Media Source.
|
||||
|
||||
OBS opens udp://127.0.0.1:15099 with input format mpegts. Nothing is bound to
|
||||
the network: this process sends MPEG-TS packets to the computer's loopback.
|
||||
"""
|
||||
import argparse
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
PACKET = 188
|
||||
DATAGRAM = PACKET * 7
|
||||
|
||||
|
||||
def remote_command(height=720, fps=30, audio=True):
|
||||
if height not in (720, 1080) or fps not in (30, 60):
|
||||
raise ValueError('height must be 720 or 1080; fps must be 30 or 60')
|
||||
rate = 3 if height == 720 else 6
|
||||
clock = '-thread_queue_size 1024 -use_wallclock_as_timestamps 1'
|
||||
video = f'{clock} -f v4l2 -video_size 1920x1080 -i /dev/video99'
|
||||
sound = f' {clock} -f pulse -i @DEFAULT_MONITOR@' if audio else ''
|
||||
maps = '-map 0:v:0 -map 1:a:0' if audio else '-map 0:v:0'
|
||||
encode = (f'-vf fps={fps},scale=-2:{height},format=yuv420p '
|
||||
f'-c:v libx264 -preset ultrafast -tune zerolatency -g {fps * 2} -bf 0 '
|
||||
f'-b:v {rate}M -maxrate {rate}M -bufsize {rate}M '
|
||||
'-x264-params repeat-headers=1')
|
||||
if audio:
|
||||
encode += ' -af aresample=async=1 -c:a aac -b:a 160k'
|
||||
return ("export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}; "
|
||||
"test -e /dev/video99 || { echo 'SteamVR mirror /dev/video99 is missing' >&2; exit 3; }; "
|
||||
f'ffmpeg -hide_banner -loglevel warning -nostdin {video}{sound} '
|
||||
f'{maps} {encode} -f mpegts -flush_packets 1 -')
|
||||
|
||||
|
||||
def relay(source, destination):
|
||||
"""Preserve 188-byte MPEG-TS packet boundaries in loopback datagrams."""
|
||||
pending = bytearray()
|
||||
total = 0
|
||||
while data := source.read(DATAGRAM):
|
||||
pending.extend(data)
|
||||
size = min(len(pending) // PACKET * PACKET, DATAGRAM)
|
||||
if size:
|
||||
destination.send(bytes(pending[:size]))
|
||||
del pending[:size]
|
||||
total += size
|
||||
if pending:
|
||||
raise RuntimeError('Frame stream ended with an incomplete MPEG-TS packet')
|
||||
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)')
|
||||
parser.add_argument('--port', type=int, default=15099, help='local OBS UDP port (default: 15099)')
|
||||
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')
|
||||
if not args.frame or args.frame.startswith('-') or any(c.isspace() for c in args.frame):
|
||||
parser.error('frame must be one SSH host or alias')
|
||||
command = remote_command(args.height, args.fps, not args.no_audio)
|
||||
ssh = ['ssh', '-T', '-o', 'BatchMode=yes', args.frame, 'bash', '-lc', shlex.quote(command)]
|
||||
target = ('127.0.0.1', args.port)
|
||||
print(f'Sending Frame view to udp://127.0.0.1:{args.port} for OBS; press Ctrl+C to stop.', file=sys.stderr)
|
||||
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)
|
||||
if code or not amount:
|
||||
raise RuntimeError(f'Frame stream stopped (SSH exit {code}; {amount} bytes). '
|
||||
'Check SteamVR and the audio monitor; try --no-audio if needed.')
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
finally:
|
||||
proc.stdout.close()
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
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__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (OSError, RuntimeError) as error:
|
||||
print(error, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -1,23 +0,0 @@
|
||||
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');
|
||||
@@ -1,41 +0,0 @@
|
||||
// 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');
|
||||
@@ -95,20 +95,8 @@ 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();
|
||||
@@ -117,16 +105,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[2].url, '/api/android');
|
||||
await f.answer(2, {apps: [{package: 'cached.app', label: 'Cached app'}]});
|
||||
assert.equal(f.requests[1].url, '/api/android');
|
||||
await f.answer(1, {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[3].reject(Error('refresh failed')); await failed;
|
||||
f.requests[2].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(4, {apps: []}); await empty;
|
||||
const empty = f.run('loadInstalled()'); await f.answer(3, {apps: []}); await empty;
|
||||
assert.match(f.element('content').innerHTML, /Nothing installed yet/);
|
||||
assert.doesNotMatch(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
@@ -134,12 +122,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(2, {apps: [{package: 'new.app', label: 'New app'}]}); await newer;
|
||||
const newer = f.run('loadInstalled()'); await f.answer(1, {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; // request 1 is the independent bootstrap catalogue search
|
||||
await f.answer(4, {apps: []}); await newest; // bootstrap's catalogue search is request 2
|
||||
f.requests[3].reject(Error('late failure')); await old;
|
||||
assert.equal(f.run('st.installedState'), 'ready');
|
||||
assert.equal(f.run('st.installedError'), '');
|
||||
@@ -149,12 +137,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[2].reject(Error('latest offline')); await newest;
|
||||
const newest = f.run('loadInstalled()'); f.requests[1].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(3, {}); await malformed;
|
||||
const malformed = f.run('loadInstalled()'); await f.answer(2, {}); await malformed;
|
||||
assert.equal(f.run('st.installedState'), 'error');
|
||||
assert.match(f.element('content').innerHTML, /data-retry-installed/);
|
||||
});
|
||||
@@ -209,12 +197,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');
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
"""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
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / 'scripts' / 'obs-headset-source.py'
|
||||
spec = importlib.util.spec_from_file_location('obs_headset_source', SCRIPT)
|
||||
obs = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(obs)
|
||||
|
||||
|
||||
class Receiver:
|
||||
def __init__(self):
|
||||
self.chunks = []
|
||||
|
||||
def send(self, data):
|
||||
self.chunks.append(data)
|
||||
|
||||
|
||||
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()
|
||||
self.assertEqual(obs.relay(io.BytesIO(stream), receiver), len(stream))
|
||||
self.assertEqual(b''.join(receiver.chunks), stream)
|
||||
self.assertTrue(all(len(chunk) % 188 == 0 and len(chunk) <= 1316
|
||||
for chunk in receiver.chunks))
|
||||
|
||||
def test_incomplete_packet_fails(self):
|
||||
with self.assertRaisesRegex(RuntimeError, 'incomplete'):
|
||||
obs.relay(io.BytesIO(b'x' * 189), Receiver())
|
||||
|
||||
def test_remote_command_uses_recording_sources(self):
|
||||
with_audio = obs.remote_command()
|
||||
self.assertIn('/dev/video99', with_audio)
|
||||
self.assertIn('@DEFAULT_MONITOR@', with_audio)
|
||||
self.assertIn('-map 1:a:0', with_audio)
|
||||
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()
|
||||
@@ -1,17 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,138 +0,0 @@
|
||||
"""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()
|
||||
+1
-2
@@ -411,8 +411,7 @@ 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, "host": self.host,
|
||||
"codec": q["codec"], "max": q["max"], "fps": q["fps"],
|
||||
params = {"src": src, "t": ticket, "tag": tag, "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)
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""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())
|
||||
+1
-34
@@ -1240,11 +1240,6 @@
|
||||
</div>
|
||||
<div class="hint" id="recStatus" role="status" hidden></div>
|
||||
<div class="hint" id="viewHint"></div>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1865,7 +1860,6 @@
|
||||
<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">
|
||||
@@ -4394,7 +4388,7 @@ function renderCats() {
|
||||
$("storeCats").querySelectorAll("[data-cat]").forEach(b => b.setAttribute("aria-pressed", String(b.dataset.cat === sourceState.category)));
|
||||
const [, label, eyebrow, blurb] = storeCat(sourceState.category);
|
||||
$("storeHeading").hidden = !sourceState.category;
|
||||
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>${sourceState.category === "emulators" ? '<p><a href="https://github.com/saphid/frame-control/blob/main/docs/emulators.md" target="_blank" rel="noopener noreferrer">Set up an emulator and your own games →</a></p>' : ""}`;
|
||||
$("storeHeading").innerHTML = `<div class="store-eyebrow">${esc(sourceState.category ? eyebrow : "Android apps for your Frame")}</div><h1>${esc(sourceState.category ? label : "Apps & VR")}</h1><p>${esc(blurb)}</p>`;
|
||||
}
|
||||
function renderStore() {
|
||||
const apps=sourceState.apps, q=$("sourceQ").value.trim(), browsing=!q && !sourceState.category && !sourceState.installable && !sourceState.source;
|
||||
@@ -5495,33 +5489,6 @@ $("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;
|
||||
|
||||
+1
-39
@@ -18,16 +18,10 @@
|
||||
#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>
|
||||
@@ -35,14 +29,10 @@
|
||||
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") || "", host = q.get("host") || "";
|
||||
document.title = host === "windows" ? "PC" : "Mac";
|
||||
const src = q.get("src") || "test", ticket = q.get("t") || "", tag = q.get("tag") || "";
|
||||
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 };
|
||||
|
||||
@@ -137,29 +127,6 @@ 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();
|
||||
@@ -354,7 +321,6 @@ 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 };
|
||||
@@ -365,7 +331,6 @@ 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) {}
|
||||
@@ -374,7 +339,6 @@ 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 });
|
||||
});
|
||||
@@ -382,7 +346,6 @@ 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();
|
||||
@@ -391,7 +354,6 @@ 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);
|
||||
|
||||
@@ -76,7 +76,6 @@ 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()
|
||||
@@ -3498,22 +3497,11 @@ 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":
|
||||
|
||||
+1
-4
@@ -552,10 +552,7 @@ $("kbd").onclick = e => {
|
||||
};
|
||||
$("kbdBtn").onclick = () => $("kbd").hidden = !$("kbd").hidden;
|
||||
|
||||
// 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();
|
||||
loadInstalled().then(() => search());
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,40 +0,0 @@
|
||||
<!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 & 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>
|
||||
Reference in new issue
Block a user