Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 73b97fedc5 windows-vm.sh: stop the input helper before giving up on a click
On a timeout or a setup error the request now stops the scheduled task and
waits for it to end before releasing the mutex, so a stalled helper can't
act later inside another request.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:50:13 +10:00
saphidandClaude Opus 5.5 924e37aa30 windows-vm.sh: serialise clicks with a named mutex instead of a lock file
Windows releases the mutex when the request's process ends, so there's no
stale lock to recover and no window where two requests can take each
other's. Requests sent together now run one after another.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:44:35 +10:00
saphidandClaude Opus 5.5 bd24436fbc windows-vm.sh: an atomic click lock, and setup errors fail the click
input.txt is now created with CreateNew, so only one request can hold it; a
lock older than 15 s is treated as left by a dead run. The outer PowerShell
stops on any error, says why in one line, releases the lock and exits 1.
docs/testing.md names both keys Set Up Connection can add to the headset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:39:07 +10:00
saphidandClaude Opus 5.5 895cc0ebbd windows-vm.sh: review fixes for the headset key and input helper
- frame-key remove deletes only the exact line add wrote, through a private
  temporary file, and leaves authorized_keys alone if it can't read or
  rewrite it. add checks the VM's key is a plain ed25519 key, keeps a last
  line without a newline intact, and doesn't duplicate a key the headset
  already trusts.
- The input helper stops on any error before acknowledging, refuses a
  second click while one is in flight, and coordinates are bounded.
- Host, container and user names are checked before they reach a remote
  shell; PowerShell errors come back as plain text.
