Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 551cc54bbc Release 0.4.1
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 23:07:51 +11:00
Alex Southwell c3e651cd25 Merge pull request #67 from saphid/fix/contact-email-review-followups
Contact email: withdrawal covers reports, strict consent, review follow-ups to #59
2026-10-05 23:06:11 +11:00
Alex Southwell f0ba42bfba Merge pull request #70 from saphid/fix/windows-ssh-config-acl
Windows: fix ssh config ACL, link-local IPv6, and Set Up Connection under python -I
2026-10-05 23:00:44 +11:00
saphid 99fc15bd79 Merge remote-tracking branch 'origin/main' into tmp/contact67 2026-10-05 22:55:13 +11:00
saphid e63dc43c2f Merge remote-tracking branch 'origin/main' into fix/windows-ssh-config-acl 2026-10-05 22:52:52 +11:00
Alex Southwell 80f433a2d3 Merge pull request #61 from saphid/fix/windows-rdp-report
Remote desktop from Windows: sign in as steamos, and say why when the Frame doesn't answer
2026-10-05 22:52:44 +11:00
saphidandClaude Opus 5.5 07f44f9082 Windows: fix ssh config ACL, link-local IPv6, and setup under python -I
- ~/.ssh/config writes swapped in a temp file that inherited the .ssh folder's
  ACL; Windows' OpenSSH refuses one granting another account (even a deleted
  one) more than read: "Bad owner or permissions". Writes now give the file an
  owner-only ACL (frame_host.make_private), and the server repairs a refused
  config once per run and retries.
- frame_link.probe named a link-local IPv6 zone with if_indextoname, which on
  Windows is "ethernet_32769"; Windows' ssh can't resolve that, so a headset
  found at fe80:: showed as "can't find the Frame". Use the zone number there.
