mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 07:00:37 +02:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6fb5e917a | ||
|
|
daf30515b1 | ||
|
|
2f056dbcff | ||
|
|
fa6d4fd81b | ||
|
|
e3176a2de8 | ||
|
|
7cc4abaffa | ||
|
|
02b9413f78 | ||
|
|
1a08258e3d | ||
|
|
a84d6b912b | ||
|
|
1cd56740a6 | ||
|
|
b685a601f7 | ||
|
|
31de17aab8 | ||
|
|
ffef4d897a | ||
|
|
094c12c6d8 | ||
|
|
416f3181cc | ||
|
|
c46bf68dd0 | ||
|
|
dc4a64a9c3 | ||
|
|
08fbe730c6 | ||
|
|
1cf353f419 | ||
|
|
c0183ca8b2 | ||
|
|
bf8a478063 | ||
|
|
215bc357ef | ||
|
|
7d6ff7f919 | ||
|
|
1343900aa1 | ||
|
|
d2a5db7caf | ||
|
|
a08ba6f65b | ||
|
|
53c51e1888 | ||
|
|
72e4ccf080 | ||
|
|
a9740ad6f2 | ||
|
|
cb6f294299 | ||
|
|
b87be6866b | ||
|
|
747dbcf72f | ||
|
|
409e328880 | ||
|
|
c5a614d989 | ||
|
|
0f33b4f094 | ||
|
|
49378b2c80 | ||
|
|
22863f2f87 | ||
|
|
b779376f5b | ||
|
|
8bd8d63546 | ||
|
|
42b51afc5a | ||
|
|
6597a90059 | ||
|
|
c3e3f2629c | ||
|
|
90fd2684ba | ||
|
|
fa05a4b310 | ||
|
|
93ebd34f2c | ||
|
|
34e91988b1 | ||
|
|
5874fe33f6 | ||
|
|
98ef6944c9 | ||
|
|
2e9bc8624b | ||
|
|
c69ea6266f | ||
|
|
43e19d17f6 | ||
|
|
175c39a2b0 | ||
|
|
51ef5d8283 | ||
|
|
ba33d2ff40 | ||
|
|
41ac28248a | ||
|
|
9dd57cde4e | ||
|
|
d383a26746 | ||
|
|
318bc3b84f | ||
|
|
cb182dbce5 | ||
|
|
18334b5989 | ||
|
|
13eb65603b | ||
|
|
a954fc83c9 |
No files matched your search
@@ -217,7 +217,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
|
||||
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
|
||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
|
||||
+80
-16
@@ -159,10 +159,16 @@ async function startServer() {
|
||||
|
||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||
// way on Windows); SIGTERM does the same elsewhere.
|
||||
// Resolves once it has exited (or after 20 s), so a replacement can take the server
|
||||
// lock: server.py allows one per user.
|
||||
function endServer(child) {
|
||||
const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
|
||||
: new Promise((resolve) => child.once("exit", resolve));
|
||||
try { child.stdin.end(); } catch {}
|
||||
if (!IS_WIN) child.kill("SIGTERM");
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
|
||||
// server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
|
||||
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
|
||||
return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
|
||||
}
|
||||
|
||||
function stopServer() {
|
||||
@@ -183,27 +189,37 @@ function serverDied(why) {
|
||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
||||
}
|
||||
|
||||
async function restartServer() {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) endServer(old);
|
||||
await load();
|
||||
// Restarts that overlap share one: two could each start a server, and the one
|
||||
// that lost the lock would leave the app pointing at nothing.
|
||||
let restarting = null;
|
||||
function restartServer() {
|
||||
if (!restarting) {
|
||||
restarting = (async () => {
|
||||
const old = server;
|
||||
server = null;
|
||||
url = null;
|
||||
if (old) await endServer(old);
|
||||
if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
|
||||
await load();
|
||||
})().finally(() => { restarting = null; });
|
||||
}
|
||||
return restarting;
|
||||
}
|
||||
|
||||
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||
const CHROME_CSS = IS_MAC && `
|
||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
||||
header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
|
||||
header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
|
||||
`;
|
||||
|
||||
// Restart Server can start a new load while an older one is still waiting for
|
||||
// its server; only the newest load may touch the window.
|
||||
let loadGen = 0;
|
||||
let starting = null; // loads that overlap share one server start
|
||||
async function load() {
|
||||
const gen = ++loadGen;
|
||||
try {
|
||||
if (!url) await startServer();
|
||||
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||
} catch (e) {
|
||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||
@@ -254,6 +270,46 @@ 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 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}$/;
|
||||
let devices = [];
|
||||
ipcMain.on("devices:changed", (e, list) => {
|
||||
if (!fromUi(e) || !Array.isArray(list)) return;
|
||||
const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
|
||||
.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;
|
||||
buildMenu();
|
||||
});
|
||||
const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
|
||||
|
||||
// SSH through the server, so it goes to the headset and address the app is using
|
||||
// (with its own pinned identity), and refuses when there's none.
|
||||
function openSsh() {
|
||||
if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
|
||||
const body = JSON.stringify({ what: "terminal" });
|
||||
const req = http.request(new URL("/api/open", url), {
|
||||
method: "POST", timeout: 15000,
|
||||
headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (c) => { data += c; });
|
||||
res.on("end", () => {
|
||||
if (res.statusCode === 200) return;
|
||||
let why = `HTTP ${res.statusCode}`;
|
||||
try { why = JSON.parse(data).error || why; } catch {}
|
||||
dialog.showErrorBox("Couldn't open SSH", why);
|
||||
});
|
||||
});
|
||||
req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
|
||||
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
|
||||
req.end(body);
|
||||
}
|
||||
function showDevices() {
|
||||
if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
|
||||
}
|
||||
|
||||
ipcMain.handle("comfort:notify", (e, message) => {
|
||||
if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
|
||||
if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
|
||||
@@ -425,13 +481,14 @@ async function runInTerminal(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setUpConnection() {
|
||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
||||
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||
async function setUpConnection(name = activeAlias()) {
|
||||
if (!ALIAS_RE.test(name)) return;
|
||||
const alias = `FRAME_ALIAS=${name}`;
|
||||
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
||||
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||
}
|
||||
|
||||
function buildMenu() {
|
||||
@@ -446,8 +503,15 @@ function buildMenu() {
|
||||
{
|
||||
label: "Frame",
|
||||
submenu: [
|
||||
{ label: "Set Up Connection…", click: setUpConnection },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
||||
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
|
||||
{ type: "separator" },
|
||||
...(devices.length > 1 ? [{
|
||||
label: "Headset",
|
||||
submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
|
||||
click: () => { if (win) win.webContents.send("use-device", d.id); } })),
|
||||
}] : []),
|
||||
{ label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
|
||||
{ type: "separator" },
|
||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||
|
||||
+8
-1
@@ -2,7 +2,8 @@
|
||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
||||
// the page where a dropped file or folder lives, so a folder can be sideloaded
|
||||
// as a title without zipping it (the local server reads it from there).
|
||||
// It can open Set Up Connection when the headset can't be reached.
|
||||
// It can open Set Up Connection when the headset can't be reached, and keeps the
|
||||
// Frame menu's list of headsets up to date.
|
||||
// It also receives frame-control://install links (docs/web-install.md): only
|
||||
// what the link asked for, never an install; the page asks the user first.
|
||||
// And it passes update state both ways: see app/updater.js.
|
||||
@@ -12,6 +13,12 @@ contextBridge.exposeInMainWorld("frameApp", {
|
||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||
onUseDevice: (cb) => {
|
||||
ipcRenderer.removeAllListeners("use-device");
|
||||
ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
|
||||
},
|
||||
// While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
|
||||
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||
|
||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
||||
browser profile and SSH tunnel and remove the profile and panel log.
|
||||
|
||||
**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
|
||||
a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
|
||||
backend, no API key, no prestarted server) and a human approved or rejected
|
||||
each change in the approval page in a real Chrome window:
|
||||
|
||||
| Tool | Result on the Frame |
|
||||
|---|---|
|
||||
| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
|
||||
| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
|
||||
| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
|
||||
| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
|
||||
| `uninstall` | Approved; app and locale removed |
|
||||
| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
|
||||
| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
|
||||
| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
|
||||
|
||||
A separate Claude Code CLI session, with only this server configured, read
|
||||
status, `computer_state` and a headset capture, and requested an install. It
|
||||
received an approval URL and did not execute anything.
|
||||
|
||||
The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
|
||||
Against a loopback stub model, a send without consent made zero requests. With
|
||||
consent it made exactly one, carrying the text and a fresh Frame screenshot.
|
||||
Consent unticked itself after sending. SIGTERM removed the panel, profile, log
|
||||
and tunnel.
|
||||
|
||||
**Not verified while unworn:** every headset capture was a uniform dark frame,
|
||||
so SteamVR's rendered view of panels and the game could not be checked; window
|
||||
captures (`xwd`) were used instead. MCP can launch a game or panel but has no
|
||||
tool to stop one: the tester stopped them over SSH. Removing an app leaves any
|
||||
runtime it pulled in; Flatpak may also remove related extensions when that
|
||||
runtime is removed by hand.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Computer-use coverage
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
# Headsets, addresses and the connection
|
||||
|
||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||
reached at more than one address: a LAN IP at home, another at the office, its
|
||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||
app is doing to reach the one in use, step by step, as it happens.
|
||||
|
||||
The code is in three modules, all stdlib-only Python on your computer:
|
||||
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
|
||||
| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
|
||||
| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
|
||||
|
||||
## Headsets
|
||||
|
||||
Each headset keeps its own SSH alias, as Set Up Connection has always written
|
||||
it: the first is `frame`, the next `frame-2`, and so on. Terminal's
|
||||
`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
|
||||
work for each one.
|
||||
|
||||
- **Nothing to migrate by hand.** On first start, the app imports every
|
||||
`# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
|
||||
the block's HostName as its first address. It also copies the host key your
|
||||
`known_hosts` already trusts for that address into the headset's own
|
||||
known_hosts file, `~/.ssh/frame-control-hosts/<id>`, so nobody is asked to trust it again.
|
||||
- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
|
||||
`ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
|
||||
When it writes its block, the app picks the headset up by itself. If Set Up
|
||||
Connection runs again and finds a headset somewhere new, that address is added
|
||||
at the top of its list.
|
||||
- **Use this headset** (or the switcher in the header, or the app's
|
||||
**Frame → Headset** menu) moves the whole app to another headset; every panel
|
||||
reloads from it. From that moment no command goes to the previous headset, even
|
||||
if the new one never answers. It waits while an install is running, since an
|
||||
install reads the SSH settings step by step.
|
||||
- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
|
||||
the box; either way it isn't imported again unless Set Up Connection changes it.
|
||||
- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
|
||||
app shows it as not set up and lets ssh's own config decide where it goes.
|
||||
|
||||
## Addresses
|
||||
|
||||
Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
|
||||
and changeable), an optional label, the networks it has worked on, and when it
|
||||
last worked with its round-trip time.
|
||||
|
||||
When connecting, the app **tries all addresses at once** (TCP to the SSH port)
|
||||
and ranks them:
|
||||
|
||||
1. addresses that worked on the network this computer is on now;
|
||||
2. mDNS names;
|
||||
3. Tailscale addresses, if Tailscale is running here;
|
||||
4. addresses not tried on this network yet;
|
||||
5. addresses that only ever worked on other networks;
|
||||
6. Tailscale addresses while Tailscale is off.
|
||||
|
||||
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||
still trying. If SSH to the winner fails in a way another address could fix
|
||||
(a different device answered there, or the link dropped), the next one that
|
||||
answered is tried. Every success records the network on that address, so next
|
||||
time on that network it's tried first.
|
||||
|
||||
**Test now** probes every address and tries SSH on each one that answers, without
|
||||
disturbing the connection in use: "SSH works", "answered as a different
|
||||
headset", "refused this computer's key", or why it didn't answer. **Find on
|
||||
Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
|
||||
status --json`, including the Mac app's own CLI) with buttons to add their
|
||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||
services and checks `ALIAS.local` and `frame.local`.
|
||||
|
||||
## Networks
|
||||
|
||||
A network is told apart by its default gateway: the router's IP address plus its
|
||||
hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
|
||||
(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
|
||||
on macOS 14 and later, which hides the Wi-Fi name from apps without Location
|
||||
permission. Where the system does share the Wi-Fi name, it's shown, and you can
|
||||
name any network yourself ("Home Wi-Fi") on the Devices tab.
|
||||
|
||||
The app rereads the gateway every 5 seconds and Tailscale's state every
|
||||
30 seconds. Changing networks reconnects.
|
||||
|
||||
## The connection, stage by stage
|
||||
|
||||
The connector runs in the server (`frame_link.Link`) and moves through:
|
||||
|
||||
1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
|
||||
2. **Finding the headset**: each address resolving, trying, answered in N ms,
|
||||
no answer, refused, or can't be found.
|
||||
3. **Opening SSH** to the address that answered.
|
||||
4. **Checking the headset's identity**: the host key must match the one pinned
|
||||
for this headset.
|
||||
5. **Logging in** as the headset's user.
|
||||
6. **Connected** via network N, address A, round trip T; or **failed** at a stage
|
||||
with the reason in plain words and a countdown to the next try (5, 10, 20,
|
||||
then every 30 seconds). Retry now skips the wait.
|
||||
|
||||
Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
|
||||
connection is an SSH ControlMaster that every command shares; when it dies (the
|
||||
headset slept or left the network) the connector notices and starts again. On
|
||||
Windows, where OpenSSH can't share a connection, the same handshake runs once
|
||||
and each command then connects on its own; a command that can't reach the
|
||||
headset makes the connector start again.
|
||||
|
||||
Once connected, every `ssh`, `scp` and `rsync` the app runs gets
|
||||
`-o HostName=<address> -o HostKeyAlias=frame-control-<id>
|
||||
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -o HashKnownHosts=no -o User=… -o Port=…`. The
|
||||
alias's block in `~/.ssh/config` is also updated to the last address that
|
||||
worked (and to the user and port you set), so Terminal's `ssh frame` and the
|
||||
scripts follow. Edits to `~/.ssh/config` take a lock file
|
||||
(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
|
||||
write over a change someone else made since the app last read the file.
|
||||
|
||||
**Host keys are pinned per headset, not per address.** Your own `known_hosts`
|
||||
is keyed by address, so a different device answering at a remembered IP (a DHCP
|
||||
lease that moved) would look like a new host there. The app keeps one known_hosts
|
||||
file per headset instead, so saving or forgetting one headset's key never touches
|
||||
another's: a different device answering at one of its
|
||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||
connection save the new one.
|
||||
|
||||
## One server at a time
|
||||
|
||||
Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
|
||||
data folder). Two would each connect, reconnect and edit the headsets on their own, and
|
||||
one could move the other's install to a different headset. A second one, say
|
||||
`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
|
||||
running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
|
||||
|
||||
## API
|
||||
|
||||
All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
|
||||
|
||||
| Request | Returns |
|
||||
|---|---|
|
||||
| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
|
||||
| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
|
||||
| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
|
||||
| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
|
||||
| `GET /api/devices/mdns?id=` | Headsets found on this network |
|
||||
| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
|
||||
|
||||
Every host, alias and user is checked against strict patterns before it's
|
||||
stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
|
||||
through a shell.
|
||||
|
||||
## The registry file
|
||||
|
||||
`devices.json` in the app's data folder (`~/Library/Application Support/Frame
|
||||
Control` on macOS, `%APPDATA%\Frame Control` on Windows,
|
||||
`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
|
||||
share the format later (it still connects to one host; see
|
||||
[iphone.md](iphone.md)):
|
||||
|
||||
```json
|
||||
{"version": 1, "active": "f67f8b7e",
|
||||
"devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
|
||||
"identity_files": ["~/.ssh/id_ed25519_frame"],
|
||||
"addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
|
||||
"networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
|
||||
"networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
|
||||
"gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
|
||||
```
|
||||
|
||||
A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
|
||||
with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
|
||||
prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
|
||||
computer for the addresses, and temporary folders for `~/.ssh`
|
||||
(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
|
||||
never touch yours.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1,29 @@
|
||||
Locked real-Frame repeat, 2026-09-29
|
||||
SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
|
||||
mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
|
||||
Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
|
||||
Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
|
||||
Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
|
||||
Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
|
||||
No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
|
||||
Headset unworn. No Linux gaming host. No pairing or streaming session reached.
|
||||
|
||||
WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
|
||||
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||
|
||||
ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
|
||||
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
|
||||
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
|
||||
Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
|
||||
Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
|
||||
|
||||
Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
|
||||
Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
|
||||
The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
|
||||
Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
|
||||
@@ -0,0 +1,122 @@
|
||||
# UEVR from Frame Control, 2026-09-29
|
||||
|
||||
**Verified observations**, with limits below. Same device and software as
|
||||
[2026-09-28](mods-2026-09-28.md): aarch64, SteamOS `0.4.1`, BUILD_ID
|
||||
`20260925.6191901`, Proton `11.0-2c` ARM64, SteamVR `2.18.1`. Times are the
|
||||
Frame's AEST clock. Headset tests ran under the shared
|
||||
`/tmp/frame-test.lock`, taken 11:13–11:28, 11:33–11:49, 12:02–12:40,
|
||||
12:44–12:52 and 13:01–13:03. Battery 45 % →
|
||||
93 %, on charge throughout.
|
||||
|
||||
**Not observed:** the VR image, head tracking and controller input. SteamVR
|
||||
reported `activity_level` 3 (user interaction timeout; the headset was not
|
||||
being worn) with `average_target_fps` 1. Every `frame_vrshot.py` stereo
|
||||
capture was a uniform dark frame. Frame submits prove the app is presenting
|
||||
to SteamVR, not that the image is correct.
|
||||
|
||||
## Manual injection (11:13–11:28)
|
||||
|
||||
Game launched with `steam steam://rungameid/1067310`. `UEVRInjector.exe` was
|
||||
started with the game process's Wine variables, and its WPF window drew
|
||||
through DXVK's D3D9. It listed `Drop-Win64-Shipping (pid: 320) (SkyArk
|
||||
(64-bit, PCD3D_SM5))`, a Wine-side process id.
|
||||
|
||||
- `--attach=Drop-Win64-Shipping` didn't inject. The button still read
|
||||
**Inject**, and the saved `OpenVRRadio=True` in `AppData/Local/praydog/…/user.config`
|
||||
was not restored on the next start.
|
||||
- XTest clicks landed on the game: `XQueryPointer` reported child
|
||||
`0x2000001` (the game) at the injector's coordinates, and
|
||||
`XLowerWindow`/`XRaiseWindow`/`XSetInputFocus` didn't change that.
|
||||
- `XSendEvent` button events to the injector window worked. After OpenVR and
|
||||
Inject, `/proc/<game>/maps` contained
|
||||
`drive_c/frame26/uevr/UEVRBackend.dll` and `openvr_api.dll`, next to
|
||||
Proton's `vrclient_x64.dll`. UEVR's log:
|
||||
|
||||
```text
|
||||
[11:17:32.948] Hooking D3D11
|
||||
[11:17:33.020] Hooked DirectX 11
|
||||
[11:17:34.263] Framework initialized
|
||||
[11:17:34.475] [VR] Requested runtime: openvr_api.dll
|
||||
[11:17:39.791] UGameViewportClient::Draw called for the first time.
|
||||
[11:17:39.792] is stereo enabled called!
|
||||
```
|
||||
|
||||
It also logged repeated `Failed to initialize Framework on DirectX 12` (probing
|
||||
before it chose D3D11) and failed Unreal scans, for example `Failed to locate
|
||||
r.EnableStereoEmulation cvar` and `Failed to find FSceneView constructor`.
|
||||
SteamVR loaded UEVR's action manifest and `bindings_oculus_touch.json` for
|
||||
`steam.app.1067310`. `vrcmd --stats` then showed `"key" :
|
||||
"steam.app.1067310"` with `frame_submits` 4439, then 5506 a few seconds later.
|
||||
|
||||
A second session's injector exited within about 20 seconds. The third,
|
||||
started fresh, injected again (`frame_submits` 1412). After the game was
|
||||
stopped, a plain launch had no UEVR modules and no SteamVR scene app. All test
|
||||
files were removed.
|
||||
|
||||
## Through Frame Control (11:33–11:49)
|
||||
|
||||
Local `ui/server.py` against the `frame` alias. First the API, then the
|
||||
page's **VR mod** dialog, driven with a headless Chromium:
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| `status` for 2177750 (HL2 VR) | `eligible: false`; `install` refused ("doesn't look like an Unreal Engine game") |
|
||||
| `install` for 1067310 | 3 s. Three downloads, hashes matched, receipt written |
|
||||
| `start`, game running | First run refused: `the UEVR window is 960 px wide, not 625`. It was measured before WPF laid it out; `start` now waits for the width to settle. Rerun: injected, `frame_submits` 27 (36 s) |
|
||||
| `uninstall` while running | Refused: "the game is running; quit it first" |
|
||||
| `start`, game not running | Launched the game, injected, `frame_submits` 45 (55 s) |
|
||||
| UI: Remove, Install | Worked; the prefix listing matched its pre-install state except an empty `UnrealVRMod`, which uninstall now also removes |
|
||||
| UI: Play in VR | Failed once: the injector died with `Process terminated. … Resource name: Arg_AccessViolationException`, after `Fontconfig error: No writable cache directories` (it had no `HOME`). Fixed by keeping the normal environment under the game's (the relaunch-on-failure added next is gone with the GUI injector; see below) |
|
||||
| UI: Play in VR ×4 | 4 of 4 injected. The three cold starts took 54, 58 and 58 s; frame submits 56, 71, 80 |
|
||||
| UI: Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipt gone |
|
||||
|
||||
## The GUI injector wasn't reliable (12:02–12:40)
|
||||
|
||||
After review fixes, cold starts through the API with UEVR's own injector:
|
||||
|
||||
- Batches of 4, 6 and 8 runs: 3/4, 4/6 and 6/8 injected. A retry that
|
||||
relaunched the injector didn't rescue a bad session. In both failures of
|
||||
the last batch, the first injector ignored the clicks and the next two
|
||||
never showed a window within 60 s.
|
||||
- Captures of the ignoring injector (`xwd`) showed its first-drawn frame,
|
||||
with OpenVR still unselected. The injector's log had no error.
|
||||
|
||||
Lock released at 12:40 before redesigning.
|
||||
|
||||
## Frame Control's own injector (12:44–12:52)
|
||||
|
||||
`start` now runs `frame_inject.py` under python.org's embeddable Python
|
||||
3.14.7 (SHA-256 `d297e5ff…1f15`, matching python.org's release-file API), in
|
||||
the game's Wine session. The old install was removed and the new one
|
||||
installed without the headset lock, since neither launches anything. The
|
||||
prefix listing after removal matched the pre-install state; the new install
|
||||
is 47 MB.
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| API `start`, cold | Injected in 35 s, `frame_submits` 39. UEVR log: `Hooked DirectX 11`, `Framework initialized`, `Requested runtime: openvr_api.dll`; `config.txt` has `Frontend_RequestedRuntime=openvr_api.dll` |
|
||||
| API `start` ×8, cold | **8/8** injected, 33–34 s each; `frame_submits` 33–42 at the check |
|
||||
| UI: dialog while running | "UEVR is running in the game now." Remove disabled (screenshot in `docs/img/uevr-dialog.png`) |
|
||||
| UI: Remove → Install → Play in VR → Remove | Each worked; Play in VR injected (`frame_submits` 106 a few seconds later) |
|
||||
| After the last Remove | `drive_c`, `AppData/Roaming` and `AppData/Local` listings matched the pre-install state; download cache and receipts gone; no game or `python.exe` processes. The empty `/tmp/frame-control-mods.lock` was deleted by hand (tmpfs) |
|
||||
|
||||
Battery 98–100 % on charge. Lock released at 12:52.
|
||||
|
||||
## After the second review (13:01–13:03)
|
||||
|
||||
`frame_inject.py` now declares every Win32 signature, checks each result,
|
||||
frees its remote buffers and closes its handles; uninstall accepts only our
|
||||
folder and UEVR's own settings folders. Rechecked: install; three cold starts
|
||||
injected in 35, 34 and 33 s (`frame_submits` 42, 38, 47), UEVR logging
|
||||
`Framework initialized` and `Requested runtime: openvr_api.dll`; uninstall
|
||||
left `drive_c`, `AppData/Roaming` and `AppData/Local` as before, with no cache,
|
||||
receipts or processes. Frame Control's injector total: 12 of 12 cold starts
|
||||
through the API, plus the UI run.
|
||||
|
||||
## Left on the Frame
|
||||
|
||||
- Gravitas (free, 7.4 GB, installed 2026-09-28 for this work) is still
|
||||
installed; nothing else from this work is left.
|
||||
- No `UEVRInjector.exe` or game processes were left running. Steam and
|
||||
SteamVR were running at the end.
|
||||
- The test lock was released at 13:03.
|
||||
+17
-7
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has four tabs: **Home** (headset view, status, screenshots),
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
||||
plain words and the app retries every few seconds, filling everything in once it
|
||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
reached, a banner says why in plain words, what was tried, and counts down to
|
||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||
counts them while they run.
|
||||
|
||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||
@@ -78,6 +81,11 @@ counts them while they run.
|
||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||
Remmina).
|
||||
- **Devices**: several headsets, each with several addresses (LAN IPs per
|
||||
network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
|
||||
tries them all at once and learns which worked on which network. Add, edit,
|
||||
reorder and test addresses, find a headset on Tailscale or on this network,
|
||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
||||
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
|
||||
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||
@@ -101,7 +109,9 @@ Frame for the keyboard and trackpad.
|
||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
||||
with a non-local `Host` header, and any `/api/` request without a custom
|
||||
header, so other websites can't drive it or read captures. Everything reaches
|
||||
the headset through the `frame` SSH alias. On macOS and Linux it keeps one
|
||||
the headset through its SSH alias (`frame` for the first one), pointed at the
|
||||
address that answered with `-o HostName=` (`ui/frame_link.py`, described in
|
||||
[devices.md](devices.md)). On macOS and Linux it keeps one
|
||||
multiplexed SSH connection open, so status and each capture take about 0.3 s.
|
||||
Windows' OpenSSH can't share a connection, so there each request connects on
|
||||
its own and the app is a little slower. What differs between the three
|
||||
|
||||
@@ -52,6 +52,7 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
|
||||
| The Steam client runs with `-cef-enable-debugging`, so its UI answers Chrome DevTools on loopback `127.0.0.1:8080`. The `SharedJSContext` page has `appStore` (owned apps), `downloadsStore` and `SteamClient.*`. `steam steam://install/<appid>` over SSH installs an owned game; when the options dialog shows (state 7), `SteamClient.Installs.ContinueInstall()` accepts it. **Verified 2026-09-25** with Balatro and Broforce. The Frame rating is `steam_hw_compat_category_packed >> 8 & 3`. | [steam-games.md](steam-games.md), `ui/frame_steam.py` |
|
||||
| Chromium Flatpak 154 has **no immersive WebXR**: `navigator.xr` exists, but `isSessionSupported("immersive-vr")` returns `false`. Web VR180 players (DL8/DeoVR embeds) still play video inline as a flat, pannable view, and their VR button opens a tab on immersiveweb.dev. Forcing it doesn't help. `--enable-features=OpenXR,WebXR --force-webxr-runtime=openxr`, with `/opt/steamvr` and `XR_RUNTIME_JSON` exposed to the Flatpak, still returns `false`. The aarch64 Linux binary has no OpenXR code at all (no `XR_RUNTIME_JSON`, `xrGetInstanceProcAddr` or loader strings), even though `chrome://flags` lists `#webxr-runtime` → OpenXR. **Why (verified against source 2026-09-25):** M154 is the first release that compiles OpenXR on Linux (`enable_openxr` includes `is_linux`, `checkout_openxr` is true in Flathub's tarball, and Flathub's GN args don't turn it off). But `content/services/isolated_xr_device/xr_runtime_provider.cc` only creates an OpenXR device under `ENABLE_OPENXR && IS_WIN`, on 154, 155 and `main`. Nothing on Linux calls the OpenXR code, so the linker drops it. The missing pieces are two unmerged Gerrit CLs (bug 506004811): [8132979](https://chromium-review.googlesource.com/c/chromium/src/+/8132979) wires the provider on Linux (with `kOpenXR` still off by default, so it needs `--enable-features=OpenXR`), and [8441736](https://chromium-review.googlesource.com/c/chromium/src/+/8441736) runs the XR service in a sandbox that allows SteamVR's sockets. The Frame does have an aarch64 runtime: `~/.config/openxr/1/active_runtime.json` → SteamVR `bin/linuxarm64/vrclient.so`. To watch in 3D, use a native player, or a Chromium built with those two CLs ([webxr-chromium.md](webxr-chromium.md)). That build (156.0.8071.0, arm64) reports `immersive-vr` as supported and starts a session that SteamVR takes as its scene app. With the headset on, the WebXR samples scene and three.js's stereo 360 video demo showed in 3D (verified 2026-09-26, seccomp sandbox off). Started with `--remote-debugging-port=9222`, Chromium answers DevTools on loopback. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web video, [panels.md](panels.md) |
|
||||
| **DeoVR (Steam app 837380, Windows/Unity) runs immersively** under Proton ARM64 + FEX: Unity's OpenVR XR plugin finds `OpenVR Headset(Steam Frame)` and the `frame_controller`, the GPU shows as Turnip Adreno 750, and AVPro Video decodes through `MF-MediaEngine-Hardware`. It played 7680×3840 and 8192×4096 H.265 VR180 SBS streams in dome/fisheye mode (`FirstFrameReady`). Unity's own `VideoPlayer` (used for grid thumbnails) fails with `0xc00d36bb`, so thumbnail previews stay blank. The first launch takes about 45 s (`ComputeShaders: InitAsync`). Log: `compatdata/837380/pfx/drive_c/users/steamuser/AppData/LocalLow/Deo VR/Deo VR/Player.log`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | [vr-video.md](vr-video.md) |
|
||||
| **A second Windows program can join a running Proton game.** Copy `WINEPREFIX`, `WINEDLLPATH`, `WINEDLLOVERRIDES`, `PATH` (Proton's `files/bin-arm64` comes first) and `DISPLAY` from `/proc/<game pid>/environ`, keeping your own `HOME` and the rest, and run `wine prog.exe` from an ordinary SSH shell. It shares the game's wineserver (outside the pressure-vessel container). Don't copy `WINESERVERSOCKET`, which is an inherited fd. A game's windows live on gamescope's X display for that app (`:1` for Gravitas), where the full-screen game keeps the pointer and focus: XTest clicks and restacking don't reach other windows, but `XSendEvent` button events sent to a window do. `xwd` captures WPF/DXVK windows but shows Vulkan game windows as black. **Verified 2026-09-29**, BUILD_ID 20260925.6191901, Proton 11.0-2c ARM64. | [mods.md](mods.md) |
|
||||
| **Wolvic (VR browser APK) runs in Lepton against SteamVR's OpenXR**, with limits. The stock Lynx build aborts (`Runtime doesn't support selected swapChain color format`: it wants `GL_RGBA8`), and the stock Quest build fails with `XR_ERROR_API_VERSION_UNSUPPORTED`. Patching `DeviceDelegateOpenXR::GetSwapChainCreateInfo` in the Lynx build's `libnative-lib.so` to `GL_SRGB8_ALPHA8` (0x8C43) and re-signing fixes start-up. The Gecko engine then segfaults in `libxul`. The Chromium-engine build (Lynx v1.3-chromium) browses fine as an immersive app. Its page reports `isSessionSupported("immersive-vr") == true`, and `requestSession` succeeds, running about 36 rAF/s, but the headset shows **black** for WebXR content, or Wolvic's loading spinner that never clears, until the session is ended. Video decodes on the software `OMX.google.h264.decoder`. Tapping the URL bar's selection menu crashes it (no clipboard service). Open URLs with `am start -a VIEW -n com.igalia.wolvic/.VRBrowserActivity -d <url>` over the instance's ADB. DevTools is at `localabstract:content_shell_devtools_remote`. **Verified 2026-09-25**, BUILD_ID 20260922.6101926. | Web VR video, [apks.md](apks.md) |
|
||||
| Tailscale runs without root as a userspace `tailscaled` user service (static arm64 build in `~/.local/share/tailscale`, lingering on). In userspace mode, inbound tailnet connections reach the Frame's **loopback**, so every port, including DevTools on 8080, is reachable from the tailnet. **Verified 2026-09-25.** | [tailscale.md](tailscale.md), `scripts/tailscale-on-frame.sh` |
|
||||
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id <win>` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||
SteamVR's original process remained running. No global settings changed.
|
||||
|
||||
### Locked repeat, 2026-09-29 (verified)
|
||||
|
||||
Acquired `/tmp/frame-test.lock` before installing or launching anything and
|
||||
released it after cleanup. Battery was 44% and charging at preflight, 46–47%
|
||||
during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
|
||||
|
||||
Reinstalled and launched both original APKs in immersive Lepton instances.
|
||||
WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
|
||||
ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
|
||||
unmodified-client test, not the newer installer's automatic compatibility-layer
|
||||
path. Neither reached a session that could be paired or exercised further.
|
||||
Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
|
||||
record the failures.
|
||||
|
||||
SteamVR's screenshot API returned a 1920×1080 headset capture after each
|
||||
launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
|
||||
and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
|
||||
rendering or a working client. The headset was unworn; visibility, controllers,
|
||||
frame rate and motion-to-photon latency could not be judged. The explicit
|
||||
OpenXR errors, rather than the dark captures, establish the client blocker.
|
||||
|
||||
Both test installs, app data, shader caches, shortcuts, containers and temporary
|
||||
capture files were removed. The original Steam and SteamVR process IDs were
|
||||
unchanged. No reboot, power action or global setting change was used. Native
|
||||
clients remain untested, and no Linux gaming host was available for Valve's
|
||||
streaming path. The recommendation below is unchanged.
|
||||
|
||||
### Relation to the VR APK branch
|
||||
|
||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||
|
||||
+85
-15
@@ -1,11 +1,14 @@
|
||||
# Flat-to-VR mods and Beat Saber songs
|
||||
|
||||
**Status: feasibility work, not an installer.** Frame Control does not yet
|
||||
manage mods or Beat Saber songs. [Issue #26](https://github.com/saphid/frame-control/issues/26)
|
||||
stays open: neither UEVR injection nor Beat Saber custom-song playback has
|
||||
been verified on this Frame. Alex has an owned copy on a Quest 2; that copy
|
||||
has not been inspected. There is no Mods button until the underlying
|
||||
install, playback and removal have been checked.
|
||||
**Status: UEVR works from Frame Control for Unreal Engine games; Beat Saber
|
||||
songs are blocked.** Installed games have a **VR mod** button. It installs
|
||||
UEVR, starts the game with UEVR injected, and removes UEVR again. That whole
|
||||
loop was run on a real Frame on 2026-09-29 with Gravitas, from the app's own
|
||||
UI ([evidence](evidence/mods-2026-09-29.md)). The headset was unworn, so
|
||||
the VR image, head tracking and controls haven't been seen yet. Beat Saber
|
||||
custom songs need an owned Beat Saber on the Frame, which there isn't
|
||||
([below](#beat-saber-songs-first)).
|
||||
[Issue #26](https://github.com/saphid/frame-control/issues/26) stays open for those.
|
||||
|
||||
The mod manager must be Frame Control's own implementation. Mods and songs
|
||||
are permitted third-party content; BSManager, ModsBeforeFriday, MO2 and other
|
||||
@@ -15,27 +18,87 @@ unofficial mod mirrors are part of this work.
|
||||
|
||||
## Per-game support
|
||||
|
||||
Checked 2026-09-28 on SteamOS **0.4.1**, BUILD_ID **20260925.6191901**, aarch64,
|
||||
with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
Checked 2026-09-28 and 2026-09-29 on SteamOS **0.4.1**, BUILD_ID
|
||||
**20260925.6191901**, aarch64, with **Proton 11.0-2c ARM64** and SteamVR **2.18.1**. “Verified” describes only
|
||||
the observation stated, not a promise that the game is playable. “Documented”
|
||||
means an upstream source describes it; “inferred” means it still needs a test.
|
||||
|
||||
| Game / build | Mod or content | Evidence and support status | Next check |
|
||||
|---|---|---|---|
|
||||
| Half-Life 2: VR Mod – Episode One, Steam 2177750, build 25413453 | Official Steam community mod, shared base depot 658920 build 25413418 | **Verified: startup only.** Already installed; launched through Proton ARM64. The stereo headset capture showed its first-time setup, and SteamVR loaded `bindings_frame.json`. Gameplay, controller interaction, fresh installation and removal are unverified. | Complete first-time setup and play a level before offering a tested install shortcut. |
|
||||
| Gravitas, Steam 1067310, Windows | UEVR 1.05 | **Verified: prerequisites and windows only.** Free Steam install completed. The game produced a `SkyArk (64-bit, PCD3D_SM5)` window. UEVR needed .NET; with official .NET 6.0.36 libraries it produced a `UEVR` window. A combined run exited 1 with X11 errors before injection was verified. **Inferred: compatibility remains unknown**, not proven broken. | Retry during a stable headset session; verify injection, stereo scene output, controls and removal. |
|
||||
| Gravitas, Steam 1067310, Windows, Unreal Engine 4 | UEVR 1.05, from Frame Control | **Verified 2026-09-29: installs, injects, SteamVR receives frames, removes cleanly.** Twelve cold **Play in VR** runs (33–35 s each), and the full cycle from the app's UI, all loaded `UEVRBackend.dll` into the game, with UEVR logging `Hooked DirectX 11` and `Requested runtime: openvr_api.dll`. SteamVR's compositor counted frame submits for `steam.app.1067310`. Remove restored the prefix to its pre-install file listing. **Not seen:** the stereo image, head tracking and controls (headset unworn). | Wear the headset: check the image, tracking, UEVR's in-headset menu and controller input. |
|
||||
| Beat Saber, Steam 620980, Windows / Proton | Basic custom songs; later SongCore and version-matched mods | **Verified: absent from the 868-game library returned by this Frame.** Store metadata lists Windows, not Linux. **Documented:** the PC game reads basic maps from `Beat Saber_Data/CustomLevels` without a mod manager. Playback on Frame is unverified. | An already-owned, legitimately installed copy is required. Do not buy it as part of this task. |
|
||||
| Beat Saber, claimed native ARM64 build | Custom songs / native mods | **Inferred: unverified.** The research mentions this build but supplies no verified official distributable or tested layout. CPU architecture alone does not identify Android versus Linux, the game version or the mod ABI. | Establish official provenance, ownership, binary type and version before touching files. Do not apply Quest patches to an unidentified build. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2**, currently charging. No APK, version or installed mods inspected; no Frame playback verified. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Beat Saber, Alex's Quest 2 copy | Custom songs / Android mods | **Documented: owner-reported copy on Quest 2.** Not reachable on 2026-09-29 (no device on `adb` from the Mac). No APK, version or installed mods inspected; no Frame playback verified. A Quest copy is a Meta store purchase; copying it to another headset would need its entitlement check to pass there, which this work won't work around. This does not establish ownership of the Steam build. | When the Quest is available, inspect the owned copy's version and supported transfer path, then test Lepton/OpenXR compatibility without bypassing entitlement checks. |
|
||||
| Hogwarts Legacy, Steam 990080 | R.E.A.L. | **Verified: listed in this Frame's owned library, not installed.** Official release access and redistribution permission were not established; the referenced author Patreon page returned HTTP 403. No archive downloaded or game tested. | Obtain a current free release from the author and confirm its terms before any test. A news report saying “free” is not a redistribution grant. |
|
||||
| Horizon Zero Dawn, Steam 1151640; Horizon Forbidden West, Steam 2420110 | R.E.A.L. | **Verified: both listed as owned, neither installed.** Same source/permission blocker as above; runtime support is unverified. | Check each game's supported version against an accessible official release. |
|
||||
| Half-Life 2 VR / other OpenVR games | OpenComposite, per-game replacement | **Documented:** forwards OpenVR calls to OpenXR. **Inferred: Frame compatibility unknown.** Not installed or tested. HL2 VR reached setup with the shipped OpenVR path already. | Test a specific game and replacement DLL only if needed; preserve its original DLL. Never switch the shared headset's runtime globally. |
|
||||
| Doom / Quake / Half-Life Team Beef ports | Author's VR ports plus separately owned or free game data | **Inferred: untested.** Android ARM64 support does not establish OpenXR extension or controller compatibility on Lepton. | Choose an official release and legally usable data set, then test that exact port. |
|
||||
| Skyrim VR, Steam 611670 | SKSEVR / HIGGS / PLANCK stack | **Verified: Skyrim VR is absent from this library.** Owning flat Skyrim or Special Edition is not the VR game's entitlement. Runtime and mod support are unverified. | An already-owned VR copy and version-matched official mod releases are required. |
|
||||
|
||||
The [test record](evidence/mods-2026-09-28.md) distinguishes process startup,
|
||||
visible output and failures. It also records a SteamVR restart during the
|
||||
shared session, which prevents attributing the failed UEVR attempt to FEX.
|
||||
The test records ([2026-09-28](evidence/mods-2026-09-28.md),
|
||||
[2026-09-29](evidence/mods-2026-09-29.md)) distinguish process startup,
|
||||
visible output, injection and failures.
|
||||
|
||||
## UEVR from Frame Control
|
||||
|
||||

|
||||
|
||||
**Get games → an installed game → VR mod.** The dialog says whether the game
|
||||
can take UEVR, then offers **Install UEVR**, **▶ Play in VR** and **Remove
|
||||
UEVR**. The server route is `POST /api/mods {"action": "status" | "install" |
|
||||
"start" | "uninstall", "appid": …}`, and the work happens on the Frame in
|
||||
[`ui/frame_mods.py`](../ui/frame_mods.py). It uses only the standard library,
|
||||
like `frame_steam.py`.
|
||||
|
||||
- **Which games.** Unreal Engine games, recognised by their
|
||||
`…/Binaries/Win64/*-Win64-Shipping.exe` (inferred as the general rule; checked
|
||||
with Gravitas, and HL2 VR is correctly refused). The game needs a Proton
|
||||
prefix, so it must have been played once.
|
||||
- **Install.** Downloads praydog's [UEVR 1.05 release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and python.org's [Windows x64 embeddable Python 3.14.7](https://www.python.org/downloads/release/python-3147/).
|
||||
Each is checked against a pinned SHA-256; Python's comes from python.org's
|
||||
release-file API. Archives are unpacked into a staging folder, rejecting
|
||||
entries that escape it, symlinks and oversized contents, then moved into
|
||||
`<prefix>/drive_c/frame-control/` (about 47 MB). Nothing goes into the
|
||||
game's own folder. A receipt in `~/.local/share/frame-control/mods/` lists
|
||||
what to remove, including UEVR's own settings folders only if they didn't
|
||||
exist before. Install and remove refuse while the game is running, and only
|
||||
one mod action runs at a time.
|
||||
- **Play in VR.** Launches the game through Steam if it isn't running and
|
||||
waits for its window. It then runs a short script under that Python, with
|
||||
the game's Proton, prefix and Wine settings read from the game process, so
|
||||
both share one Wine session. The script repeats what UEVR's own injector does
|
||||
when you press Inject with OpenVR chosen: load `UEVRPluginNullifier.dll` and
|
||||
call its `nullify`, load `openvr_api.dll`, set `Frontend_RequestedRuntime` in
|
||||
UEVR's per-game `config.txt`, then load `UEVRBackend.dll`. Each load is a
|
||||
`LoadLibraryW` remote thread, as in the frontend's `Injector.cs`. `start`
|
||||
then checks that `UEVRBackend.dll` is mapped into the game and reads
|
||||
SteamVR's frame count for the app (`vrcmd --stats`).
|
||||
- **Remove.** Deletes what the receipt lists, then the download cache once
|
||||
no game uses it. Verified: the prefix's file listing matched its
|
||||
pre-install state afterwards.
|
||||
|
||||
Why not UEVR's own injector (verified 2026-09-29):
|
||||
|
||||
- It's a .NET 6 WPF app, so it needs Microsoft's .NET runtimes (about 70 MB
|
||||
of downloads) in the prefix.
|
||||
- Under gamescope, the full-screen game keeps the pointer and focus. XTest
|
||||
clicks, raising or refocusing the injector window don't change that, so in
|
||||
the headset you can't click it. Sending X `ButtonPress`/`ButtonRelease`
|
||||
events straight to its window does work.
|
||||
- `--attach=<process>` didn't inject under Wine, and it didn't restore its
|
||||
saved OpenVR choice.
|
||||
- Driven that way, it injected in 6 of 8 cold starts. In a bad session every
|
||||
new injector ignored input or never showed its window, even after
|
||||
relaunching. Its process list reads each process's main window title, a
|
||||
cross-process window message; a window that isn't answering would block its
|
||||
UI thread (inferred from the frontend's source, not proven). Without a
|
||||
`HOME` it also died in .NET at startup.
|
||||
- Frame Control's own script injected in 12 of 12 cold starts through the API, in 33–35 s, and again from the UI.
|
||||
- UEVR's DirectX 12 probing logs errors before it settles on DirectX 11 for
|
||||
Gravitas, and some of its Unreal engine scans fail on this older UE4 game.
|
||||
Neither stopped the injection.
|
||||
|
||||
## Beat Saber: songs first
|
||||
|
||||
@@ -62,7 +125,10 @@ or dependency for Frame Control.
|
||||
|
||||
## Requirements for our manager
|
||||
|
||||
These are **planned**, not implemented or verified:
|
||||
For UEVR, items 1–5 are implemented in `frame_mods.py` and covered by
|
||||
fake-library tests (`tests/test_mods.py`), apart from checking ownership
|
||||
through Steam; item 6 is met for Gravitas. Beat Saber songs would need the
|
||||
same, and none of it is built for them:
|
||||
|
||||
1. Resolve the selected Steam game's real library, installed build, executable
|
||||
architecture and Proton prefix. Confirm ownership through Steam; a directory
|
||||
@@ -91,7 +157,11 @@ These are **planned**, not implemented or verified:
|
||||
- [UEVR 1.05 official release](https://github.com/praydog/UEVR/releases/tag/1.05)
|
||||
and [author's usage instructions](https://github.com/praydog/UEVR#getting-started).
|
||||
- [Microsoft .NET 6 release metadata](https://builds.dotnet.microsoft.com/dotnet/release-metadata/6.0/releases.json),
|
||||
including the SHA-512 hashes used for the test runtimes.
|
||||
including the SHA-512 hashes used for the injector tests.
|
||||
- [UEVR frontend source](https://github.com/praydog/UEVR-Frontend): `UEVR/MainWindow.xaml.cs`
|
||||
(`Inject_Clicked`) and `UEVR/Injector.cs`, the steps `frame_inject.py` follows.
|
||||
- [python.org release-file API](https://www.python.org/api/v2/downloads/release_file/) for the
|
||||
embeddable Python's `sha256_sum`.
|
||||
- [OpenComposite's OpenXR branch](https://gitlab.com/znixian/OpenOVR/-/tree/openxr),
|
||||
including per-game installation and the need to preserve original DLLs.
|
||||
- [R.E.A.L. author post referenced by the research](https://www.patreon.com/realvr/posts/but-wheres-link-165840151)
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ Frame Control's **Get games** section lists the games you own with each one's
|
||||
Steam Frame rating, installs them on the Frame, and searches the Steam store.
|
||||
This page covers how it works underneath, so you can do the same from a shell.
|
||||
|
||||
For flat-to-VR mods and Beat Saber custom songs, see the
|
||||
[per-game feasibility table](mods.md). Mod support is separate from Steam's
|
||||
Frame rating; there is no mod installer yet.
|
||||
Installed Unreal Engine games also have a **VR mod** button that installs,
|
||||
starts and removes UEVR; see [VR mods](mods.md) for how it works and the
|
||||
per-game table. Mod support is separate from Steam's Frame rating.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
||||
|
||||

|
||||
|
||||
**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
|
||||
through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
|
||||
the owned player. Our generated test files:
|
||||
|
||||
| Case | Result |
|
||||
|---|---|
|
||||
| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
|
||||
| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
|
||||
| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
|
||||
| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
|
||||
| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
|
||||
| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
|
||||
| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
|
||||
| Second Play while one runs | Refused: "Stop the current media…" |
|
||||
|
||||
Stop always left the unit inactive, and no player process remained.
|
||||
|
||||
**Standby (verified):** an unworn Frame turns its displays off a few seconds
|
||||
after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
|
||||
first run's movie died there. The player now drops frames while the headset
|
||||
is in standby, keeps the audio and its clock going, and resumes the picture
|
||||
when the headset wakes. The 8 s movie above dropped 40 frames and finished.
|
||||
Stills and the theatre surround are re-sent after waking. Five minutes
|
||||
without an accepted frame is reported as an error. Headset-view captures
|
||||
taken during standby show a flat dark frame, not our screen.
|
||||
|
||||

|
||||
|
||||
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
||||
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||
|
||||
+16
-3
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
||||
fi
|
||||
}
|
||||
|
||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||
# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
|
||||
# running app and this script never write over each other's change.
|
||||
write_config() {
|
||||
local lockfd="" rc
|
||||
zmodload zsh/system 2>/dev/null
|
||||
touch "$CONFIG.frame-control.lock" 2>/dev/null
|
||||
zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
|
||||
write_config_locked; rc=$?
|
||||
[[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
|
||||
return $rc
|
||||
}
|
||||
|
||||
# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
|
||||
write_config_locked() {
|
||||
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||
local tmp
|
||||
local tmp new="$CONFIG.frame-control.$$"
|
||||
tmp=$(mktemp) || return 1
|
||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
||||
# value it sees per option, so this block must precede any other "Host frame"
|
||||
@@ -126,7 +138,8 @@ write_config() {
|
||||
print -r -- "Host *"
|
||||
print -r -- "$END_MARK"
|
||||
cat "$tmp"
|
||||
} > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||
} > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
|
||||
|| { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||
HERE=${0:A:h}
|
||||
cmd=${1:-status}
|
||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||
|
||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
|
||||
|
||||
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
||||
# written the way Steam's settings page does (steamui module exporting the
|
||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||
# ("Interactive authentication required") but allows one from a user unit.
|
||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||
import json, os, subprocess, sys
|
||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||
from frame_steam import Page
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
||||
# Frame Control passes the headset it has chosen: its address and pinned identity.
|
||||
ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
|
||||
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||
id="" name=""
|
||||
|
||||
@@ -109,4 +111,4 @@ EOF
|
||||
)
|
||||
b64=$(print -rn -- "$remote" | base64)
|
||||
|
||||
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
|
||||
each step of a connection, and plays a ControlMaster. What each HostName does comes
|
||||
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
|
||||
"slow" (hangs after connecting);
|
||||
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ["FAKESSH_LOG"], "a") as f:
|
||||
f.write(json.dumps(args) + "\n")
|
||||
hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
|
||||
opts = {}
|
||||
i = 0
|
||||
while i < len(args) and args[i].startswith("-"):
|
||||
if args[i] in ("-o", "-O", "-p", "-l"):
|
||||
key = args[i]
|
||||
val = args[i + 1]
|
||||
if key == "-o":
|
||||
k, _, v = val.partition("=")
|
||||
opts[k.lower()] = v
|
||||
else:
|
||||
opts[key] = val
|
||||
i += 2
|
||||
else:
|
||||
opts[args[i]] = True
|
||||
i += 1
|
||||
alias = args[i] if i < len(args) else ""
|
||||
host = opts.get("hostname", alias).replace("%%", "%")
|
||||
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
|
||||
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
|
||||
|
||||
if "-G" in opts:
|
||||
print(f"hostname {alias}\nport 22\nuser tester")
|
||||
sys.exit(0)
|
||||
if opts.get("-O") == "check":
|
||||
sys.exit(0 if os.path.exists(marker) else 255)
|
||||
if opts.get("-O") == "exit":
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
sys.exit(0)
|
||||
|
||||
what = hosts.get(host)
|
||||
if what is None:
|
||||
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
|
||||
sys.exit(255)
|
||||
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
|
||||
say("debug1: Connection established.")
|
||||
if what == "slow":
|
||||
time.sleep(30)
|
||||
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
|
||||
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
|
||||
if what == "wrong":
|
||||
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
|
||||
say("Host key verification failed.")
|
||||
sys.exit(255)
|
||||
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
|
||||
say("debug1: Next authentication method: publickey")
|
||||
if what == "denied":
|
||||
say(f"tester@{host}: Permission denied (publickey).")
|
||||
sys.exit(255)
|
||||
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
|
||||
if opts.get("controlmaster") == "yes":
|
||||
open(marker, "w").close()
|
||||
def bye(*_):
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
sys.exit(0)
|
||||
signal.signal(signal.SIGTERM, bye)
|
||||
while True:
|
||||
time.sleep(0.2)
|
||||
if not os.path.exists(marker):
|
||||
sys.exit(0)
|
||||
sys.exit(0)
|
||||
+23
-2
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
|
||||
with mock.patch.object(server.frame_host, 'MUX', True), \
|
||||
mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
|
||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
||||
# Per headset (its tag), and per process for a private server.
|
||||
self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
|
||||
self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
|
||||
|
||||
|
||||
class ComputerState(unittest.TestCase):
|
||||
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class ScriptsFollowTheHeadset(unittest.TestCase):
|
||||
"""keep_awake and panel tools run scripts that ssh on their own: they must reach the
|
||||
headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
|
||||
|
||||
@unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
|
||||
def test_scripts_get_the_routed_alias_and_options(self):
|
||||
import shlex
|
||||
fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
|
||||
SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
|
||||
with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
|
||||
agent.run_script(fake, 'keep-awake.sh', ['status'])
|
||||
env = run.call_args.kwargs['env']
|
||||
self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
|
||||
self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
|
||||
# and the script turns that back into the same argv
|
||||
out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
|
||||
env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
|
||||
self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
|
||||
@@ -0,0 +1,400 @@
|
||||
"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
|
||||
host keys and input checks. Everything works in temporary folders.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_devices as fd # noqa: E402
|
||||
|
||||
CONFIG = """Host lxso1
|
||||
HostName 192.168.1.109
|
||||
|
||||
# >>> steam-frame (frame) >>>
|
||||
Host frame
|
||||
HostName frame.tail1234.ts.net
|
||||
User steamos
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
IdentityFile ~/.ssh/id_rsa_frame_devkit
|
||||
IdentitiesOnly yes
|
||||
ServerAliveInterval 30
|
||||
Host *
|
||||
# <<< steam-frame (frame) <<<
|
||||
# >>> steam-frame (frame-2) >>>
|
||||
Host frame-2
|
||||
HostName 192.168.1.60
|
||||
Port 2222
|
||||
User deck
|
||||
IdentityFile ~/.ssh/id_ed25519_frame
|
||||
Host *
|
||||
# <<< steam-frame (frame-2) <<<
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
"""
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
self.ssh = self.dir / "ssh"
|
||||
self.ssh.mkdir()
|
||||
(self.ssh / "config").write_text(CONFIG)
|
||||
old = os.environ.get("FRAME_CONTROL_SSH_DIR")
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
|
||||
self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
|
||||
else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
|
||||
self.reg = fd.Registry(self.dir / "devices.json")
|
||||
|
||||
|
||||
class Validation(unittest.TestCase):
|
||||
def test_hosts(self):
|
||||
for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"):
|
||||
self.assertEqual(fd.check_host(good), good)
|
||||
for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
|
||||
"frame%en0", "x" * 300, None, 5, "frame/../x"):
|
||||
with self.assertRaises(fd.DeviceError, msg=repr(bad)):
|
||||
fd.check_host(bad)
|
||||
|
||||
def test_names(self):
|
||||
self.assertEqual(fd.check_alias("frame-2"), "frame-2")
|
||||
for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fd.check_alias(bad)
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fd.check_user(bad)
|
||||
for bad in ("0", "65536", "x", None, "22; id"):
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fd.check_port(bad)
|
||||
self.assertEqual(fd.check_port("2222"), 2222)
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fd.check_text("line\nbreak", "label")
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fd.check_kind("wifi")
|
||||
|
||||
def test_ipv6_zone_is_escaped_for_ssh(self):
|
||||
self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
|
||||
|
||||
|
||||
class Migration(Base):
|
||||
def test_blocks_are_parsed(self):
|
||||
blocks = fd.parse_blocks(CONFIG)
|
||||
self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
|
||||
self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
|
||||
self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
|
||||
self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
|
||||
|
||||
def test_existing_headsets_are_imported_once(self):
|
||||
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||
devices = self.reg.devices()
|
||||
self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
|
||||
frame, second = devices
|
||||
self.assertEqual(frame["name"], "Steam Frame")
|
||||
self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
|
||||
self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
|
||||
self.assertEqual((second["user"], second["port"]), ("deck", 2222))
|
||||
self.assertEqual(self.reg.active(), frame["id"])
|
||||
self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
|
||||
# It's all on disk, in the documented shape.
|
||||
data = json.loads((self.dir / "devices.json").read_text())
|
||||
self.assertEqual(data["version"], 1)
|
||||
self.assertEqual(len(data["devices"]), 2)
|
||||
self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
|
||||
|
||||
def test_first_import_keeps_using_frame(self):
|
||||
# Set Up Connection puts each new block first; the app used `frame` before.
|
||||
blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
|
||||
head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
|
||||
second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
|
||||
(self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
|
||||
+ "# >>> steam-frame (frame) >>>" + first + tail)
|
||||
self.reg.sync_from_config(seed=False)
|
||||
self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
|
||||
self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh"), "needs ssh")
|
||||
def test_a_port_inherited_from_another_host_entry_is_kept(self):
|
||||
(self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
|
||||
self.reg.sync_from_config(seed=False)
|
||||
self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
|
||||
self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
|
||||
# Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
|
||||
self.assertTrue(fd.rewrite_block("frame", port=22))
|
||||
self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
|
||||
self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
|
||||
|
||||
def test_setup_finding_a_new_address_adds_it(self):
|
||||
self.reg.sync_from_config(seed=False)
|
||||
(self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
|
||||
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||
hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
|
||||
self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
|
||||
|
||||
def test_setup_changing_the_login_updates_the_headset(self):
|
||||
self.reg.sync_from_config(seed=False)
|
||||
(self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
|
||||
self.assertTrue(self.reg.sync_from_config(seed=False))
|
||||
d = self.reg.by_alias("frame")
|
||||
self.assertEqual((d["user"], d["port"]), ("deck", 2200))
|
||||
|
||||
def test_removed_headset_stays_removed_until_setup_changes_it(self):
|
||||
self.reg.sync_from_config(seed=False)
|
||||
second = self.reg.by_alias("frame-2")
|
||||
self.reg.remove_device(second["id"])
|
||||
self.reg.sync_from_config(seed=False)
|
||||
self.assertIsNone(self.reg.by_alias("frame-2"))
|
||||
self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
|
||||
self.reg.sync_from_config(seed=False)
|
||||
self.assertIsNotNone(self.reg.by_alias("frame-2"))
|
||||
|
||||
def test_corrupt_registry_is_ignored(self):
|
||||
(self.dir / "bad.json").write_text("{not json")
|
||||
self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
|
||||
(self.dir / "evil.json").write_text(json.dumps({"devices": [
|
||||
{"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
|
||||
{"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
|
||||
devices = fd.Registry(self.dir / "evil.json").devices()
|
||||
self.assertEqual([d["alias"] for d in devices], ["ok"])
|
||||
self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
|
||||
|
||||
|
||||
|
||||
class SharedFile(Base):
|
||||
"""The desktop app and a standalone server can share devices.json."""
|
||||
|
||||
def test_one_server_never_saves_over_anothers_change(self):
|
||||
self.reg.sync_from_config(seed=False)
|
||||
other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
|
||||
d = self.reg.by_alias("frame")
|
||||
self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
|
||||
other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
|
||||
hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
|
||||
self.assertIn("100.101.1.2", hosts)
|
||||
self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
|
||||
|
||||
def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
|
||||
self.reg.sync_from_config(seed=False)
|
||||
other = fd.Registry(self.dir / "devices.json")
|
||||
mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
|
||||
self.reg.set_active(mine)
|
||||
other.set_active(theirs)
|
||||
self.assertEqual(self.reg.active(), mine) # after a refresh
|
||||
self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
|
||||
self.assertEqual(self.reg.active(), mine)
|
||||
self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
|
||||
|
||||
class ConfigRewrite(Base):
|
||||
def test_hostname_user_and_port_change_only_inside_the_block(self):
|
||||
cfg = self.ssh / "config"
|
||||
self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
|
||||
text = cfg.read_text()
|
||||
self.assertIn(" HostName 192.168.1.237\n", text)
|
||||
self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
|
||||
self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
|
||||
self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
|
||||
block = fd.parse_blocks(cfg.read_text())[0]
|
||||
self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
|
||||
self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly
|
||||
self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
|
||||
self.assertIn(" Port 22\n", cfg.read_text())
|
||||
self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
|
||||
if os.name != "nt":
|
||||
self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_concurrent_edits_all_land(self):
|
||||
import threading
|
||||
def edit(alias, prefix):
|
||||
for n in range(15):
|
||||
fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
|
||||
threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
|
||||
threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
blocks = fd.parse_blocks((self.ssh / "config").read_text())
|
||||
self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
|
||||
self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
|
||||
|
||||
def test_learning_skips_a_block_someone_changed(self):
|
||||
# The connector learned an address, but Set Up Connection moved the block meanwhile.
|
||||
self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
|
||||
expect={"hostname": "old.example", "user": None, "port": None}))
|
||||
self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
|
||||
self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
|
||||
expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
|
||||
|
||||
def test_zone_is_escaped_and_read_back(self):
|
||||
fd.rewrite_block("frame", hostname="fe80::1%en0")
|
||||
self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
|
||||
self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
|
||||
|
||||
def test_missing_block_is_left_alone(self):
|
||||
self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
|
||||
self.assertFalse(fd.remove_block("frame-9"))
|
||||
self.assertTrue(fd.remove_block("frame-2"))
|
||||
self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
|
||||
class Pins(Base):
|
||||
def test_seed_copies_the_trusted_key_under_the_device_alias(self):
|
||||
(self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
|
||||
self.assertFalse(fd.pinned("d1"))
|
||||
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
|
||||
self.assertTrue(fd.pinned("d1"))
|
||||
self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
|
||||
self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
|
||||
self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
|
||||
self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
|
||||
self.assertTrue(fd.forget_pin("d1"))
|
||||
self.assertFalse(fd.pinned("d1"))
|
||||
self.assertFalse(fd.forget_pin("d1"))
|
||||
|
||||
def test_each_headset_has_its_own_file(self):
|
||||
(self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
|
||||
fd.seed_pin("da", ["a.local"])
|
||||
fd.seed_pin("db", ["b.local"])
|
||||
fd.forget_pin("da")
|
||||
self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
|
||||
self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
|
||||
|
||||
def test_hashed_and_non_default_port_entries(self):
|
||||
kh = self.ssh / "known_hosts"
|
||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||
|
||||
def test_hashed_pins_are_found_and_forgotten(self):
|
||||
target = fd.known_hosts("d4")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
||||
self.assertNotIn("frame-control-d4", target.read_text())
|
||||
self.assertTrue(fd.pinned("d4"))
|
||||
self.assertTrue(fd.forget_pin("d4"))
|
||||
self.assertFalse(fd.pinned("d4"))
|
||||
|
||||
def test_known_hosts_option_uses_the_override(self):
|
||||
self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
|
||||
os.environ.pop("FRAME_CONTROL_SSH_DIR")
|
||||
self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_address_editing(self):
|
||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||
self.assertEqual(a["kind"], "mdns")
|
||||
self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.add_address(d["id"], "frame-3.local") # already there
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.add_address(d["id"], "frame-3.local; id")
|
||||
self.reg.move_address(d["id"], "100.100.1.1", -1)
|
||||
self.reg.move_address(d["id"], "100.100.1.1", -1)
|
||||
self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
|
||||
hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
|
||||
self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
|
||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
|
||||
self.reg.update_address(d["id"], "192.168.1.40", label="Home")
|
||||
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
|
||||
self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
|
||||
self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
|
||||
moved = self.reg.get(d["id"])["addresses"][1]
|
||||
self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
|
||||
self.reg.remove_address(d["id"], "192.168.1.41")
|
||||
self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.remove_address(d["id"], "nope")
|
||||
|
||||
def test_devices(self):
|
||||
a = self.reg.add_device("frame")
|
||||
b = self.reg.add_device("frame-2", name="Office")
|
||||
self.assertEqual(self.reg.active(), a["id"])
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.add_device("frame")
|
||||
self.reg.set_active(b["id"])
|
||||
self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.update_device(b["id"], user="bad user")
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.update_device(b["id"], user="steam", port="bad")
|
||||
self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
|
||||
self.reg.remove_device(b["id"])
|
||||
self.assertEqual(self.reg.active(), a["id"])
|
||||
self.assertFalse(self.reg.emptied())
|
||||
self.reg.remove_device(a["id"])
|
||||
self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
|
||||
self.reg.add_device("frame-4")
|
||||
self.assertFalse(self.reg.emptied())
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.get(b["id"])
|
||||
|
||||
def test_networks_get_names(self):
|
||||
net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
|
||||
self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
|
||||
self.reg.record_network(net)
|
||||
self.reg.name_network("n-1", "Home Wi-Fi")
|
||||
self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
|
||||
self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
|
||||
self.assertEqual(self.reg.network_name(None), "No network")
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
self.reg.name_network("n-unknown", "x")
|
||||
|
||||
|
||||
class Order(unittest.TestCase):
|
||||
def addr(self, host, kind, networks=()):
|
||||
return {"host": host, "kind": kind, "networks": list(networks)}
|
||||
|
||||
def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
|
||||
addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
|
||||
self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
|
||||
self.addr("192.168.1.237", "lan", ["n-home"])]
|
||||
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
||||
self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
|
||||
# Tailscale off: its addresses go last.
|
||||
order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
|
||||
self.assertEqual(order[-1], "100.64.1.2")
|
||||
# On an unknown network nothing has worked yet; the user's order breaks ties.
|
||||
ranked = fd.order_addresses(addrs, None, True)
|
||||
self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
|
||||
self.assertEqual(ranked[0][1], "mDNS name")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class RoutingLongLivedSsh(unittest.TestCase):
|
||||
"""The keyboard agent and the Mac view keep their own ssh open: switching headset
|
||||
must end or retarget them, or input would go on reaching the old headset."""
|
||||
|
||||
def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
|
||||
import server
|
||||
from unittest import mock
|
||||
before = (server.FRAME, list(server.HOST_OPTS))
|
||||
self.addCleanup(lambda: server.route(*before))
|
||||
with mock.patch.object(server._input, "stop") as stop, \
|
||||
mock.patch.object(server.macview, "retarget") as retarget:
|
||||
server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
|
||||
stop.assert_not_called()
|
||||
server.route("frame-2", ["-o", "HostName=192.0.2.2"])
|
||||
stop.assert_called_once()
|
||||
retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
|
||||
@@ -0,0 +1,695 @@
|
||||
"""frame_link: finding a headset among its addresses and following each stage of
|
||||
connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
|
||||
Also the server's /api/connection, its event stream, and /api/devices.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_devices as fd # noqa: E402
|
||||
import frame_link as fl # noqa: E402
|
||||
import frame_network as fn # noqa: E402
|
||||
|
||||
FAKESSH = ROOT / "tests" / "fakessh"
|
||||
NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
|
||||
"ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
|
||||
|
||||
|
||||
def explain(msg):
|
||||
"""A cut-down server.unreachable, so this needs no server import."""
|
||||
if "Could not resolve" in msg:
|
||||
return "Can't find the Frame on the network."
|
||||
if "refused" in msg:
|
||||
return "The Frame refused the connection."
|
||||
if "timed out" in msg.lower():
|
||||
return "The Frame isn't answering."
|
||||
if "Permission denied" in msg:
|
||||
return "The Frame didn't accept this computer's SSH key."
|
||||
return None
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
srv.bind(("127.0.0.1", 0))
|
||||
srv.listen(4)
|
||||
port = srv.getsockname()[1]
|
||||
seen = []
|
||||
res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
|
||||
self.assertEqual(res["state"], "answered")
|
||||
self.assertEqual(res["ip"], "127.0.0.1")
|
||||
self.assertIsInstance(res["rtt_ms"], float)
|
||||
self.assertEqual(seen, ["resolving", "trying"])
|
||||
# Closed now. Windows retries a refused connect for about 2 s before saying so.
|
||||
self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
|
||||
self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
|
||||
|
||||
def test_failed_probes_read_like_ssh(self):
|
||||
# So the server's UNREACHABLE table words them like any other ssh failure.
|
||||
self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
|
||||
self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
|
||||
self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
|
||||
|
||||
|
||||
class Pick(unittest.TestCase):
|
||||
def pick(self, results, tried=()):
|
||||
return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
|
||||
|
||||
def test_best_ranked_answer_wins(self):
|
||||
now = time.monotonic()
|
||||
ok = lambda t=now: {"state": "answered", "t": t}
|
||||
no = {"state": "timeout", "t": now}
|
||||
self.assertEqual(self.pick([no, ok(), ok()]), 1)
|
||||
self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
|
||||
self.assertIsNone(self.pick([no, no]))
|
||||
# A worse-ranked answer waits PREFER for a better one still trying, then goes.
|
||||
t0 = time.monotonic()
|
||||
self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
|
||||
self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
|
||||
|
||||
def test_gives_up_on_slow_lookups_at_the_deadline(self):
|
||||
t0 = time.monotonic()
|
||||
results = [None]
|
||||
self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
|
||||
self.assertLess(time.monotonic() - t0, 2)
|
||||
self.assertEqual(results[0]["state"], "timeout")
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
|
||||
class Connecting(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
|
||||
self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
|
||||
(self.dir / "ssh").mkdir()
|
||||
self.log = self.dir / "calls.jsonl"
|
||||
env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
|
||||
"FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
|
||||
patcher = mock.patch.dict(os.environ, env)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
|
||||
p = mock.patch.object(fn, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.srv = socket.socket()
|
||||
self.srv.bind(("127.0.0.1", 0))
|
||||
self.srv.listen(16)
|
||||
self.addCleanup(self.srv.close)
|
||||
self.port = self.srv.getsockname()[1]
|
||||
self.reg = fd.Registry(self.dir / "devices.json")
|
||||
self.routes = []
|
||||
self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
|
||||
control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
|
||||
explain=explain)
|
||||
self.addCleanup(self.link.stop)
|
||||
|
||||
def test_start_routes_to_the_saved_headset_before_serving(self):
|
||||
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
|
||||
b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
|
||||
self.reg.set_active(b["id"])
|
||||
with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
|
||||
self.link.start()
|
||||
self.assertEqual(self.routes[0][0], "frame-2")
|
||||
self.assertIn("HostName=192.0.2.2", self.routes[0][1])
|
||||
self.assertNotEqual(a["id"], b["id"])
|
||||
|
||||
def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
|
||||
a = self.reg.add_device("frame", hosts=["192.0.2.1"])
|
||||
self.reg.add_device("frame-2", hosts=["192.0.2.2"])
|
||||
self.reg.set_active(a["id"])
|
||||
other = fd.Registry(self.dir / "devices.json") # another Frame Control server
|
||||
other.remove_device(a["id"])
|
||||
self.link.work = lambda: 1
|
||||
self.assertTrue(self.link.active_device().get("none"))
|
||||
self.link.work = lambda: 0
|
||||
self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
|
||||
|
||||
def test_nothing_elsewhere_moves_a_running_install(self):
|
||||
"""A bare alias in use, then another server sets up and picks a headset."""
|
||||
self.link.override = None
|
||||
self.hosts({"localhost": "ok"})
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||
self.link.connect(["start"])
|
||||
started = self.routes[-1]
|
||||
other = fd.Registry(self.dir / "devices.json")
|
||||
other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
|
||||
self.link.work = lambda: 1
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||
self.link.close_master()
|
||||
self.link.connect(["dropped"])
|
||||
self.assertEqual(self.routes[-1][0], started[0])
|
||||
self.assertTrue(self.link.deferred)
|
||||
|
||||
def listen6(self):
|
||||
"""A "different device": the same port on IPv6 loopback."""
|
||||
try:
|
||||
six = socket.socket(socket.AF_INET6)
|
||||
self.addCleanup(six.close)
|
||||
six.bind(("::1", self.port))
|
||||
six.listen(4)
|
||||
except OSError:
|
||||
self.skipTest("no IPv6 loopback")
|
||||
|
||||
def pin(self, device_id):
|
||||
fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
|
||||
fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
|
||||
|
||||
def hosts(self, mapping):
|
||||
# An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
|
||||
if "localhost" in mapping:
|
||||
mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping)
|
||||
os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
|
||||
|
||||
def calls(self):
|
||||
return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
|
||||
|
||||
def device(self, *hosts):
|
||||
d = self.reg.add_device("frame-t", port=self.port, hosts=[])
|
||||
for h in hosts:
|
||||
self.reg.add_address(d["id"], h, kind="lan")
|
||||
return d
|
||||
|
||||
def test_falls_through_to_the_address_that_is_really_the_headset(self):
|
||||
# Tried in this order: a name that doesn't resolve, a different device, the headset.
|
||||
self.listen6()
|
||||
d = self.device("nothing.invalid", "::1", "127.0.0.1")
|
||||
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual(s["phase"], "connected", s["error"])
|
||||
self.assertEqual(s["via"]["host"], "127.0.0.1")
|
||||
self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
|
||||
rows = {p["host"]: p for p in s["probes"]}
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||
self.assertIn("different headset", rows["::1"]["detail"])
|
||||
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-t")
|
||||
self.assertIn("HostName=127.0.0.1", opts)
|
||||
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
|
||||
self.assertIn(f"Port={self.port}", opts)
|
||||
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
|
||||
self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
|
||||
# ssh takes an option's first value: accept-new must come before the commands' own "yes".
|
||||
self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
|
||||
self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
|
||||
self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
|
||||
# It learned: 127.0.0.1 works on this network.
|
||||
learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
|
||||
self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
|
||||
self.assertEqual(learned["::1"]["networks"], [])
|
||||
self.assertTrue(self.link.alive())
|
||||
self.link.close_master()
|
||||
self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
|
||||
|
||||
def test_stages_are_published_as_they_happen(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
steps, versions = [], []
|
||||
real = self.link.stage
|
||||
|
||||
def stage(sid, state, detail=None):
|
||||
real(sid, state, detail)
|
||||
steps.append((sid, state))
|
||||
versions.append(self.link.snapshot()["version"])
|
||||
self.link.stage = stage
|
||||
before = self.link.snapshot()["version"]
|
||||
self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
|
||||
self.link.connect(["start"])
|
||||
started = [sid for sid, state in steps if state == "active"]
|
||||
self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
|
||||
self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
|
||||
self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
|
||||
self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
|
||||
|
||||
def test_nothing_answers(self):
|
||||
self.device("nothing.invalid", "also-nothing.invalid")
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual(s["phase"], "failed")
|
||||
self.assertEqual(s["error"]["stage"], "find")
|
||||
self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
|
||||
self.assertGreater(s["retry_at"], time.time())
|
||||
self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
|
||||
|
||||
def test_refused_key_stops_at_login(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "denied"})
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
|
||||
self.assertIn("SSH key", s["error"]["message"])
|
||||
|
||||
def test_pinned_identity_is_checked_strictly(self):
|
||||
d = self.device("localhost")
|
||||
self.pin(d["id"])
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
|
||||
self.assertIn("StrictHostKeyChecking=yes", master)
|
||||
|
||||
def test_ssh_goes_to_the_ipv4_address_that_answered(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
|
||||
self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
|
||||
|
||||
def test_no_headset_after_removing_them_all(self):
|
||||
d = self.device("localhost")
|
||||
self.reg.remove_device(d["id"])
|
||||
self.link.connect(["switch"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
|
||||
self.assertIn("No headset", s["error"]["message"])
|
||||
self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
|
||||
|
||||
def test_a_headset_without_addresses_reaches_nothing(self):
|
||||
d = self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
|
||||
self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
|
||||
self.link.connect(["switch"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
|
||||
self.assertIn("no addresses", s["error"]["message"])
|
||||
|
||||
def test_switching_back_to_the_frame_alias_the_server_started_with(self):
|
||||
self.link.override = self.link.session_alias = "frame-bare"
|
||||
other = self.device("localhost")
|
||||
ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
|
||||
self.assertEqual(ids, ["alias-frame-bare", other["id"]])
|
||||
fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
|
||||
self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
|
||||
fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
|
||||
self.assertEqual(self.link.active_device()["alias"], "frame-bare")
|
||||
self.assertEqual(self.routes[-1][0], "frame-bare")
|
||||
|
||||
def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
|
||||
d = self.device("localhost")
|
||||
self.link.override = self.link.session_alias = "frame-t"
|
||||
fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
|
||||
self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
|
||||
|
||||
def test_setup_changing_the_login_waits_for_installs(self):
|
||||
d = self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
cfg = self.dir / "ssh" / "config"
|
||||
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
|
||||
f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
|
||||
self.link.watch_config() # the block's login is recorded
|
||||
routes = len(self.routes)
|
||||
cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
|
||||
running = [1]
|
||||
self.link.work = lambda: running[0]
|
||||
self.link.config_mtime = None
|
||||
self.link.watch_config()
|
||||
self.assertEqual(len(self.routes), routes) # an install is running: not yet
|
||||
self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
|
||||
self.assertIn("User=steamos", self.routes[-1][1])
|
||||
running[0] = 0
|
||||
self.link.watch_config()
|
||||
self.assertIn("User=deck", self.routes[-1][1])
|
||||
|
||||
def test_a_rename_leaves_the_login_in_the_config_alone(self):
|
||||
d = self.device("localhost")
|
||||
cfg = self.dir / "ssh" / "config"
|
||||
cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
|
||||
"Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
|
||||
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
|
||||
self.assertIn("User deck", cfg.read_text())
|
||||
out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
|
||||
self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
|
||||
self.assertIn("left as it is", out["message"])
|
||||
|
||||
def test_a_late_failure_from_the_last_headset_is_ignored(self):
|
||||
self.link.state["phase"] = "connected"
|
||||
self.link.gen = 3
|
||||
self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
|
||||
self.assertEqual(self.link.kicks, [])
|
||||
self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
|
||||
self.assertEqual(len(self.link.kicks), 1)
|
||||
|
||||
def test_a_jump_hosts_login_isnt_the_headsets(self):
|
||||
opts = ["-o", "HostName=10.0.0.5"]
|
||||
self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
|
||||
self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
|
||||
self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
|
||||
["-o", "HostName=FRAME.LOCAL"], "frame"))
|
||||
|
||||
def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
|
||||
said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
|
||||
"channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
|
||||
self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
|
||||
for i, _ in fl.STAGES]
|
||||
self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
|
||||
self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
|
||||
"stop")
|
||||
|
||||
def test_a_reconnect_being_started_isnt_a_live_connection(self):
|
||||
self.link.state["phase"] = "connected"
|
||||
self.assertTrue(self.link.alive())
|
||||
self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
|
||||
self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
|
||||
|
||||
def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
|
||||
self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
|
||||
self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
|
||||
self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
|
||||
|
||||
def test_a_bare_alias_lets_ssh_config_decide(self):
|
||||
self.link.override = "frame-bare"
|
||||
self.hosts({"localhost": "ok"})
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual(s["phase"], "connected", s["error"])
|
||||
self.assertTrue(s["device"]["transient"])
|
||||
# Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
|
||||
"-o", "User=tester"]))
|
||||
self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
|
||||
|
||||
def test_each_headset_has_its_own_shared_connection(self):
|
||||
"""ssh's %C hashes only address, user and port: two headsets at one address
|
||||
(one moved) must still never share a ControlMaster."""
|
||||
a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
|
||||
pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
|
||||
self.assertNotEqual(pa, pb)
|
||||
self.assertIn("aaaa1111", pa)
|
||||
long_alias = fl.Link.bare("x" * 64)
|
||||
path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
|
||||
self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
|
||||
|
||||
def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
|
||||
self.link.override = "frame-bare"
|
||||
self.hosts({"localhost": "ok"})
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
|
||||
self.link.connect(["start"])
|
||||
pinned = self.routes[-1][1]
|
||||
# ~/.ssh/config now sends the alias elsewhere, but an install is running.
|
||||
self.link.work = lambda: 1
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
|
||||
self.link.close_master()
|
||||
self.link.connect(["dropped"])
|
||||
self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
|
||||
self.assertEqual(self.link.snapshot()["phase"], "connected")
|
||||
self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
|
||||
|
||||
def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
|
||||
d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
|
||||
self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
|
||||
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
|
||||
|
||||
def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
|
||||
self.link.override = "frame-jump"
|
||||
self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
|
||||
self.link.connect(["start"])
|
||||
s = self.link.snapshot()
|
||||
self.assertEqual(s["phase"], "connected", s["error"])
|
||||
self.assertEqual(s["via"]["why"], "through a jump host")
|
||||
|
||||
def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
|
||||
d = self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
self.reg.update_device(d["id"], port=1) # nothing listens there
|
||||
self.link.connect(["switch"])
|
||||
self.assertEqual(self.link.snapshot()["phase"], "failed")
|
||||
self.assertIn("Port=1", self.routes[-1][1])
|
||||
|
||||
def test_test_now_checks_every_address_without_touching_the_connection(self):
|
||||
self.listen6()
|
||||
d = self.device("::1", "127.0.0.1", "nothing.invalid")
|
||||
self.pin(d["id"])
|
||||
self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
|
||||
self.link.test(d["id"])
|
||||
rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
|
||||
self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
|
||||
self.assertEqual(rows["::1"]["ssh"], "wrong")
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(self.routes, [])
|
||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||
|
||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
other = self.reg.add_device("frame-other", port=self.port)
|
||||
self.reg.add_address(other["id"], "nothing.invalid")
|
||||
self.link.use(other["id"])
|
||||
self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
|
||||
self.assertEqual(self.link.snapshot()["phase"], "connecting")
|
||||
self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
|
||||
self.link.connect(["switch"])
|
||||
self.assertEqual(self.link.snapshot()["phase"], "failed")
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-other")
|
||||
self.assertIn("HostName=nothing.invalid", opts)
|
||||
self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
|
||||
|
||||
def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
other = self.reg.add_device("frame-other", port=self.port)
|
||||
self.reg.add_address(other["id"], "nothing.invalid")
|
||||
pick = fl.Link.pick
|
||||
|
||||
def switch_then_pick(*args):
|
||||
if not getattr(self, "switched", False):
|
||||
self.switched = True
|
||||
self.link.use(other["id"]) # the user switches while A is being found
|
||||
return pick(*args)
|
||||
with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
|
||||
self.link.connect(["start"])
|
||||
self.assertEqual(self.routes[-1][0], "frame-other")
|
||||
self.assertEqual(self.link.snapshot()["phase"], "connecting")
|
||||
self.assertFalse(self.link.alive())
|
||||
self.assertIsNone(self.link.master)
|
||||
|
||||
def test_no_switching_while_something_is_installing(self):
|
||||
d = self.device("localhost")
|
||||
other = self.reg.add_device("frame-other")
|
||||
for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
|
||||
{"action": "update", "id": d["id"], "port": 2222},
|
||||
{"action": "address-remove", "id": d["id"], "host": "localhost"},
|
||||
{"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
|
||||
{"action": "forget-identity", "id": d["id"]}):
|
||||
with self.assertRaises(fd.DeviceError, msg=body):
|
||||
fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
|
||||
# Renaming, or changing another headset, is fine.
|
||||
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
|
||||
fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
|
||||
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
|
||||
|
||||
def test_no_reconnecting_under_a_running_install(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
|
||||
fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
|
||||
|
||||
def test_terminals_get_the_address_by_name(self):
|
||||
d = self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
alias, opts = self.link.named_route()
|
||||
self.assertEqual(alias, "frame-t")
|
||||
self.assertIn("HostName=localhost", opts)
|
||||
self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
|
||||
|
||||
def test_renaming_during_an_install_is_fine(self):
|
||||
d = self.device("localhost")
|
||||
# The page sends the user and port along with the name, unchanged.
|
||||
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
|
||||
"port": str(self.port)}, None, busy=lambda: 1)
|
||||
self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
|
||||
|
||||
def test_a_rename_shows_at_once(self):
|
||||
d = self.device("localhost")
|
||||
self.hosts({"localhost": "ok"})
|
||||
self.link.connect(["start"])
|
||||
fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
|
||||
self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
|
||||
|
||||
def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
|
||||
self.device("localhost")
|
||||
self.hosts({"localhost": "slow"})
|
||||
t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
|
||||
t.start()
|
||||
for _ in range(100):
|
||||
if self.link.pending:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
proc = self.link.pending
|
||||
self.assertIsNotNone(proc)
|
||||
self.link.stop()
|
||||
t.join(10)
|
||||
self.assertFalse(t.is_alive())
|
||||
self.assertIsNotNone(proc.poll())
|
||||
self.assertIsNone(self.link.master)
|
||||
|
||||
def test_test_now_goes_through_a_jump_host(self):
|
||||
d = self.device("10.99.99.99")
|
||||
self.pin(d["id"])
|
||||
self.hosts({"10.99.99.99": "ok"})
|
||||
with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
|
||||
self.link.test(d["id"])
|
||||
row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
|
||||
self.assertEqual(row["ssh"], "ok", row)
|
||||
self.assertIn("jump host", row["detail"])
|
||||
|
||||
def test_devices_api_checks_everything(self):
|
||||
d = self.device("localhost")
|
||||
bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
|
||||
{"action": "address-add", "id": d["id"], "host": "a\nHost *"},
|
||||
{"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
|
||||
{"action": "update", "id": d["id"], "user": "root; id"},
|
||||
{"action": "update", "id": d["id"], "port": 0},
|
||||
{"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
|
||||
{"action": "setup", "alias": "-F/etc/passwd"},
|
||||
{"action": "setup", "alias": "frame-9", "host": "$(id)"},
|
||||
{"action": "use", "id": "nope"},
|
||||
{"action": "explode"}]
|
||||
for body in bad:
|
||||
with self.assertRaises(fd.DeviceError, msg=body):
|
||||
fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
|
||||
# Removing every headset leaves none in use, rather than falling back to the `frame` alias.
|
||||
spare = self.reg.add_device("frame-spare")
|
||||
fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
|
||||
# The only headset, whose ssh alias would stay: not without removing that too.
|
||||
(self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
|
||||
"Host *\n# <<< steam-frame (frame-t) <<<\n")
|
||||
with self.assertRaises(fd.DeviceError):
|
||||
fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
|
||||
opened = []
|
||||
out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
|
||||
open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
|
||||
self.assertEqual(opened, [("frame-9", "192.168.1.50")])
|
||||
self.assertIn("frame-9", out["message"])
|
||||
self.assertEqual(out["active"], d["id"])
|
||||
self.assertEqual(fl.next_alias(self.link), "frame")
|
||||
(self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
|
||||
self.assertEqual(fl.next_alias(self.link), "frame-2")
|
||||
|
||||
|
||||
@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
|
||||
class ServerConnection(unittest.TestCase):
|
||||
"""The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
|
||||
ssh_dir = cls.dir / "ssh"
|
||||
ssh_dir.mkdir()
|
||||
cls.srv = socket.socket() # the "headset's" port 22
|
||||
cls.srv.bind(("127.0.0.1", 0))
|
||||
cls.srv.listen(16)
|
||||
(ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
|
||||
f" Port {cls.srv.getsockname()[1]}\n"
|
||||
" User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
|
||||
env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
|
||||
"FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
|
||||
"FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}),
|
||||
"PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
|
||||
env.pop("FRAME_ALIAS", None)
|
||||
cls.log = tempfile.TemporaryFile()
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||
stdout=subprocess.PIPE, stderr=cls.log, text=True)
|
||||
cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.proc.terminate()
|
||||
cls.proc.wait(timeout=15)
|
||||
cls.proc.stdout.close()
|
||||
cls.log.close()
|
||||
cls.srv.close()
|
||||
shutil.rmtree(cls.dir, ignore_errors=True)
|
||||
|
||||
def request(self, method, path, body=None, key="1"):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||
conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"X-Frame-UI": key, "Content-Type": "application/json"})
|
||||
r = conn.getresponse()
|
||||
data = json.loads(r.read() or b"{}")
|
||||
conn.close()
|
||||
return r.status, data
|
||||
|
||||
def wait_connected(self):
|
||||
for _ in range(100):
|
||||
status, s = self.request("GET", "/api/connection")
|
||||
if s.get("phase") in ("connected", "failed"):
|
||||
return s
|
||||
time.sleep(0.1)
|
||||
self.fail(f"never connected: {s}")
|
||||
|
||||
def test_imports_the_headset_and_connects_through_its_port(self):
|
||||
s = self.wait_connected()
|
||||
self.assertEqual(s["phase"], "connected", s["error"])
|
||||
self.assertEqual(s["via"]["host"], "localhost")
|
||||
self.assertEqual(s["device"]["alias"], "frame")
|
||||
self.assertEqual(s["device"]["name"], "Steam Frame")
|
||||
self.assertEqual(s["probes"][0]["host"], "localhost")
|
||||
status, devices = self.request("GET", "/api/devices")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
|
||||
self.assertEqual(devices["nextAlias"], "frame-2")
|
||||
|
||||
def test_events_stream_the_state(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||
conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
|
||||
r = conn.getresponse()
|
||||
self.assertEqual(r.status, 200)
|
||||
self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
|
||||
line = r.fp.readline()
|
||||
self.assertTrue(line.startswith(b"data: "), line)
|
||||
self.assertIn("stages", json.loads(line[6:]))
|
||||
conn.close()
|
||||
|
||||
def test_changes_meant_for_another_headset_are_refused(self):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
|
||||
conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
|
||||
headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
|
||||
r = conn.getresponse()
|
||||
self.assertEqual(r.status, 409)
|
||||
self.assertIn("switched headsets", json.loads(r.read())["error"])
|
||||
conn.close()
|
||||
|
||||
def test_guards_and_validation(self):
|
||||
self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
|
||||
self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
|
||||
self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -10,6 +10,7 @@ Run: python3 -m unittest discover -s tests
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
@@ -42,6 +43,43 @@ class Helpers(unittest.TestCase):
|
||||
self.assertEqual(h, 1080)
|
||||
self.assertAlmostEqual(w / h, 0.5, places=2)
|
||||
|
||||
def test_the_tunnel_follows_the_headset(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||
mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||
self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
|
||||
|
||||
class Tunnel:
|
||||
ended = False
|
||||
def poll(self): return None
|
||||
def terminate(self): Tunnel.ended = True
|
||||
mv.tunnel, mv.remote_port = Tunnel(), 47999
|
||||
mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
|
||||
self.assertFalse(Tunnel.ended)
|
||||
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
|
||||
self.assertTrue(Tunnel.ended)
|
||||
self.assertIsNone(mv.tunnel)
|
||||
self.assertEqual(mv.frame, "frame-2")
|
||||
|
||||
def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
|
||||
mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
|
||||
mv.port = 47000
|
||||
ended = []
|
||||
|
||||
class Proc:
|
||||
def poll(self): return None
|
||||
def terminate(self): ended.append(self)
|
||||
def wait(self): return 0
|
||||
stderr = io.StringIO("")
|
||||
|
||||
def probe(port):
|
||||
mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
|
||||
return True
|
||||
with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
|
||||
mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
|
||||
self.assertFalse(mv._open_tunnel([], [47001]))
|
||||
self.assertIsNone(mv.tunnel)
|
||||
self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
@unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
|
||||
def test_launch_script_parses(self):
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
@@ -16,6 +19,13 @@ import frame_splat as splat
|
||||
import server
|
||||
|
||||
|
||||
def stop_now():
|
||||
"""What systemd's SIGTERM does to the player, without signalling the test process."""
|
||||
handler = signal.getsignal(signal.SIGTERM)
|
||||
if callable(handler):
|
||||
handler(signal.SIGTERM, None)
|
||||
|
||||
|
||||
class Media(unittest.TestCase):
|
||||
def test_layout_evidence_and_override(self):
|
||||
for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
|
||||
@@ -56,6 +66,156 @@ class Media(unittest.TestCase):
|
||||
self.assertNotIn('-re', cmd)
|
||||
self.assertIn('-frames:v', cmd)
|
||||
|
||||
def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
|
||||
fail=None, layout='auto'):
|
||||
"""Run the player against a fake OpenVR; returns (status, pixels calls).
|
||||
|
||||
Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
|
||||
the n-th upload busy; every status written is kept in self.writes."""
|
||||
calls = []
|
||||
self.writes = []
|
||||
write_status = player.write_status
|
||||
|
||||
def record(path, **values):
|
||||
self.writes.append(values)
|
||||
write_status(path, **values)
|
||||
|
||||
class FakeOverlay:
|
||||
def create(self, *a, **k):
|
||||
return len(calls)
|
||||
|
||||
def call(self, *a):
|
||||
pass
|
||||
|
||||
def pixels(self, handle, data, w, h):
|
||||
calls.append((handle, w, h))
|
||||
if on_pixels:
|
||||
on_pixels(len(calls))
|
||||
if len(calls) <= busy or (fail and fail(handle, len(calls))):
|
||||
raise player.OverlayBusy('standby')
|
||||
|
||||
def close(self):
|
||||
# A Stop landing during cleanup must be ignored, not become an error.
|
||||
# Call the installed handler directly: a real SIGTERM kills Windows.
|
||||
stop_now()
|
||||
|
||||
frame = bytes(4*2*4)
|
||||
proc = unittest.mock.MagicMock()
|
||||
proc.stdout = io.BytesIO(frame*4)
|
||||
proc.wait.return_value = 0
|
||||
proc.poll.return_value = 0
|
||||
old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
|
||||
with tempfile.TemporaryDirectory() as d, \
|
||||
patch.object(player, 'Overlay', FakeOverlay), \
|
||||
patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
|
||||
patch.object(player.subprocess, 'Popen', return_value=proc), \
|
||||
patch.object(player, 'write_status', side_effect=record), \
|
||||
patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
|
||||
patch.object(player.time, 'sleep', side_effect=sleeps):
|
||||
path = Path(d)/name
|
||||
path.write_bytes(b'x')
|
||||
status = Path(d)/'status.json'
|
||||
try:
|
||||
player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
|
||||
finally:
|
||||
signal.signal(signal.SIGTERM, old[0])
|
||||
signal.signal(signal.SIGINT, old[1])
|
||||
return json.loads(status.read_text()), calls
|
||||
|
||||
def test_video_survives_standby_and_stop_after_end_stays_ended(self):
|
||||
# Verified 2026-09-29: an unworn Frame enters standby within seconds and
|
||||
# SetOverlayRaw then returns RequestFailed (23) until it wakes.
|
||||
result, calls = self.play_with('clip_SBS.mp4', busy=3)
|
||||
self.assertEqual((result['state'], result['frames']), ('ended', 4))
|
||||
# Two video frames were dropped; the surround (handle 0) waited and was re-sent.
|
||||
self.assertEqual(result['dropped'], 2)
|
||||
self.assertIn((0, 1, 1), calls[3:])
|
||||
|
||||
def test_stop_mid_video_reports_stopped(self):
|
||||
result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
|
||||
self.assertEqual(result['state'], 'stopped')
|
||||
|
||||
def test_video_errors_when_steamvr_never_takes_frames(self):
|
||||
with patch.object(player, 'BUSY_LIMIT', -1), \
|
||||
self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
|
||||
self.play_with('clip_SBS.mp4', busy=99)
|
||||
|
||||
def test_still_waits_out_standby_without_a_limit(self):
|
||||
# Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
|
||||
ticks = iter(range(10))
|
||||
def sleep(_):
|
||||
if next(ticks) == 8:
|
||||
stop_now()
|
||||
with patch.object(player, 'BUSY_LIMIT', -1):
|
||||
result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
|
||||
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||
self.assertEqual(result['state'], 'stopped')
|
||||
screen = [c for c in calls if c[0] == 1]
|
||||
self.assertGreater(len(screen), 1) # retried through standby
|
||||
self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
|
||||
|
||||
def still_with_late_surround(self, name, **kw):
|
||||
# The screen takes its first frame while the surround is still refused
|
||||
# (its first upload, the drain right after the screen, and one retry).
|
||||
ticks = iter(range(10))
|
||||
def sleep(_):
|
||||
if next(ticks) == 5:
|
||||
stop_now()
|
||||
surround_tries = []
|
||||
def fail(handle, n):
|
||||
if handle == 0:
|
||||
surround_tries.append(n)
|
||||
return len(surround_tries) <= 3
|
||||
return False
|
||||
result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
|
||||
self.assertEqual(result['state'], 'stopped')
|
||||
screen = [c for c in calls if c[0] == 1]
|
||||
surround = [c for c in calls if c[0] == 0]
|
||||
self.assertEqual(len(screen), 1) # shown once, not re-sent every second
|
||||
self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
|
||||
self.assertEqual(calls[-1][0], 0)
|
||||
return calls
|
||||
|
||||
def test_photo_surround_recovers_after_screen_is_shown(self):
|
||||
self.still_with_late_surround('photo_SBS.png',
|
||||
info={'codec_name': 'png', 'width': 4, 'height': 2})
|
||||
|
||||
def test_splat_surround_recovers_after_screen_is_shown(self):
|
||||
self.still_with_late_surround('scene.splat')
|
||||
|
||||
def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
|
||||
self.assertEqual(player.BUSY_LIMIT, 300)
|
||||
def run(times, busy):
|
||||
# Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
|
||||
clock = [1000.0]
|
||||
def on_pixels(n):
|
||||
clock[0] = 1000.0 + times.get(n, times[max(times)])
|
||||
with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||
return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
|
||||
fail=lambda h, n: h == 1 and n in busy)
|
||||
# Busy for 299 s, then a frame lands: no error.
|
||||
result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
|
||||
self.assertEqual((result['state'], result['dropped']), ('ended', 2))
|
||||
# An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
|
||||
result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
|
||||
self.assertEqual((result['state'], result['dropped']), ('ended', 3))
|
||||
# 301 s in a row without an accepted frame is an error.
|
||||
with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
|
||||
run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
|
||||
|
||||
def test_status_reports_where_the_layout_came_from(self):
|
||||
video = {'codec_name': 'h264', 'width': 4, 'height': 2}
|
||||
for name, layout, tags, expect in [
|
||||
('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
|
||||
('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
|
||||
('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
|
||||
('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
|
||||
with self.subTest(name=name, layout=layout):
|
||||
self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
|
||||
playing = self.writes[0]
|
||||
self.assertEqual(playing['state'], 'playing')
|
||||
self.assertEqual((playing['layout'], playing['source']), expect)
|
||||
|
||||
def test_fake_frame_library_and_traversal(self):
|
||||
with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
|
||||
identity = 'a'*32+'/space and quote\'.png'
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""VR mod (UEVR) checks that need no headset or network: a fake Steam library on disk.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import itertools
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_mods # noqa: E402
|
||||
import test_server # noqa: E402 (not `from … import`, or unittest runs ServerGuards twice)
|
||||
|
||||
APPID = 1067310
|
||||
|
||||
|
||||
def zip_bytes(files, links=()):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
for name in links:
|
||||
info = zipfile.ZipInfo(name)
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
z.writestr(info, "/etc/passwd")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@unittest.skipIf(sys.platform == "win32", "the helper runs on the Frame (Linux): /proc, flock and POSIX prefix paths")
|
||||
class FakeLibrary(unittest.TestCase):
|
||||
"""A Steam library with Gravitas's real layout, and pinned downloads served from memory."""
|
||||
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
home = Path(tmp.name)
|
||||
self.steam = home / ".local/share/Steam"
|
||||
apps = self.steam / "steamapps"
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64").mkdir(parents=True)
|
||||
(apps / "common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").write_bytes(b"MZ" * 100)
|
||||
(apps / "common/Gravitas/Drop.exe").write_bytes(b"MZ")
|
||||
self.prefix = apps / f"compatdata/{APPID}/pfx"
|
||||
for sub in ("Roaming", "Local"):
|
||||
(self.prefix / "drive_c/users/steamuser/AppData" / sub).mkdir(parents=True)
|
||||
(apps / f"appmanifest_{APPID}.acf").write_text(
|
||||
f'"AppState"\n{{\n\t"appid"\t\t"{APPID}"\n\t"name"\t\t"Gravitas"\n\t"installdir"\t\t"Gravitas"\n}}\n')
|
||||
for name, value in (("STEAM", self.steam), ("CACHE", home / ".cache/frame-control/mods"),
|
||||
("RECEIPTS", home / ".local/share/frame-control/mods")):
|
||||
p = mock.patch.object(frame_mods, name, value)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.running = None
|
||||
p = mock.patch.object(frame_mods, "game_pid", side_effect=lambda exe: self.running)
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
self.downloads = {frame_mods.UEVR["url"]: zip_bytes({"UEVRInjector.exe": b"MZ", "UEVRBackend.dll": b"MZ",
|
||||
"openvr_api.dll": b"MZ"})}
|
||||
self.downloads[frame_mods.PYTHON["url"]] = zip_bytes({"python.exe": b"MZ", "python314.zip": b"PK"})
|
||||
self.pin(self.downloads)
|
||||
|
||||
def pin(self, downloads):
|
||||
"""Serve these bytes and pin their real hashes in place of the published ones."""
|
||||
for name in ("UEVR", "PYTHON"):
|
||||
pinned = getattr(frame_mods, name)
|
||||
p = mock.patch.object(frame_mods, name,
|
||||
dict(pinned, sha256=hashlib.sha256(downloads[pinned["url"]]).hexdigest()))
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
p = mock.patch.object(frame_mods.urllib.request, "urlopen",
|
||||
side_effect=lambda url, timeout: io.BytesIO(downloads[url]))
|
||||
self.urlopen = p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def managed(self):
|
||||
return self.prefix / "drive_c/frame-control"
|
||||
|
||||
|
||||
class Status(FakeLibrary):
|
||||
def test_unreal_game_is_eligible_and_not_installed(self):
|
||||
st = frame_mods.status(APPID)
|
||||
self.assertEqual((st["eligible"], st["exe"], st["installed"], st["running"], st["prefix"]),
|
||||
(True, "Drop-Win64-Shipping.exe", False, False, True))
|
||||
|
||||
def test_game_without_unreal_binary_is_refused(self):
|
||||
(self.steam / "steamapps/common/Gravitas/SkyArk/Binaries/Win64/Drop-Win64-Shipping.exe").unlink()
|
||||
self.assertFalse(frame_mods.status(APPID)["eligible"])
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "Unreal Engine"):
|
||||
frame_mods.install(APPID)
|
||||
self.urlopen.assert_not_called()
|
||||
|
||||
def test_uninstalled_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.status(620)
|
||||
|
||||
|
||||
class InstallUninstall(FakeLibrary):
|
||||
def test_install_then_uninstall_leaves_prefix_as_found(self):
|
||||
before = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
res = frame_mods.install(APPID)
|
||||
self.assertTrue(res["installed"])
|
||||
self.assertTrue((self.managed() / "uevr-1.05/UEVRBackend.dll").is_file())
|
||||
self.assertTrue((self.managed() / "python-3.14.7/python.exe").is_file())
|
||||
self.assertIn("UEVRBackend.dll", (self.managed() / "python-3.14.7/frame_inject.py").read_text())
|
||||
self.assertFalse(list(self.managed().glob(".staging-*")))
|
||||
# What UEVR writes on its first injection.
|
||||
roaming = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
roaming.mkdir(parents=True)
|
||||
(roaming / "log.txt").write_text("x")
|
||||
(self.prefix / "drive_c/users/steamuser/AppData/Local/praydog/x").mkdir(parents=True)
|
||||
frame_mods.uninstall(APPID)
|
||||
after = sorted(p.relative_to(self.prefix) for p in self.prefix.rglob("*"))
|
||||
self.assertEqual(after, before)
|
||||
self.assertFalse(frame_mods.CACHE.exists(), "the downloads go once no game uses them")
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_existing_uevr_settings_survive_uninstall(self):
|
||||
profile = self.prefix / "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod/Drop-Win64-Shipping"
|
||||
profile.mkdir(parents=True)
|
||||
(profile / "config.txt").write_text("mine")
|
||||
frame_mods.install(APPID)
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertEqual((profile / "config.txt").read_text(), "mine")
|
||||
|
||||
def test_second_install_is_a_no_op(self):
|
||||
frame_mods.install(APPID)
|
||||
calls = self.urlopen.call_count
|
||||
self.assertIn("already installed", frame_mods.install(APPID)["message"])
|
||||
self.assertEqual(self.urlopen.call_count, calls)
|
||||
|
||||
def test_refuses_while_game_runs(self):
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.install(APPID)
|
||||
self.running = None
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "running"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
def test_needs_a_prefix(self):
|
||||
import shutil
|
||||
shutil.rmtree(self.prefix.parent)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "play it once"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
def test_receipt_cannot_point_outside_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
victim = self.steam / "steamapps/common/Gravitas"
|
||||
r["remove"].append(str(self.prefix / "../../../common/Gravitas"))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(victim.is_dir())
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir(), "nothing removed when the receipt is bad")
|
||||
|
||||
|
||||
def test_receipt_cannot_delete_other_things_in_the_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
users = self.prefix / "drive_c/users"
|
||||
r["remove"].append(str(users))
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "didn't create"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue(users.is_dir())
|
||||
|
||||
def test_failed_removal_keeps_the_receipt(self):
|
||||
frame_mods.install(APPID)
|
||||
with mock.patch.object(frame_mods.shutil, "rmtree"): # e.g. a permission error, swallowed
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't remove"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertIsNotNone(frame_mods.read_receipt(APPID), "Remove can be tried again")
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertFalse(self.managed().exists())
|
||||
|
||||
def test_receipt_cannot_name_another_prefix(self):
|
||||
frame_mods.install(APPID)
|
||||
r = frame_mods.read_receipt(APPID)
|
||||
r["prefix"] = "/"
|
||||
frame_mods.receipt_path(APPID).write_text(json.dumps(r))
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't app"):
|
||||
frame_mods.uninstall(APPID)
|
||||
self.assertTrue((self.managed() / "uevr-1.05").is_dir())
|
||||
|
||||
|
||||
class Downloads(FakeLibrary):
|
||||
def test_hash_mismatch_deletes_and_installs_nothing(self):
|
||||
self.downloads[frame_mods.UEVR["url"]] = zip_bytes({"UEVRBackend.dll": b"tampered"})
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "doesn't match"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((frame_mods.CACHE / "UEVR.zip").exists())
|
||||
self.assertFalse(self.managed().exists())
|
||||
self.assertIsNone(frame_mods.read_receipt(APPID))
|
||||
|
||||
def test_archive_escaping_its_folder_is_refused(self):
|
||||
for bad in (zip_bytes({"UEVRBackend.dll": b"MZ", "../../evil.dll": b"MZ"}),
|
||||
zip_bytes({"UEVRBackend.dll": b"MZ"}, links=["openvr_api.dll"])):
|
||||
downloads = dict(self.downloads, **{frame_mods.UEVR["url"]: bad})
|
||||
self.pin(downloads)
|
||||
with self.subTest(), self.assertRaises(frame_mods.Fail):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse((self.prefix / "drive_c/evil.dll").exists())
|
||||
self.assertFalse(list(self.managed().glob("*")) if self.managed().exists() else [])
|
||||
import shutil
|
||||
shutil.rmtree(frame_mods.CACHE, ignore_errors=True)
|
||||
|
||||
def test_oversized_archive_is_refused(self):
|
||||
with mock.patch.object(frame_mods, "MAX_UNPACKED", 3):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "more than expected"):
|
||||
frame_mods.install(APPID)
|
||||
|
||||
|
||||
def test_oversized_download_is_stopped(self):
|
||||
with mock.patch.object(frame_mods, "MAX_DOWNLOAD", 10):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "bigger than expected"):
|
||||
frame_mods.install(APPID)
|
||||
self.assertFalse(list(frame_mods.CACHE.glob("*")))
|
||||
|
||||
|
||||
class Actions(FakeLibrary):
|
||||
def test_one_change_at_a_time(self):
|
||||
import fcntl
|
||||
lock = Path(tempfile.mkdtemp()) / "mods.lock"
|
||||
with mock.patch.object(frame_mods, "LOCK", lock), open(lock, "w") as held:
|
||||
fcntl.flock(held, fcntl.LOCK_EX)
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "another mod action"):
|
||||
frame_mods.main(["install", str(APPID)])
|
||||
self.assertFalse(frame_mods.status(APPID)["installed"])
|
||||
self.assertEqual(frame_mods.main(["status", str(APPID)])["appid"], APPID, "status never waits")
|
||||
|
||||
def test_zero_frames_is_an_answer(self):
|
||||
stats = json.dumps([{"key": f"steam.app.{APPID}", "frame_submits": 0}, {"operation": "status"}])
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=stats)):
|
||||
self.assertEqual(frame_mods.wait("frames", 5, lambda: frame_mods.frame_submits(APPID)), 0)
|
||||
with mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout='{"odd": 1}')):
|
||||
self.assertIsNone(frame_mods.frame_submits(APPID))
|
||||
|
||||
|
||||
class Start(FakeLibrary):
|
||||
def environ(self, text):
|
||||
"""The game's /proc/<pid>/environ; every other file reads normally."""
|
||||
real = frame_mods.Path.read_text
|
||||
return mock.patch.object(frame_mods.Path, "read_text", autospec=True, side_effect=lambda path, *a, **k:
|
||||
text if str(path).startswith("/proc/") else real(path, *a, **k))
|
||||
|
||||
def test_needs_install_first(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "isn't installed"):
|
||||
frame_mods.start(APPID)
|
||||
|
||||
def run_start(self, reply, loaded=(False, True, True, True)):
|
||||
frame_mods.install(APPID)
|
||||
self.running = 4242
|
||||
env = "WINEPREFIX=/x\0DISPLAY=:1\0WINESERVERSOCKET=17\0PATH=/p\0"
|
||||
with self.environ(env), \
|
||||
mock.patch.object(frame_mods, "loaded", side_effect=list(loaded) + [True] * 5), \
|
||||
mock.patch.object(frame_mods, "x11_windows", return_value=[(1, "SkyArk", 1280)]), \
|
||||
mock.patch.object(frame_mods.subprocess, "run", return_value=mock.Mock(stdout=reply, stderr="")) as run, \
|
||||
mock.patch.object(frame_mods, "frame_submits", return_value=7), \
|
||||
mock.patch.object(frame_mods.time, "sleep"):
|
||||
return frame_mods.start(APPID), run
|
||||
|
||||
def test_injects_with_the_games_wine_settings(self):
|
||||
res, run = self.run_start('noise\n{"pid": 320}\n')
|
||||
self.assertEqual((res["frame_submits"], res["message"]), (7, "UEVR injected into Gravitas; SteamVR is receiving its frames"))
|
||||
cmd, env = run.call_args.args[0], run.call_args.kwargs["env"]
|
||||
self.assertEqual(cmd[:2], ["wine", "C:\\frame-control\\python-3.14.7\\python.exe"])
|
||||
self.assertEqual(cmd[3:], ["Drop-Win64-Shipping.exe", "C:\\frame-control\\uevr-1.05",
|
||||
"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\Drop-Win64-Shipping"])
|
||||
self.assertEqual(env["WINEPREFIX"], "/x")
|
||||
self.assertNotIn("WINESERVERSOCKET", env, "an inherited fd number means nothing to a new process")
|
||||
self.assertIn("HOME", env, "fontconfig needs a HOME for its cache")
|
||||
|
||||
def test_reports_the_injection_error(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "couldn't open"):
|
||||
self.run_start('{"error": "couldn\'t open Drop-Win64-Shipping.exe (5)"}\n')
|
||||
|
||||
def test_success_is_checked_in_the_game(self):
|
||||
with self.assertRaisesRegex(frame_mods.Fail, "has no UEVRBackend.dll"):
|
||||
with mock.patch.object(frame_mods.time, "time", side_effect=itertools.count(0, 5)):
|
||||
self.run_start('{"pid": 320}\n', loaded=[False] * 40)
|
||||
|
||||
def test_inject_script_is_valid_python(self):
|
||||
compile(frame_mods.INJECT_PY, "frame_inject.py", "exec")
|
||||
|
||||
|
||||
class Helper(unittest.TestCase):
|
||||
@unittest.skipIf(sys.platform == "win32", "uninstall takes the flock lock, which only exists on the Frame (Linux)")
|
||||
def test_unexpected_errors_are_still_json(self):
|
||||
# A receipt from another version, without "exe": a KeyError, not a traceback.
|
||||
script = (ROOT / "ui" / "frame_mods.py").read_text().replace(
|
||||
"def read_receipt(appid):", "def read_receipt(appid):\n return {'prefix': '/x'}\n\n\ndef _unused(appid):")
|
||||
out = subprocess.run([sys.executable, "-", "uninstall", "1"], input=script,
|
||||
capture_output=True, text=True, timeout=30, env={"HOME": tempfile.mkdtemp()})
|
||||
self.assertEqual(out.returncode, 1, out.stderr)
|
||||
self.assertIn("KeyError", json.loads(out.stdout)["error"])
|
||||
|
||||
def test_bad_usage_prints_json_error(self):
|
||||
for args in ([], ["install"], ["install", "12x"], ["remove", "620"]):
|
||||
out = subprocess.run([sys.executable, str(ROOT / "ui" / "frame_mods.py"), *args],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(out.returncode, 1, args)
|
||||
self.assertIn("error", json.loads(out.stdout), args)
|
||||
|
||||
|
||||
class ModRoutes(test_server.ServerGuards):
|
||||
"""Reuses ServerGuards' server (unresolvable SSH alias); validation runs before any SSH."""
|
||||
|
||||
def test_mod_input_validation(self):
|
||||
for body in ({"action": "install", "appid": "1; reboot"}, {"action": "install", "appid": ""},
|
||||
{"action": "delete", "appid": "1067310"}, {"appid": "1067310"}):
|
||||
status, payload = self.post("/api/mods", body)
|
||||
self.assertEqual(status, 400, f"{body} -> {payload}")
|
||||
|
||||
def test_needs_custom_header(self):
|
||||
self.assertEqual(self.request("POST", "/api/mods", {"action": "status", "appid": "1"})[0], 403)
|
||||
|
||||
def test_runs_the_mods_helper_with_its_timeout(self):
|
||||
import server
|
||||
with mock.patch.object(server, "steam_frame", return_value={"ok": 1}) as run:
|
||||
self.assertEqual(server.mods({"action": "install", "appid": "1067310"}), {"ok": 1})
|
||||
run.assert_called_once_with("install", "1067310", timeout=600, script="frame_mods.py")
|
||||
|
||||
|
||||
for name in [n for n in dir(test_server.ServerGuards) if n.startswith("test_")]:
|
||||
setattr(ModRoutes, name, None)
|
||||
@@ -0,0 +1,147 @@
|
||||
"""frame_network's parsers, with what macOS, Linux and Windows print.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
|
||||
import frame_network as fn # noqa: E402
|
||||
|
||||
MAC_ROUTE = """ route to: default
|
||||
destination: default
|
||||
mask: default
|
||||
gateway: 192.168.1.1
|
||||
interface: en0
|
||||
flags: <UP,GATEWAY,DONE,STATIC,PRCLONING,GLOBAL>
|
||||
"""
|
||||
MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
|
||||
MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
|
||||
MAC_SUMMARY = """<dictionary> {
|
||||
BSSID : <redacted>
|
||||
ConnectionID : 1
|
||||
InterfaceType : WiFi
|
||||
LinkStatusActive : TRUE
|
||||
NetworkID : <redacted>
|
||||
SSID : <redacted>
|
||||
Security : WPA2_PSK
|
||||
}"""
|
||||
MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : <redacted>\n Security", "SSID : Home Net\n Security")
|
||||
MAC_SUMMARY_WIRED = "<dictionary> {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
|
||||
|
||||
LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
|
||||
default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
|
||||
"""
|
||||
LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
|
||||
NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
|
||||
|
||||
WIN_ROUTE = """===========================================================================
|
||||
Interface List
|
||||
12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
===========================================================================
|
||||
|
||||
IPv4 Route Table
|
||||
===========================================================================
|
||||
Active Routes:
|
||||
Network Destination Netmask Gateway Interface Metric
|
||||
0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
|
||||
0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
|
||||
===========================================================================
|
||||
Persistent Routes:
|
||||
None
|
||||
"""
|
||||
WIN_ARP = """
|
||||
Interface: 192.168.1.50 --- 0xc
|
||||
Internet Address Physical Address Type
|
||||
192.168.1.1 b4-fb-e4-b5-67-55 dynamic
|
||||
"""
|
||||
NETSH = """
|
||||
There is 1 interface on the system:
|
||||
|
||||
Name : Wi-Fi
|
||||
Description : Intel(R) Wi-Fi 6 AX201 160MHz
|
||||
State : connected
|
||||
SSID : Office 5G
|
||||
BSSID : 12:34:56:78:9a:bc
|
||||
Network type : Infrastructure
|
||||
"""
|
||||
NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
|
||||
|
||||
TAILSCALE = json.dumps({
|
||||
"BackendState": "Running",
|
||||
"CurrentTailnet": {"Name": "example.github"},
|
||||
"Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
|
||||
"Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
|
||||
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
|
||||
"nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
|
||||
"TailscaleIPs": ["100.77.1.2"]}},
|
||||
})
|
||||
|
||||
|
||||
class Parsers(unittest.TestCase):
|
||||
def test_macos(self):
|
||||
self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
|
||||
self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
|
||||
self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
|
||||
self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
|
||||
self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
|
||||
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
|
||||
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
|
||||
self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
|
||||
|
||||
def test_linux(self):
|
||||
self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
|
||||
self.assertEqual(fn.parse_route_linux(""), (None, None))
|
||||
self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
|
||||
self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
|
||||
self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
|
||||
self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
|
||||
|
||||
def test_windows(self):
|
||||
self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
|
||||
self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
|
||||
self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
|
||||
self.assertIsNone(fn.parse_netsh(NETSH_OFF))
|
||||
|
||||
def test_mac_addresses(self):
|
||||
self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
|
||||
for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
|
||||
self.assertIsNone(fn.norm_mac(bad), bad)
|
||||
|
||||
def test_network_id_is_stable_and_needs_both_parts(self):
|
||||
a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
|
||||
self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
|
||||
self.assertTrue(a.startswith("n-"))
|
||||
self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
|
||||
self.assertIsNone(fn.network_id("192.168.1.1", None))
|
||||
self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
|
||||
|
||||
def test_tailscale(self):
|
||||
ts = fn.parse_tailscale(TAILSCALE)
|
||||
self.assertTrue(ts["up"])
|
||||
self.assertEqual(ts["ip"], "100.101.102.103")
|
||||
self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
|
||||
self.assertEqual(ts["tailnet"], "example.github")
|
||||
frame = ts["peers"][0]
|
||||
self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
|
||||
("frame", "frame.tail1234.ts.net", "linux", True))
|
||||
self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
|
||||
self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
|
||||
self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
|
||||
|
||||
def test_address_kinds(self):
|
||||
cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
|
||||
"100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale",
|
||||
"192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
|
||||
"frame.example.com": "manual", "8.8.8.8": "manual"}
|
||||
for host, kind in cases.items():
|
||||
self.assertEqual(fn.guess_kind(host), kind, host)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+43
-1
@@ -34,7 +34,9 @@ class ServerGuards(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.port = free_port()
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
|
||||
cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
|
||||
env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
|
||||
"FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
|
||||
cls.log = tempfile.TemporaryFile()
|
||||
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
|
||||
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
|
||||
@@ -238,6 +240,46 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(self.post("/api/nope", {})[0], 404)
|
||||
|
||||
|
||||
class OneServer(unittest.TestCase):
|
||||
"""Two servers for one user would each connect and edit headsets on their own."""
|
||||
|
||||
def start(self, env):
|
||||
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
|
||||
return proc
|
||||
|
||||
def test_a_second_server_is_refused_until_the_first_exits(self):
|
||||
data = tempfile.mkdtemp(prefix="frame-one-server-")
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
|
||||
"FRAME_CONTROL_SERVER_WAIT": "1"}
|
||||
first = self.start(env)
|
||||
self.assertIn("Frame Control on", first.stdout.readline())
|
||||
second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(second.returncode, 1)
|
||||
self.assertIn("already running", second.stderr)
|
||||
first.terminate()
|
||||
first.wait(10)
|
||||
self.assertIn("Frame Control on", self.start(env).stdout.readline())
|
||||
|
||||
def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
|
||||
"""The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
|
||||
data = tempfile.mkdtemp(prefix="frame-one-server-")
|
||||
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
|
||||
"FRAME_CONTROL_SERVER_WAIT": "1"}
|
||||
self.assertIn("Frame Control on", self.start(env).stdout.readline())
|
||||
private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
|
||||
line = private.stdout.readline()
|
||||
self.assertIn("Frame Control on", line)
|
||||
port = int(line.split("http://127.0.0.1:")[1].split()[0])
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
|
||||
headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
|
||||
r = conn.getresponse()
|
||||
self.assertEqual(r.status, 403, r.read())
|
||||
|
||||
|
||||
class ArtworkSettings(unittest.TestCase):
|
||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||
|
||||
|
||||
+6
-1
@@ -1,7 +1,9 @@
|
||||
"""Agent actions and one-use human approvals. No model SDK or network calls here."""
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -129,7 +131,10 @@ def run_script(server, name, args):
|
||||
script = server.HERE.parent / 'scripts' / name
|
||||
if not script.exists() or not shutil.which('zsh') or server.LOCAL:
|
||||
raise ValueError(name + ' requires a computer with zsh and the matching script installed')
|
||||
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60)
|
||||
# The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
|
||||
env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
|
||||
'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
|
||||
result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
|
||||
if result.returncode:
|
||||
raise ValueError(result.stderr.strip() or 'Script failed')
|
||||
return {'message': result.stdout.strip()}
|
||||
|
||||
+48
-4
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
|
||||
scripts/connect.sh (which the Mac app uses); same config block, so either can
|
||||
re-run over the other. Idempotent.
|
||||
|
||||
Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
|
||||
Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
|
||||
Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
|
||||
terminals that don't pass the environment on, like Windows' `start`)
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
@@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER):
|
||||
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
|
||||
|
||||
|
||||
class config_lock:
|
||||
"""The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
|
||||
running app and this setup never write over each other's change."""
|
||||
|
||||
def __enter__(self):
|
||||
self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
|
||||
for _ in range(300):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
self.fh.seek(0)
|
||||
msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
return self # 30 s: go ahead rather than fail the setup
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.fh.close() # closing releases the lock
|
||||
return False
|
||||
|
||||
|
||||
def write_config(host, port=22, user=FRAME_USER):
|
||||
make_ssh_dir()
|
||||
with config_lock():
|
||||
_write_config(host, port, user)
|
||||
|
||||
|
||||
def _write_config(host, port, user):
|
||||
"""Replace our managed block and put it first: ssh uses the first value it sees per
|
||||
option. The trailing "Host *" returns the rest of the file to global scope."""
|
||||
make_ssh_dir()
|
||||
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
|
||||
kept, skip = [], False
|
||||
for line in old.splitlines():
|
||||
@@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER):
|
||||
elif not skip:
|
||||
kept.append(line)
|
||||
block = config_block(host, port, user)
|
||||
tmp = CONFIG.with_name("config.frame-control.tmp")
|
||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||
if os.name != "nt":
|
||||
tmp.chmod(0o600)
|
||||
@@ -367,9 +398,22 @@ def pair_with_devkit(host, port, user):
|
||||
return chosen[0], "paired, but key login still fails"
|
||||
|
||||
|
||||
def use_alias(alias):
|
||||
"""--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
|
||||
global FRAME_ALIAS, BEGIN, END
|
||||
if not NAME_RE.fullmatch(alias):
|
||||
sys.exit(f"--alias must be a plain name, not {alias!r}")
|
||||
FRAME_ALIAS = alias
|
||||
BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
|
||||
END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv and argv[0] in ("-h", "--help"):
|
||||
sys.exit(__doc__)
|
||||
if len(argv) >= 2 and argv[0] == "--alias":
|
||||
use_alias(argv[1])
|
||||
argv = argv[2:]
|
||||
say("==> Looking for the Steam Frame")
|
||||
found = pick_host(argv[0] if argv else None)
|
||||
while not found:
|
||||
|
||||
@@ -0,0 +1,802 @@
|
||||
"""The headsets Frame Control knows, and the addresses each can be reached at.
|
||||
|
||||
One headset can answer at several addresses: a LAN IP at home, another in the
|
||||
office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The
|
||||
registry keeps them all, learns which worked on which network, and hands the
|
||||
connector (frame_link.py) an order to try them in.
|
||||
|
||||
Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the
|
||||
iPhone app can share it later; docs/devices.md describes it:
|
||||
|
||||
{"version": 1, "active": "<device id>",
|
||||
"devices": [{"id", "name", "alias", "user", "port", "identity_files",
|
||||
"addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label",
|
||||
"networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}],
|
||||
"networks": {"<network id>": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}}
|
||||
|
||||
Headsets set up before this existed live only in ~/.ssh/config, in the managed
|
||||
`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and
|
||||
ui/frame_connect.py write; they're imported from there, so nobody has to add
|
||||
them again. Each device keeps its alias: Terminal's `ssh frame` and the helper
|
||||
scripts go on working, and the connector rewrites the block's HostName to the
|
||||
last address that worked, so they follow it.
|
||||
|
||||
Host keys are pinned per headset, not per address: ssh gets
|
||||
`-o HostKeyAlias=frame-control-<id>` and a known_hosts file of the headset's own
|
||||
(~/.ssh/frame-control-hosts/<id>), so a different device answering at a
|
||||
remembered IP is caught.
|
||||
|
||||
Python stdlib only.
|
||||
"""
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import frame_host
|
||||
import frame_network
|
||||
|
||||
VERSION = 1
|
||||
# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that
|
||||
# could start an option, add a line, or carry a directive.
|
||||
NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?")
|
||||
TEXT_MAX = 60
|
||||
KINDS = ("lan", "mdns", "tailscale", "manual")
|
||||
KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"}
|
||||
DEFAULT_USER = "steamos"
|
||||
|
||||
|
||||
class DeviceError(ValueError):
|
||||
"""Bad input from the page; the server answers 400 with the message."""
|
||||
|
||||
|
||||
def ssh_dir():
|
||||
"""~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one."""
|
||||
return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
|
||||
|
||||
|
||||
def ssh_config():
|
||||
return ssh_dir() / "config"
|
||||
|
||||
|
||||
PIN_DIR = "frame-control-hosts"
|
||||
|
||||
|
||||
def known_hosts(device_id):
|
||||
"""The headset's own known_hosts file: one per headset, so saving or forgetting one
|
||||
headset's key (by ssh or by the app) can never touch another's."""
|
||||
return ssh_dir() / PIN_DIR / device_id
|
||||
|
||||
|
||||
def known_hosts_opt(device_id):
|
||||
"""How ssh is told about it. `~` rather than the full path when it's the usual
|
||||
place, so a home folder with a space in its name can't split the option."""
|
||||
if os.environ.get("FRAME_CONTROL_SSH_DIR"):
|
||||
return str(known_hosts(device_id))
|
||||
return f"~/.ssh/{PIN_DIR}/{device_id}"
|
||||
|
||||
|
||||
def host_key_alias(device_id):
|
||||
return f"frame-control-{device_id}"
|
||||
|
||||
|
||||
# ---- validation ----------------------------------------------------------------
|
||||
|
||||
def check_alias(alias):
|
||||
if not isinstance(alias, str) or not NAME_RE.fullmatch(alias):
|
||||
raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore")
|
||||
return alias
|
||||
|
||||
|
||||
def check_user(user):
|
||||
if not isinstance(user, str) or not NAME_RE.fullmatch(user):
|
||||
raise DeviceError("The user name must be letters, digits, dot, dash or underscore")
|
||||
return user
|
||||
|
||||
|
||||
def check_host(host):
|
||||
host = host.strip() if isinstance(host, str) else host
|
||||
if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host
|
||||
or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address
|
||||
raise DeviceError(f"{host!r} isn't a host name or IP address")
|
||||
return host
|
||||
|
||||
|
||||
def check_port(port):
|
||||
try:
|
||||
port = int(port)
|
||||
except (TypeError, ValueError):
|
||||
raise DeviceError("The port must be a number") from None
|
||||
if not 1 <= port <= 65535:
|
||||
raise DeviceError("The port must be between 1 and 65535")
|
||||
return port
|
||||
|
||||
|
||||
def check_text(text, what):
|
||||
text = (text or "").strip() if isinstance(text, (str, type(None))) else None
|
||||
if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text):
|
||||
raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters")
|
||||
return text
|
||||
|
||||
|
||||
def check_kind(kind):
|
||||
if kind not in KINDS:
|
||||
raise DeviceError(f"The kind must be one of {', '.join(KINDS)}")
|
||||
return kind
|
||||
|
||||
|
||||
def ssh_host(host):
|
||||
"""A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled."""
|
||||
return host.replace("%", "%%")
|
||||
|
||||
|
||||
# ---- ~/.ssh/config's managed blocks ---------------------------------------------
|
||||
|
||||
BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>")
|
||||
|
||||
|
||||
def begin_mark(alias):
|
||||
return f"# >>> steam-frame ({alias}) >>>"
|
||||
|
||||
|
||||
def end_mark(alias):
|
||||
return f"# <<< steam-frame ({alias}) <<<"
|
||||
|
||||
|
||||
def parse_blocks(text):
|
||||
"""The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}]."""
|
||||
blocks, cur = [], None
|
||||
for line in text.splitlines():
|
||||
m = BLOCK_RE.fullmatch(line.strip())
|
||||
if m:
|
||||
cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "port_set": False,
|
||||
"identity_files": []}
|
||||
continue
|
||||
if cur is None:
|
||||
continue
|
||||
if line.strip() == end_mark(cur["alias"]):
|
||||
blocks.append(cur)
|
||||
cur = None
|
||||
continue
|
||||
f = line.split(None, 1)
|
||||
if len(f) != 2:
|
||||
continue
|
||||
key, value = f[0].lower(), f[1].strip()
|
||||
if key == "hostname" and cur["hostname"] is None:
|
||||
cur["hostname"] = value.replace("%%", "%")
|
||||
elif key == "user" and cur["user"] is None:
|
||||
cur["user"] = value
|
||||
elif key == "port" and value.isdigit():
|
||||
cur["port"], cur["port_set"] = int(value), True
|
||||
elif key == "identityfile":
|
||||
cur["identity_files"].append(value)
|
||||
return blocks
|
||||
|
||||
|
||||
def read_config(path=None):
|
||||
path = Path(path or ssh_config())
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and
|
||||
# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock
|
||||
# file). _edit_config also notices any other program writing in between.
|
||||
_config_lock = threading.Lock()
|
||||
LOCK_NAME = "config.frame-control.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def file_lock(path, timeout=30):
|
||||
"""An exclusive lock on `path` (created if need be) shared with other processes:
|
||||
POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fh = open(path, "a+")
|
||||
try:
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
try:
|
||||
if frame_host.WINDOWS:
|
||||
import msvcrt
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except OSError:
|
||||
if time.monotonic() > deadline:
|
||||
raise OSError(f"{path} stayed locked (is Set Up Connection running?)")
|
||||
time.sleep(0.1)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
if frame_host.WINDOWS:
|
||||
import msvcrt
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
fcntl.lockf(fh, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
fh.close()
|
||||
|
||||
|
||||
def _write_config(path, text, expected):
|
||||
"""Swap the file in whole (as frame_connect.write_config does), keeping it private.
|
||||
Returns False, writing nothing, if the file no longer holds `expected`."""
|
||||
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
|
||||
tmp = Path(tmp)
|
||||
try:
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
if not frame_host.WINDOWS:
|
||||
tmp.chmod(0o600)
|
||||
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||
if read_config(path) != expected:
|
||||
return False
|
||||
try:
|
||||
os.replace(tmp, path)
|
||||
return True
|
||||
except PermissionError:
|
||||
time.sleep(0.25)
|
||||
raise OSError(f"{path} stayed locked by another program")
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
def _edit_config(path, change):
|
||||
"""Apply change(lines) -> new lines or None to the file, retrying if another program
|
||||
wrote it meanwhile. -> True if the file changed."""
|
||||
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
|
||||
for _ in range(5):
|
||||
text = read_config(path)
|
||||
new = change(text.splitlines())
|
||||
if new is None:
|
||||
return False
|
||||
if _write_config(path, "\n".join(new) + "\n", text):
|
||||
return True
|
||||
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
||||
|
||||
|
||||
def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
|
||||
"""Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
|
||||
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||
if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
|
||||
describes the block, checked under the lock: someone else changed it meanwhile."""
|
||||
def change(lines):
|
||||
if expect:
|
||||
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
|
||||
# A port the block doesn't set is inherited from elsewhere in the file: not compared.
|
||||
if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"])
|
||||
for k, v in expect.items()):
|
||||
return None
|
||||
block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
|
||||
# Port 22 needs no line, unless the block would otherwise inherit another port
|
||||
# from a later Host entry (which ssh would use).
|
||||
force = bool(port) and block is not None and not block["port_set"] and \
|
||||
effective_port(alias, config_path) != int(port)
|
||||
return _rewritten(lines, alias, hostname, user, port, force)
|
||||
config_path = Path(path or ssh_config())
|
||||
return _edit_config(config_path, change)
|
||||
|
||||
|
||||
def _rewritten(lines, alias, hostname, user, port, force_port=False):
|
||||
begin, end = begin_mark(alias), end_mark(alias)
|
||||
if begin not in lines or end not in lines:
|
||||
return None
|
||||
i, j = lines.index(begin), lines.index(end)
|
||||
if j < i:
|
||||
return None
|
||||
block = lines[i:j]
|
||||
want = {"hostname": ssh_host(hostname) if hostname else None, "user": user,
|
||||
"port": str(port) if port else None}
|
||||
out, seen = [], set()
|
||||
for line in block:
|
||||
f = line.split(None, 1)
|
||||
key = f[0].lower() if f else ""
|
||||
if key in want and want[key] is not None and key not in seen:
|
||||
seen.add(key)
|
||||
# An existing Port line is kept, even for 22: dropping it could let a later
|
||||
# `Host *` Port apply to Terminal but not to the app.
|
||||
out.append(f" {f[0]} {want[key]}")
|
||||
else:
|
||||
out.append(line)
|
||||
if want["port"] and (want["port"] != "22" or force_port) and "port" not in seen:
|
||||
at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2)
|
||||
out.insert(at, f" Port {want['port']}")
|
||||
new = lines[:i] + out + lines[j:]
|
||||
return None if new == lines else new
|
||||
|
||||
|
||||
def remove_block(alias, path=None):
|
||||
def change(lines):
|
||||
begin, end = begin_mark(alias), end_mark(alias)
|
||||
if begin not in lines or end not in lines or lines.index(end) < lines.index(begin):
|
||||
return None
|
||||
return lines[:lines.index(begin)] + lines[lines.index(end) + 1:]
|
||||
return _edit_config(Path(path or ssh_config()), change)
|
||||
|
||||
|
||||
def effective_port(alias, config):
|
||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||
try:
|
||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return 22
|
||||
m = re.search(r"^port (\d+)$", out, re.M)
|
||||
port = int(m.group(1)) if m else 22
|
||||
return port if 1 <= port <= 65535 else 22
|
||||
|
||||
|
||||
# ---- pinned host keys -------------------------------------------------------------
|
||||
|
||||
def _keygen(*args):
|
||||
try:
|
||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
timeout=10)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
def pinned(device_id):
|
||||
"""Whether a key is saved for the headset. Entries are plain text (ssh gets
|
||||
HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
|
||||
target = known_hosts(device_id)
|
||||
try:
|
||||
lines = target.read_text(encoding="utf-8").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return False
|
||||
name = host_key_alias(device_id)
|
||||
if any(line.split(None, 1)[0].split(",").count(name) for line in lines
|
||||
if line.strip() and not line.startswith("#")):
|
||||
return True
|
||||
r = _keygen("-F", name, "-f", str(target))
|
||||
return bool(r and r.returncode == 0 and r.stdout.strip())
|
||||
|
||||
|
||||
def seed_pin(device_id, hosts, port=22, sources=None):
|
||||
"""Copy the host keys ssh already trusts for one of `hosts` into the headset's file
|
||||
under its alias, so moving to per-headset pinning asks nobody to trust anything again.
|
||||
-> True if a key is pinned."""
|
||||
if pinned(device_id):
|
||||
return True
|
||||
sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
|
||||
name = host_key_alias(device_id)
|
||||
for host in hosts:
|
||||
wanted = host if port == 22 else f"[{host}]:{port}"
|
||||
keys = []
|
||||
for src in sources:
|
||||
if not Path(src).is_file():
|
||||
continue
|
||||
r = _keygen("-F", wanted, "-f", str(src))
|
||||
for line in (r.stdout if r else "").splitlines():
|
||||
f = line.split()
|
||||
if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
|
||||
keys.append(f"{name} {f[1]} {f[2]}")
|
||||
if keys:
|
||||
target = known_hosts(device_id)
|
||||
target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
|
||||
fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
|
||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||
fh.write("\n".join(dict.fromkeys(keys)) + "\n")
|
||||
os.replace(tmp, target) # whole file at once: ssh never sees half of it
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def forget_pin(device_id):
|
||||
"""Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
|
||||
trusts whatever key the headset shows, as a first connection does."""
|
||||
try:
|
||||
known_hosts(device_id).unlink()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
# ---- address order --------------------------------------------------------------------
|
||||
|
||||
def order_addresses(addresses, network_id, tailscale_up):
|
||||
"""The order to try a device's addresses in, each with why it's there:
|
||||
known to work on this network, then mDNS, then Tailscale if it's up, then the rest
|
||||
(addresses that only ever worked elsewhere last). The user's order breaks ties."""
|
||||
def group(a):
|
||||
nets = a.get("networks") or []
|
||||
if network_id and network_id in nets:
|
||||
return 0, "worked on this network before"
|
||||
if a["kind"] == "mdns":
|
||||
return 1, "mDNS name"
|
||||
if a["kind"] == "tailscale":
|
||||
return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running")
|
||||
if nets:
|
||||
return 4, "worked on another network"
|
||||
return 3, "not tried on this network yet"
|
||||
ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0]))
|
||||
return [(a, group(a)[1]) for _, a in ranked]
|
||||
|
||||
|
||||
# ---- the registry ------------------------------------------------------------------------
|
||||
|
||||
def new_address(host, kind=None, label=""):
|
||||
host = check_host(host)
|
||||
return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host),
|
||||
"label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None}
|
||||
|
||||
|
||||
class Registry:
|
||||
"""devices.json, loaded once and saved on every change. Thread-safe."""
|
||||
|
||||
def __init__(self, path=None, config=None):
|
||||
self.path = Path(path or frame_host.data_dir("devices.json"))
|
||||
self.config = Path(config) if config else None # None: ssh_config() at call time
|
||||
self.lock = threading.RLock()
|
||||
self._depth = 0 # nested _changing() calls
|
||||
self._mtime = None # devices.json as last loaded or saved
|
||||
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
|
||||
self.load()
|
||||
|
||||
# -- storage --
|
||||
def load(self):
|
||||
with self.lock:
|
||||
try:
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if isinstance(data, dict) and isinstance(data.get("devices"), list):
|
||||
data.setdefault("networks", {})
|
||||
data.setdefault("active", None)
|
||||
data["devices"] = [d for d in data["devices"] if self._sane(d)]
|
||||
# The headset in use is this server's own choice: another server picking a
|
||||
# different one mustn't move commands (an install, say) under it. The file's
|
||||
# choice is only where a server starts.
|
||||
# Kept even if another server removed it, so the connector can see that
|
||||
# (and not quietly move to another headset in the middle of an install).
|
||||
mine = self.data.get("active")
|
||||
if mine:
|
||||
data["active"] = mine
|
||||
self.data = data
|
||||
|
||||
@staticmethod
|
||||
def _sane(d):
|
||||
try:
|
||||
check_alias(d["alias"])
|
||||
d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", ""))
|
||||
and a.get("kind") in KINDS]
|
||||
for a in d["addresses"]:
|
||||
a.setdefault("networks", [])
|
||||
a.setdefault("label", "")
|
||||
return NAME_RE.fullmatch(d.get("id", "")) is not None
|
||||
except (KeyError, TypeError, DeviceError):
|
||||
return False
|
||||
|
||||
def save(self):
|
||||
with self.lock:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, self.path)
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
|
||||
def _refresh(self):
|
||||
"""Pick up what another Frame Control server saved (the app and a standalone
|
||||
server can share devices.json)."""
|
||||
with self.lock:
|
||||
try:
|
||||
if self.path.stat().st_mtime_ns != self._mtime:
|
||||
self.load()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _changing(self):
|
||||
"""Every change holds this registry's lock and a lock file shared with other
|
||||
processes, and starts from what's on disk, so no server saves over another's
|
||||
change. Nested calls (sync_from_config adding a device) share the outer one."""
|
||||
with self.lock:
|
||||
if self._depth:
|
||||
self._depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._depth -= 1
|
||||
return
|
||||
with file_lock(self.path.with_name(self.path.name + ".lock")):
|
||||
self.load()
|
||||
self._depth = 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._depth = 0
|
||||
|
||||
def snapshot(self):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return copy.deepcopy(self.data)
|
||||
|
||||
# -- lookups --
|
||||
def devices(self):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return copy.deepcopy(self.data["devices"])
|
||||
|
||||
def _find(self, device_id):
|
||||
for d in self.data["devices"]:
|
||||
if d["id"] == device_id:
|
||||
return d
|
||||
raise DeviceError("No such headset (it may have been removed)")
|
||||
|
||||
def get(self, device_id):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return copy.deepcopy(self._find(device_id))
|
||||
|
||||
def by_alias(self, alias):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
|
||||
|
||||
def active(self):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return self.data.get("active")
|
||||
|
||||
def emptied(self):
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
return bool(self.data.get("emptied")) and not self.data["devices"]
|
||||
|
||||
def set_active(self, device_id):
|
||||
with self._changing():
|
||||
self._find(device_id)
|
||||
self.data["active"] = device_id
|
||||
self.save()
|
||||
|
||||
# -- devices --
|
||||
def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()):
|
||||
with self._changing():
|
||||
check_alias(alias)
|
||||
if any(d["alias"] == alias for d in self.data["devices"]):
|
||||
raise DeviceError(f"There's already a headset with the alias {alias}")
|
||||
ids = {d["id"] for d in self.data["devices"]}
|
||||
device_id = secrets.token_hex(4)
|
||||
while device_id in ids:
|
||||
device_id = secrets.token_hex(4)
|
||||
d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"),
|
||||
"alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port),
|
||||
"identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None,
|
||||
"added": time.time()}
|
||||
for host in hosts:
|
||||
if host and not any(a["host"] == host for a in d["addresses"]):
|
||||
d["addresses"].append(new_address(host))
|
||||
self.data["devices"].append(d)
|
||||
self.data.pop("emptied", None)
|
||||
if not self.data.get("active"):
|
||||
self.data["active"] = device_id
|
||||
self.save()
|
||||
return copy.deepcopy(d)
|
||||
|
||||
def update_device(self, device_id, name=None, user=None, port=None):
|
||||
"""-> the device after the change. The caller mirrors user and port into ~/.ssh/config."""
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
# Check everything first: a rejected edit changes nothing.
|
||||
name = None if name is None else (check_text(name, "name") or d["alias"])
|
||||
user = None if user is None else check_user(user)
|
||||
port = None if port is None else check_port(port)
|
||||
d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None})
|
||||
self.save()
|
||||
return copy.deepcopy(d)
|
||||
|
||||
def remove_device(self, device_id):
|
||||
"""Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again
|
||||
unless Set Up Connection changes it."""
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
self.data["devices"].remove(d)
|
||||
self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
|
||||
if not self.data["devices"]:
|
||||
self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias
|
||||
if self.data.get("active") == device_id:
|
||||
self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None
|
||||
self.save()
|
||||
return d
|
||||
|
||||
# -- addresses --
|
||||
def _addr(self, d, host):
|
||||
for a in d["addresses"]:
|
||||
if a["host"] == host:
|
||||
return a
|
||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||
|
||||
def add_address(self, device_id, host, kind=None, label=""):
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = new_address(host, kind, label)
|
||||
if any(x["host"] == a["host"] for x in d["addresses"]):
|
||||
raise DeviceError(f"{a['host']} is already on the list")
|
||||
if len(d["addresses"]) >= 32:
|
||||
raise DeviceError("That's enough addresses for one headset")
|
||||
d["addresses"].append(a)
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
def update_address(self, device_id, host, new_host=None, kind=None, label=None):
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = self._addr(d, host)
|
||||
# Check everything first: a rejected edit changes nothing.
|
||||
moved = new_host is not None and new_host != host
|
||||
if moved:
|
||||
new_host = check_host(new_host)
|
||||
if any(x["host"] == new_host for x in d["addresses"]):
|
||||
raise DeviceError(f"{new_host} is already on the list")
|
||||
kind = None if kind is None else check_kind(kind)
|
||||
label = None if label is None else check_text(label, "label")
|
||||
if moved:
|
||||
a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
|
||||
if kind is not None:
|
||||
a["kind"] = kind
|
||||
if label is not None:
|
||||
a["label"] = label
|
||||
self.save()
|
||||
return copy.deepcopy(a)
|
||||
|
||||
def remove_address(self, device_id, host):
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
d["addresses"].remove(self._addr(d, host))
|
||||
self.save()
|
||||
|
||||
def move_address(self, device_id, host, delta):
|
||||
with self._changing():
|
||||
d = self._find(device_id)
|
||||
a = self._addr(d, host)
|
||||
i = d["addresses"].index(a)
|
||||
j = max(0, min(len(d["addresses"]) - 1, i + int(delta)))
|
||||
d["addresses"].insert(j, d["addresses"].pop(i))
|
||||
self.save()
|
||||
|
||||
def record_success(self, device_id, host, network_id, rtt_ms):
|
||||
"""Learn: this address worked on this network."""
|
||||
with self._changing():
|
||||
try:
|
||||
a = self._addr(self._find(device_id), host)
|
||||
except DeviceError:
|
||||
return
|
||||
if network_id and network_id not in a["networks"]:
|
||||
a["networks"] = (a["networks"] + [network_id])[-16:]
|
||||
a["last_ok"] = time.time()
|
||||
a["last_rtt_ms"] = rtt_ms
|
||||
self.save()
|
||||
|
||||
def undismiss(self, alias):
|
||||
"""Set Up Connection is about to run for this alias: import its block again."""
|
||||
with self._changing():
|
||||
if self.data.get("dismissed", {}).pop(alias, None) is not None:
|
||||
self.save()
|
||||
|
||||
def set_config_host(self, device_id, host):
|
||||
with self._changing():
|
||||
try:
|
||||
self._find(device_id)["config_host"] = host
|
||||
except DeviceError:
|
||||
return
|
||||
self.save()
|
||||
|
||||
# -- networks --
|
||||
def record_network(self, net):
|
||||
"""Remember a network we've seen (for naming it), keeping its user-given name."""
|
||||
if not net or not net.get("id"):
|
||||
return
|
||||
with self._changing():
|
||||
known = self.data["networks"].get(net["id"]) or {"name": ""}
|
||||
changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or
|
||||
time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known)
|
||||
known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"),
|
||||
gateway_mac=net.get("gateway_mac"), last_seen=time.time())
|
||||
self.data["networks"][net["id"]] = known
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def name_network(self, network_id, name):
|
||||
with self._changing():
|
||||
if network_id not in self.data["networks"]:
|
||||
raise DeviceError("That network hasn't been seen")
|
||||
self.data["networks"][network_id]["name"] = check_text(name, "network name")
|
||||
self.save()
|
||||
|
||||
def network_name(self, net):
|
||||
"""What to call a network: the name given to it, its Wi-Fi name, or its router."""
|
||||
if not net:
|
||||
return "No network"
|
||||
self._refresh()
|
||||
with self.lock:
|
||||
known = self.data["networks"].get(net.get("id") or "") or {}
|
||||
if known.get("name"):
|
||||
return known["name"]
|
||||
ssid = net.get("ssid") or known.get("ssid")
|
||||
if ssid:
|
||||
return ssid
|
||||
if net.get("gateway"):
|
||||
return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}"
|
||||
return "No network"
|
||||
|
||||
# -- ~/.ssh/config --
|
||||
def sync_from_config(self, seed=True):
|
||||
"""Import managed blocks we don't know yet, and pick up a HostName that Set Up
|
||||
Connection changed since we last looked. -> True if anything changed."""
|
||||
blocks = parse_blocks(read_config(self.config))
|
||||
for b in blocks:
|
||||
if not b["port_set"]:
|
||||
# No Port in the block: another Host entry may give one (ssh uses the first).
|
||||
b["port"] = effective_port(b["alias"], self.config or ssh_config())
|
||||
changed = False
|
||||
with self._changing():
|
||||
first = not self.data["devices"] and not self.data.get("active")
|
||||
for b in blocks:
|
||||
host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None
|
||||
user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER
|
||||
d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None)
|
||||
dismissed = self.data.get("dismissed", {})
|
||||
if d is None and b["alias"] in dismissed:
|
||||
if dismissed[b["alias"]] == (host or ""):
|
||||
continue # removed on the Devices tab; unchanged since
|
||||
del dismissed[b["alias"]]
|
||||
if d is None:
|
||||
try:
|
||||
d = self._find(self.add_device(b["alias"], user=user, port=b["port"],
|
||||
identity_files=b["identity_files"])["id"])
|
||||
except DeviceError:
|
||||
continue
|
||||
if host:
|
||||
d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
|
||||
d["config_host"] = host
|
||||
changed = True
|
||||
if seed and host:
|
||||
seed_pin(d["id"], [host], b["port"])
|
||||
elif host and host != d.get("config_host"):
|
||||
# Set Up Connection ran again and found the headset somewhere new.
|
||||
d["config_host"] = host
|
||||
if not any(a["host"] == host for a in d["addresses"]):
|
||||
d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
|
||||
if seed:
|
||||
seed_pin(d["id"], [host], b["port"])
|
||||
changed = True
|
||||
if d.get("config_login") != [user, b["port"]]:
|
||||
# Set Up Connection (or an edit) changed who to log in as, or the port.
|
||||
if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]:
|
||||
d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"]
|
||||
d["config_login"] = [user, b["port"]]
|
||||
changed = True
|
||||
if d["identity_files"] != b["identity_files"] and b["identity_files"]:
|
||||
d["identity_files"] = b["identity_files"][:8]
|
||||
changed = True
|
||||
d["managed"] = True
|
||||
aliases = {b["alias"] for b in blocks}
|
||||
for d in self.data["devices"]:
|
||||
d["managed"] = d["alias"] in aliases
|
||||
if first:
|
||||
# First import: the headset the app used before is `frame`, even if Set Up
|
||||
# Connection put another block above it.
|
||||
frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None)
|
||||
if frame and self.data.get("active") != frame["id"]:
|
||||
self.data["active"] = frame["id"]
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
return changed
|
||||
+11
-5
@@ -56,13 +56,19 @@ def cache_dir(*parts):
|
||||
return base.joinpath(*parts)
|
||||
|
||||
|
||||
def control_path(*, private=False):
|
||||
def control_path(tag="x", *, private=None):
|
||||
"""ssh ControlPath for the shared connection, or None where it isn't supported.
|
||||
|
||||
`tag` names the headset: ssh's %C hashes only the address, user and port, so two
|
||||
headsets reached at the same address (one of them moved) would otherwise share a
|
||||
connection, and one's commands would run on the other.
|
||||
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
|
||||
"""
|
||||
# A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
|
||||
if private is None:
|
||||
private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||
suffix = f"-{os.getpid()}" if private else ""
|
||||
return f"/tmp/frame-ui-{os.getuid()}{suffix}-%C" if MUX else None
|
||||
return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
|
||||
|
||||
|
||||
def which(name, *extra):
|
||||
@@ -258,9 +264,9 @@ def open_steam_link():
|
||||
return "Steam Link isn't installed; opened its download page"
|
||||
|
||||
|
||||
def open_rdp(alias):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos)."""
|
||||
host = ssh_hostname(alias)
|
||||
def open_rdp(alias, host=None):
|
||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||
host = host or ssh_hostname(alias)
|
||||
if MAC:
|
||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||
return "Opened Windows App"
|
||||
|
||||
+1232
File diff suppressed because it is too large.
Load diff
+37
-3
@@ -120,6 +120,10 @@ class MacView:
|
||||
self.tunnel_ssh = list(tunnel_ssh)
|
||||
self.run = run
|
||||
self.frame = frame
|
||||
self.host_opts = [] # the headset in use and how to reach it (see retarget)
|
||||
# Short, never held across ssh: retarget() and publishing a new tunnel check and
|
||||
# change the headset together (self.lock is held while a tunnel is being opened).
|
||||
self.route_lock = threading.Lock()
|
||||
self.track = track or (lambda proc: None) # the server ends these on exit
|
||||
self.lock = threading.Lock()
|
||||
self.token = secrets.token_urlsafe(24)
|
||||
@@ -239,13 +243,37 @@ class MacView:
|
||||
last = self._last_tunnel_error
|
||||
raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
|
||||
|
||||
def retarget(self, alias, host_opts):
|
||||
"""The server now reaches the headset as `alias` with `host_opts` (another address,
|
||||
or another headset). Only the short route_lock, never self.lock: this runs while
|
||||
the server routes, which a tunnel being opened may be waiting on."""
|
||||
# host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
|
||||
# not the shared master.
|
||||
opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
|
||||
if not value.startswith("ControlPath=") for x in (flag, value)]
|
||||
with self.route_lock:
|
||||
moved = alias != self.frame
|
||||
self.frame, self.host_opts = alias, opts
|
||||
tunnel = self.tunnel
|
||||
if moved and tunnel is not None:
|
||||
# Another headset: its viewers can't be the old one's. The supervisor
|
||||
# reopens a tunnel to the new one if anything is being shown.
|
||||
self.tunnel, self.remote_port = None, None
|
||||
if moved and tunnel is not None and tunnel.poll() is None:
|
||||
tunnel.terminate()
|
||||
|
||||
def _open_tunnel(self, via, ports):
|
||||
"""Tries the ports on one route; True once the tunnel answers. With self.lock held."""
|
||||
last = ""
|
||||
for port in ports:
|
||||
proc = subprocess.Popen([*self.tunnel_ssh, *via, "-o", "ExitOnForwardFailure=yes",
|
||||
with self.route_lock:
|
||||
target = (self.frame, self.host_opts) # retarget() may change these meanwhile
|
||||
# `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
|
||||
# while the headset's pinned identity (in host_opts) still checks it.
|
||||
proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
|
||||
"-o", "ExitOnForwardFailure=yes",
|
||||
"-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
|
||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", self.frame],
|
||||
"-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, text=True)
|
||||
# A taken port makes ssh exit once it's connected; a working
|
||||
@@ -259,7 +287,11 @@ class MacView:
|
||||
ok = True
|
||||
break
|
||||
if ok:
|
||||
self.tunnel, self.remote_port = proc, port
|
||||
with self.route_lock: # checked and published together, so a switch can't slip between
|
||||
ok = target[0] == self.frame # else the app switched headset while this connected
|
||||
if ok:
|
||||
self.tunnel, self.remote_port = proc, port
|
||||
if ok:
|
||||
self.track(proc)
|
||||
self._supervise()
|
||||
return True
|
||||
@@ -294,6 +326,8 @@ class MacView:
|
||||
socket.create_connection((ip, 22), timeout=1).close()
|
||||
except OSError:
|
||||
return [] # not plugged into this Mac
|
||||
if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
|
||||
return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
|
||||
alias = self.frame
|
||||
try:
|
||||
cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
|
||||
|
||||
+80
-14
@@ -33,6 +33,15 @@ SLOTS = {
|
||||
}
|
||||
|
||||
|
||||
class OverlayBusy(RuntimeError):
|
||||
"""SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
|
||||
unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
|
||||
|
||||
|
||||
# A screen that can't take a frame for this long is broken, not asleep.
|
||||
BUSY_LIMIT = 300
|
||||
|
||||
|
||||
class Overlay:
|
||||
def __init__(self):
|
||||
self.handles = []
|
||||
@@ -53,6 +62,8 @@ class Overlay:
|
||||
def call(self, name, *values):
|
||||
slot, args = SLOTS[name]
|
||||
rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
|
||||
if rc == 23 and name == 'SetOverlayRaw':
|
||||
raise OverlayBusy('SteamVR is not accepting frames (standby?)')
|
||||
if rc:
|
||||
raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
|
||||
|
||||
@@ -75,11 +86,14 @@ class Overlay:
|
||||
self.call('ShowOverlay', handle)
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
for h in reversed(self.handles):
|
||||
# Best effort: SteamVR removes a disconnected client's overlays anyway,
|
||||
# and a teardown error must not overwrite a finished playback's status.
|
||||
for h in reversed(self.handles):
|
||||
try:
|
||||
self.call('DestroyOverlay', h)
|
||||
finally:
|
||||
self.vr.VR_ShutdownInternal()
|
||||
except RuntimeError:
|
||||
pass
|
||||
self.vr.VR_ShutdownInternal()
|
||||
|
||||
|
||||
def probe(path):
|
||||
@@ -114,6 +128,11 @@ def decoder_command(path, info, width, height, audio, photo=False):
|
||||
return cmd
|
||||
|
||||
|
||||
def ignore_signals():
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
|
||||
def write_status(path, **values):
|
||||
tmp = path.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(values))
|
||||
@@ -135,6 +154,46 @@ def play(args):
|
||||
photo = plan['kind'] == 'photo'
|
||||
command = decoder_command(path, info, width, height, audio, photo)
|
||||
vr, proc, frames, started = None, None, 0, time.monotonic()
|
||||
dropped, busy_since, pending = 0, None, []
|
||||
|
||||
def show(handle, data, w, h, video=False):
|
||||
"""Submit a frame. During standby return False; a video frame is dropped."""
|
||||
nonlocal dropped, busy_since
|
||||
try:
|
||||
vr.pixels(handle, data, w, h)
|
||||
except OverlayBusy:
|
||||
if not video:
|
||||
return False # stills and the surround just wait; nothing is lost
|
||||
dropped += 1
|
||||
busy_since = busy_since or time.monotonic()
|
||||
if time.monotonic() - busy_since > BUSY_LIMIT:
|
||||
raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
|
||||
return False
|
||||
if video:
|
||||
busy_since = None
|
||||
drain()
|
||||
return True
|
||||
|
||||
def drain():
|
||||
"""Re-send anything that arrived during standby (e.g. the theatre surround)."""
|
||||
while pending:
|
||||
item = pending.pop(0)
|
||||
try:
|
||||
vr.pixels(*item)
|
||||
except OverlayBusy:
|
||||
pending.insert(0, item)
|
||||
return
|
||||
|
||||
def hold(handle, data, w, h):
|
||||
"""Keep a still (photo or splat) up until Stop, retrying through standby."""
|
||||
shown = False
|
||||
while True:
|
||||
if shown:
|
||||
drain()
|
||||
else:
|
||||
shown = show(handle, data, w, h)
|
||||
time.sleep(1)
|
||||
|
||||
# systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
|
||||
# ownership; no unrelated Steam/SteamVR process or setting is touched.
|
||||
def stop(signum, frame):
|
||||
@@ -146,14 +205,13 @@ def play(args):
|
||||
if args.theatre:
|
||||
surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
|
||||
vr.call('SetOverlayAlpha', surround, .85)
|
||||
vr.pixels(surround, b'\x00\x00\x00\xff', 1, 1)
|
||||
if not show(surround, b'\x00\x00\x00\xff', 1, 1):
|
||||
pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
|
||||
screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
|
||||
plan['layout'] != 'mono', aspect)
|
||||
if splat:
|
||||
vr.pixels(screen, data, width, height)
|
||||
write_status(status, state='playing', file=path.name, frames=1, **plan)
|
||||
while True:
|
||||
time.sleep(1)
|
||||
hold(screen, data, width, height)
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
video_start = time.monotonic()
|
||||
while True:
|
||||
@@ -163,11 +221,14 @@ def play(args):
|
||||
data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
|
||||
if not photo:
|
||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||
vr.pixels(screen, data, outw, outh)
|
||||
if photo:
|
||||
still = data, outw, outh
|
||||
else:
|
||||
show(screen, data, outw, outh, video=True)
|
||||
frames += 1
|
||||
if frames == 1 or frames % 30 == 0:
|
||||
write_status(status, state='playing', file=path.name, frames=frames,
|
||||
seconds=time.monotonic()-started, **plan)
|
||||
dropped=dropped, seconds=time.monotonic()-started, **plan)
|
||||
if not photo:
|
||||
time.sleep(max(0, video_start + frames/30 - time.monotonic()))
|
||||
rc = proc.wait(timeout=10)
|
||||
@@ -176,12 +237,17 @@ def play(args):
|
||||
if not frames:
|
||||
raise RuntimeError('Decoder produced no frames')
|
||||
if photo:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
write_status(status, state='ended', frames=frames, seconds=time.monotonic()-started)
|
||||
hold(screen, *still)
|
||||
# From here on a Stop can't change the outcome; don't let it turn
|
||||
# 'ended' into an error while we write status and clean up.
|
||||
ignore_signals()
|
||||
write_status(status, state='ended', frames=frames, dropped=dropped,
|
||||
seconds=time.monotonic()-started)
|
||||
except InterruptedError:
|
||||
write_status(status, state='stopped', frames=frames)
|
||||
ignore_signals()
|
||||
write_status(status, state='stopped', frames=frames, dropped=dropped)
|
||||
finally:
|
||||
ignore_signals()
|
||||
if proc:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
@@ -88,7 +88,7 @@ def run(body):
|
||||
'--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
|
||||
'--property=StandardError=append:'+str(RUNTIME/'player.log'),
|
||||
'python3', str(RUNTIME/'frame_media_player.py'), str(path),
|
||||
'--layout', plan['layout'], '--status', str(STATUS)]
|
||||
'--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
|
||||
if body.get('theatre'):
|
||||
command.append('--theatre')
|
||||
started = subprocess.run(command, capture_output=True, text=True, timeout=15)
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Runs ON the Frame (piped over SSH): UEVR, a flat-to-VR mod for Unreal games.
|
||||
|
||||
Usage: python3 - status APPID # can UEVR run here, is it installed, is the game running
|
||||
python3 - install APPID # download the pinned official release into the game's prefix
|
||||
python3 - start APPID # launch the game if needed, inject UEVR, confirm SteamVR frames
|
||||
python3 - uninstall APPID # remove exactly what install (and UEVR's first run) created
|
||||
Prints one JSON object. Errors are {"error": "..."} with exit status 1.
|
||||
|
||||
Verified 2026-09-29 on SteamOS 0.4.1 (build 20260925.6191901), Proton 11.0
|
||||
ARM64 and SteamVR 2.18.1 with Gravitas (1067310): after injection the game has
|
||||
UEVRBackend.dll loaded and SteamVR counts frame submits for it. Stereo image,
|
||||
head tracking and controls were not seen: the headset was unworn (docs/mods.md).
|
||||
|
||||
UEVR's own injector (a .NET WPF app) is not used. Under Wine and FEX it hung
|
||||
or ignored input in about one session in four, and gamescope keeps real input
|
||||
away from it. Instead the official Windows embeddable Python runs INJECT_PY in
|
||||
the game's Wine session, doing what the injector does on Inject.
|
||||
|
||||
Nothing here is bundled. UEVR comes from praydog's GitHub release and Python
|
||||
from python.org, each checked against a pinned hash. Files live in
|
||||
<prefix>/drive_c/frame-control, never in the game's own folder, and a receipt
|
||||
records what to remove.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
HOME = Path.home()
|
||||
STEAM = HOME / ".local/share/Steam"
|
||||
CACHE = HOME / ".cache/frame-control/mods"
|
||||
RECEIPTS = HOME / ".local/share/frame-control/mods"
|
||||
LOCK = Path("/tmp/frame-control-mods.lock") # tmpfs: gone at reboot, never left in ~
|
||||
VRCMD = "/opt/steamvr/bin/linuxarm64/vrcmd"
|
||||
MANAGED = "frame-control" # drive_c/<this> inside the game's prefix
|
||||
|
||||
UEVR = {"version": "1.05", "dir": "uevr-1.05",
|
||||
"url": "https://github.com/praydog/UEVR/releases/download/1.05/UEVR.zip",
|
||||
"sha256": "af4f2f91306802d7ee4e8497d483a547ac8e9a3067dbafb81324100524215d3c"}
|
||||
# Windows x64 embeddable Python, to run INJECT_PY. Hash from python.org's
|
||||
# release-file API (sha256_sum).
|
||||
PYTHON = {"version": "3.14.7", "dir": "python-3.14.7",
|
||||
"url": "https://www.python.org/ftp/python/3.14.7/python-3.14.7-embed-amd64.zip",
|
||||
"sha256": "d297e5ff019966817ad8502465176139f2d3d840fa4ed84b13bed399a6ab1f15"}
|
||||
MAX_UNPACKED = 200 << 20 # the two archives unpack to about 50 MB
|
||||
MAX_DOWNLOAD = 50 << 20 # each archive is 7–13 MB
|
||||
|
||||
# The game's environment a second program needs to join its Wine session. Not
|
||||
# WINESERVERSOCKET and friends: those are inherited file descriptors.
|
||||
GAME_ENV = re.compile(r"(WINEPREFIX|WINEDLLPATH|WINEDLLOVERRIDES|WINEFSYNC|WINEDEBUG|PATH|DISPLAY|"
|
||||
r"XDG_RUNTIME_DIR|PROTON_VR_RUNTIME|FEX_APP_CONFIG|FEX_APP_CONFIG_LOCATION|"
|
||||
r"SteamAppId|SteamGameId|STEAM_COMPAT_DATA_PATH|WINE_LARGE_ADDRESS_AWARE)=")
|
||||
|
||||
|
||||
# Runs under Windows Python in the game's Wine session: the steps of UEVR
|
||||
# 1.05's frontend (UEVR/MainWindow.xaml.cs Inject_Clicked, UEVR/Injector.cs)
|
||||
# with "Nullify VR plugins" on and the OpenVR runtime. Prints one JSON line.
|
||||
INJECT_PY = r"""
|
||||
import ctypes, json, os, sys
|
||||
from ctypes import wintypes as W
|
||||
exe, uevr, profile = sys.argv[1:4]
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
H, P, SZ = W.HANDLE, ctypes.c_void_p, ctypes.c_size_t
|
||||
for name, res, args in (
|
||||
("OpenProcess", H, [W.DWORD, W.BOOL, W.DWORD]),
|
||||
("CloseHandle", W.BOOL, [H]),
|
||||
("VirtualAllocEx", P, [H, P, SZ, W.DWORD, W.DWORD]),
|
||||
("VirtualFreeEx", W.BOOL, [H, P, SZ, W.DWORD]),
|
||||
("WriteProcessMemory", W.BOOL, [H, P, P, SZ, P]),
|
||||
("CreateRemoteThread", H, [H, P, SZ, P, P, W.DWORD, P]),
|
||||
("WaitForSingleObject", W.DWORD, [H, W.DWORD]),
|
||||
("GetModuleHandleW", W.HMODULE, [W.LPCWSTR]),
|
||||
("LoadLibraryW", W.HMODULE, [W.LPCWSTR]),
|
||||
("FreeLibrary", W.BOOL, [W.HMODULE]),
|
||||
("GetProcAddress", P, [W.HMODULE, ctypes.c_char_p]),
|
||||
("CreateToolhelp32Snapshot", H, [W.DWORD, W.DWORD]),
|
||||
("Process32FirstW", W.BOOL, [H, P]), ("Process32NextW", W.BOOL, [H, P]),
|
||||
("Module32FirstW", W.BOOL, [H, P]), ("Module32NextW", W.BOOL, [H, P])):
|
||||
fn = getattr(k32, name)
|
||||
fn.restype, fn.argtypes = res, args
|
||||
INVALID = ctypes.c_void_p(-1).value
|
||||
|
||||
class Entry(ctypes.Structure): # PROCESSENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("usage", W.DWORD), ("pid", W.DWORD), ("heap", ctypes.c_void_p),
|
||||
("module", W.DWORD), ("threads", W.DWORD), ("parent", W.DWORD), ("prio", ctypes.c_long),
|
||||
("flags", W.DWORD), ("name", W.WCHAR * 260)]
|
||||
|
||||
class Module(ctypes.Structure): # MODULEENTRY32W
|
||||
_fields_ = [("size", W.DWORD), ("mid", W.DWORD), ("pid", W.DWORD), ("glbl", W.DWORD), ("proc", W.DWORD),
|
||||
("base", ctypes.c_void_p), ("bytes", W.DWORD), ("handle", W.HMODULE),
|
||||
("name", W.WCHAR * 256), ("path", W.WCHAR * 260)]
|
||||
|
||||
def fail(msg):
|
||||
print(json.dumps({"error": f"{msg} (Windows error {ctypes.get_last_error()})"})); sys.exit(1)
|
||||
|
||||
def snapshot(flags, pid, entry, first, next_, match):
|
||||
snap = k32.CreateToolhelp32Snapshot(flags, pid)
|
||||
if not snap or snap == INVALID:
|
||||
fail("couldn't list processes")
|
||||
try:
|
||||
entry.size = ctypes.sizeof(entry)
|
||||
ok = first(snap, ctypes.byref(entry))
|
||||
while ok:
|
||||
if match(entry):
|
||||
return entry
|
||||
ok = next_(snap, ctypes.byref(entry))
|
||||
finally:
|
||||
k32.CloseHandle(snap)
|
||||
|
||||
def remote_call(proc, fn, arg, wait_ms):
|
||||
t = k32.CreateRemoteThread(proc, None, 0, fn, arg, 0, None)
|
||||
if not t:
|
||||
fail("CreateRemoteThread failed")
|
||||
try:
|
||||
return k32.WaitForSingleObject(t, wait_ms) == 0 # WAIT_OBJECT_0
|
||||
finally:
|
||||
k32.CloseHandle(t)
|
||||
|
||||
def inject(proc, pid, name):
|
||||
path = os.path.join(uevr, name)
|
||||
data = ctypes.create_unicode_buffer(path)
|
||||
mem = k32.VirtualAllocEx(proc, None, ctypes.sizeof(data), 0x3000, 0x04) # commit|reserve, read/write
|
||||
if not mem:
|
||||
fail(f"couldn't allocate in {exe}")
|
||||
try:
|
||||
if not k32.WriteProcessMemory(proc, mem, data, ctypes.sizeof(data), None):
|
||||
fail(f"couldn't write into {exe}")
|
||||
load = k32.GetProcAddress(k32.GetModuleHandleW("kernel32.dll"), b"LoadLibraryW")
|
||||
if not remote_call(proc, load, mem, 30000):
|
||||
fail(f"loading {name} into {exe} didn't finish in 30 s")
|
||||
finally:
|
||||
k32.VirtualFreeEx(proc, mem, 0, 0x8000) # MEM_RELEASE
|
||||
m = snapshot(0x18, pid, Module(), k32.Module32FirstW, k32.Module32NextW, # TH32CS_SNAPMODULE | SNAPMODULE32
|
||||
lambda m: m.path.lower() == path.lower())
|
||||
if not m:
|
||||
fail(f"{name} didn't load into {exe}")
|
||||
return path, m.base
|
||||
|
||||
e = snapshot(2, 0, Entry(), k32.Process32FirstW, k32.Process32NextW, # TH32CS_SNAPPROCESS
|
||||
lambda e: e.name.lower() == exe.lower())
|
||||
if not e:
|
||||
fail(f"{exe} isn't running")
|
||||
pid = e.pid
|
||||
proc = k32.OpenProcess(0x1F0FFF, False, pid) # PROCESS_ALL_ACCESS
|
||||
if not proc:
|
||||
fail(f"couldn't open {exe}")
|
||||
path, base = inject(proc, pid, "UEVRPluginNullifier.dll")
|
||||
local = k32.LoadLibraryW(path)
|
||||
fn = local and k32.GetProcAddress(local, b"nullify")
|
||||
if not fn:
|
||||
fail("couldn't find UEVRPluginNullifier.dll's nullify")
|
||||
offset = fn - local # same DLL, same layout in both processes
|
||||
k32.FreeLibrary(local)
|
||||
# The frontend waits 2 s for nullify and carries on either way; so do we, but say so.
|
||||
nullified = remote_call(proc, base + offset, None, 2000)
|
||||
inject(proc, pid, "openvr_api.dll")
|
||||
# The frontend saves the runtime choice in the per-game config before UEVRBackend reads it.
|
||||
os.makedirs(profile, exist_ok=True)
|
||||
cfg = os.path.join(profile, "config.txt")
|
||||
lines = open(cfg).read().splitlines() if os.path.exists(cfg) else []
|
||||
lines = [l for l in lines if not l.startswith("Frontend_RequestedRuntime=")] + ["Frontend_RequestedRuntime=openvr_api.dll"]
|
||||
open(cfg, "w").write("\n".join(lines) + "\n")
|
||||
inject(proc, pid, "UEVRBackend.dll")
|
||||
k32.CloseHandle(proc)
|
||||
print(json.dumps({"pid": pid, "nullified": nullified}))
|
||||
"""
|
||||
|
||||
|
||||
class Fail(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def libraries():
|
||||
"""Steam library folders: the default one plus any in libraryfolders.vdf."""
|
||||
libs = [STEAM]
|
||||
try:
|
||||
text = (STEAM / "steamapps/libraryfolders.vdf").read_text(errors="replace")
|
||||
libs += [Path(p.replace("\\\\", "\\")) for p in re.findall(r'"path"\s+"([^"]+)"', text)]
|
||||
except OSError:
|
||||
pass
|
||||
seen, out = set(), []
|
||||
for lib in libs:
|
||||
key = os.path.realpath(lib)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
out.append(lib)
|
||||
return out
|
||||
|
||||
|
||||
def game(appid):
|
||||
"""Where the game is installed and its Proton prefix, from its app manifest."""
|
||||
for lib in libraries():
|
||||
manifest = lib / f"steamapps/appmanifest_{appid}.acf"
|
||||
try:
|
||||
text = manifest.read_text(errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
m = re.search(r'"installdir"\s+"([^"]+)"', text)
|
||||
if not m:
|
||||
continue
|
||||
name = re.search(r'"name"\s+"([^"]*)"', text)
|
||||
return {"name": name.group(1) if name else str(appid),
|
||||
"dir": lib / "steamapps/common" / m.group(1),
|
||||
"prefix": lib / f"steamapps/compatdata/{appid}/pfx"}
|
||||
raise Fail(f"app {appid} isn't installed on this Frame")
|
||||
|
||||
|
||||
def shipping_exe(game_dir):
|
||||
"""The Unreal Engine game binary (…/Binaries/Win64/*-Win64-Shipping.exe), or None."""
|
||||
found = [p for p in game_dir.glob("*/Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
found += [p for p in game_dir.glob("Binaries/Win64/*.exe") if p.name.lower().endswith("-win64-shipping.exe")]
|
||||
return max(found, key=lambda p: p.stat().st_size) if found else None
|
||||
|
||||
|
||||
def game_pid(exe_name):
|
||||
"""The running Wine process for this .exe (its cmdline is the Windows path)."""
|
||||
want = "\\" + exe_name.lower()
|
||||
for d in Path("/proc").iterdir():
|
||||
if not d.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
cmd = (d / "cmdline").read_bytes().split(b"\0")[0].decode(errors="replace").lower()
|
||||
except OSError:
|
||||
continue
|
||||
if cmd.endswith(want):
|
||||
return int(d.name)
|
||||
return None
|
||||
|
||||
|
||||
def loaded(pid, name):
|
||||
try:
|
||||
return any(line.rstrip().endswith("/" + name) for line in open(f"/proc/{pid}/maps"))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def receipt_path(appid):
|
||||
return RECEIPTS / f"{appid}-uevr.json"
|
||||
|
||||
|
||||
def read_receipt(appid):
|
||||
try:
|
||||
return json.loads(receipt_path(appid).read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def managed_dir(g):
|
||||
return g["prefix"] / "drive_c" / MANAGED
|
||||
|
||||
|
||||
def user_dirs(g, exe):
|
||||
"""What UEVR itself writes into the prefix when it first runs."""
|
||||
user = g["prefix"] / "drive_c/users/steamuser/AppData"
|
||||
return {"profile": user / "Roaming/UnrealVRMod" / exe.stem, # per-game settings and log.txt
|
||||
"injector": user / "Local/praydog"} # the injector's own settings
|
||||
|
||||
|
||||
def status(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
r = read_receipt(appid)
|
||||
pid = game_pid(exe.name) if exe else None
|
||||
return {"appid": appid, "name": g["name"], "mod": "UEVR", "version": UEVR["version"],
|
||||
"eligible": bool(exe), "exe": exe.name if exe else None,
|
||||
"prefix": g["prefix"].is_dir(), "installed": bool(r),
|
||||
"running": bool(pid), "injected": bool(pid and loaded(pid, "UEVRBackend.dll"))}
|
||||
|
||||
|
||||
def fetch(url, digest, algo):
|
||||
"""Download into the cache once, and check its hash every time it's used."""
|
||||
CACHE.mkdir(parents=True, exist_ok=True)
|
||||
path = CACHE / url.rsplit("/", 1)[1]
|
||||
if not path.exists():
|
||||
part = path.with_suffix(path.suffix + ".part")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=60) as r, open(part, "wb") as f:
|
||||
for block in iter(lambda: r.read(1 << 20), b""):
|
||||
if f.tell() + len(block) > MAX_DOWNLOAD:
|
||||
raise Fail(f"{url} is bigger than expected; stopped the download")
|
||||
f.write(block)
|
||||
except (OSError, Fail) as e:
|
||||
part.unlink(missing_ok=True)
|
||||
raise Fail(f"download failed: {url}: {e}")
|
||||
part.rename(path)
|
||||
h = hashlib.new(algo)
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
if h.hexdigest() != digest:
|
||||
path.unlink()
|
||||
raise Fail(f"{path.name} doesn't match its published {algo}; deleted it, try again")
|
||||
return path
|
||||
|
||||
|
||||
def unpack(archive, dest, budget):
|
||||
"""Extract a ZIP into dest, refusing anything that would land outside it."""
|
||||
root = os.path.realpath(dest)
|
||||
with zipfile.ZipFile(archive) as z:
|
||||
for info in z.infolist():
|
||||
target = os.path.realpath(os.path.join(dest, info.filename))
|
||||
if target != root and not target.startswith(root + os.sep):
|
||||
raise Fail(f"{archive.name} has an entry outside its folder: {info.filename}")
|
||||
if (info.external_attr >> 16) & 0o170000 == 0o120000:
|
||||
raise Fail(f"{archive.name} contains a symlink: {info.filename}")
|
||||
budget -= info.file_size
|
||||
if budget < 0:
|
||||
raise Fail(f"{archive.name} unpacks to more than expected")
|
||||
z.extractall(dest)
|
||||
return budget
|
||||
|
||||
|
||||
def install(appid):
|
||||
g = game(appid)
|
||||
exe = shipping_exe(g["dir"])
|
||||
if not exe:
|
||||
raise Fail(f"{g['name']} doesn't look like an Unreal Engine game (no *-Win64-Shipping.exe), so UEVR can't hook it")
|
||||
if not g["prefix"].is_dir():
|
||||
raise Fail(f"{g['name']} has no Proton prefix yet; play it once, then add the mod")
|
||||
if game_pid(exe.name):
|
||||
raise Fail(f"{g['name']} is running; quit it first")
|
||||
if read_receipt(appid):
|
||||
return {"message": f"UEVR {UEVR['version']} is already installed for {g['name']}", **status(appid)}
|
||||
archives = [(fetch(UEVR["url"], UEVR["sha256"], "sha256"), UEVR["dir"])]
|
||||
archives.append((fetch(PYTHON["url"], PYTHON["sha256"], "sha256"), PYTHON["dir"]))
|
||||
base = managed_dir(g)
|
||||
dirs = user_dirs(g, exe)
|
||||
fresh = [str(d) for d in (dirs["profile"].parent, base) if not d.exists()] # remove on uninstall if empty
|
||||
stage = base / f".staging-{os.getpid()}"
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
try:
|
||||
budget = MAX_UNPACKED
|
||||
for archive, sub in archives:
|
||||
budget = unpack(archive, stage / sub, budget)
|
||||
for need in (stage / UEVR["dir"] / "UEVRBackend.dll", stage / PYTHON["dir"] / "python.exe"):
|
||||
if not need.is_file():
|
||||
raise Fail(f"the download has no {need.name}")
|
||||
if game_pid(exe.name): # it may have started during the download
|
||||
raise Fail(f"{g['name']} started; quit it first")
|
||||
(stage / PYTHON["dir"] / "frame_inject.py").write_text(INJECT_PY)
|
||||
for sub in (UEVR["dir"], PYTHON["dir"]):
|
||||
shutil.rmtree(base / sub, ignore_errors=True)
|
||||
(stage / sub).rename(base / sub)
|
||||
finally:
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
if str(base) in fresh:
|
||||
try:
|
||||
base.rmdir() # only succeeds if the install failed and left it empty
|
||||
except OSError:
|
||||
pass
|
||||
receipt = {"appid": appid, "mod": "UEVR", "version": UEVR["version"], "exe": exe.name,
|
||||
"installed": int(time.time()), "prefix": str(g["prefix"]),
|
||||
"remove": [str(base / UEVR["dir"]), str(base / PYTHON["dir"])],
|
||||
# Parents that didn't exist yet (UEVR makes UnrealVRMod), deepest first.
|
||||
"remove_if_empty": fresh,
|
||||
# UEVR creates these on first injection. Remove them on uninstall
|
||||
# only if they weren't there before, so earlier settings survive.
|
||||
"remove_if_created": {k: str(v) for k, v in dirs.items() if not v.exists()},
|
||||
"sources": [UEVR["url"], PYTHON["url"]]}
|
||||
RECEIPTS.mkdir(parents=True, exist_ok=True)
|
||||
tmp = receipt_path(appid).with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(receipt, indent=1))
|
||||
tmp.rename(receipt_path(appid))
|
||||
return {"message": f"Installed UEVR {UEVR['version']} for {g['name']}", **status(appid)}
|
||||
|
||||
|
||||
def x11_windows(display):
|
||||
"""(id, name, width) for each named top-level window, via xwininfo."""
|
||||
try:
|
||||
out = subprocess.run(["xwininfo", "-root", "-tree"], env={**os.environ, "DISPLAY": display},
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return []
|
||||
return [(int(m[1], 16), m[2], int(m[3])) for m in re.finditer(r'(0x[0-9a-f]+) "([^"]*)":.*?\)\s+(\d+)x\d+', out)]
|
||||
|
||||
|
||||
def frame_submits(appid):
|
||||
"""SteamVR's count of frames the app has submitted, or None if it isn't a scene app."""
|
||||
try:
|
||||
stats = json.loads(subprocess.run([VRCMD, "--stats"], capture_output=True, text=True, timeout=15).stdout)
|
||||
except (OSError, ValueError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
if not isinstance(stats, list):
|
||||
return None
|
||||
return next((s.get("frame_submits") for s in stats if isinstance(s, dict) and s.get("key") == f"steam.app.{appid}"), None)
|
||||
|
||||
|
||||
def wait(what, seconds, check, step=2):
|
||||
end = time.time() + seconds
|
||||
while time.time() < end:
|
||||
v = check()
|
||||
if v is not None and v is not False: # 0 frames is still an answer
|
||||
return v
|
||||
time.sleep(step)
|
||||
raise Fail(f"timed out waiting for {what}")
|
||||
|
||||
|
||||
def start(appid):
|
||||
g = game(appid)
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for {g['name']}")
|
||||
exe = r["exe"]
|
||||
pid = game_pid(exe)
|
||||
if pid and loaded(pid, "UEVRBackend.dll"):
|
||||
return {"message": f"UEVR is already running in {g['name']}", "frame_submits": frame_submits(appid), **status(appid)}
|
||||
if not pid:
|
||||
subprocess.Popen(["steam", f"steam://rungameid/{appid}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
env={**os.environ, "DISPLAY": os.environ.get("DISPLAY", ":0")}, start_new_session=True)
|
||||
pid = wait(f"{g['name']} to start", 120, lambda: game_pid(exe))
|
||||
try:
|
||||
environ = Path(f"/proc/{pid}/environ").read_text(errors="replace")
|
||||
except OSError:
|
||||
raise Fail(f"{exe} exited") from None
|
||||
game_env = dict(line.split("=", 1) for line in environ.split("\0") if GAME_ENV.match(line))
|
||||
display = game_env.get("DISPLAY")
|
||||
if not display or "WINEPREFIX" not in game_env:
|
||||
raise Fail(f"{exe} doesn't look like a Proton game process")
|
||||
# UEVR hooks the game's D3D11/12 device, so wait for its window. Proton's
|
||||
# own helper windows ("Steam", "Default IME", …) are small.
|
||||
wait("the game window", 90, lambda: any(w[2] >= 640 for w in x11_windows(display)))
|
||||
time.sleep(10)
|
||||
# Our own environment (HOME and the rest) under the game's Wine settings.
|
||||
env = {**os.environ, **game_env}
|
||||
base = f"C:\\{MANAGED}"
|
||||
profile = f"C:\\users\\steamuser\\AppData\\Roaming\\UnrealVRMod\\{Path(exe).stem}"
|
||||
try:
|
||||
out = subprocess.run(["wine", f"{base}\\{PYTHON['dir']}\\python.exe", f"{base}\\{PYTHON['dir']}\\frame_inject.py",
|
||||
exe, f"{base}\\{UEVR['dir']}", profile], env=env, capture_output=True, text=True,
|
||||
timeout=120, stdin=subprocess.DEVNULL,
|
||||
cwd=str(Path(r["prefix"]) / "drive_c" / MANAGED))
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Fail("the injection step didn't finish in 2 minutes") from None
|
||||
reply = None
|
||||
for line in reversed(out.stdout.splitlines()):
|
||||
try:
|
||||
reply = json.loads(line)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(reply, dict):
|
||||
reply = None
|
||||
if not reply or "error" in reply:
|
||||
raise Fail("UEVR injection failed: " + (reply or {}).get("error", (out.stderr.strip().splitlines() or ["no output"])[-1]))
|
||||
try:
|
||||
wait("UEVRBackend.dll to load in the game", 20, lambda: loaded(pid, "UEVRBackend.dll"))
|
||||
except Fail:
|
||||
raise Fail(f"the injection reported success but {exe} has no UEVRBackend.dll") from None
|
||||
submits = None
|
||||
try:
|
||||
submits = wait("SteamVR frames", 30, lambda: frame_submits(appid))
|
||||
except Fail:
|
||||
pass
|
||||
return {"message": f"UEVR injected into {g['name']}" + ("; SteamVR is receiving its frames" if submits is not None
|
||||
else "; SteamVR hasn't reported frames from it yet"),
|
||||
"frame_submits": submits, **status(appid)}
|
||||
|
||||
|
||||
def uninstall(appid):
|
||||
r = read_receipt(appid)
|
||||
if not r:
|
||||
raise Fail(f"UEVR isn't installed for app {appid}")
|
||||
if game_pid(r["exe"]):
|
||||
raise Fail("the game is running; quit it first")
|
||||
prefix = os.path.realpath(r["prefix"])
|
||||
if not prefix.endswith(f"/steamapps/compatdata/{appid}/pfx"):
|
||||
raise Fail(f"receipt names a prefix that isn't app {appid}'s: {r['prefix']}")
|
||||
# Only ever our folder and UEVR's own settings folders for this game, even
|
||||
# if the receipt says otherwise.
|
||||
managed = os.path.join(prefix, "drive_c", MANAGED)
|
||||
uevr_dirs = {os.path.realpath(str(d)) for d in user_dirs({"prefix": Path(prefix)}, Path(r["exe"])).values()}
|
||||
targets = list(r.get("remove", [])) + list(r.get("remove_if_created", {}).values())
|
||||
for t in targets:
|
||||
real = os.path.realpath(t)
|
||||
if not (real.startswith(managed + os.sep) or real in uevr_dirs):
|
||||
raise Fail(f"receipt lists a path Frame Control didn't create: {t}")
|
||||
for t in targets:
|
||||
shutil.rmtree(t, ignore_errors=True)
|
||||
left = [t for t in targets if os.path.lexists(t)]
|
||||
if left: # keep the receipt, so Remove can be tried again
|
||||
raise Fail("couldn't remove " + ", ".join(left))
|
||||
parents = {os.path.realpath(os.path.join(prefix, "drive_c/users/steamuser/AppData/Roaming/UnrealVRMod")), managed}
|
||||
for t in r.get("remove_if_empty", []):
|
||||
if os.path.realpath(t) in parents:
|
||||
try:
|
||||
os.rmdir(t)
|
||||
except OSError:
|
||||
pass # something else lives there now
|
||||
receipt_path(appid).unlink()
|
||||
if not any(RECEIPTS.glob("*-uevr.json")): # no other game uses the downloads
|
||||
shutil.rmtree(CACHE, ignore_errors=True)
|
||||
try:
|
||||
RECEIPTS.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return {"message": "Removed UEVR", "appid": appid, "removed": targets}
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) != 2 or argv[0] not in ("status", "install", "start", "uninstall") or not argv[1].isdigit():
|
||||
raise Fail("usage: status|install|start|uninstall APPID")
|
||||
action = {"status": status, "install": install, "start": start, "uninstall": uninstall}[argv[0]]
|
||||
if action is status:
|
||||
return status(int(argv[1]))
|
||||
import fcntl # only on the Frame; the server and its tests also import this module on Windows
|
||||
# One change at a time, or an uninstall could delete what an install is moving in.
|
||||
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOCK, "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
raise Fail("another mod action is still running on the Frame; try again when it finishes") from None
|
||||
return action(int(argv[1]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(json.dumps(main(sys.argv[1:])))
|
||||
except Fail as e:
|
||||
print(json.dumps({"error": str(e)}))
|
||||
sys.exit(1)
|
||||
except Exception as e: # still one JSON object, for the server to show
|
||||
print(json.dumps({"error": f"{type(e).__name__}: {e}"}))
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Which network this computer is on, and whether Tailscale is up.
|
||||
|
||||
Frame Control remembers which of a headset's addresses worked on which network,
|
||||
so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
|
||||
friendly one, but macOS 14+ hides it from apps without Location permission, and
|
||||
wired networks have none. So every network is identified by a fingerprint of
|
||||
its default gateway: the router's IP and MAC address, which stay the same for a
|
||||
given home or office network. The user can give a fingerprint a name.
|
||||
|
||||
Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
|
||||
is a short command with a timeout, and each parser has fixtures in
|
||||
tests/test_network.py.
|
||||
"""
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import frame_host
|
||||
|
||||
TIMEOUT = 3
|
||||
|
||||
|
||||
def run(argv, timeout=TIMEOUT):
|
||||
"""A command's stdout, or "" if it's missing, fails or takes too long."""
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
|
||||
**({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def valid_ip(text):
|
||||
try:
|
||||
ipaddress.ip_address(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def norm_mac(text):
|
||||
""""b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
|
||||
parts = re.split(r"[:-]", (text or "").strip())
|
||||
if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
|
||||
return None
|
||||
mac = ":".join(p.lower().zfill(2) for p in parts)
|
||||
return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
|
||||
|
||||
|
||||
# ---- default gateway -------------------------------------------------------
|
||||
|
||||
def parse_route_macos(text):
|
||||
"""`route -n get default` -> (gateway, interface)."""
|
||||
gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
|
||||
iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
|
||||
gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
|
||||
return gateway, iface.group(1) if iface else None
|
||||
|
||||
|
||||
def parse_route_linux(text):
|
||||
"""`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
|
||||
best = None
|
||||
for line in text.splitlines():
|
||||
m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
|
||||
if not m or not valid_ip(m.group(1)):
|
||||
continue
|
||||
metric = re.search(r"\bmetric (\d+)", line)
|
||||
key = int(metric.group(1)) if metric else 0
|
||||
if best is None or key < best[0]:
|
||||
best = (key, m.group(1), m.group(2))
|
||||
return (best[1], best[2]) if best else (None, None)
|
||||
|
||||
|
||||
def parse_route_windows(text):
|
||||
"""`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
|
||||
best = None
|
||||
for line in text.splitlines():
|
||||
f = line.split()
|
||||
if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
|
||||
if best is None or int(f[4]) < best[0]:
|
||||
best = (int(f[4]), f[2], f[3])
|
||||
return (best[1], best[2]) if best else (None, None)
|
||||
|
||||
|
||||
# ---- the gateway's MAC address ----------------------------------------------
|
||||
|
||||
def parse_arp_macos(text, ip):
|
||||
"""`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
|
||||
m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
|
||||
return norm_mac(m.group(1)) if m else None
|
||||
|
||||
|
||||
def parse_neigh_linux(text, ip):
|
||||
"""`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
|
||||
for line in text.splitlines():
|
||||
f = line.split()
|
||||
if f and f[0] == ip and "lladdr" in f:
|
||||
return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
|
||||
return None
|
||||
|
||||
|
||||
def parse_arp_windows(text, ip):
|
||||
"""`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
|
||||
for line in text.splitlines():
|
||||
f = line.split()
|
||||
if len(f) >= 2 and f[0] == ip:
|
||||
return norm_mac(f[1])
|
||||
return None
|
||||
|
||||
|
||||
# ---- Wi-Fi name --------------------------------------------------------------
|
||||
|
||||
def parse_summary_macos(text):
|
||||
"""`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "<redacted>" without Location permission."""
|
||||
kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
|
||||
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
|
||||
name = ssid.group(1) if ssid else None
|
||||
if name in ("<redacted>", ""):
|
||||
name = None
|
||||
return name, (kind.group(1).lower() == "wifi") if kind else None
|
||||
|
||||
|
||||
def parse_nmcli(text):
|
||||
"""`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
|
||||
for line in text.splitlines():
|
||||
if line.startswith("yes:"):
|
||||
return line[4:].replace("\\:", ":") or None
|
||||
return None
|
||||
|
||||
|
||||
def parse_netsh(text):
|
||||
"""`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
|
||||
state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
|
||||
ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
|
||||
if not ssid or (state and state.group(1).lower() != "connected"):
|
||||
return None
|
||||
return ssid.group(1)
|
||||
|
||||
|
||||
# ---- Tailscale -----------------------------------------------------------------
|
||||
|
||||
def tailscale_cli():
|
||||
extra = []
|
||||
if frame_host.MAC:
|
||||
extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
|
||||
elif frame_host.WINDOWS:
|
||||
for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
|
||||
if base:
|
||||
extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
|
||||
return frame_host.which("tailscale", *extra)
|
||||
|
||||
|
||||
def parse_tailscale(text):
|
||||
"""`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
|
||||
|
||||
Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return {"up": False, "peers": []}
|
||||
if not isinstance(data, dict):
|
||||
return {"up": False, "peers": []}
|
||||
me = data.get("Self") or {}
|
||||
tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
|
||||
out = {"up": data.get("BackendState") == "Running",
|
||||
"ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
|
||||
"name": (me.get("DNSName") or "").rstrip(".") or None,
|
||||
"tailnet": tailnet, "peers": []}
|
||||
for p in (data.get("Peer") or {}).values():
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
|
||||
"ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
|
||||
"os": p.get("OS") or "", "online": bool(p.get("Online"))})
|
||||
return out
|
||||
|
||||
|
||||
def tailscale_status():
|
||||
cli = tailscale_cli()
|
||||
if not cli:
|
||||
return {"up": False, "installed": False, "peers": []}
|
||||
out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
|
||||
out["installed"] = True
|
||||
return out
|
||||
|
||||
|
||||
TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
|
||||
TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
|
||||
|
||||
|
||||
def is_tailscale(host):
|
||||
host = host.lower().rstrip(".")
|
||||
if host.endswith(".ts.net"):
|
||||
return True
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return False
|
||||
return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
|
||||
|
||||
|
||||
def guess_kind(host):
|
||||
"""What sort of address a host is: mdns, tailscale, lan or manual."""
|
||||
h = host.lower().rstrip(".")
|
||||
if h.endswith(".local"):
|
||||
return "mdns"
|
||||
if is_tailscale(h):
|
||||
return "tailscale"
|
||||
try:
|
||||
ip = ipaddress.ip_address(h.split("%")[0])
|
||||
if ip.is_private or ip.is_link_local:
|
||||
return "lan"
|
||||
except ValueError:
|
||||
pass
|
||||
return "manual"
|
||||
|
||||
|
||||
# ---- putting it together ----------------------------------------------------------
|
||||
|
||||
def network_id(gateway, mac):
|
||||
"""A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
|
||||
if not gateway or not mac:
|
||||
return None
|
||||
return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
|
||||
|
||||
|
||||
def local_ip(towards="192.0.2.1"):
|
||||
"""This computer's address on the default route (UDP connect sends nothing)."""
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect((towards, 9))
|
||||
return s.getsockname()[0]
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def gateway():
|
||||
"""(gateway IP, interface) of the default route."""
|
||||
if frame_host.MAC:
|
||||
return parse_route_macos(run(["route", "-n", "get", "default"]))
|
||||
if frame_host.WINDOWS:
|
||||
return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
|
||||
return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
|
||||
|
||||
|
||||
def gateway_mac(ip):
|
||||
if frame_host.MAC:
|
||||
return parse_arp_macos(run(["arp", "-n", ip]), ip)
|
||||
if frame_host.WINDOWS:
|
||||
return parse_arp_windows(run(["arp", "-a", ip]), ip)
|
||||
return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
|
||||
|
||||
|
||||
def poke(ip):
|
||||
"""Make the system look up the gateway's MAC (an ARP entry can expire)."""
|
||||
try:
|
||||
with socket.create_connection((ip, 53), timeout=0.3):
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def wifi(interface):
|
||||
"""(ssid or None, is_wifi or None) for the default route's interface."""
|
||||
if frame_host.MAC:
|
||||
if interface:
|
||||
ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
|
||||
if ssid or is_wifi is False:
|
||||
return ssid, is_wifi
|
||||
m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
|
||||
return (m.group(1).strip() if m else None), is_wifi
|
||||
return None, None
|
||||
if frame_host.WINDOWS:
|
||||
ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
|
||||
return ssid, True if ssid else None
|
||||
if frame_host.which("nmcli"):
|
||||
ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
|
||||
else:
|
||||
ssid = run(["iwgetid", "-r"]).strip() or None
|
||||
return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
|
||||
|
||||
|
||||
def fingerprint():
|
||||
"""The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
|
||||
gw, iface = gateway()
|
||||
mac = None
|
||||
if gw:
|
||||
mac = gateway_mac(gw)
|
||||
if not mac:
|
||||
poke(gw)
|
||||
mac = gateway_mac(gw)
|
||||
return gw, iface, mac
|
||||
|
||||
|
||||
def current_network(fp=None, with_tailscale=True):
|
||||
"""Everything the connection status shows about this computer's network."""
|
||||
gw, iface, mac = fp or fingerprint()
|
||||
ssid, is_wifi = wifi(iface) if gw else (None, None)
|
||||
net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
|
||||
"ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
|
||||
if with_tailscale:
|
||||
ts = tailscale_status()
|
||||
net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
|
||||
return net
|
||||
+696
-22
@@ -86,6 +86,58 @@
|
||||
.wait { color: var(--muted); font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
|
||||
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
||||
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
|
||||
.pill .dot { flex: none; }
|
||||
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
||||
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
|
||||
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
.dlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
.dlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.dlg h3, [data-page=devices] h3 { margin: 16px 0 6px; font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--muted); font-weight: 700; }
|
||||
.dlg label, .dev-form label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
.dlg label input, .dev-form label input { margin-top: 5px; }
|
||||
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 14px; margin: 0; font-size: 13px; }
|
||||
.facts dt { color: var(--muted); } .facts dd { margin: 0; color: var(--bright); overflow-wrap: anywhere; }
|
||||
.stages { list-style: none; margin: 0; padding: 0; }
|
||||
.stages li { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 8px; padding: 6px 0; border-top: 1px solid rgba(255,255,255,.05); }
|
||||
.stages li:first-child { border-top: 0; }
|
||||
.stages .ic { width: 16px; height: 16px; border-radius: 50%; margin-top: 2px; display: grid; place-items: center; font-size: 11px; font-weight: 700; }
|
||||
.stages .done .ic { background: rgba(89,191,64,.2); color: var(--green-hi); }
|
||||
.stages .failed .ic { background: rgba(217,65,38,.25); color: #ff8a73; }
|
||||
.stages .pending .ic { border: 1.5px solid var(--dim); }
|
||||
.stages .active .ic { border: 2px solid rgba(255,255,255,.15); border-top-color: var(--blue); animation: spin .8s linear infinite; }
|
||||
.stages .lb { color: var(--bright); font-size: 13.5px; } .stages .pending .lb { color: var(--muted); }
|
||||
.stages .dt { grid-column: 2 / 4; color: var(--muted); font-size: 12.5px; overflow-wrap: anywhere; }
|
||||
.stages .failed .dt { color: #ff8a73; }
|
||||
.stages .tm { color: var(--dim); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.probes { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.probes td { padding: 5px 8px 5px 0; border-top: 1px solid rgba(255,255,255,.05); vertical-align: top; }
|
||||
.probes td:first-child { color: var(--bright); min-width: 170px; overflow-wrap: anywhere; }
|
||||
.res-answered, .res-ok { color: var(--green-hi); } .res-refused, .res-sshfailed, .res-wrong, .res-denied { color: #ff8a73; }
|
||||
.res-timeout, .res-unresolved, .res-unreachable, .res-unpinned { color: var(--warn); }
|
||||
.res-trying, .res-resolving, .res-waiting, .res-checking { color: var(--link); }
|
||||
.dev-grid { display: grid; grid-template-columns: minmax(260px, 1fr) minmax(0, 2.2fr); gap: 22px; align-items: start; }
|
||||
@media (max-width: 1000px) { .dev-grid { grid-template-columns: 1fr; } }
|
||||
.dev-item { cursor: pointer; border-radius: 3px; padding: 10px !important; margin: 0 -10px; }
|
||||
.dev-item:hover { background: rgba(255,255,255,.04); }
|
||||
.dev-item.sel { background: rgba(26,159,255,.12); }
|
||||
.dev-form { display: grid; grid-template-columns: 2fr 1.3fr 1fr .8fr; gap: 0 10px; align-items: end; }
|
||||
.dev-form input[readonly] { color: var(--muted); }
|
||||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s { white-space: normal; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
|
||||
.found { margin-top: 10px; }
|
||||
|
||||
/* ---- drop anywhere ---- */
|
||||
#media select { min-width: 0; max-width: 100%; flex: 1; }
|
||||
.dropzone { position: fixed; inset: 0; z-index: 40; display: grid; place-items: center; pointer-events: none;
|
||||
@@ -279,9 +331,9 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
@@ -290,7 +342,7 @@
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2, #aboutDlg h2, #modDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
.about-text { max-height: 55vh; overflow: auto; }
|
||||
.about-text pre { white-space: pre-wrap; font-size: 12px; color: var(--muted); }
|
||||
.about-text summary { cursor: pointer; margin: 8px 0; }
|
||||
@@ -591,9 +643,11 @@
|
||||
<a href="#games" title="Steam games and sideloaded titles (2)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8h12a4 4 0 0 1 4 4v1a4 4 0 0 1-7 2.6h-6A4 4 0 0 1 2 13v-1a4 4 0 0 1 4-4z"/><path d="M7 11v3M5.5 12.5h3"/><circle cx="16" cy="11.5" r=".6"/><circle cx="18" cy="13.5" r=".6"/></svg>Games<kbd>2</kbd></a>
|
||||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
|
||||
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||||
@@ -624,6 +678,7 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||
<div class="s" id="offDetail">Retrying every few seconds. Everything fills in when it answers.</div></div>
|
||||
<button class="small desk-only" id="offDetails" hidden>Details</button>
|
||||
<button class="small" id="offSetup" hidden>Set Up Connection…</button>
|
||||
<button class="action small" id="offRetry">Retry now</button>
|
||||
</div>
|
||||
@@ -1079,6 +1134,25 @@
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page" data-page="devices">
|
||||
<div class="dev-grid">
|
||||
<section class="panel">
|
||||
<div class="shelf-head"><h2>Headsets</h2><span class="count" id="devCount"></span><span class="spacer"></span>
|
||||
<button class="action small" id="devAdd">+ Add a headset…</button></div>
|
||||
<div class="list" id="devList"><div class="sub">Loading…</div></div>
|
||||
<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>
|
||||
<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">
|
||||
<input type="text" id="netName" maxlength="60" placeholder="Name this network, e.g. Home Wi-Fi">
|
||||
<button class="small" id="netSave">Save</button></div>
|
||||
<div class="hint">Networks are told apart by their router (its IP and hardware address), so this works on wired
|
||||
networks and when your computer won't share the Wi-Fi name.</div>
|
||||
</section>
|
||||
<section class="panel dev-panel" id="devDetail"><div class="sub">Pick a headset.</div></section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||||
@@ -1113,6 +1187,21 @@
|
||||
<div class="actions"><button id="apkAltClose">Close</button></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="modDlg" aria-labelledby="modTitle">
|
||||
<h2 id="modTitle">Play in VR with UEVR</h2>
|
||||
<p id="modState" class="sub"></p>
|
||||
<p class="sub">UEVR by praydog turns many Unreal Engine games into VR games. Frame Control downloads
|
||||
the official UEVR 1.05 release and python.org's Windows Python, which it uses to load UEVR into the game
|
||||
(about 20 MB), checks their hashes, and keeps them in this game's Proton prefix, not the game's folder.
|
||||
Remove takes them out again.</p>
|
||||
<p class="sub">Tested on the Frame with one game so far. Whether a game looks and plays right in VR varies;
|
||||
UEVR's own menu in the headset has the per-game settings.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="modMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="modClose">Close</button>
|
||||
<button type="button" class="small" id="modRemove" hidden>Remove UEVR</button>
|
||||
<button type="button" class="action small" id="modInstall" hidden>Install UEVR</button>
|
||||
<button type="button" class="play small" id="modStart" hidden>▶ PLAY IN VR</button></div>
|
||||
</dialog>
|
||||
<dialog id="aboutDlg" aria-labelledby="aboutTitle">
|
||||
<h2 id="aboutTitle">About and licences</h2>
|
||||
<p>Frame Control is MIT-licensed. It ships other people's software, each under its own licence: the notices and
|
||||
@@ -1209,6 +1298,45 @@
|
||||
<button type="submit" class="action small" id="pwGo">Restart</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="connDlg" class="dlg" aria-labelledby="connTitle">
|
||||
<h2 id="connTitle">Connection</h2>
|
||||
<div class="sub" id="connWhy"></div>
|
||||
<h3>This computer</h3>
|
||||
<dl class="facts" id="connNet"></dl>
|
||||
<h3>Steps</h3>
|
||||
<ol class="stages" id="connStages"></ol>
|
||||
<h3>Addresses</h3>
|
||||
<table class="probes"><tbody id="connProbes"></tbody></table>
|
||||
<div class="row rep-actions"><span class="sub" id="connRetry"></span><span class="spacer"></span>
|
||||
<button type="button" class="small desk-only" id="connDevices">Manage headsets</button>
|
||||
<button type="button" class="small desk-only" id="connSetup">Set Up Connection…</button>
|
||||
<button type="button" class="small action" id="connRetryBtn">Retry now</button>
|
||||
<button type="button" class="small" id="connClose">Close</button></div>
|
||||
</dialog>
|
||||
<dialog id="addDevDlg" class="dlg" aria-labelledby="addDevTitle">
|
||||
<form method="dialog" id="addDevForm">
|
||||
<h2 id="addDevTitle">Add a headset</h2>
|
||||
<div class="sub">This opens Set Up Connection in a terminal window. On the headset, first turn on Steam Settings →
|
||||
System → Enable Developer Mode, then Developer → Set User Password. Setup finds the headset, adds a key and
|
||||
asks for that password once (or for a tap in the headset under Developer → Pair new host).</div>
|
||||
<label>SSH alias (how Terminal and the scripts reach it: <code>ssh NAME</code>)<input type="text" id="addAlias" maxlength="64" required pattern="[A-Za-z0-9][A-Za-z0-9._\-]*"></label>
|
||||
<label>Address (optional: an IP or host name; left empty, setup looks for it)<input type="text" id="addHost" maxlength="253" placeholder="e.g. 192.168.1.40 or frame.local"></label>
|
||||
<div class="row rep-actions"><span class="sub" id="addMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="addCancel">Cancel</button>
|
||||
<button type="submit" class="action small" id="addGo">Set Up…</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="rmDevDlg" class="dlg" aria-labelledby="rmDevTitle">
|
||||
<form method="dialog" id="rmDevForm">
|
||||
<h2 id="rmDevTitle">Remove this headset?</h2>
|
||||
<div class="sub" id="rmDevText"></div>
|
||||
<label style="display:flex; gap:8px; align-items:center; color:var(--text)"><input type="checkbox" id="rmDevConfig">
|
||||
<span id="rmDevConfigText"></span></label>
|
||||
<div class="row rep-actions"><span class="sub" id="rmMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="rmCancel">Cancel</button>
|
||||
<button type="submit" class="small danger" id="rmGo">Remove</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
@@ -1285,9 +1413,18 @@ async function saveToDevice(items) {
|
||||
}
|
||||
const savesToDevice = () => !!(window.frameApp && window.frameApp.saveImages);
|
||||
|
||||
async function api(path, body) {
|
||||
// Bumped when the app moves to another headset: answers to requests made before
|
||||
// then are about the old one, so they're dropped rather than shown.
|
||||
let devGen = 0;
|
||||
// Answers that don't depend on the headset (this computer's data, the catalogue, jobs).
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
// Changes name the headset the page was showing, so the server refuses one meant
|
||||
// for a headset it has since switched away from.
|
||||
const dev = device ? { "X-Frame-Device": device } : {};
|
||||
const opts = body === undefined ? { headers: {"X-Frame-UI": UI_KEY} } : {
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY}, body: JSON.stringify(body) };
|
||||
method: "POST", headers: {"Content-Type": "application/json", "X-Frame-UI": UI_KEY, ...dev}, body: JSON.stringify(body) };
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch {
|
||||
@@ -1295,6 +1432,11 @@ async function api(path, body) {
|
||||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||||
}
|
||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
|
||||
throw err;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.offline = !!data.offline;
|
||||
@@ -1306,6 +1448,7 @@ async function api(path, body) {
|
||||
// What a panel shows when its data couldn't be read: a quiet placeholder while
|
||||
// the Frame is unreachable (the banner explains), the real error otherwise.
|
||||
function failed(el, e) {
|
||||
if (e.stale) return; // about the previous headset: leave the panel to the new one
|
||||
el.innerHTML = e.offline ? `<div class="wait">Waiting for the Frame</div>` : `<div class="sub">${esc(e.message)}</div>`;
|
||||
}
|
||||
async function act(label, fn, btn) {
|
||||
@@ -1322,6 +1465,7 @@ async function act(label, fn, btn) {
|
||||
}
|
||||
|
||||
function setConn(ok, text) {
|
||||
if (window.connPill && window.connPill()) return; // the connector's pill (below) says more
|
||||
$("conn").innerHTML = `<span class="dot ${ok ? "on" : "off"}"></span><span>${esc(text)}</span>`;
|
||||
}
|
||||
|
||||
@@ -1337,6 +1481,7 @@ function setOnline(ok, why) {
|
||||
setConn(false, "Offline");
|
||||
$("battChip").hidden = true;
|
||||
if (was !== false) { log(why || "Can't reach the Frame", "e"); schedule(); }
|
||||
if (window.connBanner) window.connBanner();
|
||||
} else if (was === false) {
|
||||
log("Connected to the Frame again", "ok");
|
||||
reloadAll();
|
||||
@@ -2409,8 +2554,9 @@ function setVolume(pct, muted) {
|
||||
$("vol").oninput = () => {
|
||||
setVolume(+$("vol").value);
|
||||
clearTimeout(volTimer);
|
||||
const dev = window.connDevice; // the headset whose slider this was, even 250 ms later
|
||||
volTimer = setTimeout(async () => {
|
||||
try { await api("/api/volume", { level: $("vol").value / 100 }); } catch (e) { toast(e.message, true); }
|
||||
try { await api("/api/volume", { level: $("vol").value / 100 }, dev); } catch (e) { toast(e.message, true); }
|
||||
volTimer = null;
|
||||
}, 250);
|
||||
};
|
||||
@@ -2426,9 +2572,10 @@ $("clipSend").onclick = () => {
|
||||
// In the app, Electron reads the clipboard; in a browser, the server does.
|
||||
async function sendComputerClipboard() {
|
||||
if (!window.frameApp) return api("/api/clipboard", { fromComputer: true });
|
||||
const device = window.connDevice; // the headset it was sent to, not one switched to while reading
|
||||
const text = await window.frameApp.readClipboard();
|
||||
if (!text) throw new Error("The clipboard is empty (or holds something other than text)");
|
||||
return api("/api/clipboard", { text });
|
||||
return api("/api/clipboard", { text }, device);
|
||||
}
|
||||
$("clipMac").onclick = () => act($("clipMac").textContent, sendComputerClipboard, $("clipMac"));
|
||||
|
||||
@@ -2461,21 +2608,28 @@ window.addEventListener("drop", e => {
|
||||
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
|
||||
sendFiles([...e.dataTransfer.files], dirs);
|
||||
});
|
||||
function upload(file, mode) {
|
||||
function upload(file, mode, dev = window.connDevice) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/upload");
|
||||
xhr.setRequestHeader("X-Frame-UI", UI_KEY);
|
||||
if (dev) xhr.setRequestHeader("X-Frame-Device", dev);
|
||||
xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name));
|
||||
xhr.setRequestHeader("X-Mode", mode);
|
||||
if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value);
|
||||
const bar = $("prog").firstElementChild;
|
||||
$("prog").style.display = "block"; bar.style.width = "0";
|
||||
xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; };
|
||||
const gen = devGen;
|
||||
xhr.onload = () => {
|
||||
$("prog").style.display = "none";
|
||||
let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; }
|
||||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk);
|
||||
if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here
|
||||
const err = new Error(data.error || "Switched headsets");
|
||||
err.stale = true;
|
||||
return xhr.status < 300 ? resolve(data) : reject(err);
|
||||
}
|
||||
if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev);
|
||||
xhr.status < 300 ? resolve(data) : reject(new Error(data.error));
|
||||
};
|
||||
xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); };
|
||||
@@ -2519,7 +2673,7 @@ $("mediaStop").onclick = () => act("Stop media", async () => {
|
||||
}, $("mediaStop"));
|
||||
|
||||
let apkLookup = 0;
|
||||
async function checkApkAlternatives(apk) {
|
||||
async function checkApkAlternatives(apk, dev = window.connDevice) {
|
||||
const lookup = ++apkLookup;
|
||||
$("apkAltReason").textContent = apk.blocker;
|
||||
$("apkAltVersions").textContent = "Checking F-Droid for older versions…";
|
||||
@@ -2530,12 +2684,12 @@ async function checkApkAlternatives(apk) {
|
||||
if (apk.version_code != null) query.set("code", apk.version_code);
|
||||
try {
|
||||
const result = await api(`/api/apk-versions?${query}`);
|
||||
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result);
|
||||
if (lookup === apkLookup && $("apkAltDlg").open) showApkAlternatives(apk.blocker, result, dev);
|
||||
} catch (e) {
|
||||
if (lookup === apkLookup) $("apkAltVersions").textContent = e.message;
|
||||
}
|
||||
}
|
||||
function showApkAlternatives(reason, result) {
|
||||
function showApkAlternatives(reason, result, dev = window.connDevice) {
|
||||
$("apkAltReason").textContent = reason;
|
||||
$("apkAltNote").textContent = `${result.versions.length} of ${result.total} compatible versions. ${result.note}`;
|
||||
$("apkAltErrors").textContent = result.errors.join(" · ");
|
||||
@@ -2550,9 +2704,10 @@ function showApkAlternatives(reason, result) {
|
||||
const v = result.versions[+b.dataset.version];
|
||||
if (installing.has(result.package)) return;
|
||||
b.disabled = true; b.textContent = "Installing…";
|
||||
// For the headset the APK was checked against: refused (409) if the app has switched since.
|
||||
const res = await runJob(`Install ${result.package} ${v.version}`, result.package, () => api("/api/android", {
|
||||
action: "install", package: result.package, url: v.url
|
||||
}));
|
||||
}, dev));
|
||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||
await loadAndroid();
|
||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||||
@@ -2579,7 +2734,12 @@ $("aboutClose").onclick = () => $("aboutDlg").close();
|
||||
|
||||
const TITLE_EXT = /\.(zip|exe)$/i;
|
||||
async function sendFiles(files, dirs = new Set()) {
|
||||
const dev = window.connDevice; // the whole batch is for the headset it was dropped on
|
||||
for (const [i, f] of files.entries()) {
|
||||
if (window.connDevice !== dev) {
|
||||
toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true);
|
||||
break;
|
||||
}
|
||||
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
|
||||
if (dirs.has(i)) {
|
||||
if (path) await sideload(f, path, true);
|
||||
@@ -2589,7 +2749,7 @@ async function sendFiles(files, dirs = new Set()) {
|
||||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev));
|
||||
if (apk && res && res.app) await offerTest(res.app);
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
@@ -2614,20 +2774,24 @@ function candLabel(c) {
|
||||
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
|
||||
// One at a time, so a second drop doesn't take over the dialog of the first.
|
||||
let sideloadQueue = Promise.resolve();
|
||||
function sideload(...args) {
|
||||
const run = sideloadQueue.then(() => sideloadOne(...args));
|
||||
function sideload(file, path, isDir = false) {
|
||||
const dev = window.connDevice; // for the headset it was dropped on, even if it waits its turn
|
||||
const run = sideloadQueue.then(() => {
|
||||
if (window.connDevice !== dev) return toast(`Switched headsets: didn't add ${file.name}`, true);
|
||||
return sideloadOne(file, path, isDir, dev);
|
||||
});
|
||||
sideloadQueue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
async function sideloadOne(file, path, isDir = false) {
|
||||
async function sideloadOne(file, path, isDir = false, dev = window.connDevice) {
|
||||
const canPush = !isDir && file.size > 0;
|
||||
log(`Reading ${file.name}…`);
|
||||
let r;
|
||||
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title"); }
|
||||
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title", dev); }
|
||||
catch (e) {
|
||||
log(`${file.name}: ${e.message}`, "e");
|
||||
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||||
return;
|
||||
}
|
||||
// Its own fresh list, so the dialog can say whether this replaces an installed title.
|
||||
@@ -2636,7 +2800,7 @@ async function sideloadOne(file, path, isDir = false) {
|
||||
const choice = await confirmTitle(r.plan, canPush, installed);
|
||||
if (choice === "push") {
|
||||
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
|
||||
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push", dev));
|
||||
return;
|
||||
}
|
||||
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
|
||||
@@ -2813,7 +2977,8 @@ function gameButtons(id, name, free) {
|
||||
const open = `<button class="small" data-gm="store" data-id="${id}" data-name="${esc(name)}" title="Open the store page in the headset">Store on Frame</button>`;
|
||||
if (d && d.appid === id) return `<span class="tag">${d.paused ? "Paused" : "Downloading"} ${d.percent ?? 0}%</span>`;
|
||||
if (g && g.installed) return `<span class="tag">Installed</span>` +
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>`;
|
||||
`<button class="play small" data-launch="${id}" data-name="${esc(name)}">▶ PLAY</button>` +
|
||||
`<button class="small" data-mod="${id}" data-name="${esc(name)}" title="Flat-to-VR mod for Unreal Engine games">VR mod</button>`;
|
||||
if (g) return `<button class="action small" data-gm="install" data-id="${id}" data-name="${esc(name)}">Install on Frame</button>`;
|
||||
return `<a href="${storeUrl}" target="_blank" rel="noopener" title="Opens in your browser">${free ? "Get" : "Buy"} on Steam ↗</a>${open}`;
|
||||
}
|
||||
@@ -2885,6 +3050,45 @@ document.body.addEventListener("click", async e => {
|
||||
});
|
||||
loadOwned();
|
||||
|
||||
// ---- VR mod (UEVR) for one installed game; frame_mods.py does the work ----
|
||||
const mod = { appid: null, name: "" };
|
||||
function showMod(st, note) {
|
||||
const busy = note === null;
|
||||
$("modTitle").textContent = `${mod.name} in VR`;
|
||||
$("modState").textContent = !st ? "Checking the game on the Frame…"
|
||||
: !st.eligible ? "This doesn't look like an Unreal Engine game, so UEVR can't run it."
|
||||
: !st.prefix ? "Play the game once first, so Steam sets up its Proton prefix."
|
||||
: st.injected ? "UEVR is running in the game now. Put on the headset."
|
||||
: st.installed ? `UEVR ${st.version} is installed for this game.${st.running ? " The game is running; Play in VR adds UEVR to it." : ""}`
|
||||
: `UEVR ${st.version} isn't installed for this game yet.`;
|
||||
$("modMsg").textContent = busy ? "Working… this can take a few minutes" : note || "";
|
||||
const ok = st && st.eligible && st.prefix;
|
||||
$("modInstall").hidden = !ok || st.installed; $("modStart").hidden = !ok || !st.installed || st.injected;
|
||||
$("modRemove").hidden = !st || !st.installed;
|
||||
for (const b of ["modInstall", "modStart", "modRemove"]) $(b).disabled = busy || (b === "modRemove" && st && st.running);
|
||||
}
|
||||
async function modAction(action, label) {
|
||||
const appid = mod.appid;
|
||||
showMod(mod.st, null);
|
||||
const res = await act(`${label} for ${mod.name}`, () => api("/api/mods", { action, appid }));
|
||||
if (appid !== mod.appid) return;
|
||||
try { mod.st = await api("/api/mods", { action: "status", appid }); } catch {}
|
||||
showMod(mod.st, res ? res.message : "Didn't work; the log has the reason");
|
||||
}
|
||||
document.body.addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-mod]"); if (!b) return;
|
||||
mod.appid = b.dataset.mod; mod.name = b.dataset.name; mod.st = null;
|
||||
showMod(null, "");
|
||||
$("modDlg").showModal();
|
||||
const appid = mod.appid;
|
||||
try { const st = await api("/api/mods", { action: "status", appid }); if (appid === mod.appid) { mod.st = st; showMod(st, ""); } }
|
||||
catch (err) { if (appid === mod.appid) $("modState").textContent = err.message; }
|
||||
});
|
||||
$("modInstall").onclick = () => modAction("install", "Install UEVR");
|
||||
$("modStart").onclick = () => modAction("start", "Start UEVR");
|
||||
$("modRemove").onclick = () => modAction("uninstall", "Remove UEVR");
|
||||
$("modClose").onclick = () => $("modDlg").close();
|
||||
|
||||
// ---- Discover apps ----
|
||||
const sourceState = { vr: null, installable: false, source: "", apps: [], sources: [], request: 0, detail: null, detailRequest: 0, jobs: new Map() };
|
||||
const storeName = s => String(s || "").replace(/ \(demo\)$/i, "");
|
||||
@@ -4031,6 +4235,476 @@ setView("headset");
|
||||
refresh().then(loadShots); // after status, so app names resolve
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script>
|
||||
// ---- the connection and the Devices tab (ui/frame_link.py, ui/frame_devices.py, docs/devices.md) ----
|
||||
// The server streams the connection's state (server-sent events, read with fetch so the
|
||||
// X-Frame-UI header goes along) every time it changes: which headset, this computer's
|
||||
// network, each stage of connecting, and what every address answered.
|
||||
const link = { s: null, offset: 0, live: false, phase: null, device: null, rev: -1, lastData: 0, reload: false };
|
||||
const dv = { list: [], sel: null, data: null, found: null, editing: null };
|
||||
const STAGE_ICON = { done: "✓", failed: "✕", pending: "", active: "", skipped: "–" };
|
||||
const KIND_TAG = { lan: "LAN", mdns: "mDNS", tailscale: "Tailscale", manual: "Other" };
|
||||
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||
const serverNow = () => Date.now() / 1000 - link.offset;
|
||||
function ago(t) {
|
||||
if (!t) return "never";
|
||||
const s = Math.max(0, serverNow() - t);
|
||||
return s < 60 ? "just now" : s < 3600 ? `${Math.round(s / 60)} min ago` : s < 86400 ? `${Math.round(s / 3600)} h ago`
|
||||
: `${Math.round(s / 86400)} d ago`;
|
||||
}
|
||||
function secs(a, b) { return a && b ? `${Math.max(0, b - a).toFixed(1)} s` : a ? `${Math.max(0, serverNow() - a).toFixed(0)} s` : ""; }
|
||||
const activeStage = s => (s.stages || []).find(x => x.state === "active") || (s.stages || []).filter(x => x.state === "failed").pop();
|
||||
function netLabel(s) {
|
||||
const n = s.network;
|
||||
if (s.via && s.via.kind === "tailscale") return "Tailscale";
|
||||
return n ? n.name : "Checking the network";
|
||||
}
|
||||
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
||||
|
||||
// The pill in the header: always there, and says what's happening right now.
|
||||
function renderPill() {
|
||||
const s = link.s;
|
||||
if (!link.live || !s) return false;
|
||||
const dev = s.device ? s.device.name : "Frame";
|
||||
let dot = "wait", line;
|
||||
if (s.phase === "connected") {
|
||||
dot = "on";
|
||||
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
|
||||
} else if (s.phase === "failed") {
|
||||
dot = "off";
|
||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
|
||||
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
|
||||
} else {
|
||||
const st = activeStage(s);
|
||||
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
|
||||
line = st ? st.label + (st.id === "find" && probing ? `: ${probing.host}` : st.detail && st.id !== "network" ? `: ${st.detail}` : "…")
|
||||
: "Connecting…";
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
|
||||
$("conn").title = `${dev}: ${line}. Click for details.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
|
||||
// The banner (when the headset can't be reached) repeats the reason and the countdown.
|
||||
function renderBanner() {
|
||||
const s = link.s;
|
||||
if (!link.live || !s) return;
|
||||
$("offDetails").hidden = false;
|
||||
if (s.phase === "failed" && s.error) {
|
||||
$("offline").hidden = false;
|
||||
$("offMsg").textContent = s.error.message;
|
||||
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
|
||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
|
||||
(left != null ? `Trying again in ${left} s.` : "");
|
||||
} else if (s.phase === "connecting" && online === false) {
|
||||
const st = activeStage(s);
|
||||
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
|
||||
}
|
||||
}
|
||||
window.connBanner = renderBanner;
|
||||
|
||||
function renderConnDlg() {
|
||||
const s = link.s;
|
||||
if (!s || !$("connDlg").open) return;
|
||||
$("connTitle").textContent = s.device ? `${s.device.name} (ssh ${s.device.alias})` : "Connection";
|
||||
$("connWhy").textContent = s.phase === "connected" ? `Connected via ${viaText(s.via)}${s.via && s.via.why ? " (" + s.via.why + ")" : ""}`
|
||||
: s.phase === "failed" ? (s.error ? s.error.message : "Not connected")
|
||||
: `${s.reason || "Connecting"}…`;
|
||||
const n = s.network || {}, ts = n.tailscale || {};
|
||||
const facts = [["Network", n.name || "—"], ["Wi-Fi", n.ssid || (n.wifi ? "Wi-Fi (name hidden by the system)" : n.wifi === false ? "Wired" : "—")],
|
||||
["Router", n.gateway ? `${n.gateway}${n.gateway_mac ? " · " + n.gateway_mac : ""}` : "No default route"],
|
||||
["This computer", n.local_ip || "—"],
|
||||
["Tailscale", ts.up ? `On${ts.ip ? " · " + ts.ip : ""}` : ts.installed ? "Installed, not running" : "Not installed"]];
|
||||
$("connNet").innerHTML = facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("");
|
||||
$("connStages").innerHTML = (s.stages || []).map(st => `<li class="${st.state}"><span class="ic">${STAGE_ICON[st.state] || ""}</span>
|
||||
<span class="lb">${esc(st.id === "login" && s.device && s.device.user ? "Logging in as " + s.device.user : st.label)}</span>
|
||||
<span class="tm">${esc(st.state === "pending" ? "" : secs(st.started, st.ended))}</span>
|
||||
${st.detail ? `<span class="dt">${esc(st.detail)}</span>` : ""}</li>`).join("") +
|
||||
(s.phase === "connected" ? `<li class="done"><span class="ic">✓</span><span class="lb">Connected</span><span class="tm"></span>
|
||||
<span class="dt">${esc(`via ${netLabel(s)}, ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? `, ${s.via.rtt_ms} ms` : ""))}</span></li>` : "");
|
||||
$("connProbes").innerHTML = (s.probes || []).map(p => `<tr><td>${esc(p.host)}${p.label ? `<div class="sub">${esc(p.label)}</div>` : ""}</td>
|
||||
<td><span class="tag">${esc(KIND_TAG[p.kind] || p.kind)}</span></td><td class="sub">${esc(p.why || "")}</td>
|
||||
<td class="res-${esc(p.state)}">${esc(p.detail)}${p.ip && p.ip !== p.host ? ` <span class="sub">${esc(p.ip)}</span>` : ""}</td></tr>`).join("")
|
||||
|| `<tr><td class="sub">No addresses tried yet.</td></tr>`;
|
||||
const left = s.phase === "failed" && s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
$("connRetry").textContent = left != null ? `Trying again in ${left} s` : s.finished && s.phase === "connected" ? `Connected ${ago(s.finished)}` : "";
|
||||
}
|
||||
|
||||
function onConnection(s) {
|
||||
const prev = link.s;
|
||||
link.s = s; link.live = !s.local; link.lastData = Date.now();
|
||||
link.offset = Date.now() / 1000 - s.now;
|
||||
if (!link.live) return;
|
||||
renderPill(); renderConnDlg();
|
||||
const devId = s.device && s.device.id;
|
||||
window.connDevice = devId || null;
|
||||
if (link.device !== null && devId !== link.device) {
|
||||
log(`Now using ${s.device.name}`, "ok");
|
||||
state = null;
|
||||
online = null;
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
refreshing = null; // and the next status check must be a new one
|
||||
if (live) toggleLive(false); // the other headset's video or captures
|
||||
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
|
||||
viewGen++; // a capture still on its way is the other headset's too
|
||||
lastImg = null; lastShot = null;
|
||||
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
|
||||
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
|
||||
$("saveBtn").disabled = true;
|
||||
// Lists and their buttons (Remove, Launch…) were the other headset's: clear them
|
||||
// before anyone clicks one, until the new headset's arrive.
|
||||
["games", "titleList", "andApps", "flatpaks", "shotGrid", "gmGrid"].forEach(id => {
|
||||
if ($(id)) $(id).innerHTML = `<div class="wait">Waiting for ${esc(s.device.name)}</div>`;
|
||||
});
|
||||
disp.list = []; disp.port = null;
|
||||
// The lists behind those panels too, so filters can't bring the old ones back.
|
||||
gm.owned = null; gm.byId = new Map(); gm.results = []; gm.store = []; gm.storeQ = null; gm.shown = 0; gm.seq++;
|
||||
androidApps = []; shots.list = [];
|
||||
titlesSeq++; disp.seq++; // answers to loads already on their way are ignored
|
||||
if (cat.apps) filterCatalog(); // "Installed" tags were the other headset's
|
||||
// Confirmations still open were checked against the other headset.
|
||||
["titleDlg", "apkAltDlg", "pwDlg", "repDlg"].forEach(id => { if ($(id) && $(id).open) $(id).close(); });
|
||||
if ($("wiDlg").open && !wi.job && !wi.starting) $("wiDlg").close();
|
||||
$("dispSel").innerHTML = "<option>Loading…</option>"; $("dispSel").disabled = true; $("dispCtl").hidden = true;
|
||||
link.reload = true; // everything on the page was the other headset's
|
||||
$("battChip").hidden = true;
|
||||
}
|
||||
if (s.phase !== link.phase || devId !== link.device) {
|
||||
if (s.phase === "connected") {
|
||||
log(`Connected to ${s.device.name} via ${viaText(s.via)} on ${netLabel(s)}`, "ok");
|
||||
$("offline").hidden = true;
|
||||
const reload = link.reload;
|
||||
link.reload = false;
|
||||
refresh().then(() => { if (reload) reloadAll(); });
|
||||
} else if (s.phase === "failed" && s.error) {
|
||||
setOnline(false, s.error.message);
|
||||
} else if (s.phase === "connecting" && prev && prev.phase === "connected") {
|
||||
log(`${s.reason || "Reconnecting"}; reconnecting…`);
|
||||
}
|
||||
}
|
||||
renderBanner();
|
||||
const netId = s.network ? s.network.id || s.network.gateway || "" : null;
|
||||
if (s.devices_rev !== link.rev || devId !== link.device || (netId !== null && netId !== link.net)) {
|
||||
link.rev = s.devices_rev; link.net = netId; // also when only the network changed: the Devices tab shows it
|
||||
loadDevices();
|
||||
}
|
||||
link.phase = s.phase; link.device = devId;
|
||||
if (page === "devices") renderTests();
|
||||
}
|
||||
|
||||
// Read the event stream; fall back to polling if it can't be streamed.
|
||||
async function watchConnection() {
|
||||
for (;;) {
|
||||
const ctl = new AbortController();
|
||||
const watchdog = setInterval(() => { if (Date.now() - link.lastData > 40000) ctl.abort(); }, 5000);
|
||||
try {
|
||||
link.lastData = Date.now();
|
||||
const r = await fetch("/api/connection/events", { headers: { "X-Frame-UI": UI_KEY }, signal: ctl.signal });
|
||||
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
|
||||
const reader = r.body.getReader(), dec = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
link.lastData = Date.now();
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let i;
|
||||
while ((i = buf.indexOf("\n\n")) >= 0) {
|
||||
const chunk = buf.slice(0, i); buf = buf.slice(i + 2);
|
||||
if (chunk.startsWith("data: ")) onConnection(JSON.parse(chunk.slice(6)));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try { onConnection(await api("/api/connection")); } catch {}
|
||||
} finally { clearInterval(watchdog); }
|
||||
if (link.s && link.s.local) return;
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
setInterval(() => { if (link.live && link.s && link.s.phase === "failed") { renderPill(); renderBanner(); renderConnDlg(); } }, 1000);
|
||||
|
||||
$("conn").onclick = () => {
|
||||
if (!link.live) { if (online === false) refresh(); return; }
|
||||
$("connDlg").showModal(); renderConnDlg();
|
||||
};
|
||||
$("offDetails").onclick = () => $("conn").click();
|
||||
$("connClose").onclick = () => $("connDlg").close();
|
||||
$("connDevices").onclick = () => { $("connDlg").close(); location.hash = "devices"; };
|
||||
async function retryNow() {
|
||||
try { await api("/api/devices", { action: "retry" }); } catch (e) { toast(e.message, true); }
|
||||
}
|
||||
$("connRetryBtn").onclick = retryNow;
|
||||
$("offRetry").onclick = () => { if (link.live) retryNow(); else refresh(); };
|
||||
async function setUpHeadset(alias, host) {
|
||||
return act(`Set Up Connection for ${alias}`, () => api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) }));
|
||||
}
|
||||
function setUpActive() {
|
||||
const s = link.s;
|
||||
if (HOST.mobile && window.frameApp && window.frameApp.setUpConnection) return window.frameApp.setUpConnection();
|
||||
setUpHeadset(s && s.device ? s.device.alias : "frame");
|
||||
}
|
||||
$("connSetup").onclick = setUpActive;
|
||||
|
||||
// ---- Devices tab ----
|
||||
PAGES.push("devices");
|
||||
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
|
||||
async function loadDevices() {
|
||||
if (!link.live) return;
|
||||
const seq = ++devicesSeq;
|
||||
let data;
|
||||
try { data = await api("/api/devices"); } catch (e) { if (seq === devicesSeq) failed($("devList"), e); return; }
|
||||
if (seq !== devicesSeq) return;
|
||||
dv.data = data;
|
||||
dv.list = dv.data.devices;
|
||||
if (!dv.list.some(d => d.id === dv.sel)) dv.sel = dv.data.active;
|
||||
const sel = $("devSel");
|
||||
sel.hidden = dv.list.length < 2;
|
||||
sel.innerHTML = dv.list.map(d => `<option value="${esc(d.id)}"${d.active ? " selected" : ""}>${esc(d.name)}</option>`).join("");
|
||||
if (window.frameApp && window.frameApp.devicesChanged) {
|
||||
window.frameApp.devicesChanged(dv.list.map(d => ({ id: d.id, name: d.name, alias: d.alias, active: !!d.active })));
|
||||
}
|
||||
renderDevices();
|
||||
}
|
||||
$("devSel").onchange = e => useDevice(e.target.value);
|
||||
async function useDevice(id) {
|
||||
const d = dv.list.find(x => x.id === id);
|
||||
if (!d || d.active) return;
|
||||
const res = await act(`Switch to ${d.name}`, () => api("/api/devices", { action: "use", id }));
|
||||
if (!res) { // refused (an install is running): show the headset still in use
|
||||
const current = dv.list.find(x => x.active);
|
||||
if (current) $("devSel").value = current.id;
|
||||
}
|
||||
}
|
||||
async function devAction(label, body, btn) {
|
||||
const res = await act(label, () => api("/api/devices", body), btn);
|
||||
if (res) loadDevices(); // in order with every other load, so a late answer can't undo a newer one
|
||||
return res;
|
||||
}
|
||||
|
||||
function deviceStatus(d) {
|
||||
const s = link.s;
|
||||
if (d.active && s) {
|
||||
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
|
||||
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
|
||||
}
|
||||
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
||||
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
|
||||
}
|
||||
|
||||
function renderDevices() {
|
||||
if (!dv.data) return;
|
||||
$("devCount").textContent = dv.list.length;
|
||||
$("devList").innerHTML = dv.list.map(d => {
|
||||
const [dot, text] = deviceStatus(d);
|
||||
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
||||
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
||||
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
||||
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
||||
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
||||
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
||||
if (e.target.closest("[data-use]")) return useDevice(e.target.closest("[data-use]").dataset.use);
|
||||
dv.sel = el.dataset.dev; dv.found = null; dv.editing = null; renderDevices();
|
||||
});
|
||||
const n = dv.data.network;
|
||||
$("netNow").textContent = n ? `${n.name}${n.gateway && !n.name.includes(n.gateway) ? ` (router ${n.gateway})` : ""}${n.tailscale && n.tailscale.up ? " · Tailscale on" : ""}` : "Checking…";
|
||||
if (document.activeElement !== $("netName")) {
|
||||
const known = n && dv.data.networks.find(x => x.id === n.id);
|
||||
$("netName").value = known ? known.name || "" : "";
|
||||
$("netName").disabled = $("netSave").disabled = !(n && n.id);
|
||||
}
|
||||
renderDetail();
|
||||
}
|
||||
$("netSave").onclick = e => {
|
||||
const n = dv.data && dv.data.network;
|
||||
if (n && n.id) devAction("Name this network", { action: "name-network", network: n.id, name: $("netName").value }, e.currentTarget);
|
||||
};
|
||||
|
||||
function renderDetail() {
|
||||
const d = dv.list.find(x => x.id === dv.sel), el = $("devDetail");
|
||||
if (!d) { el.innerHTML = `<div class="sub">Pick a headset.</div>`; return; }
|
||||
if (el.contains(document.activeElement) && document.activeElement.matches("input, select") && el.dataset.dev === d.id) {
|
||||
renderTests(); return; // don't rebuild the form under someone typing
|
||||
}
|
||||
el.dataset.dev = d.id;
|
||||
if (d.transient) {
|
||||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2></div>
|
||||
<div class="sub">This is the <code>${esc(d.alias)}</code> SSH alias, which Set Up Connection hasn't configured, so
|
||||
ssh's own settings decide where it goes. Run Set Up Connection to manage its addresses here.</div>
|
||||
<div class="row" style="margin-top:14px"><button class="action small" id="dvSetup">Set Up Connection…</button></div>`;
|
||||
$("dvSetup").onclick = () => setUpHeadset(d.alias);
|
||||
return;
|
||||
}
|
||||
const kinds = Object.entries(dv.data.kinds);
|
||||
const kindSel = (id, v) => `<select id="${id}">${kinds.map(([k, label]) => `<option value="${k}"${k === v ? " selected" : ""}>${esc(label)}</option>`).join("")}</select>`;
|
||||
const rows = d.addresses.map((a, i) => dv.editing === a.host ? `<div class="item addr"><div class="addr-edit">
|
||||
<input type="text" id="edHost" value="${esc(a.host)}" maxlength="253" aria-label="Address">${kindSel("edKind", a.kind)}
|
||||
<input type="text" id="edLabel" value="${esc(a.label)}" maxlength="60" placeholder="Label" aria-label="Label">
|
||||
<button class="small action" id="edSave">Save</button><button class="small" id="edCancel">Cancel</button></div></div>`
|
||||
: `<div class="item addr" data-host="${esc(a.host)}">
|
||||
<div class="grow"><div class="t">${esc(a.host)} <span class="tag">${esc(KIND_TAG[a.kind] || a.kind)}</span>${a.label ? ` <span class="sub">${esc(a.label)}</span>` : ""}</div>
|
||||
<div class="s">${a.network_names.length ? "Worked on " + esc(a.network_names.join(", ")) : "Hasn't worked on any network yet"}
|
||||
· last OK ${esc(ago(a.last_ok))}${a.last_rtt_ms != null ? ` (${esc(a.last_rtt_ms)} ms)` : ""}</div>
|
||||
<div class="s test" data-test="${esc(a.host)}"></div></div>
|
||||
<button class="small" data-mv="-1" title="Try earlier"${i ? "" : " disabled"}>↑</button>
|
||||
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
||||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
||||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
||||
${d.active ? "" : `<button class="small action" id="dvUse">Use this headset</button>`}</div>
|
||||
<form class="dev-form" id="dvForm">
|
||||
<label>Name<input type="text" id="dvName" value="${esc(d.name)}" maxlength="60"></label>
|
||||
<label>SSH alias<input type="text" value="${esc(d.alias)}" readonly title="Terminal: ssh ${esc(d.alias)}"></label>
|
||||
<label>User<input type="text" id="dvUser" value="${esc(d.user)}" maxlength="64"></label>
|
||||
<label>Port<input type="text" id="dvPort" value="${esc(d.port)}" maxlength="5" inputmode="numeric"></label>
|
||||
</form>
|
||||
<div class="row" style="margin-top:10px"><button class="small" id="dvSave">Save</button>
|
||||
<span class="sub">User and port are written to its block in <code>~/.ssh/config</code> too.</span></div>
|
||||
<h3>Addresses <span class="sub" style="text-transform:none; letter-spacing:0; font-weight:400">tried all at once; this order breaks ties</span></h3>
|
||||
<div class="list" id="dvAddrs">${rows || '<div class="sub">No addresses yet: add one, or find it below.</div>'}</div>
|
||||
<form class="addr-add" id="dvAdd">
|
||||
<input type="text" id="adHost" maxlength="253" placeholder="IP address or host name" aria-label="New address">
|
||||
${kindSel("adKind", "")}
|
||||
<input type="text" id="adLabel" maxlength="60" placeholder="Label, e.g. Office" aria-label="Label">
|
||||
<button class="small" type="submit">Add</button></form>
|
||||
<div class="row" style="margin-top:14px">
|
||||
<button class="small action" id="dvTest"${d.addresses.length ? "" : " disabled"}>Test now</button>
|
||||
<button class="small" id="dvTs">Find on Tailscale</button>
|
||||
<button class="small" id="dvMdns">Find on this network</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="small" id="dvForget" title="After reinstalling SteamOS the headset shows a new identity">Forget identity</button>
|
||||
<button class="small danger" id="dvRemove">Remove…</button></div>
|
||||
<div class="found" id="dvFound"></div>
|
||||
<div class="hint">Identity: ${d.pinned ? "saved. A different device answering at one of these addresses is refused."
|
||||
: "not saved yet; the next connection saves it."} Kind "auto" is picked from the address:
|
||||
<code>.local</code> names are mDNS, <code>100.x</code> and <code>.ts.net</code> are Tailscale.</div>`;
|
||||
$("adKind").insertAdjacentHTML("afterbegin", `<option value="" selected>Kind: auto</option>`);
|
||||
$("adKind").value = "";
|
||||
if ($("dvUse")) $("dvUse").onclick = () => useDevice(d.id);
|
||||
$("dvSave").onclick = e => devAction(`Save ${d.name}`, { action: "update", id: d.id, name: $("dvName").value,
|
||||
user: $("dvUser").value, port: $("dvPort").value }, e.currentTarget);
|
||||
$("dvAdd").onsubmit = e => {
|
||||
e.preventDefault();
|
||||
const host = $("adHost").value.trim();
|
||||
if (!host) return $("adHost").focus();
|
||||
devAction(`Add ${host}`, { action: "address-add", id: d.id, host, kind: $("adKind").value, label: $("adLabel").value });
|
||||
};
|
||||
el.querySelectorAll("[data-host]").forEach(row => {
|
||||
const host = row.dataset.host;
|
||||
row.querySelectorAll("[data-mv]").forEach(b => b.onclick = () =>
|
||||
devAction("Move " + host, { action: "address-move", id: d.id, host, delta: +b.dataset.mv }, b));
|
||||
row.querySelector("[data-ed]").onclick = () => { dv.editing = host; el.dataset.dev = ""; renderDetail(); $("edHost").focus(); };
|
||||
row.querySelector("[data-rm]").onclick = e => devAction(`Remove ${host}`, { action: "address-remove", id: d.id, host }, e.currentTarget);
|
||||
});
|
||||
if ($("edSave")) {
|
||||
const host = dv.editing;
|
||||
$("edSave").onclick = async e => {
|
||||
dv.editing = null;
|
||||
await devAction(`Save ${host}`, { action: "address-update", id: d.id, host, newHost: $("edHost").value.trim(),
|
||||
kind: $("edKind").value, label: $("edLabel").value }, e.currentTarget);
|
||||
};
|
||||
$("edCancel").onclick = () => { dv.editing = null; el.dataset.dev = ""; renderDetail(); };
|
||||
}
|
||||
$("dvTest").onclick = e => devAction(`Test ${d.name}'s addresses`, { action: "test", id: d.id }, e.currentTarget);
|
||||
$("dvTs").onclick = e => findOn("tailscale", d, e.currentTarget);
|
||||
$("dvMdns").onclick = e => findOn("mdns", d, e.currentTarget);
|
||||
$("dvForget").onclick = e => devAction(`Forget ${d.name}'s identity`, { action: "forget-identity", id: d.id }, e.currentTarget);
|
||||
$("dvRemove").onclick = () => askRemove(d);
|
||||
renderFound(d);
|
||||
renderTests();
|
||||
}
|
||||
|
||||
function renderTests() {
|
||||
const d = dv.list.find(x => x.id === dv.sel), t = d && link.s && (link.s.tests || {})[d.id];
|
||||
document.querySelectorAll("#dvAddrs [data-test]").forEach(el => {
|
||||
const row = t && t.rows.find(r => r.host === el.dataset.test);
|
||||
el.className = "s test" + (row ? ` res-${row.ssh || row.state}` : "");
|
||||
el.textContent = row ? `Test: ${row.detail}${row.ssh === "checking" ? " · checking SSH…" : ""}` : "";
|
||||
});
|
||||
}
|
||||
|
||||
async function findOn(where, d, btn) {
|
||||
btn.disabled = true;
|
||||
$("dvFound").innerHTML = `<div class="wait">${where === "tailscale" ? "Asking Tailscale for its devices…" : "Looking for headsets on this network…"}</div>`;
|
||||
try {
|
||||
dv.found = { where, dev: d.id, data: await api(`/api/devices/${where}?id=${encodeURIComponent(d.id)}`) };
|
||||
} catch (e) { dv.found = { where, dev: d.id, error: e.message }; }
|
||||
finally { btn.disabled = false; }
|
||||
if (dv.sel === d.id) renderFound(d); // another headset may be showing by now
|
||||
}
|
||||
function renderFound(d) {
|
||||
const f = dv.found, el = $("dvFound");
|
||||
if (!f || f.dev !== d.id) { el.innerHTML = ""; return; }
|
||||
if (f.error) { el.innerHTML = `<div class="sub">${esc(f.error)}</div>`; return; }
|
||||
const add = (host, kind, label, done) => done ? `<span class="sub">Added</span>`
|
||||
: `<button class="small" data-add="${esc(host)}" data-kind="${kind}" data-label="${esc(label)}">Add ${esc(host)}</button>`;
|
||||
let rows;
|
||||
if (f.where === "tailscale") {
|
||||
if (!f.data.up) { el.innerHTML = `<div class="sub">${esc(f.data.message)}</div>`; return; }
|
||||
rows = f.data.peers.slice(0, 20).map(p => `<div class="item"><span class="dot ${p.online ? "on" : ""}"></span>
|
||||
<div class="grow"><div class="t">${esc(p.name)} ${p.likely ? '<span class="tag">Likely</span>' : ""}</div>
|
||||
<div class="s">${esc(p.dns)} · ${esc(p.ip || "")} · ${esc(p.os || "?")} · ${p.online ? "online" : "offline"}</div></div>
|
||||
${p.added ? '<span class="sub">Added</span>' : add(p.dns || p.ip, "tailscale", "Tailscale", false) + (p.ip && p.dns ? add(p.ip, "tailscale", "Tailscale IP", false) : "")}</div>`);
|
||||
} else {
|
||||
rows = f.data.hosts.map(h => `<div class="item"><span class="dot ${h.state === "answered" ? "on" : ""}"></span>
|
||||
<div class="grow"><div class="t">${esc(h.host)} ${h.advertised ? '<span class="tag">SteamOS devkit</span>' : ""}</div>
|
||||
<div class="s res-${esc(h.state)}">${esc(h.detail)}${h.ip ? " · " + esc(h.ip) : ""}</div></div>
|
||||
${add(h.host, "mdns", "mDNS", h.added)}${h.ip && !h.ip.includes(":") && !h.added ? add(h.ip, "lan", "", false) : ""}</div>`);
|
||||
if (!f.data.tool) rows.push(`<div class="sub">Install Bonjour (Windows) or avahi (Linux) to search for SteamOS devkit services.</div>`);
|
||||
}
|
||||
el.innerHTML = `<h3>${f.where === "tailscale" ? "On your tailnet" : "On this network"}</h3>` +
|
||||
(rows.length ? `<div class="list">${rows.join("")}</div>` : `<div class="sub">Nothing found.</div>`);
|
||||
el.querySelectorAll("[data-add]").forEach(b => b.onclick = async () => {
|
||||
const res = await devAction(`Add ${b.dataset.add}`, { action: "address-add", id: d.id, host: b.dataset.add,
|
||||
kind: b.dataset.kind, label: b.dataset.label }, b);
|
||||
if (res) { b.replaceWith(Object.assign(document.createElement("span"), { className: "sub", textContent: "Added" })); }
|
||||
});
|
||||
}
|
||||
|
||||
function askRemove(d) {
|
||||
$("rmDevTitle").textContent = `Remove ${d.name}?`;
|
||||
$("rmDevText").textContent = `Frame Control forgets this headset, its ${d.addresses.length} address${d.addresses.length === 1 ? "" : "es"} and its saved identity. Its SSH keys stay.`;
|
||||
$("rmDevConfigText").textContent = `Also remove the '${d.alias}' entry from ~/.ssh/config (Terminal's ssh ${d.alias} stops working)`;
|
||||
// The only headset: its entry has to go too, or the app would go on using that alias.
|
||||
$("rmDevConfig").checked = dv.list.filter(x => !x.transient).length === 1; $("rmMsg").textContent = "";
|
||||
$("rmDevConfig").parentElement.hidden = !d.managed;
|
||||
$("rmCancel").onclick = () => $("rmDevDlg").close();
|
||||
$("rmDevForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const res = await devAction(`Remove ${d.name}`, { action: "remove", id: d.id, config: $("rmDevConfig").checked }, $("rmGo"));
|
||||
if (res) $("rmDevDlg").close();
|
||||
};
|
||||
$("rmDevDlg").showModal();
|
||||
}
|
||||
$("devAdd").onclick = () => {
|
||||
$("addAlias").value = (dv.data && dv.data.nextAlias) || "frame-2";
|
||||
$("addHost").value = ""; $("addMsg").textContent = "";
|
||||
$("addCancel").onclick = () => $("addDevDlg").close();
|
||||
$("addDevDlg").showModal();
|
||||
$("addAlias").select();
|
||||
};
|
||||
$("addDevForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const alias = $("addAlias").value.trim(), host = $("addHost").value.trim();
|
||||
$("addGo").disabled = true;
|
||||
try {
|
||||
const res = await api("/api/devices", { action: "setup", alias, ...(host ? { host } : {}) });
|
||||
log(res.message, "ok"); toast(res.message);
|
||||
$("addDevDlg").close();
|
||||
} catch (err) { $("addMsg").textContent = err.message; }
|
||||
finally { $("addGo").disabled = false; }
|
||||
};
|
||||
// Electron's Frame menu can switch headsets and open this tab.
|
||||
if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(id => useDevice(id));
|
||||
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
|
||||
showPage(); // #devices is a page now
|
||||
api("/api/host").then(h => {
|
||||
if (h.mobile) return;
|
||||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||||
watchConnection();
|
||||
}).catch(() => watchConnection());
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+246
-58
@@ -3,7 +3,8 @@
|
||||
|
||||
Stdlib only; runs on macOS, Linux and Windows (differences live in frame_host.py).
|
||||
Listens on 127.0.0.1 and talks to the headset through the `frame` SSH alias set
|
||||
up by scripts/connect.sh or ui/frame_connect.py.
|
||||
up by scripts/connect.sh or ui/frame_connect.py, or another headset picked on the
|
||||
Devices tab: frame_link.py finds it at one of its addresses (frame_devices.py).
|
||||
|
||||
Usage: ui/server.py [--port 47810] [--exit-on-eof] (normally started by the app)
|
||||
Env: FRAME_ALIAS (default frame)
|
||||
@@ -14,6 +15,7 @@ Env: FRAME_ALIAS (default frame)
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
@@ -45,9 +47,11 @@ import frame_android # noqa: E402
|
||||
from apk_sources import search as apk_search, SourceError # noqa: E402
|
||||
import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_steamgriddb
|
||||
import frame_comfort # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
@@ -74,18 +78,92 @@ DEVICE = os.environ.get("FRAME_DEVICE") or "phone"
|
||||
# What the Frame's KDE Connect calls this device (keyboard and trackpad).
|
||||
INPUT_NAME = DEVICE if LOCAL else socket.gethostname().split(".")[0]
|
||||
FRAME = os.environ.get("FRAME_ALIAS", "frame")
|
||||
FRAME_FROM_ENV = "FRAME_ALIAS" in os.environ
|
||||
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", FRAME):
|
||||
sys.exit(f"FRAME_ALIAS must be a plain host alias, not {FRAME!r}")
|
||||
# Reuse one SSH connection for the frequent status/screenshot calls, where ssh
|
||||
# supports it (not on Windows: there every command connects on its own).
|
||||
CONTROL = None if LOCAL else frame_host.control_path(private=os.environ.get("FRAME_PRIVATE_SSH") == "1")
|
||||
MUX = ["ssh", "-o", "BatchMode=yes", *(["-o", f"ControlPath={CONTROL}"] if CONTROL else [])]
|
||||
# A private server (the MCP adapter starts one per session) keeps its own SSH masters, and
|
||||
# uses the headsets without editing them.
|
||||
PRIVATE = os.environ.get("FRAME_PRIVATE_SSH") == "1"
|
||||
CONTROL = None if LOCAL else frame_host.control_path(private=PRIVATE)
|
||||
# The ControlPath itself is per headset: the connector puts it in HOST_OPTS.
|
||||
MUX = ["ssh", "-o", "BatchMode=yes"]
|
||||
MUX_BASE = list(MUX)
|
||||
# Commands use the master when it's up and connect directly when it isn't.
|
||||
SSH = [*MUX, *(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
SSH_TAIL = [*(["-o", "ControlMaster=no"] if CONTROL else []), "-o", "ConnectTimeout=5"]
|
||||
SSH = [*MUX, *SSH_TAIL]
|
||||
# The address the connector picked (-o HostName=... and friends), in every ssh command.
|
||||
HOST_OPTS = []
|
||||
|
||||
# Android helpers share the multiplexed connection when it's up.
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
|
||||
|
||||
_route_lock = threading.Lock()
|
||||
|
||||
|
||||
def route(alias, host_opts):
|
||||
"""Point every ssh, scp and rsync at `alias` with `host_opts` (frame_link calls this
|
||||
when it picks a headset and an address). The lists change in place, so code holding
|
||||
them follows; frame_titles reads frame_android.SSH_OPTS at call time."""
|
||||
global FRAME, HOST_OPTS
|
||||
with _route_lock:
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
FRAME = frame_android.FRAME = alias
|
||||
HOST_OPTS = list(host_opts)
|
||||
MUX[:] = [*MUX_BASE, *HOST_OPTS]
|
||||
SSH[:] = [*MUX, *SSH_TAIL]
|
||||
frame_android.SSH_OPTS = SSH[1:]
|
||||
# Long-lived ssh processes started on the old route (outside the lock: they take their own).
|
||||
mv = globals().get("macview")
|
||||
if mv:
|
||||
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
|
||||
agent = globals().get("_input")
|
||||
if moved and agent:
|
||||
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
|
||||
|
||||
|
||||
LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
|
||||
# Installs and other changes in progress. Switching headsets waits for them: they
|
||||
# read the ssh settings step by step, so a switch could send the rest (or a failed
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def working(meant=None):
|
||||
"""Counts as running work. `meant`: the headset the page made this change for; if the
|
||||
app has switched away from it, refuse (checked together with counting, so a switch
|
||||
can't slip in between)."""
|
||||
with _work_lock:
|
||||
if LINK and meant and meant != LINK.active_device()["id"]:
|
||||
raise Failure("Frame Control switched headsets; try again on this one", 409)
|
||||
_work[0] += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
with _work_lock:
|
||||
_work[0] -= 1
|
||||
|
||||
|
||||
def busy_thread(fn, *args):
|
||||
"""A thread that counts as work from before it starts until it ends."""
|
||||
with _work_lock:
|
||||
_work[0] += 1
|
||||
|
||||
def run():
|
||||
try:
|
||||
fn(*args)
|
||||
finally:
|
||||
with _work_lock:
|
||||
_work[0] -= 1
|
||||
return threading.Thread(target=run, daemon=True)
|
||||
|
||||
APPID = re.compile(r"^\d{1,10}$")
|
||||
FLATPAK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+){2,}$")
|
||||
MAX_UPLOAD = 8 * 1024**3
|
||||
@@ -228,7 +306,7 @@ def start_job(label, work, progress=False):
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
threading.Thread(target=run, daemon=True).start()
|
||||
busy_thread(run).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
@@ -241,42 +319,14 @@ def job_status(query):
|
||||
return snapshot
|
||||
|
||||
|
||||
_master_lock = threading.Lock()
|
||||
_master = None
|
||||
|
||||
|
||||
def ensure_master():
|
||||
"""Start the shared SSH connection if it isn't up (one attempt at a time).
|
||||
|
||||
No ConnectTimeout here: with it, OpenSSH's master takes ~5s to open its socket.
|
||||
"""
|
||||
global _master
|
||||
if not CONTROL:
|
||||
return
|
||||
|
||||
def up():
|
||||
try:
|
||||
return subprocess.run([*MUX, "-O", "check", FRAME], capture_output=True, stdin=subprocess.DEVNULL,
|
||||
timeout=5).returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
|
||||
with _master_lock:
|
||||
if up() or (_master and _master.poll() is None):
|
||||
return
|
||||
# Keepalives make a dead link (Frame asleep, off Wi-Fi) exit within ~10s,
|
||||
# so the next request starts a fresh master.
|
||||
_master = subprocess.Popen([*MUX, "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
|
||||
"-o", "ServerAliveCountMax=2", "-N", FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL, **frame_host.DETACHED)
|
||||
for _ in range(60):
|
||||
if up() or _master.poll() is not None:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
"""Make sure the connection to the headset is up, or being tried (frame_link.Link.ensure)."""
|
||||
if LINK:
|
||||
LINK.ensure()
|
||||
|
||||
|
||||
def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
route_gen = LINK.gen if LINK else None # which headset this command is for
|
||||
try:
|
||||
ensure_master()
|
||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
||||
@@ -288,6 +338,8 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
||||
raise Failure(f"Timed out talking to {FRAME}")
|
||||
if r.returncode != 0:
|
||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
||||
if r.returncode == 255 and LINK and unreachable(err):
|
||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||
failure.stdout = r.stdout if text else r.stdout.decode(errors="replace")
|
||||
raise failure
|
||||
@@ -350,6 +402,12 @@ print(r.stdout, end='')
|
||||
|
||||
def headset_view():
|
||||
"""Both eyes as SteamVR composites them (see frame_vrshot.py); PNG bytes."""
|
||||
# Counts as work: the copy and clean-up must reach the headset that took the capture.
|
||||
with working():
|
||||
return _headset_view()
|
||||
|
||||
|
||||
def _headset_view():
|
||||
# `timeout`: VR_Init can block if SteamVR is restarting.
|
||||
out = ssh("timeout 15 python3 -", stdin=(HERE / "frame_vrshot.py").read_text(), timeout=30)
|
||||
# SteamVR prints its own notices (e.g. about vrwebhelper) on stdout too, so
|
||||
@@ -514,13 +572,14 @@ def launch(body):
|
||||
return {"message": f"Launching {appid}"}
|
||||
|
||||
|
||||
def steam_frame(*args, timeout=40):
|
||||
"""Run frame_steam.py on the Frame (it drives the Steam client) and return its JSON."""
|
||||
def steam_frame(*args, timeout=40, script="frame_steam.py"):
|
||||
"""Run frame_steam.py (it drives the Steam client), or another on-Frame
|
||||
helper with the same JSON contract, on the Frame and return its JSON."""
|
||||
try:
|
||||
out = ssh("python3 - " + " ".join(map(shlex.quote, args)),
|
||||
stdin=(HERE / "frame_steam.py").read_text(), timeout=timeout)
|
||||
stdin=(HERE / script).read_text(), timeout=timeout)
|
||||
except Failure as e:
|
||||
# frame_steam.py prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# The helper prints {"error": ...} on stdout when it fails, but ssh()
|
||||
# reports stderr instead if there was any, so look in both.
|
||||
for line in [*reversed(getattr(e, "stdout", "").splitlines()), *reversed(str(e).splitlines())]:
|
||||
try:
|
||||
@@ -571,6 +630,20 @@ def vr(body):
|
||||
return result
|
||||
|
||||
|
||||
# Downloads are about 20 MB; starting waits for the game, the injection and SteamVR.
|
||||
MOD_TIMEOUT = {"status": 40, "install": 600, "start": 900, "uninstall": 60}
|
||||
|
||||
|
||||
def mods(body):
|
||||
"""UEVR for one installed Unreal game: status, install, start (inject) or uninstall."""
|
||||
appid, action = str(body.get("appid", "")), body.get("action")
|
||||
if not APPID.match(appid):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in MOD_TIMEOUT:
|
||||
raise Failure("action must be " + ", ".join(MOD_TIMEOUT), 400)
|
||||
return steam_frame(action, appid, timeout=MOD_TIMEOUT[action], script="frame_mods.py")
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
q = parse_qs(query)
|
||||
cc = (q.get("cc") or [""])[0].upper()
|
||||
@@ -1111,19 +1184,25 @@ def open_thing(body):
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
return power(what, body.get("password"))
|
||||
raise Failure("open that from the app", 400)
|
||||
# The headset and address in use, as every other command gets them (one snapshot).
|
||||
with _route_lock:
|
||||
alias, opts = LINK.named_route() if LINK else (FRAME, list(HOST_OPTS))
|
||||
host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), None)
|
||||
if what in ("terminal", "reboot", "poweroff", "suspend", "rdp", "sftp") and host and host.endswith(".invalid"):
|
||||
raise Failure("No headset address to use: add one on the Devices tab", 400)
|
||||
try:
|
||||
if what == "terminal":
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', FRAME])}"}
|
||||
return {"message": f"Opened an SSH session in {terminal(['ssh', *opts, alias])}"}
|
||||
if what in ("reboot", "poweroff", "suspend"):
|
||||
# logind answers "challenge" over SSH, so sudo (and the password) is needed.
|
||||
where = terminal(["ssh", "-t", FRAME, "sudo", "systemctl", what])
|
||||
where = terminal(["ssh", "-t", *opts, alias, "sudo", "systemctl", what])
|
||||
return {"message": f"Confirm with the Developer Mode password in {where} to {what}"}
|
||||
if what == "steamlink":
|
||||
return {"message": frame_host.open_steam_link()}
|
||||
if what == "rdp":
|
||||
return {"message": frame_host.open_rdp(FRAME)}
|
||||
return {"message": frame_host.open_rdp(alias, host)}
|
||||
if what == "sftp":
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', FRAME])}"}
|
||||
return {"message": f"Opened an SFTP session in {terminal(['sftp', *opts, alias])}"}
|
||||
if what == "shots":
|
||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
frame_host.open_path(SHOTS_DIR)
|
||||
@@ -1257,7 +1336,8 @@ def stage_title(path, temp_dir=None, name=None):
|
||||
raise
|
||||
token = secrets.token_hex(12)
|
||||
with _titles_lock:
|
||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
|
||||
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time(),
|
||||
"device": LINK.active_device()["id"] if LINK else None}
|
||||
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
|
||||
"token": token, "plan": frame_titles.public(plan)}
|
||||
|
||||
@@ -1302,13 +1382,15 @@ def titles(body):
|
||||
if action == "discard":
|
||||
_drop_staged(entry)
|
||||
return {"message": "Discarded"}
|
||||
if LINK and entry.get("device") != LINK.active_device()["id"]:
|
||||
_drop_staged(entry) # it was checked against the other headset's titles
|
||||
raise Failure("Frame Control switched headsets since this was read; drop the file again", 409)
|
||||
with _titles_lock:
|
||||
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
|
||||
"message": None, "title": None, "time": time.time()}
|
||||
ensure_master()
|
||||
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
|
||||
threading.Thread(target=_run_title_install, daemon=True,
|
||||
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
|
||||
busy_thread(_run_title_install, token, entry, opt("name"), opt("exe"), opt("runtime")).start()
|
||||
return {"message": f"Installing {entry['plan']['source']}", "job": token}
|
||||
if action not in ("launch", "remove", "refresh-art"):
|
||||
raise Failure("unknown action", 400)
|
||||
@@ -1421,7 +1503,7 @@ class AdbTunnel:
|
||||
fwd = [a for p, lp in self.local.items() for a in ("-L", f"127.0.0.1:{lp}:127.0.0.1:{p}")]
|
||||
# Its own connection (ControlPath=none), so killing it drops the forwards.
|
||||
self.proc = _proc = subprocess.Popen(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none",
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "ControlPath=none", *HOST_OPTS,
|
||||
"-o", "ExitOnForwardFailure=yes", "-o", "ServerAliveInterval=5", "-N", *fwd, FRAME],
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
_live_tunnels.add(_proc)
|
||||
@@ -1696,7 +1778,7 @@ def webinstall_start(body):
|
||||
_web_jobs.clear()
|
||||
_web_jobs[pid] = job
|
||||
# Started under the lock, so shutdown never sees a thread it can't join.
|
||||
worker = threading.Thread(target=_webinstall_run, args=(plan, job), daemon=True)
|
||||
worker = busy_thread(_webinstall_run, plan, job)
|
||||
_web_workers.add(worker)
|
||||
worker.start()
|
||||
return {"job": pid}
|
||||
@@ -2079,12 +2161,60 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/mods": mods, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action}
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
||||
"/api/devices": lambda body: devices_post(body)}
|
||||
|
||||
|
||||
|
||||
|
||||
# ---- headsets and the connection (frame_devices.py, frame_link.py) ----------
|
||||
|
||||
def open_setup(alias, host=None):
|
||||
"""Set Up Connection for `alias` in a terminal window, as the app's menu does."""
|
||||
if frame_host.MAC:
|
||||
argv = ["env", f"FRAME_ALIAS={alias}", "zsh", str(HERE.parent / "scripts" / "connect.sh")]
|
||||
else:
|
||||
argv = [sys.executable, str(HERE / "frame_connect.py"), "--alias", alias]
|
||||
return terminal(argv + ([host] if host else []))
|
||||
|
||||
|
||||
def devices_post(body):
|
||||
if not LINK:
|
||||
raise Failure("Headsets are managed from the computer app", 400)
|
||||
if PRIVATE:
|
||||
raise Failure("Headsets are managed in the Frame Control app", 403)
|
||||
try:
|
||||
# Under the work lock: nothing can start on the old headset while it switches.
|
||||
with _work_lock:
|
||||
return frame_link.devices_action(LINK, body, open_setup, lambda: _work[0])
|
||||
except frame_devices.DeviceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
def devices_get(path, query):
|
||||
if not LINK:
|
||||
raise Failure("Headsets are managed from the computer app", 400)
|
||||
q = parse_qs(query)
|
||||
device = (q.get("id") or [None])[0]
|
||||
try:
|
||||
if path == "/api/devices":
|
||||
return dict(frame_link.devices_view(LINK), nextAlias=frame_link.next_alias(LINK))
|
||||
if path == "/api/devices/tailscale":
|
||||
return frame_link.tailscale_find(LINK, device)
|
||||
return frame_link.mdns_find(LINK, device)
|
||||
except frame_devices.DeviceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
|
||||
|
||||
def connection_state():
|
||||
if not LINK: # on the Frame itself there's nothing to find
|
||||
return {"local": True, "phase": "connected", "version": 0}
|
||||
return LINK.snapshot()
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
@@ -2205,6 +2335,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"os": "SteamOS", "fileManager": None, "computer": DEVICE, "mobile": True} if LOCAL else
|
||||
{"os": frame_host.NAME, "fileManager": frame_host.FILE_MANAGER,
|
||||
"computer": "Mac" if frame_host.MAC else "PC"})
|
||||
elif path == "/api/connection":
|
||||
self.send_json(connection_state())
|
||||
elif path == "/api/connection/events":
|
||||
self.connection_events()
|
||||
elif path in ("/api/devices", "/api/devices/tailscale", "/api/devices/mdns"):
|
||||
self.send_json(devices_get(path, url.query))
|
||||
elif path.startswith("/source-image/"):
|
||||
from apk_sources import _images
|
||||
try:
|
||||
@@ -2298,10 +2434,12 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if not self.local_request():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
meant = self.headers.get("X-Frame-Device")
|
||||
body = None
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
self.send_json(self.upload())
|
||||
with working(meant):
|
||||
self.send_json(self.upload())
|
||||
return
|
||||
handler = POST.get(path)
|
||||
if not handler:
|
||||
@@ -2313,7 +2451,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
if not isinstance(body, dict):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
||||
result = handler(body)
|
||||
self.send_json(result)
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
@@ -2327,6 +2467,30 @@ class Handler(BaseHTTPRequestHandler):
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def connection_events(self):
|
||||
"""Server-sent events: the connection state each time it changes, until the page goes.
|
||||
(The page reads it with fetch, which can send the X-Frame-UI header; EventSource can't.)"""
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.end_headers()
|
||||
self.close_connection = True
|
||||
version = -1
|
||||
try:
|
||||
while True:
|
||||
snap = connection_state() if version < 0 else (LINK.wait(version, 15) if LINK else None)
|
||||
if snap is None:
|
||||
if not LINK and version >= 0:
|
||||
time.sleep(15)
|
||||
self.wfile.write(b": still here\n\n") # keeps proxies and the page's watchdog happy
|
||||
else:
|
||||
version = max(snap["version"], 0)
|
||||
self.wfile.write(b"data: " + json.dumps(snap).encode() + b"\n\n")
|
||||
self.wfile.flush()
|
||||
except OSError:
|
||||
pass # the page went away
|
||||
|
||||
def stream_video(self, query):
|
||||
"""Raw H.264 of the headset view until the page disconnects (see stream_command)."""
|
||||
global _stream_proc
|
||||
@@ -2466,6 +2630,24 @@ class LoopbackServer(ThreadingHTTPServer):
|
||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
||||
|
||||
|
||||
_ONE_SERVER = None
|
||||
|
||||
|
||||
def one_server():
|
||||
"""Only one Frame Control server per user: two would each connect, reconnect and
|
||||
edit the headsets on their own, and could move each other's installs to another
|
||||
headset. Held until this process exits. (FRAME_CONTROL_DATA_DIR gives a second,
|
||||
separate one, as the tests do. A private server, the MCP adapter's, runs alongside:
|
||||
it can't add, remove or switch headsets.)"""
|
||||
lock = frame_devices.file_lock(frame_host.data_dir("server.lock"), timeout=float(os.environ.get("FRAME_CONTROL_SERVER_WAIT") or 20)) # while the app restarts it
|
||||
try:
|
||||
lock.__enter__()
|
||||
except OSError:
|
||||
sys.exit("Frame Control is already running on this computer (the app, or a server started "
|
||||
"from a terminal). Quit it, then try again.")
|
||||
return lock
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 47810)))
|
||||
@@ -2477,6 +2659,14 @@ def main():
|
||||
sweep_tmp()
|
||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||
frame_telemetry.start()
|
||||
global LINK, _ONE_SERVER
|
||||
if not LOCAL:
|
||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||
_ONE_SERVER = one_server()
|
||||
LINK = frame_link.Link(frame_devices.Registry(), env_alias=FRAME if FRAME_FROM_ENV else None,
|
||||
mux_base=MUX_BASE, control=CONTROL, apply=route, explain=unreachable)
|
||||
LINK.work_lock, LINK.work = _work_lock, lambda: _work[0]
|
||||
LINK.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
@@ -2498,10 +2688,8 @@ def main():
|
||||
webinstall_shutdown()
|
||||
macview.shutdown() # close the headset's viewers before the agent goes
|
||||
# The master was started with -N, so it stays up until told to exit.
|
||||
if CONTROL:
|
||||
subprocess.run([*MUX, "-O", "exit", FRAME], capture_output=True, stdin=subprocess.DEVNULL)
|
||||
if _master and _master.poll() is None:
|
||||
_master.terminate()
|
||||
if LINK:
|
||||
LINK.stop()
|
||||
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
|
||||
Reference in new issue
Block a user