- docs/testing.md says what frame-key does and doesn't undo.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:32:35 +10:00
saphidandClaude Opus 5.5 f7dbad500a Docs: signing in over RDP as steamos reaches a Plasma desktop (verified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:24:48 +10:00
saphidandClaude Opus 5.5 3f20e5f9d3 Windows test VM: a driver script and how to set one up
scripts/windows-vm.sh drives a dockur/windows Windows 11 VM from the Mac over
ssh -J: start/stop, PowerShell, copy files in, screenshots via QEMU's
screendump, clicks and scrolls replayed by a scheduled task in the signed-in
session, QEMU key names, and a tagged, removable headset key for tests
against a real Frame. docs/testing.md explains setting it up (Windows from
Microsoft, loopback-only ports) and the pitfalls found reproducing the
Windows "RDP not working" report. docs/streaming.md records what Remote
Desktop from Windows does against the Frame's xrdp (verified 2026-09-30).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:29:56 +10:00
26 changed files with 363 additions and 1691 deletions

No files matched your search

+1 -1
View File
@@ -222,7 +222,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [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 |
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
| [Open questions](docs/open-questions.md) | What's still unchecked |
<details>
+6 -28
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, nativeImage, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, 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; serverStarted = Date.now(); return; }
if (await ping(target)) { url = target; return; }
await new Promise((r) => setTimeout(r, 100));
}
if (server === child) server = null;
@@ -175,27 +175,18 @@ function stopServer() {
if (server) endServer(server);
}
function errorPage(message, title = "Frame Control couldn't start") {
function errorPage(message) {
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>${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>`;
<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>`;
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) return;
if (Date.now() - serverStarted > 60000) restartServer();
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
// Restarts that overlap share one: two could each start a server, and the one
@@ -272,20 +263,7 @@ 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 -4
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 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 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,9 +12,7 @@ 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) => {
+2 -7
View File
@@ -88,13 +88,8 @@ 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). 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.
Linux). Sleep, restart and shut down open a terminal window because SteamOS
asks for the sudo password over SSH.
## How it works
+3 -54
View File
@@ -103,10 +103,8 @@ privately to Frame Control's PostHog project as a `problem_report` event, the
same way as the analytics above, so only the maintainer can read it and
nothing is published. It works whatever the analytics settings are, because
the person sends it deliberately. The report has the kind, title and text you
wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me
with follow-up questions** (the report then carries `contact_followup: true`);
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
wrote, how to reach you if you gave it, a short reference shown after sending,
and the diagnostics below. It has its own random id, so it isn't linked to
your analytics events.
With **Include diagnostics** ticked (the default), the report adds:
@@ -130,60 +128,11 @@ The maintainer reads reports on the Frame Control dashboard in PostHog, or
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
API key as `frame_compat_db.py sync`.
## Contact email (optional)
Frame Control never needs an email address. If you'd like to leave one, there
are two separate choices, both off until you tick them:
| Choice | What it's for |
|---|---|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
You're asked once, in a bar at the top of the page, after the Frame has
connected for the first time, and never while or straight after the
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, whatever the analytics settings are, because you chose to. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
the environment that starts Frame Control. A copy run from a source checkout
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
These switches cover the analytics above. A problem report or a contact email
is sent only because you pressed its Send or Save button, so those still go
when you choose to send them (a contact change saved while offline is sent
by itself once PostHog can be reached); if you don't, nothing is sent.
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
## Update checks
+14 -14
View File
@@ -21,24 +21,24 @@ The confidence labels are the same as in [ssh.md](ssh.md).
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
Remote Desktop never asks for a user or password. It warns that the certificate
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
frame" box with the username blank. Any user but `steamos` gets "User does not
exist, or could not be authenticated". Signing in as `steamos` with the
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
It's a new session on display `:10`, separate from what the headset shows,
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
it running, and the next sign-in reconnects to it. To end it, find its
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
and run `loginctl terminate-session <id>`; the headset's own session keeps
running.
**Recommendation for A:** start with Steam Link for macOS, because Valve
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
+81
View File
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
## Unit tests
@@ -163,6 +164,86 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
accepted. The fake now refuses them the same way, and Frame Control makes ids
Steam accepts.
## Windows test VM
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
depends on the Windows desktop (Remote Desktop, the installer, the bundled
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
Setting it up, once, on the Linux machine:
- **Windows comes from Microsoft.** The container downloads the Windows 11
image from Microsoft on first start. Windows runs unactivated, which is fine
for testing. Don't use activation workarounds or third-party Windows images.
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
`127.0.0.1:8006:8006` (the web console) and, if you need it,
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
testing, and a `docker stop` shuts Windows down cleanly.
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
as its default shell, and put a dedicated public key (for example
`~/.ssh/id_ed25519_winvm` on the Mac) in
`C:\ProgramData\ssh\administrators_authorized_keys`.
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
and the tests.
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
```sh
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
scripts/windows-vm.sh shot screen.png # what's on its screen
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
scripts/windows-vm.sh down # shut Windows down
```
- **Clicks go through a scheduled task.** Commands over SSH run in a
session with no desktop, so `click` and `scroll` write the position to a
file, and a scheduled task running as the signed-in user replays it. The
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
the same time run one after another. QEMU's own `mouse_move` is relative
and drifts, so the script doesn't use it.
- **Screenshots may not show the pointer.** Check the result of a click (a
menu that opens, a button that changes) rather than the pointer's position.
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
Do the same if you run `ssh` in the VM by hand.
- **Starting the app.** Run Frame Control in the signed-in session, not over
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
one `click` uses.
**Testing against a real Frame.** The VM reaches the headset on the LAN like
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
that makes the VM's keys. So the VM doesn't keep access between test runs,
afterwards take the lines it added out of the headset's
`~/.ssh/authorized_keys`: the ones matching the VM's
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
in the VM now fails. Then, around each run:
```sh
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
# ... test ...
scripts/windows-vm.sh frame-key remove # and take it out again
```
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
already trusts that key through another line. `remove` deletes only the exact
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
the file alone if it can't rewrite it. Follow the shared-device
procedure in [Headset smoke test](#headset-smoke-test) before installing or
launching anything on the headset.
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
connect it to the Frame and follow Remote Desktop through to the Frame's
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
`frame-key` left `authorized_keys` byte-for-byte as it was.
## Owned media player
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env zsh
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
# The VM is a dockur/windows container on another machine; this reaches it over
# SSH through that machine. See docs/testing.md#windows-test-vm.
#
# Usage: scripts/windows-vm.sh <command> [args]
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
# ps '<PowerShell>' run PowerShell as the VM's user
# put <file> [<dir>] copy a file in (default: the user's Downloads)
# shot <out.png> save the VM's screen
# click <x> <y> click screen pixel x,y in the signed-in session
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
# alias) for a test run, then take it out again
#
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
set -euo pipefail
setopt extendedglob
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
ctr=${WINVM_CONTAINER:-frame-winvm}
user=${WINVM_USER:-frame}
port=${WINVM_PORT:-2222}
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
die() { print -u2 "windows-vm: $*"; exit 1 }
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
# These go into commands run by a shell on the other machine, so keep them plain.
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
vm_ps() {
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
}
# QEMU's monitor inside the container: one command per line on stdin.
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
# Input has to come from the signed-in desktop session, not SSH's session 0, so
# a scheduled task running as the user replays one click or wheel turn written
# to input.txt, then deletes the file to say it's done. Any error stops it
# before that, so the caller times out instead of reporting a click.
INPUT_PS1='$ErrorActionPreference = "Stop"
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
"@
[WinVm.Input]::SetProcessDPIAware() | Out-Null
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
Start-Sleep -Milliseconds 150
if ($a[2] -eq "click") {
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
Remove-Item "$PSScriptRoot\input.txt"'
pointer() { # x y click|wheel [notches]
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
vm_ps '$ErrorActionPreference = "Stop"
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
# Giving up: end the helper first, or it could wake up and act in a later request.
function Abandon {
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
foreach ($i in 1..25) {
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
Start-Sleep -Milliseconds 200
}
Remove-Item $req -ErrorAction SilentlyContinue
}
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
# One request at a time: the helper, the task and input.txt are shared. Windows
# frees the mutex when this process ends, however it ends.
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
New-Item -ItemType Directory -Force $d | Out-Null
[IO.File]::WriteAllText($req, "'"$*"'")
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
Start-ScheduledTask -TaskName WindowsVmInput
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
Abandon
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
}
(( $# )) || die "usage: see the top of $0"
cmd=$1; shift
case $cmd in
up)
ssh $host "docker start $ctr" >/dev/null
for i in {1..60}; do
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
sleep 5
done
die "Windows didn't answer on SSH within 5 minutes" ;;
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
put)
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
shot)
(( $# == 1 )) || die "usage: shot <out.png>"
print "screendump /tmp/windows-vm-shot.ppm" | monitor
sleep 1
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
import re, struct, sys, zlib
d = sys.stdin.buffer.read()
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
w, h = int(m[1]), int(m[2]); px = d[m.end():]
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
' $1
print $1 ;;
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
keys)
(( $# )) || die "usage: keys <key>..."
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
for k; do print "sendkey $k"; done | monitor ;;
frame-key)
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
f=~/.ssh/authorized_keys
if [ "$1" = add ]; then
if grep -qxF "$3" "$f"; then exit 0; fi
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
echo "$3" >> "$f"
else
t=$(mktemp "$f.XXXXXX") || exit 1
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
fi
fi
EOF
print "frame-key $1: done" ;;
*) die "unknown command: $cmd (see the top of $0)" ;;
esac
-280
View File
@@ -1,280 +0,0 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
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"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = (e["properties"] for e in self.events())
self.assertEqual((without["contact"], without["contact_followup"]), ("", False))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
+2 -5
View File
@@ -17,7 +17,6 @@ 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
@@ -275,8 +274,7 @@ 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")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
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())
@@ -285,8 +283,7 @@ 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")
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
stdin=subprocess.DEVNULL, check=True, timeout=10)
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"))
+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(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
-87
View File
@@ -1,87 +0,0 @@
"""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
@@ -1,161 +0,0 @@
"""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,8 +111,6 @@ 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]}),
@@ -123,10 +121,6 @@ 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"})
@@ -285,18 +279,6 @@ 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."""
+3 -5
View File
@@ -392,14 +392,13 @@ class ReportProblem(Base):
got = self.serve()
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
"contact": "me@example.com", "contactFollowup": True})
"contact": "me@example.com"})
path, body = got[0]
event = body["batch"][0]
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
props = event["properties"]
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
self.assertIs(props["contact_followup"], True)
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, True))
self.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
self.assertIn(res["id"], res["message"])
@@ -422,9 +421,8 @@ class ReportProblem(Base):
def test_the_inbox_skips_malformed_reports(self):
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
"0.4.0", "macOS", "", "", None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None],
["short"], good]
"0.4.0", "macOS", "", ""]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
mock.patch("builtins.print") as out:
-162
View File
@@ -1,162 +0,0 @@
"""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 = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
timeout=timeout, text=isinstance(input, str) or input is None)
p = subprocess.run(['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:
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
subprocess.run(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:
+4 -5
View File
@@ -11,17 +11,16 @@ 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 = 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)
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)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
+5 -10
View File
@@ -24,10 +24,6 @@ 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")
@@ -334,9 +330,8 @@ 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 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")
if os.name != "nt":
tmp.chmod(0o600)
# 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):
@@ -354,9 +349,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 frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
capture_output=True).returncode == 0
def configured_user():
-218
View File
@@ -1,218 +0,0 @@
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
Two separate opt-in choices, both off until ticked:
- updates: occasional notices about Frame Control releases and updates
- followup: the maintainer may ask follow-up questions, mainly about problem reports
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
as problem reports (frame_report.py), so only the maintainer can read them. Every change
sends a new event under this copy's own random contact id (not the analytics id), numbered
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
removing the address sends a withdrawal with no address in it, and wipes the address from
the local log of what was sent. The maintainer lists who agreed to what with
`python3 ui/frame_report.py contacts`. Nothing here sends email.
A change that can't be sent (offline) waits in the state file and is retried in the
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
too: once it has been shown or dismissed it never comes back.
"""
import json
import os
import re
import threading
import time
import uuid
import frame_host
import frame_telemetry
STATE = frame_host.data_dir('contact')
FILE = STATE / 'contact.json'
EMAIL_MAX = 254
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
RETRY_EVERY = 600
_lock = threading.RLock()
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
_wake = threading.Event()
_retrier = None
def _defaults():
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
'prompt': 'new', 'pending': None, 'rev': 0}
def load():
with _lock:
s = _defaults()
try:
with open(FILE) as f:
saved = json.load(f)
if isinstance(saved, dict):
s.update({k: v for k, v in saved.items() if k in s})
except (OSError, ValueError):
pass
return s
def _save(s):
STATE.mkdir(parents=True, exist_ok=True)
tmp = FILE.with_suffix('.tmp')
tmp.write_text(json.dumps(s, indent=1))
os.replace(tmp, FILE)
def valid_email(email):
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
and the Frame has connected at least once (setup worked), so it never greets a new install."""
s = load()
set_up = bool(frame_telemetry.settings().get('frames_seen'))
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
def _event(s):
email = s['email'] if s['updates'] or s['followup'] else ''
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
'followup': bool(email and s['followup']),
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
def _send_pending(block=True):
"""Send what's waiting, including changes made while sending. True if nothing is left
waiting. Without block, a send already under way is left to pick up the newest change."""
if not _send_lock.acquire(blocking=block):
return False
try:
while True:
with _lock:
event = load()['pending']
if event is None:
break
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
return False
_sent(event)
finally:
_send_lock.release()
# A change saved just as this finished found the lock still held and left it to us.
with _lock:
left = load()['pending'] is not None
return _send_pending(block=False) if left else True
def _sent(event):
with _lock:
s = load()
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
s['pending'] = None
_save(s)
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
if event['properties']['email'] in ('', s['email']):
try:
frame_telemetry.record_sent([event])
except OSError:
pass
def _forget_locally(email):
"""Take a removed address out of the log of what was sent (contact events and reports)."""
with frame_telemetry._lock:
_removed[email.lower()] = time.time()
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
hit = False
for e in rows:
p = e.get('properties') or {}
for k in ('email', 'contact'):
if p.get(k) and str(p[k]).strip().lower() == email.lower():
p[k], hit = '<removed>', True
if hit:
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
def redact_removed(event, started):
"""Before logging a report (started at time.time() `started`) whose address was removed
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
removal can't slip between this and the log."""
p = event.get('properties') or {}
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
if removed_at is not None and started <= removed_at:
p['contact'] = '<removed>'
def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
email = str(body.get('email') or '').strip()
updates, followup = bool(body.get('updates')), bool(body.get('followup'))
if email and not valid_email(email):
raise ValueError("that doesn't look like an email address")
if email and not (updates or followup):
raise ValueError('tick what the address may be used for, or remove it')
if not email:
updates = followup = False
with _lock:
s = load()
old = s['email']
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
s.update(email=email, updates=updates, followup=followup)
if body.get('fromPrompt') or email:
s['prompt'] = 'answered'
if changed:
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
# is sent even for an address still waiting here: its send may already be under way.
s['rev'] += 1
s['pending'] = _event(s)
_save(s)
if old and old.lower() != email.lower():
try:
_forget_locally(old)
except OSError:
pass
if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it
return state()
def prompt(body):
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
action = body.get('prompt')
if action not in ('shown', 'dismissed'):
raise ValueError('unknown prompt action')
with _lock:
s = load()
if s['prompt'] in ('new', 'shown'):
s['prompt'] = action
_save(s)
return state()
def start():
"""Retry a change that couldn't be sent, from now on in the background."""
global _retrier
if _retrier:
return
def loop():
while True:
try:
_send_pending()
except Exception:
pass
_wake.wait(RETRY_EVERY)
_wake.clear()
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
_retrier.start()
+6 -36
View File
@@ -241,7 +241,8 @@ def _write_config(path, text, expected):
try:
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
fh.write(text)
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
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
@@ -270,37 +271,6 @@ 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
@@ -363,8 +333,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 = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
stdin=subprocess.DEVNULL, timeout=10).stdout
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)
@@ -376,8 +346,8 @@ def effective_port(alias, config):
def _keygen(*args):
try:
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10)
except (OSError, subprocess.TimeoutExpired):
return None
+8 -141
View File
@@ -5,16 +5,12 @@ 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"
@@ -36,45 +32,6 @@ 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)."""
@@ -196,19 +153,6 @@ 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
@@ -284,46 +228,10 @@ 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 = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
out = subprocess.run(["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():
@@ -356,65 +264,24 @@ 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 f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
return "Opened Windows App"
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", str(rdp_file(host))])
# Windows asks about the unsigned connection file first.
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
_spawn(["mstsc.exe", f"/v:{host}"])
return f"Opened Remote Desktop to {host}"
if which("remmina"):
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
return f"Opened Remmina to {host}: {RDP_LOGIN}"
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
return f"Opened Remmina to {host}"
for name in ("xfreerdp3", "xfreerdp"):
if which(name):
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
return f"Opened FreeRDP to {host}"
raise HostError("No RDP client found: install Remmina or FreeRDP")
+9 -10
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 = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
timeout=10).stdout
except (OSError, subprocess.TimeoutExpired):
out = ""
got = {}
@@ -108,8 +108,7 @@ 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
# 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])}"
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
except (OSError, AttributeError):
pass
left = deadline - now()
@@ -766,8 +765,8 @@ class Link:
if not self.control:
return False
try:
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
return subprocess.run([*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
@@ -778,8 +777,8 @@ class Link:
pending.kill()
if self.control and self.alias:
try:
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
stdin=subprocess.DEVNULL, timeout=5)
subprocess.run([*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:
@@ -984,8 +983,8 @@ class Link:
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
try:
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors="replace", timeout=20)
r = subprocess.run(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")
+7 -68
View File
@@ -6,10 +6,6 @@ project as a `problem_report` event: only the maintainer can read it, and
nothing is published. It is sent whatever the analytics settings are, because
the person sends it deliberately. Diagnostics are scrubbed first
(frame_telemetry.scrub); the person's own words are sent as written.
An email address goes with a report only when the person ticks "may contact me with
follow-up questions" (contact_followup). Standing choices made in Settings are
frame_contact.py's `contact_consent` events; `contacts` lists them.
"""
import os
import platform
@@ -17,7 +13,6 @@ import sys
import time
import uuid
import frame_contact
import frame_host
import frame_telemetry
@@ -112,26 +107,19 @@ def send(body):
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
title, text, diag = compose(body)
followup = bool(body.get('contactFollowup'))
contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box')
ref = uuid.uuid4().hex[:8].upper()
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
started = time.time()
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
with frame_telemetry._lock: # the lock a removal holds while wiping its address
frame_contact.redact_removed(event, started)
frame_telemetry.record_sent([event])
frame_telemetry.record_sent([event])
except OSError:
pass # it was sent; failing to log it here mustn't make the person send it again
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
@@ -147,71 +135,22 @@ def inbox(days=30):
import frame_compat_db
res = frame_compat_db._posthog_query(
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
"properties.contact_followup "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
"ORDER BY timestamp DESC LIMIT 200")
return res.get('results') or []
def _yes(v):
return v is True or str(v).lower() in ('true', '1')
def contacts():
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
the highest rev from that copy (then time), so every field comes from the same event
whatever order they arrived in or what the clocks said."""
import frame_compat_db
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
res = frame_compat_db._posthog_query(
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
out = {'updates': [], 'followup': []}
for row in res.get('results') or []:
if not isinstance(row, list) or len(row) != 5:
continue
_, email, updates, followup, ts = row
email = str(email or '').strip()
if not frame_contact.valid_email(email):
continue
for kind, agreed in (('updates', updates), ('followup', followup)):
if _yes(agreed):
out[kind].append((email, str(ts or '')[:10]))
return out
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
def main():
cmd, *args = sys.argv[1:] or ['inbox']
if cmd == 'contacts':
kinds = args[:1] or ['updates', 'followup']
if not set(kinds) <= {'updates', 'followup'}:
sys.exit(USAGE)
found = contacts()
for kind in kinds:
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
f" ({len(found[kind])})")
for email, since in found[kind]:
print(f" {email} (since {since})")
print()
return
if cmd != 'inbox':
sys.exit(USAGE)
sys.exit('usage: frame_report.py inbox [days]')
for row in inbox(*(args[:1] or [30])):
if not isinstance(row, list) or len(row) != 11:
if not isinstance(row, list) or len(row) != 10:
continue
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
# Reports from before contact_followup existed only carried an address given for a reply.
reply = contact and (row[10] is None or _yes(row[10]))
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
f"{', may follow up at ' + contact if reply else ''}")
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
print(' ' + text.replace('\n', '\n '))
if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
+35 -302
View File
@@ -175,7 +175,7 @@
.seg { display: inline-flex; background: rgba(0,0,0,.3); border-radius: 3px; padding: 2px; }
.seg button { background: transparent; height: 28px; font-size: 12.5px; letter-spacing: .6px; text-transform: uppercase; }
.seg button.on { background: var(--btn-hi); color: var(--bright); }
input[type=text], input[type=search], input[type=url], input[type=password], input[type=email], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
input[type=text], input[type=search], input[type=url], input[type=password], textarea { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 9px 11px; font: inherit; }
textarea { resize: vertical; min-height: 76px; }
input:focus, textarea:focus { outline: none; border-color: var(--blue); background: rgba(0,0,0,.4); }
@@ -258,12 +258,7 @@
.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; 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; }
.shot-card .s { color: var(--muted); font-size: 12px; }
/* ---- library shelf (portrait capsules, like Steam's library home) ---- */
.shelf { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
@@ -301,11 +296,6 @@
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
.notice .grow { flex: 1; min-width: 260px; }
.notice .progress { width: 160px; margin-top: 0; display: block; }
.contact-opts { display: flex; gap: 6px 18px; flex-wrap: wrap; margin-top: 8px; }
.contact-opts label { display: inline-flex; gap: 7px; align-items: center; cursor: pointer; }
.contact-opts input { width: 15px; height: 15px; margin: 0; accent-color: var(--blue); }
#contactNotice input[type=email] { width: min(320px, 100%); margin-top: 8px; padding: 7px 10px; }
#cEmail { max-width: 420px; }
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
.popt b { font-weight: 600; color: var(--text); }
@@ -684,17 +674,6 @@
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
<button class="action small" id="noticeOk">OK</button>
</div>
<form class="notice" id="contactNotice" hidden>
<div class="grow"><b>Leave an email address?</b> Optional: Frame Control works the same either way, and
you can change or remove it any time in Privacy &amp; updates.
<div><input type="email" id="cpEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address" required></div>
<div class="contact-opts">
<label><input type="checkbox" id="cpUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cpFollowup"> The maintainer may contact me with follow-up questions</label></div></div>
<span class="sub" id="cpMsg" role="status"></span>
<button type="button" class="small" id="cpNo">No thanks</button>
<button type="submit" class="action small" id="cpSave">Save</button>
</form>
<div class="banner" id="offline" role="alert" hidden>
<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>
@@ -722,7 +701,6 @@
<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>
@@ -889,7 +867,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. 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>
<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>
</section>
</div>
@@ -1149,17 +1127,6 @@
<span class="sub">Error messages and where in Frame Control they happened, with your home
folder, user name, addresses and keys removed.</span></label>
<div class="hint" id="tStatus"></div>
<h3 style="margin-top:22px">Contact email (optional)</h3>
<form id="contactForm">
<input type="email" id="cEmail" maxlength="254" placeholder="you@example.com" aria-label="Email address">
<div class="contact-opts">
<label><input type="checkbox" id="cUpdates"> Email me about Frame Control updates</label>
<label><input type="checkbox" id="cFollowup"> The maintainer may contact me with follow-up questions</label></div>
<div class="row" style="margin-top:10px"><button type="submit" class="small action" id="cSave">Save</button>
<button type="button" class="small" id="cRemove">Remove my email</button></div>
</form>
<div class="hint" id="cStatus">Sent privately to the Frame Control maintainer, never shared or published.
Updates are occasional release notices; follow-up questions are mainly about problem reports you send.</div>
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
<button class="small" id="updateCheck" hidden>Check for updates</button>
@@ -1267,9 +1234,7 @@
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
placeholder="What you did, what happened, and what you expected."></textarea></label>
<label class="popt"><input type="checkbox" id="bugFollowup"><b>The maintainer may contact me with follow-up questions</b>
<span class="sub">Optional. Your email address goes with this report only when this is ticked.</span></label>
<label class="field">Your email address<input type="email" id="bugContact" maxlength="254" placeholder="you@example.com" disabled></label>
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
@@ -1904,7 +1869,7 @@ async function capture() {
if (source === "panel") $("srcBadge").textContent = `Desktop · ${shotPanel?.name || "panel"}`;
ctrlShow();
$("stamp").hidden = false; $("stamp").textContent = new Date().toLocaleTimeString();
$("saveBtn").disabled = $("copyBtn").disabled = false;
$("saveBtn").disabled = false;
return true;
} catch (e) {
log("Capture failed: " + e.message, "e");
@@ -1986,7 +1951,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 = $("copyBtn").disabled = false;
$("stamp").hidden = false; $("saveBtn").disabled = false;
}
frames++;
const now = performance.now();
@@ -2055,35 +2020,6 @@ $("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");
@@ -3561,11 +3497,8 @@ loadReports();
api("/api/host").then(applyHostWording).catch(() => {});
// ---- Steam screenshots from the headset ----
// 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 shots = { list: [], urls: [] };
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);
@@ -3577,85 +3510,35 @@ async function shotBlob(id, thumb) {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
return r.blob();
}
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;
async function loadShots() {
$("shotsRefresh").disabled = true;
try {
got = await api("/api/shots");
shots.list = (await api("/api/shots")).shots;
} catch (e) {
if (!quiet && gen === shots.gen) { shots.sig = null; failed($("shotGrid"), e); }
return;
return failed($("shotGrid"), e);
} finally { $("shotsRefresh").disabled = false; }
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, () => {}); }
}
shots.urls.forEach(URL.revokeObjectURL); shots.urls = [];
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" 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)">
$("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">
<div class="row"><div class="grow">
<div class="t">${esc(shotApp(s.appid))}</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>
<div class="s">${esc(new Date(s.time * 1000).toLocaleString())}</div></div>
${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);
@@ -3673,7 +3556,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 = $("copyBtn").disabled = false;
$("saveBtn").disabled = false;
$("view").scrollIntoView({ behavior: "smooth" });
} catch (e) {
toast("Couldn't open the screenshot: " + e.message, true);
@@ -3692,95 +3575,15 @@ 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);
};
// 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();
$("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;
@@ -4161,68 +3964,7 @@ $("noticeMore").onclick = async () => {
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
};
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
const telemetryLoaded = loadTelemetry();
// ---- contact email: two separate opt-ins (ui/frame_contact.py, docs/privacy.md) ----
const contact = { email: "", updates: false, followup: false };
function renderContact(s) {
Object.assign(contact, s);
$("cEmail").value = s.email; $("cUpdates").checked = s.updates; $("cFollowup").checked = s.followup;
$("cRemove").hidden = !s.email;
$("cStatus").textContent = s.waiting ? "Saved here; it's sent to the maintainer when Frame Control can reach PostHog."
: s.email ? `Saved. ${s.email} may get ${[s.updates && "update notices", s.followup && "follow-up questions"].filter(Boolean).join(" and ")}. Remove it any time.`
: "Sent privately to the Frame Control maintainer, never shared or published. Updates are occasional release notices; follow-up questions are mainly about problem reports you send.";
}
async function saveContact(change) {
const s = await api("/api/contact", change);
renderContact(s);
return s;
}
async function loadContact() {
await telemetryLoaded;
try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt();
}
// One time only, only once the Frame has connected, and never on top of the privacy notice or
// straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects.
async function checkContactPrompt() {
if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return;
let s;
try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").hidden) return;
$("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
}
$("contactNotice").onsubmit = async e => {
e.preventDefault();
if (!$("cpUpdates").checked && !$("cpFollowup").checked) { $("cpMsg").textContent = "Tick at least one, or choose No thanks."; return; }
$("cpSave").disabled = true;
try {
await saveContact({ email: $("cpEmail").value, updates: $("cpUpdates").checked, followup: $("cpFollowup").checked, fromPrompt: true });
$("contactNotice").hidden = true;
toast("Thanks! Change or remove it any time in Privacy & updates.");
} catch (err) { $("cpMsg").textContent = `Couldn't save: ${err.message}`; }
finally { $("cpSave").disabled = false; }
};
$("cpNo").onclick = async () => {
try { await api("/api/contact/prompt", { prompt: "dismissed" }); $("contactNotice").hidden = true; }
catch (err) { $("cpMsg").textContent = `Couldn't save that: ${err.message}. Try again.`; }
};
$("contactForm").onsubmit = async e => {
e.preventDefault();
const email = $("cEmail").value.trim();
if (email && !$("cUpdates").checked && !$("cFollowup").checked) {
$("cStatus").textContent = "Tick what your email may be used for, or use Remove my email."; return;
}
try { await saveContact({ email, updates: $("cUpdates").checked, followup: $("cFollowup").checked });
toast(email ? "Contact email saved." : "Contact email removed."); }
catch (err) { toast(`Couldn't save: ${err.message}`, true); }
};
$("cRemove").onclick = async () => {
try { await saveContact({ email: "", updates: false, followup: false }); toast("Contact email removed. Neither choice applies any more."); }
catch (err) { toast(`Couldn't remove it: ${err.message}`, true); }
};
loadContact();
loadTelemetry();
function pageEvent(event, properties) {
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
}
@@ -4237,8 +3979,8 @@ document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () =
const bug = { preview: "" };
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
function bugReportText() {
const email = $("bugFollowup").checked ? $("bugContact").value.trim() : "";
const body = `Kind: ${$("bugKind").value}${email ? `\nFollow-up questions welcome: ${email}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
const contact = $("bugContact").value.trim();
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
return { title: $("bugTitleIn").value.trim(), body };
}
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
@@ -4254,20 +3996,12 @@ function openBugReport() {
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
$("bugCancel").textContent = "Cancel";
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
// A standing yes to follow-up questions (Privacy & updates) fills this in; it can be unticked.
$("bugFollowup").checked = contact.followup; $("bugContact").value = contact.followup ? contact.email : "";
$("bugContact").disabled = !contact.followup; $("bugContact").required = contact.followup;
$("bugDlg").showModal();
loadBugPreview();
}
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
$("bugLogs").onchange = loadBugPreview;
$("bugFollowup").onchange = () => {
const on = $("bugFollowup").checked;
$("bugContact").disabled = !on; $("bugContact").required = on;
if (on && !$("bugContact").value) { $("bugContact").value = contact.email; $("bugContact").focus(); }
};
$("bugCancel").onclick = () => $("bugDlg").close();
$("bugCopy").onclick = async () => {
const { title, body } = bugReportText();
@@ -4281,8 +4015,7 @@ $("bugForm").onsubmit = async e => {
try {
const res = await api("/api/report", {
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
contactFollowup: $("bugFollowup").checked, contact: $("bugFollowup").checked ? $("bugContact").value : "",
diagnostics: $("bugDiag").checked ? bug.preview : "" });
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
$("bugCancel").textContent = "Close";
log(res.message, "ok");
@@ -4444,7 +4177,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>
@@ -4565,7 +4298,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 = $("copyBtn").disabled = 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 => {
@@ -4574,7 +4307,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 = []; resetShots();
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.
@@ -4590,7 +4323,7 @@ function onConnection(s) {
$("offline").hidden = true;
const reload = link.reload;
link.reload = false;
refresh().then(() => { if (reload) reloadAll(); checkContactPrompt(); });
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") {
+10 -71
View File
@@ -50,7 +50,6 @@ import frame_catalog # noqa: E402
import frame_devices # noqa: E402
import frame_steamgriddb
import frame_comfort # noqa: E402
import frame_contact # noqa: E402
import frame_host # noqa: E402
import frame_link # noqa: E402
import frame_macview # noqa: E402
@@ -333,14 +332,12 @@ 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 = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
text=text, errors="replace" if text else None, timeout=timeout)
r = subprocess.run([*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}")
@@ -349,30 +346,6 @@ 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)
@@ -530,8 +503,8 @@ def save_shots(body):
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
try:
try:
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)
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)
except subprocess.TimeoutExpired:
raise Failure("Copying screenshots timed out")
if r.returncode != 0:
@@ -1219,14 +1192,6 @@ 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)
@@ -2186,7 +2151,6 @@ POST = {
"/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/contact": frame_contact.save, "/api/contact/prompt": frame_contact.prompt,
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
"/api/devices": lambda body: devices_post(body)}
@@ -2282,7 +2246,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 = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
r = subprocess.run(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:
@@ -2290,11 +2254,6 @@ 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
@@ -2327,11 +2286,8 @@ 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'")
try:
self.end_headers()
self.wfile.write(data)
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
raise ClientGone() from e
self.end_headers()
self.wfile.write(data)
def send_json(self, obj, status=200):
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
@@ -2374,8 +2330,6 @@ 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":
@@ -2426,8 +2380,6 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(macview_state(parse_qs(url.query)))
elif path == "/api/telemetry":
self.send_json(frame_telemetry.state())
elif path == "/api/contact":
self.send_json(frame_contact.state())
elif path == "/api/computer/state":
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
elif path == "/api/status":
@@ -2453,8 +2405,6 @@ 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:
@@ -2489,8 +2439,6 @@ class Handler(BaseHTTPRequestHandler):
with (contextlib.nullcontext() if path == "/api/devices" 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)
@@ -2666,10 +2614,6 @@ 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
@@ -2700,7 +2644,6 @@ def main():
sweep_tmp()
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
frame_telemetry.start()
frame_contact.start()
global LINK, _ONE_SERVER
if not LOCAL:
if not PRIVATE: # a private server only uses the headsets (see one_server)
@@ -2713,16 +2656,12 @@ def main():
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
if args.exit_on_eof:
def watch_stdin():
# 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
sys.stdin.buffer.read()
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