- frame_connect.py imports frame_host (since #60), but the app runs it with
  python -I, which leaves its folder off sys.path: Set Up Connection exited
  with ModuleNotFoundError. Add the folder, as server.py does.

Verified on a Windows 11 VM against OpenSSH_for_Windows 9.5p2: the old write
reproduces the reported error with an orphan SID's Modify ACE; the new write,
repair and server retry all leave a config ssh accepts; ssh to %ethernet_32769
fails to resolve while %5 connects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 22:43:51 +11:00
saphid c305daae15 Merge remote-tracking branch 'origin/main' into tmp/rdp61
# Conflicts:
#	ui/frame_host.py
#	ui/server.py
2026-10-05 22:41:12 +11:00
Alex Southwell a4031db052 Merge pull request #60 from saphid/fix/windows-test-suite
Windows: stop ssh/scp/ssh-keygen hanging when stderr is captured
2026-10-05 22:39:14 +11:00
saphidandClaude Opus 5.5 049d50f43a Merge main into fix/contact-email-review-followups
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 21:38:54 +10:00
Alex Southwell 28073e212a Merge pull request #65 from saphid/fix/server-stdin-abort
A stop signal no longer crashes the server, and the app restarts it by itself
2026-09-30 22:58:55 +10:00
saphidandClaude Opus 5.5 6abc765e22 App: Try Again also works when the server is up but its page failed to load
The button was accepted only while no server was known. If the server answered
and the page then failed to load, the error page showed with the server still
known, and the button did nothing. It's now accepted from the error page itself
(the window's only data: page).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 22:51:24 +10:00
saphid f73efe414c Merge main into fix/server-stdin-abort 2026-09-30 22:39:14 +10:00
Alex Southwell 704e5d7780 Merge pull request #59 from saphid/feat/contact-email-opt-in
Optional contact email with separate update and follow-up consent
2026-09-30 22:09:42 +10:00
Alex Southwell edbe8d4109 Merge pull request #63 from saphid/screenshot-copy
Screenshots: Copy, right-click menu, and new shots appear on their own
2026-09-30 20:43:22 +10:00
saphidandClaude Opus 5.5 f2c8466ba9 Screenshots: Refresh retries every failed preview, even if a background check lands meanwhile
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:28 +10:00
saphidandClaude Opus 5.5 6cf01729e2 Screenshots: fixes from review
- A right-click menu open when the headset changes closes, so it can't act on the other headset's shot.
- A preview being retried by Refresh is no longer dropped when a background check lands first.
- A late failure from a headset switched away from no longer drops the new headset's preview.
- Tab and Escape close the menu and give focus back to where it was.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:26:48 +10:00
saphidandClaude Opus 5.5 63c1a9ff55 docs: xrdp sign-in from Windows verified on a real Frame
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:25:49 +10:00
saphidandClaude Opus 5.5 47a29afb4c Screenshots: recheck fixes
- In Control, a right-click on the viewer goes to the Frame only; the copy menu stays out of the way.
- Thumbnails no longer hold up the next check: a save shows as saved straight away, and a
  new shot appears while older previews are still loading.
- Refresh (or a save) during a background check reads again after it, so the answer is fresh.
- A preview that failed is retried on Refresh, not by every background check.
- Copy reports a failure if the app refuses the image, and if the browser can't copy text.
- Windows: Show in File Explorer works when the path has spaces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:19:03 +10:00
saphidandClaude Opus 5.5 a0f810c018 App: restart the server by itself when it stops, and a Try Again button on the error page
A server that had been up for a minute starts again without asking. One that
stops sooner shows the error page, now headed "Frame Control stopped", with a
Try Again button (the menu item was the only way, and hard to find).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:17:24 +10:00
saphidandClaude Opus 5.5 e8db571a2c Remote desktop: say which way the Frame didn't answer
Second review: only a refused port 3389 means xrdp is off. A name that
doesn't resolve, a timeout or no route now say so, rather than telling the
person to turn on Developer Mode. All are Unreachable (a 400, no error
diagnostic). The .rdp file name is a digest of the address, since
fe80::1%2 and fe80::1:2 sanitised to the same name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:10:45 +10:00
saphidandClaude Opus 5.5 d9cd40c035 Remote desktop: review fixes
- Write the .rdp file through open(newline=), since Path.write_text(newline=)
  needs Python 3.10 and CI's checks job runs 3.9
- One .rdp file per address, so overlapping launches can't swap headsets
- xrdp not answering is NotListening, a 400 with its message rather than a
  500 filed as an error diagnostic
- /source-image/ lets ClientGone through instead of answering 404 mid-reply

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:06:14 +10:00
saphidandClaude Opus 5.5 865e8dc17f Align continuation lines after the run_ssh rename
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:58:28 +10:00
saphidandGPT-6.1 Sol 34a334fa27 Fix Windows OpenSSH stderr capture in app and tests
Windows OpenSSH 9.5 blocks while writing captured stderr to a pipe, even with stdin disconnected and a connection timeout. Capture stderr in a temporary file for one-shot OpenSSH calls on Windows, preserving subprocess output, text, check, and timeout behavior. Leave POSIX capture unchanged.

Use the shared runner for SSH, scp, key lookup, and streamed app-data transfers. Bound the real ssh-keygen hashing tests and keep their assertions; move the transfer-error mock to the runner seam. Add ten regression tests.

Verified the full suite on Windows 11 with bundled Python 3.12.14: 628 tests, OK (110 existing skips), 42.685s. Verified macOS Python 3.9.6: 628 tests, OK, 67.934s. Independent Codex gpt-6-sol high-reasoning review found no actionable issues. Protected RDP code is unchanged.

Co-Authored-By: GPT-6.1 Sol (Codex) <noreply@openai.com>
2026-09-30 12:52:24 +10:00
saphidandClaude Opus 5.5 3f273ca37a Remote desktop on Windows: say to choose Connect on mstsc's file prompt
Seen on Windows 11: an unsigned .rdp file makes mstsc ask about the
publisher before the certificate warning. Plain '>' in the message, which
a cp1252 console can print.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:07:39 +10:00
saphidandClaude Opus 5.5 72ffec45c2 Remote desktop: mention the Frame's certificate warning
Seen on Windows 11 against a real Frame: mstsc warns about xrdp's own
certificate before xrdp's login box appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:53:01 +10:00
saphidandClaude Opus 5.5 7308ac09b1 Remote desktop from Windows: sign in as steamos, and say when xrdp isn't there
A Windows user reported "RDP not working". Frame Control ran `mstsc /v:HOST`,
which offers the Windows account; the Frame's xrdp (TLS, no NLA) only accepts
steamos with the Developer Mode password. The app also said "Opened Remote
Desktop" without checking that anything answered on port 3389.

- open_rdp checks port 3389 first and explains how to turn xrdp on
- On Windows, launch mstsc with a .rdp file naming user steamos (CRLF)
- Every platform's message says to sign in as steamos with the Developer Mode password
- The server no longer logs a page closing mid-reply (WinError 10053 on
  Windows) as a 500 with an error diagnostic

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:11:57 +10:00
saphidandClaude Opus 5.5 1a5f089e57 Server: a stop signal no longer crashes it (SIGABRT) while the app holds stdin
The --exit-on-eof watcher read stdin with a buffered read, which holds stdin's
lock. When SIGTERM stopped the server first, Python aborted at exit trying to
take that lock back, and the app showed "The server stopped unexpectedly
(SIGABRT)". It now uses os.read. The startup line is printed inside the try,
so a signal that arrives while it's printed still runs the cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 20:12:47 +10:00
saphidandClaude Opus 5.5 83d74548cd Screenshots: Copy button, right-click menu, and new shots appear on their own
Each screenshot card and the viewer get a Copy button that puts the image on
the clipboard (natively in the desktop app, as PNG in a browser). Right-click
a screenshot to open, copy, save, show it in Finder, or copy its path or name;
right-click the viewer to copy or save. The shelf re-lists the Frame's
screenshots every 8 s while the window is visible and connected, redraws only
when something changed, and keeps thumbnails it already has. Switching
headsets clears the list and ignores answers still on their way.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:08:36 +10:00
20 changed files with 957 additions and 89 deletions

No files matched your search

+28 -6
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -149,7 +149,7 @@ async function startServer() {
const target = `http://127.0.0.1:${port}/`;
for (let i = 0; i < 100; i++) {
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
if (await ping(target)) { url = target; return; }
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -175,18 +175,27 @@ function stopServer() {
if (server) endServer(server);
}
function errorPage(message) {
function errorPage(message, title = "Frame Control couldn't start") {
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
<p>${esc(message)}</p>
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
}
// A server that had been running starts again by itself (something stopped it: a
// signal, a crash). One that stops again within a minute shows the error instead,
// so a server that can't stay up doesn't restart forever.
let serverStarted = 0;
function serverDied(why) {
url = null;
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
if (!win) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -263,7 +272,20 @@ function fromUi(e) {
} catch { return false; }
}
// The error page's Try Again button. The error page is the only data: page the window
// shows (`url` can still be set then: the server answered but the page failed to load).
ipcMain.handle("server:restart", (e) => {
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
});
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
if (img.isEmpty()) throw new Error("not an image");
clipboard.writeImage(img);
return true;
});
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.0",
"version": "0.4.1",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.0",
"version": "0.4.1",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+4 -2
View File
@@ -2,8 +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, and keeps the
// Frame menu's list of headsets up to date.
// It can put a screenshot on the clipboard as an image, 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,7 +12,9 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
onUseDevice: (cb) => {
+7 -2
View File
@@ -88,8 +88,13 @@ counts them while they run.
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
asks for the sudo password over SSH.
Linux). Remote desktop first checks that the Frame's xrdp answers on port
3389 (Developer Mode turns it on). On Windows it opens a connection file for
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
xrdp turns away. Accept the warning about the Frame's own certificate, then
sign in with the Developer Mode password. Sleep, restart and
shut down open a terminal window because SteamOS asks for the sudo password
over SSH.
## How it works
+14
View File
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
Mac with keyboard, mouse, and clipboard.
**Verified 2026-09-30** (Frame BUILD_ID 20260925.6191901, Windows 11 25H2,
Remote Desktop Connection): signing in to xrdp as `steamos` with the Developer
Mode password opens a Plasma (X11) desktop within about 6 seconds.
- xrdp has no NLA, so the client shows a certificate warning (xrdp's own
`www.xrdp.org` certificate) and then xrdp's own login box. Frame Control
fills in `steamos` there on Windows, Remmina and FreeRDP.
- The desktop is a separate login session (Xorg on display `:10`), not the
headset's view. It uses about 1.3 GB of the Frame's memory.
- Closing the client leaves the session running, and the next login
reconnects to it. To end it over SSH, find it with `loginctl list-sessions`
and run `loginctl terminate-session <id>`. That doesn't touch the headset's
gamescope or SteamVR session.
## B. Show the Mac's desktop inside the Frame
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
+5 -2
View File
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
import frame_devices as fd # noqa: E402
import frame_host # noqa: E402
CONFIG = """Host lxso1
HostName 192.168.1.109
@@ -274,7 +275,8 @@ class Pins(Base):
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)
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
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())
@@ -283,7 +285,8 @@ class Pins(Base):
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)
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
self.assertNotIn("frame-control-d4", target.read_text())
self.assertTrue(fd.pinned("d4"))
self.assertTrue(fd.forget_pin("d4"))
+1 -1
View File
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
+87
View File
@@ -0,0 +1,87 @@
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
import sandbox # noqa: F401
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
import frame_host
class CapturedSSH(unittest.TestCase):
def run_command(self, source, **kwargs):
with mock.patch.object(frame_host, "WINDOWS", True):
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
def test_binary_output_and_input(self):
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
"sys.stderr.buffer.write(b'error\\r\\n')",
capture_output=True, input=b"data\x00\xff")
self.assertEqual(result.stdout, b"data\x00\xff")
self.assertEqual(result.stderr, b"error\r\n")
def test_text_output_normalizes_newlines(self):
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
capture_output=True, input="hello\n", text=True)
self.assertEqual(result.stdout, "hello\n")
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
def test_explicit_encoding_and_errors(self):
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
capture_output=True, encoding="ascii", errors="replace")
self.assertEqual(result.stderr, "\ufffd\ufffd")
def test_check_preserves_error_output(self):
with self.assertRaises(subprocess.CalledProcessError) as caught:
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
capture_output=True, text=True, check=True)
self.assertEqual(caught.exception.returncode, 7)
self.assertEqual(caught.exception.stdout, "out\n")
self.assertEqual(caught.exception.stderr, "err\n")
def test_timeout_preserves_partial_stderr(self):
with self.assertRaises(subprocess.TimeoutExpired) as caught:
with mock.patch.object(frame_host, "WINDOWS", True):
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
capture_output=True, text=True, timeout=1)
self.assertEqual(caught.exception.stderr, b"waiting")
def test_streamed_stdout_is_kept_separate(self):
with tempfile.TemporaryFile() as output:
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
"sys.stderr.buffer.write(b'error')",
stdout=output, stderr=subprocess.PIPE)
output.seek(0)
self.assertEqual(output.read(), b"file")
self.assertIsNone(result.stdout)
self.assertEqual(result.stderr, b"error")
def test_uncaptured_windows_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
def test_posix_call_is_unchanged(self):
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
def test_capture_rejects_explicit_streams(self):
for stream in ("stdout", "stderr"):
with self.subTest(stream=stream), self.assertRaises(ValueError):
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
def test_real_ssh_failure_returns_stderr_without_hanging(self):
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
self.assertEqual(result.returncode, 255)
self.assertIn("Could not resolve hostname", result.stderr)
+161
View File
@@ -0,0 +1,161 @@
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
staying quiet when the page goes away mid-reply, which on Windows is
ConnectionAbortedError (WinError 10053).
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import email.message
import io
import socket
import sys
import tempfile
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_host # noqa: E402
import server # noqa: E402
def platform(name):
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
LINUX=name == "linux")
class OpenRdp(unittest.TestCase):
def setUp(self):
self.xrdp = socket.socket()
self.xrdp.bind(("127.0.0.1", 0))
self.xrdp.listen(4)
self.addCleanup(self.xrdp.close)
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
port.start()
self.addCleanup(port.stop)
self.spawned = []
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
spawn.start()
self.addCleanup(spawn.stop)
cache = tempfile.TemporaryDirectory()
self.addCleanup(cache.cleanup)
self.cache = Path(cache.name)
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
where.start()
self.addCleanup(where.stop)
def test_windows_signs_in_as_steamos(self):
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
with platform("windows"):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(len(self.spawned), 1)
argv = self.spawned[0]
self.assertEqual(argv[0], "mstsc.exe")
self.assertNotIn("/v:127.0.0.1", argv)
rdp = Path(argv[1])
self.assertEqual(rdp.suffix, ".rdp")
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
self.assertNotIn(b"\r\r", data)
lines = data.decode("utf-8").split("\r\n")
self.assertIn("full address:s:127.0.0.1", lines)
self.assertIn("username:s:steamos", lines)
self.assertIn("steamos", message)
self.assertIn("Developer Mode password", message)
self.assertIn("certificate", message)
self.assertIn("Connect", message)
def test_nothing_listening_says_why_and_opens_nothing(self):
self.xrdp.close()
for name in ("windows", "mac", "linux"):
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "127.0.0.1")
self.assertIn("Developer Mode", str(cm.exception))
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_says_which_way_it_failed(self):
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
(socket.timeout("timed out"), "didn't answer"),
(OSError(65, "No route to host"), "didn't answer")):
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
frame_host.open_rdp("frame", "frame.local")
self.assertIn(says, str(cm.exception))
self.assertNotIn("refused", str(cm.exception))
self.assertEqual(self.spawned, [])
def test_server_says_it_as_the_persons_to_fix(self):
# A 400 with the message, not a 500 filed as an error diagnostic.
self.xrdp.close()
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
self.assertRaises(server.Failure) as cm:
server.open_thing({"what": "rdp"})
self.assertEqual(cm.exception.status, 400)
self.assertIn("Developer Mode", str(cm.exception))
def test_one_file_per_address(self):
with platform("windows"):
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
self.assertEqual(len({a, b, c, d}), 4)
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
def test_address_cant_add_lines_to_the_file(self):
with platform("windows"), self.assertRaises(frame_host.HostError):
frame_host.rdp_file("frame\r\nusername:s:root")
self.assertEqual(list(self.cache.iterdir()), [])
def test_linux_clients_get_the_user(self):
with platform("linux"), mock.patch.object(frame_host, "which",
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
message = frame_host.open_rdp("frame", "127.0.0.1")
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
self.assertIn("steamos", message)
class PageGoneAway(unittest.TestCase):
"""The report's server log: the page closed while index.html was being sent, and the
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
def handler(self, path="/"):
h = server.Handler.__new__(server.Handler)
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
h.headers = email.message.Message()
h.headers["Host"] = "127.0.0.1:1"
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
h.close_connection = True
return h
def test_not_a_server_error(self):
h = self.handler()
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
h.do_GET()
diagnostic.assert_not_called()
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
def test_server_logs_nothing(self):
srv = server.LoopbackServer.__new__(server.LoopbackServer)
err = io.StringIO()
with mock.patch.object(sys, "stderr", err):
try:
raise server.ClientGone()
except server.ClientGone:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertEqual(err.getvalue(), "")
try:
raise RuntimeError("real")
except RuntimeError:
srv.handle_error(None, ("127.0.0.1", 1))
self.assertIn("RuntimeError: real", err.getvalue())
if __name__ == "__main__":
unittest.main()
+18
View File
@@ -111,6 +111,8 @@ class ServerGuards(unittest.TestCase):
("/api/volume", {"level": 1.5}),
("/api/clipboard", {"text": ""}),
("/api/open", {"what": "anything-else"}),
("/api/open", {"what": "shot", "id": "1/250820/../../.ssh/id_ed25519"}),
("/api/open", {"what": "shot"}),
("/api/shots/save", {"ids": []}),
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
("/api/shots/save", {"ids": [1]}),
@@ -121,6 +123,10 @@ class ServerGuards(unittest.TestCase):
status, payload = self.post(path, body)
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
def test_showing_a_shot_needs_it_saved_here(self):
status, payload = self.post("/api/open", {"what": "shot", "id": "1/250820/19990101000000_1.jpg"})
self.assertEqual(status, 404, payload)
def test_screenshot_ids_checked_before_ssh(self):
for shot in ("../../etc/passwd", "1/250820/x.jpg", "1/2/20260925225208_1.jpg;id", "1/250820/20260925225208_1.gif"):
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
@@ -279,6 +285,18 @@ class OneServer(unittest.TestCase):
r = conn.getresponse()
self.assertEqual(r.status, 403, r.read())
@unittest.skipIf(os.name == "nt", "no SIGTERM on Windows")
def test_sigterm_while_the_app_holds_stdin_exits_cleanly(self):
"""The app keeps stdin open; a stop signal used to abort Python (SIGABRT) at exit."""
env = {**os.environ, "FRAME_CONTROL_DATA_DIR": tempfile.mkdtemp(prefix="frame-one-server-"),
"FRAME_ALIAS": "frame-control-test.invalid"}
proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0", "--exit-on-eof"],
env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
self.addCleanup(lambda: (proc.stdin.close(), proc.stdout.close()))
self.assertIn("Frame Control on", proc.stdout.readline())
proc.terminate()
self.assertEqual(proc.wait(30), 0, proc.stdout.read())
class ArtworkSettings(unittest.TestCase):
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
+162
View File
@@ -0,0 +1,162 @@
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import shutil
import subprocess
import sys
import tempfile
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_host # noqa: E402
import frame_devices as fd # noqa: E402
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
def ran(*results):
"""subprocess.run stand-in answering whoami, then icacls."""
calls = []
def run(argv, **kw):
calls.append(argv)
return results[len(calls) - 1]
return run, calls
class MakePrivate(unittest.TestCase):
def test_windows_sets_owner_only_acl_by_sid(self):
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
subprocess.CompletedProcess([], 0))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
def test_windows_falls_back_to_username_and_reports_failure(self):
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
self.assertFalse(frame_host.make_private(Path("config")))
self.assertIn("bob:F", calls[1])
@unittest.skipIf(os.name == "nt", "POSIX modes")
def test_posix_chmods_600(self):
with tempfile.NamedTemporaryFile() as f:
os.chmod(f.name, 0o644)
self.assertTrue(frame_host.make_private(f.name))
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
class ConfigWrites(unittest.TestCase):
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
self.config = self.ssh / "config"
def test_devices_and_connect_writes_make_the_file_private(self):
import frame_connect as fc
self.config.write_text("Host other\n User me\n", encoding="utf-8")
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
fc.write_config("10.0.0.5")
self.assertTrue(fd.repair_permissions(self.config))
fd.rewrite_block("frame", path=self.config, user="deck")
self.assertEqual(private.call_count, 3)
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
def test_setup_runs_isolated_as_the_app_starts_it(self):
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
self.assertNotIn("ModuleNotFoundError", r.stderr)
self.assertIn("frame_connect.py", r.stdout + r.stderr)
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
self.assertFalse(fd.repair_permissions(self.config))
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
self.config.write_bytes(data)
with mock.patch.object(frame_host, "make_private", return_value=True):
self.assertTrue(fd.repair_permissions(self.config))
self.assertEqual(self.config.read_bytes(), data)
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
self.config.write_bytes(b"Host a\n")
before = self.config.stat().st_ino
with mock.patch.object(frame_host, "make_private", return_value=False):
self.assertFalse(fd.repair_permissions(self.config))
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
class ServerRepair(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def setUp(self):
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
mock.patch.object(frame_host, "WINDOWS", True),
mock.patch.object(self.server, "_config_repaired", False)]
for p in patches:
p.start()
self.addCleanup(p.stop)
def test_repairs_the_refused_config_once(self):
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
self.assertTrue(self.server.repair_ssh_config(REFUSED))
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_called_once()
def test_leaves_other_files_and_errors_alone(self):
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
with mock.patch.object(fd, "repair_permissions") as repair:
self.assertFalse(self.server.repair_ssh_config(key))
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
with mock.patch.object(frame_host, "WINDOWS", False):
self.assertFalse(self.server.repair_ssh_config(REFUSED))
repair.assert_not_called()
def test_ssh_retries_after_repairing(self):
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
mock.patch.object(fd, "repair_permissions", return_value=True), \
mock.patch.object(self.server, "LINK", None):
self.assertEqual(self.server.ssh("true"), "ok")
class LinkLocalZone(unittest.TestCase):
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
def probe(self, windows):
import frame_link as fl
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
sock = mock.MagicMock()
with mock.patch.object(frame_host, "WINDOWS", windows), \
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
mock.patch.object(fl.socket, "socket", return_value=sock), \
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
return fl.probe("frame.local", 22)["ip"]
def test_windows_uses_the_numeric_zone(self):
self.assertEqual(self.probe(True), "fe80::1%12")
def test_elsewhere_uses_the_interface_name(self):
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -47,8 +47,8 @@ def ssh(cmd, input=None, timeout=120):
try:
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
except subprocess.TimeoutExpired:
raise FrameError(f'timed out talking to {FRAME}')
if p.returncode != 0:
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
else:
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
try:
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
+5 -4
View File
@@ -11,16 +11,17 @@ import tempfile
import uuid
import frame_android as android
import frame_host
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None):
try:
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
+10 -5
View File
@@ -24,6 +24,10 @@ import urllib.error
import urllib.request
from pathlib import Path
# The app runs this with python -I, which leaves the script's folder off sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_host # noqa: E402
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
USER_FROM_ENV = "FRAME_USER" in os.environ
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
@@ -330,8 +334,9 @@ def _write_config(host, port, user):
block = config_block(host, port, user)
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)
if not frame_host.make_private(tmp):
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
"Frame Control repairs it when it next connects")
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
# and locked, so the swap can fail for a moment; keep trying for a while.
for attempt in range(60):
@@ -349,9 +354,9 @@ def _write_config(host, port, user):
def key_login_works():
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
def configured_user():
+36 -6
View File
@@ -241,8 +241,7 @@ def _write_config(path, text, expected):
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
if not frame_host.WINDOWS:
tmp.chmod(0o600)
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
if read_config(path) != expected:
return False
@@ -271,6 +270,37 @@ def _edit_config(path, change):
raise OSError(f"{path} kept changing while Frame Control tried to update it")
def repair_permissions(path=None):
"""Give ~/.ssh/config make_private's ACL by swapping in a byte-for-byte copy: for a
file Windows' OpenSSH refuses ("Bad owner or permissions"). -> True only if the copy
got that ACL and replaced the file."""
path = Path(path or ssh_config())
with _config_lock, file_lock(path.with_name(LOCK_NAME)):
try:
data = path.read_bytes()
except OSError:
return False
fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
tmp = Path(tmp)
try:
with os.fdopen(fd_, "wb") as fh:
fh.write(data)
if not frame_host.make_private(tmp):
return False
for attempt in range(20): # a running ssh.exe can hold the file for a moment
if path.read_bytes() != data:
return False
try:
os.replace(tmp, path)
return True
except PermissionError:
time.sleep(0.25)
return False
finally:
if tmp.exists():
tmp.unlink()
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
@@ -333,8 +363,8 @@ def remove_block(alias, path=None):
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
out = frame_host.run_ssh(["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)
@@ -346,8 +376,8 @@ def effective_port(alias, config):
def _keygen(*args):
try:
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
+141 -8
View File
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
CLI (used by the Electron app, so terminal handling lives in one place):
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
"""
import hashlib
import io
import os
import shlex
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
from pathlib import Path
MAC = sys.platform == "darwin"
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
pass
class Unreachable(HostError):
"""The Frame, or a service on it, didn't answer: the person's to sort out, not a fault here."""
def run_ssh(argv, **kwargs):
"""Run an OpenSSH tool without Windows' redirected-stderr pipe hang.
A real temporary file avoids OpenSSH's blocked asynchronous stderr writes,
while keeping subprocess.run's captured output, text, check and timeout API.
"""
if not WINDOWS:
return subprocess.run(argv, **kwargs)
if kwargs.pop("capture_output", False):
if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None:
raise ValueError("stdout and stderr arguments may not be used with capture_output")
kwargs.update(stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if kwargs.get("stderr") != subprocess.PIPE:
return subprocess.run(argv, **kwargs)
check = kwargs.pop("check", False)
text = any(kwargs.get(key) for key in ("text", "universal_newlines", "encoding", "errors"))
with tempfile.TemporaryFile() as stderr:
kwargs["stderr"] = stderr
try:
result = subprocess.run(argv, **kwargs)
except subprocess.TimeoutExpired as error:
stderr.seek(0)
error.stderr = stderr.read()
raise
stderr.seek(0)
if text:
with io.TextIOWrapper(stderr, encoding=kwargs.get("encoding"), errors=kwargs.get("errors")) as reader:
result.stderr = reader.read()
else:
result.stderr = stderr.read()
if check:
result.check_returncode()
return result
def data_dir(*parts):
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
@@ -153,6 +196,19 @@ def open_path(path):
stderr=subprocess.DEVNULL, **DETACHED)
def reveal_path(path):
"""Show a file selected in its folder (Linux file managers vary, so there the folder opens)."""
path = Path(path)
if MAC:
cmd = ["open", "-R", str(path)]
elif WINDOWS:
cmd = f'explorer /select,"{path}"' # as one string: Explorer wants the quotes after the comma
else:
return open_path(path.parent)
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, **DETACHED)
open_url = open_path # the same openers hand URLs to the default browser
@@ -228,10 +284,46 @@ def clipboard_text():
raise HostError("Can't read the clipboard")
# What Windows' OpenSSH says when it refuses ~/.ssh/config (or a key) for its ACL.
BAD_PERMISSIONS = "Bad owner or permissions on "
def make_private(path):
"""Leave only this user able to open PATH, as ssh insists for ~/.ssh/config.
Windows: an ACL of just this user, SYSTEM and Administrators, inherited nothing.
A file written into ~/.ssh otherwise takes the folder's ACL, and Windows' OpenSSH
refuses it if that grants anyone else, even an account deleted long ago
("Bad owner or permissions"). Best effort: -> False if it couldn't."""
if not WINDOWS:
try:
os.chmod(path, 0o600)
return True
except OSError:
return False
me = os.environ.get("USERNAME", "")
try: # "desktop\me","S-1-5-21-..."
# Bytes: the account name is in the console's code page, the SID is ASCII.
out = subprocess.run(["whoami", "/user", "/fo", "csv", "/nh"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
sid = out.decode("ascii", "replace").strip().rsplit(",", 1)[-1].strip('"')
if sid.startswith("S-1-"):
me = "*" + sid
except (OSError, subprocess.TimeoutExpired):
pass
if not me:
return False
try:
return subprocess.run(["icacls", str(path), "/inheritance:r", "/grant:r", f"{me}:F",
"*S-1-5-18:F", "*S-1-5-32-544:F"], capture_output=True,
stdin=subprocess.DEVNULL, timeout=10).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def ssh_hostname(alias):
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
return alias
for line in out.splitlines():
@@ -264,24 +356,65 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page"
RDP_PORT = 3389
RDP_USER = "steamos" # xrdp signs in with the Developer Mode password, not this computer's
# xrdp's certificate is its own, so every client warns about it first.
RDP_LOGIN = (f"accept the warning about the Frame's certificate, then sign in as {RDP_USER} "
"with your Developer Mode password")
def check_rdp(host, timeout=3):
"""Raise Unreachable, saying why, unless the Frame's RDP port takes a connection."""
try:
with socket.create_connection((host, RDP_PORT), timeout=timeout):
return
except ConnectionRefusedError:
raise Unreachable(f"The Frame at {host} is on but isn't accepting remote desktop (port {RDP_PORT} "
"refused). Turn on Developer Mode in Steam Settings > System on the headset, "
"then restart it and try again.") from None
except socket.gaierror:
raise Unreachable(f"Can't find {host} on the network for remote desktop. Check the headset's "
"address on the Devices tab.") from None
except OSError as e:
raise Unreachable(f"The Frame didn't answer remote desktop at {host} ({e}). It may be asleep, "
"switched off or on another network; if it's on, check Developer Mode is on "
"in Steam Settings > System.") from None
def rdp_file(host):
"""A Remote Desktop connection file for the Frame. mstsc /v: alone offers this
computer's Windows account, which xrdp turns away; the file names steamos instead."""
if any(c in host for c in "\r\n"):
raise HostError("That headset address can't be used for remote desktop")
# One file per address, so two launches close together can't swap headsets.
path = cache_dir(f"frame-{hashlib.sha256(host.encode()).hexdigest()[:16]}.rdp")
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\r\n") as f: # Path.write_text(newline=) is 3.10+
f.write(f"full address:s:{host}\nusername:s:{RDP_USER}\n")
return path
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)
# The client would open either way and then fail on its own, with nothing said here.
check_rdp(host)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
open_url("https://apps.apple.com/app/windows-app/id1295203466")
return "Windows App isn't installed; opened its App Store page"
if WINDOWS:
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
_spawn(["mstsc.exe", str(rdp_file(host))])
# Windows asks about the unsigned connection file first.
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
return f"Opened Remmina to {host}: {RDP_LOGIN}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
+10 -9
View File
@@ -74,8 +74,8 @@ def ssh_g(alias):
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
try:
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
got = {}
@@ -108,7 +108,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
ip = addr[0]
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
try: # a link-local IPv6 address only works with its interface
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
except (OSError, AttributeError):
pass
left = deadline - now()
@@ -765,8 +766,8 @@ class Link:
if not self.control:
return False
try:
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
return frame_host.run_ssh([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
@@ -777,8 +778,8 @@ class Link:
pending.kill()
if self.control and self.alias:
try:
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5)
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5)
except (OSError, subprocess.TimeoutExpired):
pass
if proc and proc.poll() is None:
@@ -983,8 +984,8 @@ class Link:
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try:
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
err = r.stderr.strip()
if r.returncode == 0:
rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
+196 -28
View File
@@ -258,7 +258,12 @@
.shot-card .row { flex-wrap: nowrap; }
.shot-card .grow { flex: 1; min-width: 0; }
.shot-card .t { color: var(--bright); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shot-card .s { color: var(--muted); font-size: 12px; }
.shot-card .s { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ctx-menu { position: fixed; z-index: 1000; min-width: 200px; padding: 4px; border-radius: 4px; background: #232c38;
box-shadow: 0 10px 28px rgba(0,0,0,.6), 0 0 0 1px rgba(255,255,255,.08); }
.ctx-menu button { display: block; width: 100%; height: 30px; padding: 0 10px; text-align: left; background: none; }
.ctx-menu button:hover, .ctx-menu button:focus-visible { background: var(--blue); color: #fff; outline: none; }
.ctx-menu hr { border: 0; border-top: 1px solid rgba(255,255,255,.1); margin: 4px 2px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
@@ -717,6 +722,7 @@
<button class="action" id="shotBtn">Capture</button>
<button id="liveBtn" title="Keep updating, as video">Live</button>
<button id="ctrlBtn" title="Control the Frame by tapping or clicking on the view (C)">Control</button>
<button id="copyBtn" disabled title="Copy the image to the clipboard (or right-click it)">Copy</button>
<button id="saveBtn" disabled>Save</button>
</span>
</div>
@@ -883,7 +889,7 @@
<button class="action small" id="shotsSaveNew" disabled>Save new to this computer</button>
</div>
<div class="shot-grid" id="shotGrid"><div class="sub">Loading…</div></div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. Click one to open it in the viewer; Save copies it to <code>~/Pictures/SteamFrame</code>.</div>
<div class="hint">Screenshots you take in the headset with Steam's screenshot shortcut. New ones appear on their own. Click one to open it in the viewer, Copy puts it on the clipboard, and Save copies it to <code>~/Pictures/SteamFrame</code>. Right-click for more.</div>
</section>
</div>
@@ -1899,7 +1905,7 @@ async function capture() {
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
ctrlShow();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = false;
$("saveBtn").disabled = $("copyBtn").disabled = false;
return true;
} catch (e) {
log("Capture failed: " + e.message, "e");
@@ -1981,7 +1987,7 @@ async function startVideo() {
$("viewer").classList.remove("busy");
c.hidden = false; $("viewerEmpty").hidden = true; $("zoombar").hidden = false; $("asleep").hidden = true;
$("srcBadge").hidden = false; $("srcBadge").textContent = `${video.label} · video`;
$("stamp").hidden = false; $("saveBtn").disabled = false;
$("stamp").hidden = false; $("saveBtn").disabled = $("copyBtn").disabled = false;
}
frames++;
const now = performance.now();
@@ -2050,6 +2056,35 @@ $("saveBtn").onclick = () => lastShot ? download(lastShot.blob, lastShot.file) :
if (!b) return toast("Couldn't encode the image", true);
download(b, `frame-${view}-${new Date().toISOString().replace(/[:.]/g, "-")}.png`);
}, "image/png");
const copyViewer = () => act("Copy image", () => copyImage(lastShot ? lastShot.blob
: new Promise((ok, bad) => $("canvas").toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"))));
$("copyBtn").onclick = copyViewer;
// Right-click the viewer to copy or save what it shows (in Control, a right-click goes to the Frame).
$("canvas").oncontextmenu = e => {
if (ctrl.on || $("saveBtn").disabled) return;
e.preventDefault();
showMenu(e, [["Copy image", copyViewer], ["Save image", () => $("saveBtn").click()]]);
};
// An image onto the clipboard. The app does it natively (JPEG too); a browser
// takes PNG only, and the blob is handed over as a promise so the click still counts.
async function copyImage(blob) {
if (window.frameApp?.writeImage) {
if (!await window.frameApp.writeImage(new Uint8Array(await (await blob).arrayBuffer()))) throw new Error("the app refused the image");
return { message: "Copied the image" };
}
if (!navigator.clipboard?.write || !window.ClipboardItem) throw new Error("This browser can't copy images here");
await navigator.clipboard.write([new ClipboardItem({ "image/png": Promise.resolve(blob).then(pngBlob) })]);
return { message: "Copied the image" };
}
async function pngBlob(blob) {
if (blob.type === "image/png") return blob;
const bmp = await createImageBitmap(blob);
const c = document.createElement("canvas");
c.width = bmp.width; c.height = bmp.height;
c.getContext("2d").drawImage(bmp, 0, 0);
bmp.close();
return new Promise((ok, bad) => c.toBlob(b => b ? ok(b) : bad(new Error("couldn't encode the image")), "image/png"));
}
function download(blob, name) {
if (savesToDevice()) return act("Save image", () => saveToDevice([{ blob, name }]));
const a = document.createElement("a");
@@ -3527,8 +3562,11 @@ loadReports();
api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
const shots = { list: [], urls: [] };
// thumbs: id -> promise of an object URL, kept across reloads so a refresh
// that finds a new shot fetches only that one's thumbnail.
const shots = { list: [], sig: null, thumbs: new Map(), loading: null, gen: 0, fill: 0 };
const STEAMVR_APPID = "250820";
const SHOTS_POLL_MS = 8000;
function shotApp(appid) {
if (appid === STEAMVR_APPID) return "SteamVR";
const g = state?.games?.find(x => x.appid === appid);
@@ -3540,35 +3578,85 @@ async function shotBlob(id, thumb) {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
async function loadShots() {
$("shotsRefresh").disabled = true;
function shotThumb(id) {
const thumbs = shots.thumbs; // this headset's: a late failure must not touch the next one's
if (!thumbs.has(id)) {
const p = shotBlob(id, true).then(b => URL.createObjectURL(b));
p.catch(() => { if (thumbs.get(id) === p) thumbs.delete(id); }); // try again next time
thumbs.set(id, p);
}
return thumbs.get(id);
}
// quiet: a background check. It keeps what's shown if the Frame can't be read,
// and redraws only when the shots (or whether they're saved here) changed.
function loadShots(quiet) {
if (shots.loading) {
// A check already on its way will do for another check; Refresh, or a save
// that just finished, reads again after it so the answer is a fresh one.
const again = () => loadShots();
return quiet === true ? shots.loading : shots.loading.then(again, again);
}
const p = readShots(quiet === true).finally(() => { if (shots.loading === p) shots.loading = null; });
return shots.loading = p;
}
// Forget the shots of a headset we've switched away from, and ignore its answers still on their way.
function resetShots() {
closeMenu(); // its items were about the other headset's shot
shots.gen++; shots.fill++; shots.loading = null; shots.list = []; shots.sig = null;
for (const p of shots.thumbs.values()) p.then(URL.revokeObjectURL, () => {});
shots.thumbs = new Map();
}
async function readShots(quiet) {
const gen = shots.gen;
if (!quiet) $("shotsRefresh").disabled = true;
let got;
try {
shots.list = (await api("/api/shots")).shots;
got = await api("/api/shots");
} catch (e) {
return failed($("shotGrid"), e);
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
return;
} finally { $("shotsRefresh").disabled = false; }
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
if (gen !== shots.gen) return;
({ shots: shots.list, folder: shots.folder } = got);
const sig = JSON.stringify(shots.list.map(s => [s.id, s.saved, shotApp(s.appid)]));
if (sig !== shots.sig) { shots.sig = sig; drawShots(); }
fillThumbs(quiet); // not waited for: the next check, or a save, needn't sit behind the thumbnails
}
// Thumbnails one at a time over the shared SSH connection. One that failed is
// tried again on Refresh, not by every background check.
async function fillThumbs(quiet) {
const run = ++shots.fill;
// Refresh puts every failed one back in line first, so a background pass that takes over carries them all on.
if (!quiet) for (const img of document.querySelectorAll("#shotGrid img[data-failed]")) delete img.dataset.failed;
for (const img of document.querySelectorAll("#shotGrid img[data-shot]:not([src])" + (quiet ? ":not([data-failed])" : ""))) {
const s = shots.list[+img.dataset.shot];
let url;
try { url = await shotThumb(s.id); } catch (e) { img.alt = "Preview failed"; img.dataset.failed = 1; }
if (run !== shots.fill) return; // a newer pass (after a check, a redraw or another headset) has taken over
if (url) img.src = url;
}
}
// "Sep 28, 10:37 PM": short enough to sit beside the card's buttons (the full date is its tooltip).
function shotTime(t) {
return new Date(t * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function drawShots() {
const ids = new Set(shots.list.map(s => s.id));
for (const [id, p] of shots.thumbs) {
if (!ids.has(id)) { shots.thumbs.delete(id); p.then(URL.revokeObjectURL, () => {}); }
}
const unsaved = shots.list.filter(s => !s.saved).length;
$("shotCount").textContent = shots.list.length ? `${shots.list.length} on the Frame` + (unsaved && !HOST.mobile ? ` · ${unsaved} not on this ${HOST.computer}` : "") : "";
$("shotsSaveNew").disabled = HOST.mobile ? !shots.list.length : !unsaved;
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer">
$("shotGrid").innerHTML = shots.list.length ? shots.list.map((s, i) => `<div class="shot-card" data-card="${i}">
<img class="thumb" data-shot="${i}" alt="Screenshot from ${esc(shotApp(s.appid))}" title="Open in the viewer (right-click for more)">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</div>
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
<div class="s" title="${esc(new Date(s.time * 1000).toLocaleString())}">${esc(shotTime(s.time))}</div></div>
<button class="small" data-shot-copy="${i}" title="Copy the image to the clipboard">Copy</button>
${s.saved && !HOST.mobile ? `<span class="tag">On ${HOST.computer}</span>` : `<button class="small" data-shot-save="${i}">Save</button>`}
</div></div>`).join("")
: `<div class="sub">No screenshots on the Frame yet.</div>`;
// Thumbnails one at a time over the shared SSH connection.
for (const img of document.querySelectorAll("#shotGrid img[data-shot]")) {
const s = shots.list[+img.dataset.shot];
try {
const url = URL.createObjectURL(await shotBlob(s.id, true));
shots.urls.push(url);
img.src = url;
} catch (e) { img.alt = "Preview failed"; }
if (!img.isConnected) return; // the list was reloaded meanwhile
}
}
async function openShot(s) {
if (live) toggleLive(false);
@@ -3586,7 +3674,7 @@ async function openShot(s) {
draw();
$("srcBadge").textContent = `Screenshot · ${shotApp(s.appid)}`;
$("stamp").hidden = false; $("stamp").textContent = new Date(s.time * 1000).toLocaleString();
$("saveBtn").disabled = false;
$("saveBtn").disabled = $("copyBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
@@ -3605,15 +3693,95 @@ async function saveShots(list, btn) {
() => api("/api/shots/save", { ids: list.map(s => s.id) }), btn);
if (res) loadShots();
}
const copyShot = (s, btn) => act("Copy screenshot", () => copyImage(shotBlob(s.id, false)), btn);
$("shotGrid").onclick = e => {
const img = e.target.closest("img[data-shot]");
if (img) return openShot(shots.list[+img.dataset.shot]);
const c = e.target.closest("[data-shot-copy]");
if (c) return copyShot(shots.list[+c.dataset.shotCopy], c);
const b = e.target.closest("[data-shot-save]");
if (b) saveShots([shots.list[+b.dataset.shotSave]], b);
};
$("shotsRefresh").onclick = loadShots;
// A small right-click menu. items: [label, fn] pairs, null for a divider.
// back: the keyboard closed it, so focus returns to where it was.
function closeMenu(back) {
const menu = document.querySelector(".ctx-menu");
if (!menu) return;
menu.remove();
if (back === true && menu.opener?.isConnected) menu.opener.focus();
}
function showMenu(e, items) {
closeMenu();
const menu = document.createElement("div");
menu.className = "ctx-menu";
menu.setAttribute("role", "menu");
menu.opener = document.activeElement;
for (const it of items) {
if (!it) { menu.append(document.createElement("hr")); continue; }
const b = document.createElement("button");
b.textContent = it[0];
b.setAttribute("role", "menuitem");
b.onclick = () => { closeMenu(); it[1](); };
menu.append(b);
}
(document.fullscreenElement || document.body).append(menu); // in front of a fullscreen viewer
const r = menu.getBoundingClientRect();
menu.style.left = Math.max(4, Math.min(e.clientX, innerWidth - r.width - 4)) + "px";
menu.style.top = Math.max(4, Math.min(e.clientY, innerHeight - r.height - 4)) + "px";
menu.querySelector("button").focus();
}
document.addEventListener("pointerdown", e => { if (!e.target.closest(".ctx-menu")) closeMenu(); }, true);
document.addEventListener("keydown", e => {
const menu = document.querySelector(".ctx-menu");
if (!menu) return;
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); return closeMenu(true); } // Tab doesn't wander off behind it
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
e.preventDefault(); // arrows move through the menu, not the page
const items = [...menu.querySelectorAll("button")];
const at = items.indexOf(document.activeElement), step = e.key === "ArrowDown" ? 1 : -1;
items[at < 0 ? (step > 0 ? 0 : items.length - 1) : (at + step + items.length) % items.length].focus();
});
addEventListener("blur", closeMenu);
document.addEventListener("fullscreenchange", closeMenu);
addEventListener("scroll", closeMenu, true);
// Right-click a screenshot for everything it can do.
$("shotGrid").oncontextmenu = e => {
const card = e.target.closest("[data-card]");
if (!card) return;
e.preventDefault();
const s = shots.list[+card.dataset.card];
const items = [
["Open in viewer", () => openShot(s)],
["Copy image", () => copyShot(s)],
];
if (!s.saved || HOST.mobile) items.push(null, [`Save to ${HOST.computer}`, () => saveShots([s])]);
else {
const sep = shots.folder.includes("\\") ? "\\" : "/";
items.push(null);
items.push([`Show in ${!HOST.fileManager || HOST.fileManager === "your file manager" ? "folder" : HOST.fileManager}`,
() => act("Show screenshot", () => api("/api/open", { what: "shot", id: s.id }))]);
items.push(["Copy file path", () => act("Copy file path", async () => {
await copyText(shots.folder + sep + s.file);
return { message: "Copied the file path" };
})]);
}
items.push(null, ["Copy file name", () => act("Copy file name", async () => {
await copyText(s.file);
return { message: "Copied " + s.file };
})]);
showMenu(e, items);
};
function copyText(text) {
if (!navigator.clipboard?.writeText) throw new Error("This browser can't copy here");
return navigator.clipboard.writeText(text);
}
$("shotsRefresh").onclick = () => loadShots();
$("shotsSaveNew").onclick = e => saveShots(shots.list.filter(s => !s.saved), e.currentTarget);
$("shotsFolder").onclick = e => act($("shotsFolder").textContent, () => api("/api/open", { what: "shots" }), e.currentTarget);
// Watch for new shots: a cheap listing over the shared SSH connection while the
// window is visible and the Frame is reachable, and again on coming back to it.
setInterval(() => { if (!document.hidden && online) loadShots(true); }, SHOTS_POLL_MS);
document.addEventListener("visibilitychange", () => { if (!document.hidden && online) loadShots(true); });
// ---- Panel switcher: our UI over SteamVR's panel API ----
let panelSeq = 0;
@@ -4292,7 +4460,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
}
setView("headset");
refresh().then(loadShots); // after status, so app names resolve
refresh().then(() => loadShots()); // after status, so app names resolve
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
</script>
<script>
@@ -4413,7 +4581,7 @@ function onConnection(s) {
lastImg = null; lastShot = null;
$("canvas").hidden = true; $("viewerEmpty").hidden = false; $("zoombar").hidden = true;
["stamp", "srcBadge", "asleep"].forEach(id => $(id).hidden = true);
$("saveBtn").disabled = true;
$("saveBtn").disabled = $("copyBtn").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 => {
@@ -4422,7 +4590,7 @@ function onConnection(s) {
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 = [];
androidApps = []; resetShots();
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.
+66 -10
View File
@@ -334,12 +334,14 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
# Never let ssh inherit our stdin: under the app it's the pipe held open for
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
except subprocess.TimeoutExpired:
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 repair_ssh_config(err):
return ssh(remote, stdin=stdin, timeout=timeout, text=text)
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}")
@@ -348,6 +350,30 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
return r.stdout
_config_repaired = False
def repair_ssh_config(err):
"""Windows' OpenSSH refused ~/.ssh/config for its ACL: give the file a private one,
once per run. -> True if it did, so the command is worth retrying."""
global _config_repaired
if _config_repaired or not frame_host.WINDOWS or frame_host.BAD_PERMISSIONS not in err:
return False
# ssh doubles the backslashes: "C:\\Users\\me/.ssh/config"
named = re.sub(r"[\\/]+", "/", err.split(frame_host.BAD_PERMISSIONS, 1)[1].splitlines()[0].strip())
config = frame_devices.ssh_config()
if not named.lower().endswith("/" + config.name.lower()):
return False # a key or another file: not ours to rewrite
_config_repaired = True
try:
if frame_devices.repair_permissions(config):
print(f"Gave {config} a private ACL: ssh refused it ({named})", file=sys.stderr)
return True
except OSError as e:
print(f"Couldn't repair {config}'s permissions: {e}", file=sys.stderr)
return False
def strip_ansi(s):
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
@@ -505,8 +531,8 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
r = frame_host.run_ssh(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -1194,6 +1220,14 @@ def open_thing(body):
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
frame_host.open_path(SHOTS_DIR)
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
if what == "shot":
saved = SHOTS_DIR / shot_path(body.get("id")).rsplit("/", 1)[-1]
if not saved.exists():
raise Failure("That screenshot isn't saved on this computer yet", 404)
frame_host.reveal_path(saved)
return {"message": f"Showed {saved.name} in {frame_host.FILE_MANAGER}"}
except frame_host.Unreachable as e:
raise Failure(str(e), 400) # theirs to turn on; nothing failed here
except frame_host.HostError as e:
raise Failure(str(e), 500)
raise Failure("unknown target", 400)
@@ -2249,7 +2283,7 @@ def push_file(path, dest="Downloads/"):
else:
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
except subprocess.TimeoutExpired:
raise Failure(f"Copying {name} timed out")
if r.returncode != 0:
@@ -2257,6 +2291,11 @@ def push_file(path, dest="Downloads/"):
return f"Sent {name} to ~/{dest}"
class ClientGone(Exception):
"""The page went away (a reload, the app quitting) before its reply was written:
nobody to answer, and nothing went wrong here."""
class Handler(BaseHTTPRequestHandler):
server_version = "FrameControl/1"
timeout = 60 # per socket operation, so a stalled client can't hold a thread
@@ -2289,8 +2328,11 @@ class Handler(BaseHTTPRequestHandler):
# Nobody may frame the UI (clickjacking).
self.send_header("X-Frame-Options", "DENY")
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)
try:
self.end_headers()
self.wfile.write(data)
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
raise ClientGone() from e
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
@@ -2333,6 +2375,8 @@ class Handler(BaseHTTPRequestHandler):
from apk_sources import _images
try:
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
except ClientGone:
raise
except Exception:
self.send_json({"error": "Artwork unavailable"}, 404)
elif path == "/api/sources/details":
@@ -2410,6 +2454,8 @@ class Handler(BaseHTTPRequestHandler):
headers=[("X-Capture-Source", "gamescope")])
else:
self.send_json({"error": "not found"}, 404)
except ClientGone:
raise
except Failure as e:
self.send_error_json(str(e), e.status, e.apk)
except ValueError as e:
@@ -2445,6 +2491,8 @@ class Handler(BaseHTTPRequestHandler):
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
result = handler(body)
self.send_json(result)
except ClientGone:
raise
except Failure as e:
if e.status >= 500:
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
@@ -2620,6 +2668,10 @@ class LoopbackServer(ThreadingHTTPServer):
socketserver.TCPServer.server_bind(self)
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
def handle_error(self, request, client_address):
if not isinstance(sys.exc_info()[1], ClientGone):
super().handle_error(request, client_address)
_ONE_SERVER = None
@@ -2663,12 +2715,16 @@ def main():
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
def watch_stdin():
sys.stdin.buffer.read()
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
# and if a signal stops the server first, Python aborts (SIGABRT) at exit
# when it can't take that lock back from this thread.
while os.read(0, 4096):
pass
threading.Thread(target=httpd.shutdown, daemon=True).start()
threading.Thread(target=watch_stdin, daemon=True).start()
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try:
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
httpd.serve_forever()
except KeyboardInterrupt:
pass