mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 07:01:42 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
132a0895d3 | ||
|
|
8ae8e38b73 |
No files matched your search
@@ -2,39 +2,6 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -25,8 +25,6 @@ See what the headset sees, install games and Android apps, move files and text a
|
||||
|
||||
<sub>Unofficial hobby project, not affiliated with Valve. Free and open source.</sub>
|
||||
|
||||
[See what was tested on a real Frame, in a VM, or only in automated checks](docs/current-support-matrix.md).
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
+9
-36
@@ -304,13 +304,13 @@ 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.
|
||||
// The OBS source is local to this computer. The Python relay owns its SSH/ffmpeg
|
||||
// process and cleans it up on SIGTERM. Keep one relay per app, and stop it when
|
||||
// the selected headset changes or the app quits.
|
||||
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 publicObs() { return { ...obsState, supported: !IS_WIN }; }
|
||||
function setObs(state, alias = "", error = "") {
|
||||
obsState = { state, alias, error };
|
||||
if (win && !win.isDestroyed()) win.webContents.send("obs:state", publicObs());
|
||||
@@ -319,12 +319,13 @@ function setObs(state, alias = "", error = "") {
|
||||
function stopObs() {
|
||||
if (!obsChild) return publicObs();
|
||||
setObs("stopping", obsState.alias);
|
||||
if (obsChild.stdin && !obsChild.stdin.writableEnded) obsChild.stdin.end();
|
||||
obsChild.kill("SIGTERM");
|
||||
return publicObs();
|
||||
}
|
||||
ipcMain.handle("obs:status", (e) => fromUi(e) ? publicObs() : null);
|
||||
ipcMain.handle("obs:start", async (e) => {
|
||||
if (!fromUi(e)) return null;
|
||||
if (IS_WIN) throw new Error("The OBS source currently supports Linux and macOS computers.");
|
||||
if (obsChild) return publicObs();
|
||||
const alias = activeAlias();
|
||||
if (!ALIAS_RE.test(alias)) throw new Error("Select a valid headset first.");
|
||||
@@ -335,8 +336,8 @@ ipcMain.handle("obs:start", async (e) => {
|
||||
// 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 });
|
||||
const child = spawn(executable, [...PY_FLAGS, OBS_SCRIPT, "--frame", alias],
|
||||
{ env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true });
|
||||
obsChild = child;
|
||||
setObs("starting", alias);
|
||||
let output = "";
|
||||
@@ -698,34 +699,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);
|
||||
});
|
||||
app.on("before-quit", () => { quitting = true; stopObs(); stopServer(); });
|
||||
process.on("exit", () => { stopObs(); stopServer(); });
|
||||
}
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.21",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.21",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.25",
|
||||
"version": "0.4.21",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.25**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
| Windows desktop in the headset | No physical Windows PC test | Windows 11 VM sent a whole desktop to a real Frame; this used an intermediate fix, not the final native DLL | Physical GPU, capture and controller input; individual windows unavailable in bundled DXGI build | 24 Oct, v0.4.10 installer + candidate fix: [VM log](evidence/windows-vm-2026-10-24.md), [PC guide](pc-in-headset.md) |
|
||||
| Linux desktop in the headset | No | No | Native Linux PC capture is disabled; lxso2 lacked a desktop portal and PipeWire session. The separate Steam `-pipewire` helper is not Frame Control's PC capture | 24 Oct, v0.4.17: [host audit](evidence/linux-pc-host-lxso2-2026-10-24.md), [Steam helper PR #163](https://github.com/saphid/frame-control/pull/163) |
|
||||
| Mac desktop in the headset | A physical Mac sent its screen/window to a real Frame | No | Current v0.4.25 Mac installer and worn-headset controller input have not been repeated | 28 Sep, pre-v0.4 / Frame OS 0.4.1: [Mac guide and measurement](streaming.md#b-show-the-macs-desktop-inside-the-frame) |
|
||||
| OBS headset source on Linux | Real Frame to OBS 30 on a Linux host; H.264 video and a synthetic 440 Hz output tone reached a saved recording | No | Real game audio, audible monitoring, perceptual A/V sync and awake headset view | 25 Oct, PR #174 head, shipped in v0.4.22: [OBS log](evidence/obs-media-source-real-2026-10-25.md) |
|
||||
| OBS headset source on Windows | No physical Windows PC test | No OBS/Frame VM end-to-end test | App Start/Stop path has automated Windows checks only | 25 Oct, v0.4.23: [Windows PR #179](https://github.com/saphid/frame-control/pull/179), [OBS guide](obs-headset-source.md) |
|
||||
| OBS headset source on macOS | No | No | Helper and OBS Media Source not tested on a Mac with a Frame | 25 Oct, v0.4.22: [OBS PR #174](https://github.com/saphid/frame-control/pull/174), [OBS guide](obs-headset-source.md) |
|
||||
| Wired connection doctor | No physical PC/Frame cable test of the doctor | No | Synthetic route/SSH tests; a separate Mac USB-C address observation does not test the doctor or game stream | 25 Oct, v0.4.23: [PR #178](https://github.com/saphid/frame-control/pull/178), [wired guide](wired-connection.md) |
|
||||
| Windows Wireless Adapter doctor | No physical Steam Wireless Adapter/dropout test | Windows 11 VM ran read-only checks without the adapter or WLAN | Adapter identification, dropouts and recovery on hardware | 24–25 Oct, v0.4.20–0.4.25: [VM log](evidence/windows-wireless-2026-10-24.md), [doctor guide](windows-wireless-doctor.md), [comparison PR #184](https://github.com/saphid/frame-control/pull/184) |
|
||||
| Linux Wireless Adapter doctor | No Steam Wireless Adapter hardware test | No | Read-only Linux host checks and mocked adapter states; no adapter-specific hardware observation | 24 Oct, v0.4.20: [PR #165](https://github.com/saphid/frame-control/pull/165), [doctor guide](linux-wireless-adapter-doctor.md) |
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
|
||||
Release notes must preserve these limits until a newer recorded check changes them. When a feature is retested, add its date, exact app/Frame version, host type and proof here. The older [v0.3.1 matrix](support-matrix.html) is a historical snapshot and does not describe current coverage.
|
||||
@@ -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,13 +1,13 @@
|
||||
# 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
|
||||
OBS on a 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
|
||||
2. In the **Linux or macOS Frame Control 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.
|
||||
@@ -23,13 +23,9 @@ run:
|
||||
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).
|
||||
Ctrl+C stops the checkout helper. The app's Start/Stop control currently supports
|
||||
Linux and macOS; Windows users can run the checkout helper with Python 3 and
|
||||
OpenSSH from a terminal, but this path has not been verified on a Windows PC.
|
||||
|
||||
`--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
|
||||
|
||||
@@ -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,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.
|
||||
@@ -52,7 +52,6 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Frame Control 0.3.1: features by OS</h1>
|
||||
<p><strong>This is a September 2026 archive.</strong> For current coverage and hardware limits, read the <a href="current-support-matrix.md">v0.4.25 support matrix</a>.</p>
|
||||
<p class="sub">Which features are built for each OS, and which were tested against a real Steam Frame
|
||||
(SteamOS 0.3.0, build 20260922.6101926). Status as of 26 September 2026, for
|
||||
<a href="https://github.com/saphid/steam-frame/pull/2">PR #2</a> (0.3.1). Every build now bundles its own
|
||||
|
||||
@@ -48,46 +48,6 @@ 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)
|
||||
|
||||
@@ -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())
|
||||
@@ -10,7 +10,6 @@ import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
PACKET = 188
|
||||
@@ -53,20 +52,6 @@ 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)')
|
||||
@@ -74,7 +59,6 @@ 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')
|
||||
@@ -87,8 +71,6 @@ 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)
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
<div class="head">
|
||||
<span class="kicker">Download</span>
|
||||
<h2>Get Frame Control</h2>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a> · <a href="https://github.com/saphid/frame-control/blob/main/docs/current-support-matrix.md">What we tested</a></p>
|
||||
<p>Choose your computer’s OS and processor, not the headset’s. Python is bundled; Linux needs <code>ssh</code>, and arm64 Linux also needs your distribution’s <code>adb</code> for Android apps. <span hidden>Version <span data-version></span>.</span> <a href="https://github.com/saphid/frame-control/releases">Release notes</a></p>
|
||||
</div>
|
||||
<div class="downloads">
|
||||
<div class="dl" id="download-mac" data-os="mac">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -56,12 +56,7 @@ class ObsSourceTests(unittest.TestCase):
|
||||
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):
|
||||
def test_relay_stops_its_ssh_child_on_app_signal(self):
|
||||
with tempfile.TemporaryDirectory() as folder, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as receiver:
|
||||
directory = Path(folder)
|
||||
stopped = directory / 'stopped'
|
||||
@@ -80,24 +75,15 @@ class ObsSourceTests(unittest.TestCase):
|
||||
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,
|
||||
process = subprocess.Popen([sys.executable, str(SCRIPT), '--frame', 'fake-frame',
|
||||
'--port', str(receiver.getsockname()[1])],
|
||||
stdin=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)
|
||||
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):
|
||||
@@ -109,8 +95,6 @@ class ObsSourceTests(unittest.TestCase):
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.communicate(timeout=5)
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
|
||||
|
||||
if __name__ == '__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()
|
||||
@@ -37,33 +37,6 @@ 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','<one>','<two>']) 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';
|
||||
|
||||
+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())
|
||||
+2
-53
@@ -1441,9 +1441,8 @@
|
||||
<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>
|
||||
<div class="row"><button class="action" id="wirelessRead">Read PC adapters</button><button id="wirelessForget" disabled>Forget previous read</button></div>
|
||||
<button class="action" id="wirelessRead">Read PC adapters</button>
|
||||
<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>
|
||||
@@ -1453,14 +1452,6 @@
|
||||
<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">
|
||||
@@ -1874,7 +1865,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">
|
||||
@@ -5671,34 +5661,7 @@ 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)})`);
|
||||
@@ -5717,8 +5680,6 @@ 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 {
|
||||
@@ -5728,26 +5689,14 @@ $('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;
|
||||
$('wirelessForget').disabled = !wirelessPrevious;
|
||||
}
|
||||
if (seq === wirelessSeq) $('wirelessRead').disabled = false;
|
||||
}
|
||||
};
|
||||
$('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;
|
||||
|
||||
+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