mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 06:00:33 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551cc54bbc | ||
|
|
c3e651cd25 | ||
|
|
f0ba42bfba | ||
|
|
99fc15bd79 | ||
|
|
e63dc43c2f | ||
|
|
80f433a2d3 | ||
|
|
07f44f9082 | ||
|
|
c305daae15 | ||
|
|
a4031db052 | ||
|
|
049d50f43a | ||
|
|
3a95d3b638 | ||
|
|
989962aecc | ||
|
|
08d75e3ffb | ||
|
|
01d5c612c0 | ||
|
|
28073e212a | ||
|
|
6abc765e22 | ||
|
|
f73efe414c | ||
|
|
704e5d7780 | ||
|
|
edbe8d4109 | ||
|
|
f2c8466ba9 | ||
|
|
6cf01729e2 | ||
|
|
63c1a9ff55 | ||
|
|
47a29afb4c | ||
|
|
a0f810c018 | ||
|
|
e8db571a2c | ||
|
|
d9cd40c035 | ||
|
|
865e8dc17f | ||
|
|
34a334fa27 | ||
|
|
3f273ca37a | ||
|
|
72ffec45c2 | ||
|
|
2ca0e6924a | ||
|
|
cf2db32721 | ||
|
|
3f0f09b138 | ||
|
|
b8ed53f2ff | ||
|
|
19a0d0af18 | ||
|
|
7308ac09b1 | ||
|
|
1a5f089e57 | ||
|
|
83d74548cd |
No files matched your search
@@ -189,7 +189,7 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
|
|||||||
|
|
||||||
This is a first public test, so reports are really useful, especially from
|
This is a first public test, so reports are really useful, especially from
|
||||||
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
Windows and Linux. The quickest way is **Report a problem** in the app (the
|
||||||
speech-bubble button at the top, or **Help → Report a Problem…**). It adds
|
warning-sign button at the top, or **Help → Report a Problem…**). It adds
|
||||||
diagnostics with personal details removed, shows you exactly what's included,
|
diagnostics with personal details removed, shows you exactly what's included,
|
||||||
and sends it privately to the maintainer; nothing is published. Without the app,
|
and sends it privately to the maintainer; nothing is published. Without the app,
|
||||||
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
|
||||||
|
|||||||
+28
-6
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// 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
|
// 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.
|
// work over the `frame` SSH alias; this file only hosts it.
|
||||||
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
|
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -149,7 +149,7 @@ async function startServer() {
|
|||||||
const target = `http://127.0.0.1:${port}/`;
|
const target = `http://127.0.0.1:${port}/`;
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||||
if (await ping(target)) { url = target; return; }
|
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
@@ -175,18 +175,27 @@ function stopServer() {
|
|||||||
if (server) endServer(server);
|
if (server) endServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message, title = "Frame Control couldn't start") {
|
||||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
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">
|
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
<p>${esc(message)}</p>
|
||||||
|
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||||
|
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||||
|
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
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) {
|
function serverDied(why) {
|
||||||
url = null;
|
url = null;
|
||||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
if (!win) return;
|
||||||
|
if (Date.now() - serverStarted > 60000) restartServer();
|
||||||
|
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restarts that overlap share one: two could each start a server, and the one
|
// Restarts that overlap share one: two could each start a server, and the one
|
||||||
@@ -263,7 +272,20 @@ function fromUi(e) {
|
|||||||
} catch { return false; }
|
} 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() : "");
|
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.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
||||||
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^44.4.5",
|
"electron": "^44.4.5",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frame-control",
|
"name": "frame-control",
|
||||||
"productName": "Frame Control",
|
"productName": "Frame Control",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
+4
-2
@@ -2,8 +2,8 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// 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
|
// 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).
|
// as a title without zipping it (the local server reads it from there).
|
||||||
// It can open Set Up Connection when the headset can't be reached, and keeps the
|
// It can put a screenshot on the clipboard as an image, open Set Up Connection when
|
||||||
// Frame menu's list of headsets up to date.
|
// 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
|
// 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.
|
// 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.
|
// And it passes update state both ways: see app/updater.js.
|
||||||
@@ -12,7 +12,9 @@ const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
|||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
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.
|
// The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
|
||||||
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
|
||||||
onUseDevice: (cb) => {
|
onUseDevice: (cb) => {
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ replies, screenshots and approval payloads are not sent to analytics.
|
|||||||
|
|
||||||
## Assistant panel
|
## Assistant panel
|
||||||
|
|
||||||
Open **Settings** (the gear in the header) **→ Open assistant settings**, or `http://127.0.0.1:47810/assistant`.
|
Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
|
||||||
To put the same page in the headset, with the HTTP server still running:
|
To put the same page in the headset, with the HTTP server still running:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+4
-16
@@ -3,9 +3,8 @@
|
|||||||
Frame Control can manage more than one Steam Frame, and each headset can be
|
Frame Control can manage more than one Steam Frame, and each headset can be
|
||||||
reached at more than one address: a LAN IP at home, another at the office, its
|
reached at more than one address: a LAN IP at home, another at the office, its
|
||||||
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
|
||||||
tab (key 5) lists them. The connection pill in the header says whether the one in
|
tab (key 5) lists them, and the connection pill in the header shows what the
|
||||||
use is connected and how (Tailscale, or the network's name); click it to add,
|
app is doing to reach the one in use, step by step, as it happens.
|
||||||
edit or reorder that headset's addresses, or to see each step of connecting.
|
|
||||||
|
|
||||||
The code is in three modules, all stdlib-only Python on your computer:
|
The code is in three modules, all stdlib-only Python on your computer:
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ and ranks them:
|
|||||||
5. addresses that only ever worked on other networks;
|
5. addresses that only ever worked on other networks;
|
||||||
6. Tailscale addresses while Tailscale is off.
|
6. Tailscale addresses while Tailscale is off.
|
||||||
|
|
||||||
Your order (on the Devices tab, or in the pill's dialog) breaks ties. The best-ranked address that answers
|
Your order on the Devices tab breaks ties. The best-ranked address that answers
|
||||||
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
wins; one that answers first waits up to 0.35 s for a better-ranked one that is
|
||||||
still trying. If SSH to the winner fails in a way another address could fix
|
still trying. If SSH to the winner fails in a way another address could fix
|
||||||
(a different device answered there, or the link dropped), the next one that
|
(a different device answered there, or the link dropped), the next one that
|
||||||
@@ -73,16 +72,6 @@ status --json`, including the Mac app's own CLI) with buttons to add their
|
|||||||
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
|
||||||
services and checks `ALIAS.local` and `frame.local`.
|
services and checks `ALIAS.local` and `frame.local`.
|
||||||
|
|
||||||
**The pill's dialog** lists the same addresses, with what each one answered, and
|
|
||||||
can add, edit, reorder and remove them without leaving the page you're on. When
|
|
||||||
the headset reports a LAN IP on the same network as this computer and that IP
|
|
||||||
isn't saved, it offers to add it. The offer puts the address first in the list,
|
|
||||||
so on that network it wins over the Tailscale name; away from home the Tailscale
|
|
||||||
name still leads. A new or edited address is tested straight away. While
|
|
||||||
connected, **Reconnect** applies your changes now rather than at the next
|
|
||||||
connection: it tries the addresses again, ranked as above, and the best-ranked
|
|
||||||
one that answers promptly wins. It doesn't pick a particular address.
|
|
||||||
|
|
||||||
## Networks
|
## Networks
|
||||||
|
|
||||||
A network is told apart by its default gateway: the router's IP address plus its
|
A network is told apart by its default gateway: the router's IP address plus its
|
||||||
@@ -133,8 +122,7 @@ file per headset instead, so saving or forgetting one headset's key never touche
|
|||||||
another's: a different device answering at one of its
|
another's: a different device answering at one of its
|
||||||
addresses is refused, and the pill says so. A headset's first connection trusts
|
addresses is refused, and the pill says so. A headset's first connection trusts
|
||||||
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
the key it shows, as Set Up Connection does. After reinstalling SteamOS the
|
||||||
headset has a new key; **Forget identity** (Devices tab → **Advanced**, with the
|
headset has a new key; **Forget identity** on the Devices tab lets the next
|
||||||
SSH alias, user and port) lets the next
|
|
||||||
connection save the new one.
|
connection save the new one.
|
||||||
|
|
||||||
## One server at a time
|
## One server at a time
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ not been verified and is not guaranteed.
|
|||||||
|
|
||||||
## Casting
|
## Casting
|
||||||
|
|
||||||
**Cast to this screen** (Home → **Right now**) starts the existing headset Live view and requests full
|
**Cast headset view** starts the existing headset Live view and requests full
|
||||||
screen where supported. Show that screen to people in the room, or use the
|
screen where supported. Show that screen to people in the room, or use the
|
||||||
computer/phone's own screen mirroring. It creates no new stream transport,
|
computer/phone's own screen mirroring. It creates no new stream transport,
|
||||||
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
public URL or LAN server. iPhone uses the inline viewer if full screen is not
|
||||||
|
|||||||
+16
-16
@@ -17,19 +17,14 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
The window has five tabs: **Home** (what the headset's doing and what you'd do
|
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||||
next, the headset view, screenshots, family and comfort, the keyboard and
|
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||||
trackpad, and VR performance, folded away), **Games** (installed games,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
sideloaded titles, getting games), **Android** (apps, the catalogue, display
|
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||||
settings, reports), **Tools** (in the headset: your computer's windows, the media
|
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||||
player and the panel switcher; then sending files and text, Flatpaks, remote and
|
connection pill in the header always shows which headset, which network this
|
||||||
power) and **Devices** (your headsets and their addresses). Keys 1–5 switch
|
computer is on, the address in use or being tried, and each step of connecting
|
||||||
between them. The gear in the header (key 6) opens **Settings**: privacy and
|
as it happens; click it for the whole timeline. When the Frame can't be
|
||||||
updates, library artwork and the assistant. The battery chip opens the headset's
|
|
||||||
details: storage, memory, temperature, Wi-Fi, uptime and SteamOS build. Files can be dropped anywhere in the window. A
|
|
||||||
connection pill in the header always shows which headset and whether it's
|
|
||||||
connected, and how (Tailscale, or the network's name). Click it to add, edit or
|
|
||||||
reorder the headset's addresses, or to see each step of connecting. When the Frame can't be
|
|
||||||
reached, a banner says why in plain words, what was tried, and counts down to
|
reached, a banner says why in plain words, what was tried, and counts down to
|
||||||
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
|
||||||
counts them while they run.
|
counts them while they run.
|
||||||
@@ -93,8 +88,13 @@ counts them while they run.
|
|||||||
name your networks, and switch headsets. See [devices.md](devices.md).
|
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
|
- **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
|
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
|
||||||
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
Linux). Remote desktop first checks that the Frame's xrdp answers on port
|
||||||
asks for the sudo password over SSH.
|
3389 (Developer Mode turns it on). On Windows it opens a connection file for
|
||||||
|
user `steamos`, because `mstsc /v:` alone offers your Windows account, which
|
||||||
|
xrdp turns away. Accept the warning about the Frame's own certificate, then
|
||||||
|
sign in with the Developer Mode password. Sleep, restart and
|
||||||
|
shut down open a terminal window because SteamOS asks for the sudo password
|
||||||
|
over SSH.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -183,5 +183,5 @@ Control implementations. MCP wraps this HTTP API without API keys. Changes
|
|||||||
require a separate user approval; power also retains its password prompt. The
|
require a separate user approval; power also retains its password prompt. The
|
||||||
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
assistant uses a user-chosen endpoint and sends nothing until the user opts in
|
||||||
for a message. Screenshot context is separately opt-in. Model replies cannot
|
for a message. Screenshot context is separately opt-in. Model replies cannot
|
||||||
operate the headset. Settings → Open assistant settings opens the page; the linked guide
|
operate the headset. Tools → Open assistant opens the page; the linked guide
|
||||||
covers putting it in a Chromium panel on the Frame.
|
covers putting it in a Chromium panel on the Frame.
|
||||||
+2
-2
@@ -114,8 +114,8 @@ don't.
|
|||||||
|
|
||||||
## Family and comfort
|
## Family and comfort
|
||||||
|
|
||||||
The shared Home card sets session limits, breaks and check-ins; **Cast to this
|
The shared Home card sets session limits, breaks and check-ins, and offers
|
||||||
screen** is in Home's **Right now** card. **Enable / test notifications** requests iOS notification
|
**Cast headset view**. **Enable / test notifications** requests iOS notification
|
||||||
permission and sends a local test. These are local notifications, not APNs push;
|
permission and sends a local test. These are local notifications, not APNs push;
|
||||||
iOS background suspension can interrupt phone alerts. The headset timer still
|
iOS background suspension can interrupt phone alerts. The headset timer still
|
||||||
runs. See [the behavior and verification limits](family-comfort.md).
|
runs. See [the behavior and verification limits](family-comfort.md).
|
||||||
+1
-1
@@ -125,7 +125,7 @@ a limit on the number of floating panels.
|
|||||||
separate from Frame Control. Public reports describe some Proton support;
|
separate from Frame Control. Public reports describe some Proton support;
|
||||||
Windows-only does not by itself prove a Frame app cannot run. Local status
|
Windows-only does not by itself prove a Frame app cannot run. Local status
|
||||||
and sources are in [VR utilities](vr-utilities.md).
|
and sources are in [VR utilities](vr-utilities.md).
|
||||||
- **Our performance HUD:** Home → VR performance (unfold it) → Open HUD in
|
- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
|
||||||
headset creates its own gamescope panel using built-in tools. It needs no
|
headset creates its own gamescope panel using built-in tools. It needs no
|
||||||
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
third-party overlay app. [Metrics and verification](vr-utilities.md).
|
||||||
|
|
||||||
|
|||||||
+71
-6
@@ -97,15 +97,24 @@ The same error is sent at most once every 10 minutes.
|
|||||||
|
|
||||||
## Report a problem
|
## Report a problem
|
||||||
|
|
||||||
**Report a problem** is the speech-bubble button in the header, also in the
|
**Report a problem** is the warning-sign button in the header, also in the
|
||||||
Settings page (**Privacy & updates**) and under **Help → Report a Problem…**. It sends the report
|
Privacy panel and under **Help → Report a Problem…**. It sends the report
|
||||||
privately to Frame Control's PostHog project as a `problem_report` event, the
|
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
|
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
|
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
|
the person sends it deliberately. The report has the kind, title and text you
|
||||||
wrote, how to reach you if you gave it, a short reference shown after sending,
|
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
email address goes with it only if you tick **The maintainer may contact me
|
||||||
your analytics events.
|
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
|
||||||
|
your analytics events. With that box ticked, the address also becomes your
|
||||||
|
**Contact email** below with follow-up questions ticked, so you remove it there
|
||||||
|
like any other. If it's a different address from the one saved there, it
|
||||||
|
replaces it, and update notices stop until you turn them on again (they were
|
||||||
|
agreed for the old address); the form says so before you send. The report then also
|
||||||
|
carries this copy's contact id and change number (`contact_id`, `contact_rev`,
|
||||||
|
see below), so removing or changing the address later takes back the
|
||||||
|
follow-up permission given with the report too.
|
||||||
|
|
||||||
With **Include diagnostics** ticked (the default), the report adds:
|
With **Include diagnostics** ticked (the default), the report adds:
|
||||||
|
|
||||||
@@ -128,11 +137,67 @@ 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
|
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||||
API key as `frame_compat_db.py sync`.
|
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 in the same visit as the first-run
|
||||||
|
privacy notice. **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, or when you send a problem report with follow-up questions ticked,
|
||||||
|
whatever the analytics settings are, because you chose to. With a report, the
|
||||||
|
address and choices are saved before the report is sent and stay saved if it
|
||||||
|
fails; like any change, they're sent as soon as PostHog can be reached. 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. This also covers problem reports you sent
|
||||||
|
from this copy with follow-up questions ticked: if your newest choice since the
|
||||||
|
report (by change number, not the clock) no longer agrees to follow-up
|
||||||
|
questions at that address, the maintainer's inbox shows the permission as
|
||||||
|
withdrawn and leaves the address out. 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
|
## Turning it all off
|
||||||
|
|
||||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
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
|
the environment that starts Frame Control. A copy run from a source checkout
|
||||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
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.
|
||||||
|
|
||||||
## Update checks
|
## Update checks
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
|||||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||||
Mac with keyboard, mouse, and clipboard.
|
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
|
## B. Show the Mac's desktop inside the Frame
|
||||||
|
|
||||||
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
|
||||||
|
|||||||
+1
-1
@@ -94,7 +94,7 @@ A new shortcut is rolled back if artwork fails; failure is never reported as
|
|||||||
an installed app with a blank tile.
|
an installed app with a blank tile.
|
||||||
|
|
||||||
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
Artwork preference is **SteamGridDB → source images → generated fallback**.
|
||||||
Set the optional free key in Frame Control's **Settings → Library artwork**, or
|
Set the optional free key in Frame Control's **Library artwork settings**, or
|
||||||
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
|
||||||
settings override the saved key. Without a key there are no provider calls or
|
settings override the saved key. Without a key there are no provider calls or
|
||||||
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ checks below. The PR stays draft.
|
|||||||
|
|
||||||
## Our performance HUD
|
## Our performance HUD
|
||||||
|
|
||||||
On **Home → VR performance** (folded until you open it), the app shows a timestamped sample
|
On **Home → VR comfort and performance**, the app shows a timestamped sample
|
||||||
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
|
||||||
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
starts our text HUD as a gamescope panel, refreshed every two seconds. In the
|
||||||
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
|
||||||
@@ -26,7 +26,7 @@ it twice reuses the existing process.
|
|||||||
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
|
||||||
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
|
||||||
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
|
||||||
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. In the headset HUD only: the app shows them once, in the battery menu at the top. |
|
| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
|
||||||
|
|
||||||
OpenVR uses background application mode, which does not start SteamVR or keep
|
OpenVR uses background application mode, which does not start SteamVR or keep
|
||||||
it running. This mode also returned live timing in a read-only device probe.
|
it running. This mode also returned live timing in a read-only device probe.
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ is required.** Chromium and immersive WebXR are not in this playback path.
|
|||||||
|
|
||||||
## Use it
|
## Use it
|
||||||
|
|
||||||
In **Tools → Media player** (or **Play a video or photo** on Home), send a file, choose its layout and press
|
In **Tools → Media in the headset**, send a file, choose its layout and press
|
||||||
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
**Play**. **Theatre** gives it a larger screen and an 85% black surround.
|
||||||
**Stop** removes both. Refresh reads the library and the player's state.
|
**Stop** removes both. Refresh reads the library and the player's state.
|
||||||
The screen follows your head; it isn't a saved world-space panel.
|
The screen follows your head; it isn't a saved world-space panel.
|
||||||
|
|||||||
@@ -55,48 +55,6 @@ const log = ()=>{}, toast = ()=>{};
|
|||||||
assert.equal($('sessionMinutes').disabled,false);
|
assert.equal($('sessionMinutes').disabled,false);
|
||||||
assert.equal($('sessionCancel').disabled,true);
|
assert.equal($('sessionCancel').disabled,true);
|
||||||
})().catch(e=>{console.error(e);process.exitCode=1;});
|
})().catch(e=>{console.error(e);process.exitCode=1;});
|
||||||
'''
|
|
||||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
|
||||||
|
|
||||||
def test_a_finished_session_leaves_the_defaults(self):
|
|
||||||
"""A one-minute test session that has ended mustn't fill the form with 1 / 0 / 0."""
|
|
||||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
|
||||||
code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
|
|
||||||
setup = r'''
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const elements = new Map();
|
|
||||||
const $ = id => {
|
|
||||||
// The form's own defaults, as in the page's HTML.
|
|
||||||
const value = {sessionMinutes:'30', breakMinutes:'20', stillMinutes:'30'}[id] || '';
|
|
||||||
if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, value, defaultValue:value,
|
|
||||||
checked:true, defaultChecked:true, type: /Alert$/.test(id) ? 'checkbox' : 'number'});
|
|
||||||
return elements.get(id);
|
|
||||||
};
|
|
||||||
const window = {frameApp:{notify:async()=>{}}};
|
|
||||||
const log = ()=>{}, toast = ()=>{};
|
|
||||||
'''
|
|
||||||
checks = r'''
|
|
||||||
renderComfort({id:'old', active:false, time:100, options:{minutes:1,breakMinutes:0,stillMinutes:0,batteryAlert:false,heatAlert:false},
|
|
||||||
events:[{id:'old:1',kind:'finished',time:90,message:'Session ended'}]});
|
|
||||||
assert.equal($('sessionMinutes').value, '30');
|
|
||||||
assert.equal($('breakMinutes').value, '20');
|
|
||||||
assert.equal($('batteryAlert').checked, true);
|
|
||||||
assert.match($('comfortEvents').textContent, /Session ended/);
|
|
||||||
assert.notEqual($('comfortEvents').textContent, 'Session ended'); // it carries a time
|
|
||||||
renderComfort({id:'new', active:true, time:200, remaining:600, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false}, events:[]});
|
|
||||||
assert.equal($('sessionMinutes').value, 45); // a running session shows its own settings
|
|
||||||
assert.equal($('heatAlert').checked, false);
|
|
||||||
// The page stays open while that session ends: the next one starts from the defaults again.
|
|
||||||
renderComfort({id:'new', active:false, time:900, options:{minutes:45,breakMinutes:15,stillMinutes:20,batteryAlert:true,heatAlert:false},
|
|
||||||
events:[{id:'new:1',kind:'finished',time:899,message:'Session ended'}]});
|
|
||||||
assert.equal($('sessionMinutes').value, '30');
|
|
||||||
assert.equal($('breakMinutes').value, '20');
|
|
||||||
assert.equal($('heatAlert').checked, true);
|
|
||||||
renderComfort({id:'new', active:false, time:905, options:{minutes:45}, events:[]});
|
|
||||||
$('sessionMinutes').value = '50'; // what's typed for the next session
|
|
||||||
renderComfort({id:'new', active:false, time:910, options:{minutes:45}, events:[]});
|
|
||||||
assert.equal($('sessionMinutes').value, '50'); // later polls leave it alone
|
|
||||||
'''
|
'''
|
||||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
"""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_only_a_real_true_counts_as_consent(self):
|
||||||
|
for wrong in ("false", "true", 1, 0, [], {}):
|
||||||
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||||
|
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
|
||||||
|
with self.assertRaisesRegex(ValueError, "true or false"):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
|
||||||
|
self.assertEqual((fc.load()["email"], self.got), ("", []))
|
||||||
|
fc.save({"email": "me@example.com", "updates": True}) # left out is no
|
||||||
|
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
|
||||||
|
|
||||||
|
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 reports(self):
|
||||||
|
return [e["properties"] for e in self.events() if e["event"] == "problem_report"]
|
||||||
|
|
||||||
|
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com"})
|
||||||
|
self.assertFalse(fc.FILE.exists()) # no follow-up: nothing kept, nothing linked
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
without, with_ = self.reports()
|
||||||
|
self.assertEqual((without["contact"], without["contact_followup"], without["contact_id"]), ("", False, ""))
|
||||||
|
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
|
||||||
|
self.assertEqual((with_["contact_id"], with_["contact_rev"]), (fc.load()["id"], fc.load()["rev"]))
|
||||||
|
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
|
||||||
|
with self.assertRaisesRegex(ValueError, "email address"):
|
||||||
|
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
|
||||||
|
|
||||||
|
def test_follow_up_given_with_a_report_is_kept_and_removed_in_settings(self):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("me@example.com", False, True))
|
||||||
|
consent = [e for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
self.assertEqual([(e["properties"]["action"], e["properties"]["rev"]) for e in consent], [("set", 1)])
|
||||||
|
self.assertEqual(consent[0]["distinct_id"], self.reports()[0]["contact_id"])
|
||||||
|
fr.send({**REPORT, "contact": "ME@example.com", "contactFollowup": True}) # already agreed
|
||||||
|
self.assertEqual(len([e for e in self.events() if e["event"] == "contact_consent"]), 1)
|
||||||
|
self.assertEqual(self.reports()[1]["contact_rev"], 1)
|
||||||
|
fc.save({"email": ""}) # Remove my email
|
||||||
|
last = self.events()[-1]
|
||||||
|
self.assertEqual((last["properties"]["action"], last["properties"]["email"], last["properties"]["rev"]),
|
||||||
|
("withdraw", "", 2))
|
||||||
|
logged = [e["properties"].get("contact") for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||||
|
self.assertEqual(logged, ["<removed>", "<removed>"])
|
||||||
|
|
||||||
|
def test_a_report_to_another_address_replaces_it_with_follow_up_only(self):
|
||||||
|
"""Update notices were agreed for the old address, not the new one (the form says so)."""
|
||||||
|
fc.save({"email": "old@example.com", "updates": True})
|
||||||
|
fr.send({**REPORT, "contact": "new@example.com", "contactFollowup": True})
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", False, True))
|
||||||
|
self.assertEqual(self.reports()[0]["contact_rev"], 2)
|
||||||
|
fc.save({"email": "new@example.com", "updates": True, "followup": False})
|
||||||
|
fr.send({**REPORT, "contact": "NEW@example.com", "contactFollowup": True}) # same address: kept
|
||||||
|
s = fc.state()
|
||||||
|
self.assertEqual((s["email"], s["updates"], s["followup"]), ("new@example.com", True, True))
|
||||||
|
|
||||||
|
def test_a_removal_while_the_report_saves_its_address_still_counts(self):
|
||||||
|
"""Removed while the report's own consent is on its way: the report keeps that consent's
|
||||||
|
rev (so the removal is newer) and is logged without the address."""
|
||||||
|
post, removed = tm.post, []
|
||||||
|
|
||||||
|
def slow_post(events, **kw):
|
||||||
|
post(events, **kw)
|
||||||
|
if not removed and events[0]["event"] == "contact_consent":
|
||||||
|
removed.append(fc.save({"email": ""})) # Remove my email, mid-send
|
||||||
|
with mock.patch.object(tm, "post", side_effect=slow_post):
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
report = self.reports()[0]
|
||||||
|
self.assertEqual((report["contact_rev"], fc.load()["rev"], fc.state()["email"]), (1, 2, ""))
|
||||||
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||||
|
for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||||
|
fr.mark_withdrawn([row], consents)
|
||||||
|
self.assertEqual(row[10], "withdrawn")
|
||||||
|
logged = [e["properties"]["contact"] for e in tm._read_lines(tm.SENT) if e["event"] == "problem_report"]
|
||||||
|
self.assertEqual(logged, ["<removed>"])
|
||||||
|
|
||||||
|
def report_row(self, contact="me@example.com", followup=True, cid="copy", rev=1):
|
||||||
|
return ["2026-09-10T10:00:00Z", "AB12CD34", "bug", "RDP", "It never connects.", contact,
|
||||||
|
"0.4.0", "Windows", "", "", followup, cid, rev]
|
||||||
|
|
||||||
|
def test_a_later_change_takes_back_a_reports_follow_up_permission(self):
|
||||||
|
reports = [self.report_row(), # removed later
|
||||||
|
self.report_row(cid="other"), # another copy, still agrees
|
||||||
|
self.report_row(rev=3), # sent after the removal
|
||||||
|
self.report_row(cid="moved"), # address changed later
|
||||||
|
self.report_row(cid="news-only"), # follow-up unticked later
|
||||||
|
self.report_row(contact="Me@Example.com", cid="case"), # same address, any case
|
||||||
|
self.report_row(cid="", followup=True), # no contact id: left alone
|
||||||
|
self.report_row(cid="bad", rev="x")] # malformed rev: treated as 0
|
||||||
|
consents = [["copy", "me@example.com", True, 1], ["copy", "", False, 2],
|
||||||
|
["other", "me@example.com", True, 1], ["other", "me@example.com", True, 2],
|
||||||
|
["moved", "new@example.com", True, 2], ["news-only", "me@example.com", False, 2],
|
||||||
|
["case", "me@example.com", True, 2], ["bad", "", False, 1], ["short"], ["x", "", False, "?"]]
|
||||||
|
fr.mark_withdrawn(reports, consents)
|
||||||
|
self.assertEqual([r[10] for r in reports],
|
||||||
|
["withdrawn", True, True, "withdrawn", "withdrawn", True, True, "withdrawn"])
|
||||||
|
|
||||||
|
def test_the_change_number_decides_not_the_clock(self):
|
||||||
|
"""The clock went back between the report and the removal: the removal still counts."""
|
||||||
|
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
|
||||||
|
with mock.patch.object(fc.time, "gmtime", return_value=time.gmtime(0)):
|
||||||
|
fc.save({"email": ""})
|
||||||
|
report = self.reports()[0]
|
||||||
|
row = self.report_row(cid=report["contact_id"], rev=report["contact_rev"])
|
||||||
|
consents = [[e["distinct_id"], e["properties"]["email"], e["properties"]["followup"], e["properties"]["rev"]]
|
||||||
|
for e in self.events() if e["event"] == "contact_consent"]
|
||||||
|
self.assertEqual(self.events()[-1]["timestamp"], "1970-01-01T00:00:00Z")
|
||||||
|
fr.mark_withdrawn([row], consents)
|
||||||
|
self.assertEqual(row[10], "withdrawn")
|
||||||
|
|
||||||
|
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
|
||||||
|
reports = [self.report_row(), ["short"]]
|
||||||
|
consents = [["copy", "", False, 2]]
|
||||||
|
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
|
||||||
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
|
||||||
|
mock.patch("builtins.print") as out:
|
||||||
|
fr.main()
|
||||||
|
self.assertIn("properties.contact_rev", q.call_args_list[0].args[0])
|
||||||
|
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
|
||||||
|
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
|
||||||
|
self.assertIn("follow-up permission since withdrawn", printed)
|
||||||
|
self.assertNotIn("me@example.com", printed)
|
||||||
|
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row(followup=False)]}) as q:
|
||||||
|
fr.inbox()
|
||||||
|
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_saving_is_not_headset_work(self):
|
||||||
|
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
|
||||||
|
import io
|
||||||
|
import server
|
||||||
|
seen = []
|
||||||
|
for path in ("/api/contact", "/api/contact/prompt"):
|
||||||
|
h = server.Handler.__new__(server.Handler)
|
||||||
|
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
|
||||||
|
h.path, h.rfile = path, io.BytesIO(body)
|
||||||
|
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
|
||||||
|
h.local_request = lambda: True
|
||||||
|
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
|
||||||
|
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
|
||||||
|
h.do_POST()
|
||||||
|
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
|
||||||
|
|
||||||
|
|
||||||
|
# Run these once, in test_telemetry, not again through the import above.
|
||||||
|
del Base, ReportProblem
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+5
-16
@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent.parent
|
|||||||
sys.path.insert(0, str(ROOT / "ui"))
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
import frame_devices as fd # noqa: E402
|
import frame_devices as fd # noqa: E402
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
|
||||||
CONFIG = """Host lxso1
|
CONFIG = """Host lxso1
|
||||||
HostName 192.168.1.109
|
HostName 192.168.1.109
|
||||||
@@ -274,7 +275,8 @@ class Pins(Base):
|
|||||||
def test_hashed_and_non_default_port_entries(self):
|
def test_hashed_and_non_default_port_entries(self):
|
||||||
kh = self.ssh / "known_hosts"
|
kh = self.ssh / "known_hosts"
|
||||||
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
kh.write_text(f"[frame.local]:2222 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
|
||||||
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
|
||||||
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
|
||||||
@@ -283,7 +285,8 @@ class Pins(Base):
|
|||||||
target = fd.known_hosts("d4")
|
target = fd.known_hosts("d4")
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_text(f"frame-control-d4 {KEY}\n")
|
target.write_text(f"frame-control-d4 {KEY}\n")
|
||||||
subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
|
frame_host.run_ssh(["ssh-keygen", "-H", "-f", str(target)], capture_output=True,
|
||||||
|
stdin=subprocess.DEVNULL, check=True, timeout=10)
|
||||||
self.assertNotIn("frame-control-d4", target.read_text())
|
self.assertNotIn("frame-control-d4", target.read_text())
|
||||||
self.assertTrue(fd.pinned("d4"))
|
self.assertTrue(fd.pinned("d4"))
|
||||||
self.assertTrue(fd.forget_pin("d4"))
|
self.assertTrue(fd.forget_pin("d4"))
|
||||||
@@ -296,20 +299,6 @@ class Pins(Base):
|
|||||||
|
|
||||||
|
|
||||||
class Registry(Base):
|
class Registry(Base):
|
||||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
|
||||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
|
||||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
|
||||||
d = self.reg.add_device("frame-4", hosts=["frame.tail1234.ts.net"])
|
|
||||||
self.reg.record_success(d["id"], "frame.tail1234.ts.net", "n-home", 6.0)
|
|
||||||
self.reg.add_address(d["id"], "192.168.1.40", kind="lan", first=True)
|
|
||||||
self.reg.record_success(d["id"], "192.168.1.40", "n-home", 1.0) # Test now found it
|
|
||||||
addrs = self.reg.get(d["id"])["addresses"]
|
|
||||||
self.assertEqual([a["host"] for a in addrs], ["192.168.1.40", "frame.tail1234.ts.net"])
|
|
||||||
at_home = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
|
|
||||||
self.assertEqual(at_home[0], "192.168.1.40")
|
|
||||||
away = [a["host"] for a, _ in fd.order_addresses(addrs, "n-cafe", True)]
|
|
||||||
self.assertEqual(away[0], "frame.tail1234.ts.net") # elsewhere Tailscale still leads
|
|
||||||
|
|
||||||
def test_address_editing(self):
|
def test_address_editing(self):
|
||||||
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
|
||||||
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class ObbTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
with self.assertRaisesRegex(android.FrameError, 'start this app'):
|
||||||
data.install_obb(PKG, [path])
|
data.install_obb(PKG, [path])
|
||||||
stream.assert_not_called()
|
stream.assert_not_called()
|
||||||
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
with patch.object(data.frame_host, 'run_ssh', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
|
||||||
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
|
||||||
data._stream('command')
|
data._stream('command')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Captured OpenSSH output keeps working on Windows and POSIX hosts."""
|
||||||
|
import sandbox # noqa: F401
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "ui"))
|
||||||
|
import frame_host
|
||||||
|
|
||||||
|
|
||||||
|
class CapturedSSH(unittest.TestCase):
|
||||||
|
def run_command(self, source, **kwargs):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
return frame_host.run_ssh([sys.executable, "-c", source], timeout=5, **kwargs)
|
||||||
|
|
||||||
|
def test_binary_output_and_input(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'error\\r\\n')",
|
||||||
|
capture_output=True, input=b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stdout, b"data\x00\xff")
|
||||||
|
self.assertEqual(result.stderr, b"error\r\n")
|
||||||
|
|
||||||
|
def test_text_output_normalizes_newlines(self):
|
||||||
|
result = self.run_command("import sys; sys.stdout.write(sys.stdin.read()); "
|
||||||
|
"sys.stderr.buffer.write(b'first\\r\\nsecond\\rthird\\n')",
|
||||||
|
capture_output=True, input="hello\n", text=True)
|
||||||
|
self.assertEqual(result.stdout, "hello\n")
|
||||||
|
self.assertEqual(result.stderr, "first\nsecond\nthird\n")
|
||||||
|
|
||||||
|
def test_explicit_encoding_and_errors(self):
|
||||||
|
result = self.run_command("import sys; sys.stderr.buffer.write(b'\\xe9\\xff')",
|
||||||
|
capture_output=True, encoding="ascii", errors="replace")
|
||||||
|
self.assertEqual(result.stderr, "\ufffd\ufffd")
|
||||||
|
|
||||||
|
def test_check_preserves_error_output(self):
|
||||||
|
with self.assertRaises(subprocess.CalledProcessError) as caught:
|
||||||
|
self.run_command("import sys; print('out'); print('err', file=sys.stderr); sys.exit(7)",
|
||||||
|
capture_output=True, text=True, check=True)
|
||||||
|
self.assertEqual(caught.exception.returncode, 7)
|
||||||
|
self.assertEqual(caught.exception.stdout, "out\n")
|
||||||
|
self.assertEqual(caught.exception.stderr, "err\n")
|
||||||
|
|
||||||
|
def test_timeout_preserves_partial_stderr(self):
|
||||||
|
with self.assertRaises(subprocess.TimeoutExpired) as caught:
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True):
|
||||||
|
frame_host.run_ssh([sys.executable, "-c", "import sys, time; "
|
||||||
|
"sys.stderr.write('waiting'); sys.stderr.flush(); time.sleep(10)"],
|
||||||
|
capture_output=True, text=True, timeout=1)
|
||||||
|
self.assertEqual(caught.exception.stderr, b"waiting")
|
||||||
|
|
||||||
|
def test_streamed_stdout_is_kept_separate(self):
|
||||||
|
with tempfile.TemporaryFile() as output:
|
||||||
|
result = self.run_command("import sys; sys.stdout.buffer.write(b'file'); "
|
||||||
|
"sys.stderr.buffer.write(b'error')",
|
||||||
|
stdout=output, stderr=subprocess.PIPE)
|
||||||
|
output.seek(0)
|
||||||
|
self.assertEqual(output.read(), b"file")
|
||||||
|
self.assertIsNone(result.stdout)
|
||||||
|
self.assertEqual(result.stderr, b"error")
|
||||||
|
|
||||||
|
def test_uncaptured_windows_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], stderr=subprocess.DEVNULL, timeout=5)
|
||||||
|
|
||||||
|
def test_posix_call_is_unchanged(self):
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", False), mock.patch.object(subprocess, "run") as run:
|
||||||
|
frame_host.run_ssh(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
run.assert_called_once_with(["ssh", "-V"], capture_output=True, check=True, timeout=5)
|
||||||
|
|
||||||
|
def test_capture_rejects_explicit_streams(self):
|
||||||
|
for stream in ("stdout", "stderr"):
|
||||||
|
with self.subTest(stream=stream), self.assertRaises(ValueError):
|
||||||
|
self.run_command("", capture_output=True, **{stream: subprocess.DEVNULL})
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("ssh"), "needs OpenSSH")
|
||||||
|
def test_real_ssh_failure_returns_stderr_without_hanging(self):
|
||||||
|
result = frame_host.run_ssh(["ssh", "-F", os.devnull, "-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=2", "frame-control-test.invalid", "true"],
|
||||||
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
||||||
|
self.assertEqual(result.returncode, 255)
|
||||||
|
self.assertIn("Could not resolve hostname", result.stderr)
|
||||||
@@ -457,47 +457,6 @@ class Connecting(unittest.TestCase):
|
|||||||
self.assertEqual(self.routes, [])
|
self.assertEqual(self.routes, [])
|
||||||
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
|
||||||
|
|
||||||
def test_a_test_started_earlier_cant_overwrite_a_newer_one(self):
|
|
||||||
d = self.device("nothing.invalid")
|
|
||||||
entered = {1: threading.Event(), 2: threading.Event()}
|
|
||||||
release = {1: threading.Event(), 2: threading.Event()}
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def probe(host, port, update=None):
|
|
||||||
calls.append(host)
|
|
||||||
n = len(calls)
|
|
||||||
entered[n].set()
|
|
||||||
release[n].wait(10)
|
|
||||||
return {"state": "refused", "detail": f"test {n}", "ip": None, "rtt_ms": None}
|
|
||||||
|
|
||||||
tests = [threading.Thread(target=self.link.test, args=(d["id"],), daemon=True) for _ in range(2)]
|
|
||||||
with mock.patch.object(fl, "probe", probe):
|
|
||||||
try:
|
|
||||||
tests[0].start()
|
|
||||||
self.assertTrue(entered[1].wait(5), "the first test never probed")
|
|
||||||
tests[1].start()
|
|
||||||
self.assertTrue(entered[2].wait(5), "the second test never probed")
|
|
||||||
# The first finishes while the second is still probing: it mustn't show its
|
|
||||||
# rows or mark the second done.
|
|
||||||
release[1].set()
|
|
||||||
tests[0].join(10)
|
|
||||||
self.assertFalse(tests[0].is_alive())
|
|
||||||
running = self.link.snapshot()["tests"][d["id"]]
|
|
||||||
self.assertFalse(running["done"])
|
|
||||||
self.assertEqual(running["rows"][0]["detail"], "Waiting")
|
|
||||||
release[2].set()
|
|
||||||
tests[1].join(10)
|
|
||||||
self.assertFalse(tests[1].is_alive())
|
|
||||||
finally:
|
|
||||||
for e in release.values():
|
|
||||||
e.set()
|
|
||||||
for t in tests:
|
|
||||||
if t.ident: # started
|
|
||||||
t.join(10)
|
|
||||||
result = self.link.snapshot()["tests"][d["id"]]
|
|
||||||
self.assertTrue(result["done"])
|
|
||||||
self.assertEqual(result["rows"][0]["detail"], "test 2")
|
|
||||||
|
|
||||||
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
|
||||||
self.device("localhost")
|
self.device("localhost")
|
||||||
self.hosts({"localhost": "ok"})
|
self.hosts({"localhost": "ok"})
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
"""The page calls showPage() while its first script is still running, before names declared
|
|
||||||
later (in that script or the second one) exist. Touching one of them there throws, and the
|
|
||||||
rest of the page's setup never runs: opening Frame Control at #games did exactly that."""
|
|
||||||
import json
|
|
||||||
import pathlib
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
||||||
STUBS = ['$', 'toggleLive', 'scrollToY', 'loadMacView', 'loadPanels', 'loadPanelSwitcher', 'loadTitles']
|
|
||||||
|
|
||||||
RUN = r'''
|
|
||||||
const vm = require('vm');
|
|
||||||
const {code, hashes} = JSON.parse(require('fs').readFileSync(0, 'utf8'));
|
|
||||||
const failures = [];
|
|
||||||
for (const hash of hashes) {
|
|
||||||
const context = vm.createContext({
|
|
||||||
location: {hash}, document: {querySelectorAll: () => [], title: ''}, live: false,
|
|
||||||
});
|
|
||||||
try { vm.runInContext(code, context); }
|
|
||||||
catch (e) { failures.push(`${hash}: ${e.message}`); }
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify(failures));
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
@unittest.skipUnless(shutil.which('node'), 'Node runs the page code')
|
|
||||||
class PageStartup(unittest.TestCase):
|
|
||||||
def test_opening_any_page_or_section_at_startup_runs(self):
|
|
||||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
|
||||||
first, second = re.findall(r'<script>(.*?)</script>', page, re.S)[:2]
|
|
||||||
tables = first[first.index('const PAGES ='):first.index('let page =')]
|
|
||||||
start = first.index('function showPage(')
|
|
||||||
show = first[start:first.index('\n}\n', start) + 3]
|
|
||||||
# Everything declared after the startup call is still uninitialised when it runs.
|
|
||||||
call = re.search(r'^showPage\(\);', first, re.M).end()
|
|
||||||
later = re.findall(r'^(?:const|let)\s+(\w+)', first[call:] + second, re.M)
|
|
||||||
later = [n for n in dict.fromkeys(later) if n not in STUBS and n not in ('PAGES', 'SECTION_PAGE', 'page')]
|
|
||||||
self.assertIn('link', later) # the name that broke #games
|
|
||||||
stubs = ''.join(f'function {n}() {{ return {{ classList: {{ toggle() {{}} }}, scrollIntoView() {{}} }}; }}\n'
|
|
||||||
for n in STUBS if n != '$')
|
|
||||||
code = ('const $ = id => id === "nowhere" ? null : { classList: { toggle() {} }, scrollIntoView() {} };\n' + stubs + tables + 'let page = "home";\n' + show +
|
|
||||||
'showPage();\n' + ''.join(f'let {n};\n' for n in later))
|
|
||||||
hashes = ['', '#home', '#games', '#android', '#tools', '#settings', '#devices', '#nowhere']
|
|
||||||
hashes += ['#' + k for k in re.findall(r'(\w+): "', tables)]
|
|
||||||
r = subprocess.run(['node', '-e', RUN], input=json.dumps({'code': code, 'hashes': hashes}),
|
|
||||||
capture_output=True, text=True)
|
|
||||||
self.assertEqual(r.returncode, 0, r.stderr)
|
|
||||||
self.assertEqual(json.loads(r.stdout), [])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Remote desktop to the Frame (frame_host.open_rdp) on each computer, with the client
|
||||||
|
launch stubbed and a real socket standing in for the Frame's xrdp. Also the server
|
||||||
|
staying quiet when the page goes away mid-reply, which on Windows is
|
||||||
|
ConnectionAbortedError (WinError 10053).
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import email.message
|
||||||
|
import io
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
import server # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def platform(name):
|
||||||
|
"""Patches frame_host to behave as on `name` ("mac", "windows" or "linux")."""
|
||||||
|
return mock.patch.multiple(frame_host, MAC=name == "mac", WINDOWS=name == "windows",
|
||||||
|
LINUX=name == "linux")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRdp(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.xrdp = socket.socket()
|
||||||
|
self.xrdp.bind(("127.0.0.1", 0))
|
||||||
|
self.xrdp.listen(4)
|
||||||
|
self.addCleanup(self.xrdp.close)
|
||||||
|
port = mock.patch.object(frame_host, "RDP_PORT", self.xrdp.getsockname()[1])
|
||||||
|
port.start()
|
||||||
|
self.addCleanup(port.stop)
|
||||||
|
self.spawned = []
|
||||||
|
spawn = mock.patch.object(frame_host, "_spawn", self.spawned.append)
|
||||||
|
spawn.start()
|
||||||
|
self.addCleanup(spawn.stop)
|
||||||
|
cache = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(cache.cleanup)
|
||||||
|
self.cache = Path(cache.name)
|
||||||
|
where = mock.patch.object(frame_host, "cache_dir", lambda *p: self.cache.joinpath(*p))
|
||||||
|
where.start()
|
||||||
|
self.addCleanup(where.stop)
|
||||||
|
|
||||||
|
def test_windows_signs_in_as_steamos(self):
|
||||||
|
# The report: mstsc /v:HOST alone offers the Windows account, which xrdp rejects.
|
||||||
|
with platform("windows"):
|
||||||
|
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertEqual(len(self.spawned), 1)
|
||||||
|
argv = self.spawned[0]
|
||||||
|
self.assertEqual(argv[0], "mstsc.exe")
|
||||||
|
self.assertNotIn("/v:127.0.0.1", argv)
|
||||||
|
rdp = Path(argv[1])
|
||||||
|
self.assertEqual(rdp.suffix, ".rdp")
|
||||||
|
data = rdp.read_bytes() # CRLF lines, as mstsc writes them, however this OS ends lines
|
||||||
|
self.assertNotIn(b"\r\r", data)
|
||||||
|
lines = data.decode("utf-8").split("\r\n")
|
||||||
|
self.assertIn("full address:s:127.0.0.1", lines)
|
||||||
|
self.assertIn("username:s:steamos", lines)
|
||||||
|
self.assertIn("steamos", message)
|
||||||
|
self.assertIn("Developer Mode password", message)
|
||||||
|
self.assertIn("certificate", message)
|
||||||
|
self.assertIn("Connect", message)
|
||||||
|
|
||||||
|
def test_nothing_listening_says_why_and_opens_nothing(self):
|
||||||
|
self.xrdp.close()
|
||||||
|
for name in ("windows", "mac", "linux"):
|
||||||
|
with self.subTest(name), platform(name), self.assertRaises(frame_host.Unreachable) as cm:
|
||||||
|
frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertIn("Developer Mode", str(cm.exception))
|
||||||
|
self.assertIn(f"port {frame_host.RDP_PORT} refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_says_which_way_it_failed(self):
|
||||||
|
# Only a refused port says xrdp is off; a wrong address or a silent network say so instead.
|
||||||
|
for error, says in ((socket.gaierror(8, "nodename nor servname provided"), "Devices tab"),
|
||||||
|
(socket.timeout("timed out"), "didn't answer"),
|
||||||
|
(OSError(65, "No route to host"), "didn't answer")):
|
||||||
|
with self.subTest(says), mock.patch.object(frame_host.socket, "create_connection", side_effect=error), \
|
||||||
|
platform("windows"), self.assertRaises(frame_host.Unreachable) as cm:
|
||||||
|
frame_host.open_rdp("frame", "frame.local")
|
||||||
|
self.assertIn(says, str(cm.exception))
|
||||||
|
self.assertNotIn("refused", str(cm.exception))
|
||||||
|
self.assertEqual(self.spawned, [])
|
||||||
|
|
||||||
|
def test_server_says_it_as_the_persons_to_fix(self):
|
||||||
|
# A 400 with the message, not a 500 filed as an error diagnostic.
|
||||||
|
self.xrdp.close()
|
||||||
|
with mock.patch.multiple(server, LOCAL=False, LINK=None, HOST_OPTS=["-o", "HostName=127.0.0.1"]), \
|
||||||
|
self.assertRaises(server.Failure) as cm:
|
||||||
|
server.open_thing({"what": "rdp"})
|
||||||
|
self.assertEqual(cm.exception.status, 400)
|
||||||
|
self.assertIn("Developer Mode", str(cm.exception))
|
||||||
|
|
||||||
|
def test_one_file_per_address(self):
|
||||||
|
with platform("windows"):
|
||||||
|
a, b = frame_host.rdp_file("192.168.1.5"), frame_host.rdp_file("fe80::1%eth0")
|
||||||
|
c, d = frame_host.rdp_file("fe80::1%2"), frame_host.rdp_file("fe80::1:2")
|
||||||
|
self.assertEqual(len({a, b, c, d}), 4)
|
||||||
|
self.assertIn(b"full address:s:192.168.1.5\r\n", a.read_bytes())
|
||||||
|
self.assertIn(b"full address:s:fe80::1%eth0\r\n", b.read_bytes())
|
||||||
|
|
||||||
|
def test_address_cant_add_lines_to_the_file(self):
|
||||||
|
with platform("windows"), self.assertRaises(frame_host.HostError):
|
||||||
|
frame_host.rdp_file("frame\r\nusername:s:root")
|
||||||
|
self.assertEqual(list(self.cache.iterdir()), [])
|
||||||
|
|
||||||
|
def test_linux_clients_get_the_user(self):
|
||||||
|
with platform("linux"), mock.patch.object(frame_host, "which",
|
||||||
|
lambda n, *e: "/usr/bin/xfreerdp" if n == "xfreerdp" else None):
|
||||||
|
message = frame_host.open_rdp("frame", "127.0.0.1")
|
||||||
|
self.assertEqual(self.spawned, [["xfreerdp", "/v:127.0.0.1", "/u:steamos", "/dynamic-resolution"]])
|
||||||
|
self.assertIn("steamos", message)
|
||||||
|
|
||||||
|
|
||||||
|
class PageGoneAway(unittest.TestCase):
|
||||||
|
"""The report's server log: the page closed while index.html was being sent, and the
|
||||||
|
server logged it as a 500, tried to answer anyway, and filed an error diagnostic."""
|
||||||
|
|
||||||
|
def handler(self, path="/"):
|
||||||
|
h = server.Handler.__new__(server.Handler)
|
||||||
|
h.command, h.path, h.request_version = "GET", path, "HTTP/1.1"
|
||||||
|
h.requestline, h.client_address = f"GET {path} HTTP/1.1", ("127.0.0.1", 1)
|
||||||
|
h.headers = email.message.Message()
|
||||||
|
h.headers["Host"] = "127.0.0.1:1"
|
||||||
|
h.wfile = mock.Mock(write=mock.Mock(side_effect=ConnectionAbortedError(10053, "aborted")))
|
||||||
|
h.close_connection = True
|
||||||
|
return h
|
||||||
|
|
||||||
|
def test_not_a_server_error(self):
|
||||||
|
h = self.handler()
|
||||||
|
with mock.patch.object(server.frame_telemetry, "diagnostic") as diagnostic, \
|
||||||
|
mock.patch.object(sys, "stderr", io.StringIO()), self.assertRaises(server.ClientGone):
|
||||||
|
h.do_GET()
|
||||||
|
diagnostic.assert_not_called()
|
||||||
|
self.assertEqual(h.wfile.write.call_count, 1) # no second, 500 reply
|
||||||
|
|
||||||
|
def test_server_logs_nothing(self):
|
||||||
|
srv = server.LoopbackServer.__new__(server.LoopbackServer)
|
||||||
|
err = io.StringIO()
|
||||||
|
with mock.patch.object(sys, "stderr", err):
|
||||||
|
try:
|
||||||
|
raise server.ClientGone()
|
||||||
|
except server.ClientGone:
|
||||||
|
srv.handle_error(None, ("127.0.0.1", 1))
|
||||||
|
self.assertEqual(err.getvalue(), "")
|
||||||
|
try:
|
||||||
|
raise RuntimeError("real")
|
||||||
|
except RuntimeError:
|
||||||
|
srv.handle_error(None, ("127.0.0.1", 1))
|
||||||
|
self.assertIn("RuntimeError: real", err.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -111,6 +111,8 @@ class ServerGuards(unittest.TestCase):
|
|||||||
("/api/volume", {"level": 1.5}),
|
("/api/volume", {"level": 1.5}),
|
||||||
("/api/clipboard", {"text": ""}),
|
("/api/clipboard", {"text": ""}),
|
||||||
("/api/open", {"what": "anything-else"}),
|
("/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": []}),
|
||||||
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
("/api/shots/save", {"ids": "1/250820/20260925225208_1.jpg"}),
|
||||||
("/api/shots/save", {"ids": [1]}),
|
("/api/shots/save", {"ids": [1]}),
|
||||||
@@ -121,6 +123,10 @@ class ServerGuards(unittest.TestCase):
|
|||||||
status, payload = self.post(path, body)
|
status, payload = self.post(path, body)
|
||||||
self.assertEqual(status, 400, f"{path} {body} -> {payload}")
|
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):
|
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"):
|
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"})
|
status, _, _ = self.request("GET", f"/api/shots/image?id={quote(shot)}", headers={"X-Frame-UI": "1"})
|
||||||
@@ -279,6 +285,18 @@ class OneServer(unittest.TestCase):
|
|||||||
r = conn.getresponse()
|
r = conn.getresponse()
|
||||||
self.assertEqual(r.status, 403, r.read())
|
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):
|
class ArtworkSettings(unittest.TestCase):
|
||||||
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
"""The settings panel's endpoints, with and without the page's X-Frame-UI key."""
|
||||||
|
|||||||
@@ -391,14 +391,16 @@ class ReportProblem(Base):
|
|||||||
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
def test_send_is_a_private_posthog_event_whatever_the_settings(self):
|
||||||
got = self.serve()
|
got = self.serve()
|
||||||
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
tm.update_settings({"usage": False}) # analytics off: a deliberate report still goes
|
||||||
|
with mock.patch.object(fr.frame_contact, "from_report", return_value=("contact-id", 1)): # test_contact
|
||||||
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
res = fr.send({"kind": "idea", "title": "Live view stops", "message": "It stops after a minute.",
|
||||||
"contact": "me@example.com"})
|
"contact": "me@example.com", "contactFollowup": True})
|
||||||
path, body = got[0]
|
path, body = got[0]
|
||||||
event = body["batch"][0]
|
event = body["batch"][0]
|
||||||
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
self.assertEqual((path, body["api_key"], event["event"]), ("/batch/", "phc_test", "problem_report"))
|
||||||
props = event["properties"]
|
props = event["properties"]
|
||||||
self.assertEqual((props["kind"], props["title"], props["message"], props["contact"], props["report_id"]),
|
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"]))
|
("idea", "Live view stops", "It stops after a minute.", "me@example.com", res["id"]))
|
||||||
|
self.assertEqual((props["contact_followup"], props["contact_id"], props["contact_rev"]), (True, "contact-id", 1))
|
||||||
self.assertEqual((props["$process_person_profile"], props["$geoip_disable"]), (False, 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.assertNotEqual(event["distinct_id"], tm.settings()["id"]) # not linked to the analytics
|
||||||
self.assertIn(res["id"], res["message"])
|
self.assertIn(res["id"], res["message"])
|
||||||
@@ -421,8 +423,9 @@ class ReportProblem(Base):
|
|||||||
|
|
||||||
def test_the_inbox_skips_malformed_reports(self):
|
def test_the_inbox_skips_malformed_reports(self):
|
||||||
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
good = ["2026-09-28T09:50:00Z", "AB12CD34", "bug", "Live view stops", "It stops.", None,
|
||||||
"0.4.0", "macOS", "", ""]
|
"0.4.0", "macOS", "", "", None, None, None]
|
||||||
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None], ["short"], good]
|
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None, None],
|
||||||
|
["short"], good]
|
||||||
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}), \
|
||||||
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
mock.patch.object(sys, "argv", ["frame_report.py", "inbox"]), \
|
||||||
mock.patch("builtins.print") as out:
|
mock.patch("builtins.print") as out:
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""Windows-only paths, faked on any OS: ~/.ssh/config's ACL and link-local IPv6 zones.
|
||||||
|
|
||||||
|
Run: python3 -m unittest discover -s tests
|
||||||
|
"""
|
||||||
|
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(ROOT / "ui"))
|
||||||
|
|
||||||
|
import frame_host # noqa: E402
|
||||||
|
import frame_devices as fd # noqa: E402
|
||||||
|
|
||||||
|
REFUSED = ("Bad permissions. Try removing permissions for user: UNKNOWN\\UNKNOWN (S-1-5-21-1-2-3-1000) "
|
||||||
|
"on file C:/Users/bob/.ssh/config.\r\nBad owner or permissions on C:\\Users\\bob/.ssh/config\r\n")
|
||||||
|
|
||||||
|
|
||||||
|
def ran(*results):
|
||||||
|
"""subprocess.run stand-in answering whoami, then icacls."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def run(argv, **kw):
|
||||||
|
calls.append(argv)
|
||||||
|
return results[len(calls) - 1]
|
||||||
|
return run, calls
|
||||||
|
|
||||||
|
|
||||||
|
class MakePrivate(unittest.TestCase):
|
||||||
|
def test_windows_sets_owner_only_acl_by_sid(self):
|
||||||
|
run, calls = ran(subprocess.CompletedProcess([], 0, '"desktop\\björn","S-1-5-21-9-8-7-1001"\r\n'.encode("cp850")),
|
||||||
|
subprocess.CompletedProcess([], 0))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run):
|
||||||
|
self.assertTrue(frame_host.make_private(Path("C:/x/config")))
|
||||||
|
self.assertEqual(calls[1][1:], [str(Path("C:/x/config")), "/inheritance:r", "/grant:r",
|
||||||
|
"*S-1-5-21-9-8-7-1001:F", "*S-1-5-18:F", "*S-1-5-32-544:F"])
|
||||||
|
|
||||||
|
def test_windows_falls_back_to_username_and_reports_failure(self):
|
||||||
|
run, calls = ran(subprocess.CompletedProcess([], 1, b""), subprocess.CompletedProcess([], 5))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", True), mock.patch.object(frame_host.subprocess, "run", run), \
|
||||||
|
mock.patch.dict(os.environ, {"USERNAME": "bob"}):
|
||||||
|
self.assertFalse(frame_host.make_private(Path("config")))
|
||||||
|
self.assertIn("bob:F", calls[1])
|
||||||
|
|
||||||
|
@unittest.skipIf(os.name == "nt", "POSIX modes")
|
||||||
|
def test_posix_chmods_600(self):
|
||||||
|
with tempfile.NamedTemporaryFile() as f:
|
||||||
|
os.chmod(f.name, 0o644)
|
||||||
|
self.assertTrue(frame_host.make_private(f.name))
|
||||||
|
self.assertEqual(os.stat(f.name).st_mode & 0o777, 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigWrites(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||||
|
self.config = self.ssh / "config"
|
||||||
|
|
||||||
|
def test_devices_and_connect_writes_make_the_file_private(self):
|
||||||
|
import frame_connect as fc
|
||||||
|
self.config.write_text("Host other\n User me\n", encoding="utf-8")
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=True) as private, \
|
||||||
|
mock.patch.object(fc, "SSH_DIR", self.ssh), mock.patch.object(fc, "CONFIG", self.config):
|
||||||
|
fc.write_config("10.0.0.5")
|
||||||
|
self.assertTrue(fd.repair_permissions(self.config))
|
||||||
|
fd.rewrite_block("frame", path=self.config, user="deck")
|
||||||
|
self.assertEqual(private.call_count, 3)
|
||||||
|
self.assertIn("User deck", self.config.read_text(encoding="utf-8"))
|
||||||
|
self.assertTrue(all(Path(c.args[0]).parent == self.ssh for c in private.call_args_list))
|
||||||
|
self.assertIn("Host other", self.config.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_setup_runs_isolated_as_the_app_starts_it(self):
|
||||||
|
r = subprocess.run([sys.executable, "-I", "-B", str(ROOT / "ui" / "frame_connect.py"), "--help"],
|
||||||
|
capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=30)
|
||||||
|
self.assertNotIn("ModuleNotFoundError", r.stderr)
|
||||||
|
self.assertIn("frame_connect.py", r.stdout + r.stderr)
|
||||||
|
|
||||||
|
def test_repair_keeps_the_bytes_and_skips_a_missing_file(self):
|
||||||
|
self.assertFalse(fd.repair_permissions(self.config))
|
||||||
|
data = "# caf\xe9 (ANSI, not UTF-8)\r\nHost a\r\n".encode("cp1252")
|
||||||
|
self.config.write_bytes(data)
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=True):
|
||||||
|
self.assertTrue(fd.repair_permissions(self.config))
|
||||||
|
self.assertEqual(self.config.read_bytes(), data)
|
||||||
|
|
||||||
|
def test_repair_fails_without_the_acl_and_leaves_the_file(self):
|
||||||
|
self.config.write_bytes(b"Host a\n")
|
||||||
|
before = self.config.stat().st_ino
|
||||||
|
with mock.patch.object(frame_host, "make_private", return_value=False):
|
||||||
|
self.assertFalse(fd.repair_permissions(self.config))
|
||||||
|
self.assertEqual((self.config.read_bytes(), self.config.stat().st_ino), (b"Host a\n", before))
|
||||||
|
self.assertEqual(sorted(f.name for f in self.ssh.iterdir()), ["config", fd.LOCK_NAME])
|
||||||
|
|
||||||
|
|
||||||
|
class ServerRepair(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
import server
|
||||||
|
cls.server = server
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.ssh = Path(tempfile.mkdtemp(prefix="frame-acl-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.ssh, ignore_errors=True)
|
||||||
|
(self.ssh / "config").write_text("Host a\n", encoding="utf-8")
|
||||||
|
patches = [mock.patch.dict(os.environ, {"FRAME_CONTROL_SSH_DIR": str(self.ssh)}),
|
||||||
|
mock.patch.object(frame_host, "WINDOWS", True),
|
||||||
|
mock.patch.object(self.server, "_config_repaired", False)]
|
||||||
|
for p in patches:
|
||||||
|
p.start()
|
||||||
|
self.addCleanup(p.stop)
|
||||||
|
|
||||||
|
def test_repairs_the_refused_config_once(self):
|
||||||
|
with mock.patch.object(fd, "repair_permissions", return_value=True) as repair:
|
||||||
|
self.assertTrue(self.server.repair_ssh_config(REFUSED))
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||||
|
repair.assert_called_once()
|
||||||
|
|
||||||
|
def test_leaves_other_files_and_errors_alone(self):
|
||||||
|
key = REFUSED.replace(".ssh/config", ".ssh/id_ed25519_frame")
|
||||||
|
with mock.patch.object(fd, "repair_permissions") as repair:
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(key))
|
||||||
|
self.assertFalse(self.server.repair_ssh_config("ssh: connect to host frame port 22: timed out"))
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", False):
|
||||||
|
self.assertFalse(self.server.repair_ssh_config(REFUSED))
|
||||||
|
repair.assert_not_called()
|
||||||
|
|
||||||
|
def test_ssh_retries_after_repairing(self):
|
||||||
|
results = iter([subprocess.CompletedProcess([], 255, "", REFUSED), subprocess.CompletedProcess([], 0, "ok", "")])
|
||||||
|
with mock.patch.object(frame_host, "run_ssh", lambda *a, **k: next(results)), \
|
||||||
|
mock.patch.object(fd, "repair_permissions", return_value=True), \
|
||||||
|
mock.patch.object(self.server, "LINK", None):
|
||||||
|
self.assertEqual(self.server.ssh("true"), "ok")
|
||||||
|
|
||||||
|
|
||||||
|
class LinkLocalZone(unittest.TestCase):
|
||||||
|
"""A .local name answering on fe80::: Windows' ssh needs fe80::1%12, not %wireless_32768."""
|
||||||
|
|
||||||
|
def probe(self, windows):
|
||||||
|
import frame_link as fl
|
||||||
|
info = [(fl.socket.AF_INET6, fl.socket.SOCK_STREAM, 6, "", ("fe80::1", 22, 0, 12))]
|
||||||
|
sock = mock.MagicMock()
|
||||||
|
with mock.patch.object(frame_host, "WINDOWS", windows), \
|
||||||
|
mock.patch.object(fl.socket, "getaddrinfo", return_value=info), \
|
||||||
|
mock.patch.object(fl.socket, "socket", return_value=sock), \
|
||||||
|
mock.patch.object(fl.socket, "if_indextoname", return_value="wireless_32768", create=True):
|
||||||
|
return fl.probe("frame.local", 22)["ip"]
|
||||||
|
|
||||||
|
def test_windows_uses_the_numeric_zone(self):
|
||||||
|
self.assertEqual(self.probe(True), "fe80::1%12")
|
||||||
|
|
||||||
|
def test_elsewhere_uses_the_interface_name(self):
|
||||||
|
self.assertEqual(self.probe(False), "fe80::1%wireless_32768")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+2
-2
@@ -47,7 +47,7 @@ def ssh(cmd, input=None, timeout=120):
|
|||||||
try:
|
try:
|
||||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
# 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}
|
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||||
p = subprocess.run(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'timed out talking to {FRAME}')
|
raise FrameError(f'timed out talking to {FRAME}')
|
||||||
@@ -120,7 +120,7 @@ def _copy(src, dest, executable=False, timeout=600):
|
|||||||
else:
|
else:
|
||||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise FrameError(f'copying {name} to the Frame timed out')
|
raise FrameError(f'copying {name} to the Frame timed out')
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import tempfile
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import frame_android as android
|
import frame_android as android
|
||||||
|
import frame_host
|
||||||
|
|
||||||
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
|
||||||
|
|
||||||
|
|
||||||
def _stream(command, src=None, dst=None):
|
def _stream(command, src=None, dst=None):
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
result = frame_host.run_ssh(['ssh', *android.SSH_OPTS, android.FRAME, command],
|
||||||
stdin=src if src else subprocess.DEVNULL,
|
stdin=src if src else subprocess.DEVNULL,
|
||||||
stdout=dst if dst else subprocess.PIPE,
|
stdout=dst if dst else subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE, timeout=1800)
|
stderr=subprocess.PIPE, timeout=1800)
|
||||||
|
|||||||
+8
-3
@@ -24,6 +24,10 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
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")
|
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||||
@@ -330,8 +334,9 @@ def _write_config(host, port, user):
|
|||||||
block = config_block(host, port, user)
|
block = config_block(host, port, user)
|
||||||
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
|
||||||
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
|
||||||
if os.name != "nt":
|
if not frame_host.make_private(tmp):
|
||||||
tmp.chmod(0o600)
|
say(" couldn't make ~/.ssh/config private; if ssh says \"Bad owner or permissions\", "
|
||||||
|
"Frame Control repairs it when it next connects")
|
||||||
# On Windows a running ssh.exe (Frame Control's own, say) keeps the config open
|
# 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.
|
# and locked, so the swap can fail for a moment; keep trying for a while.
|
||||||
for attempt in range(60):
|
for attempt in range(60):
|
||||||
@@ -349,7 +354,7 @@ def _write_config(host, port, user):
|
|||||||
|
|
||||||
def key_login_works():
|
def key_login_works():
|
||||||
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
# accept-new: trust a first-seen host key (as the copy step does); a changed one still fails.
|
||||||
return subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
return frame_host.run_ssh(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
||||||
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
"-o", "StrictHostKeyChecking=accept-new", FRAME_ALIAS, "true"],
|
||||||
capture_output=True).returncode == 0
|
capture_output=True).returncode == 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
"""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 flag(body, key):
|
||||||
|
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
|
||||||
|
v = body.get(key)
|
||||||
|
if v is not None and not isinstance(v, bool):
|
||||||
|
raise ValueError(f'{key} must be true or false')
|
||||||
|
return v is True
|
||||||
|
|
||||||
|
|
||||||
|
def from_report(email):
|
||||||
|
"""Follow-up questions agreed to with a problem report: the address becomes the contact
|
||||||
|
email with that choice ticked, so it shows in Settings and is removed the same way. Update
|
||||||
|
notices stay on only for the same address: a different one replaces the old address with
|
||||||
|
follow-up questions only (the report form says so before sending). Returns (contact id,
|
||||||
|
rev) for the report to carry, read together with the change itself: a later change from
|
||||||
|
this copy has a higher rev, and the newest such change decides whether the report's
|
||||||
|
follow-up permission still stands, whatever the clocks say."""
|
||||||
|
with _lock:
|
||||||
|
s = load()
|
||||||
|
same = s['email'].lower() == email.lower()
|
||||||
|
changed, cid, rev = _apply({'email': s['email'] if same else email,
|
||||||
|
'updates': s['updates'] and same, 'followup': True})
|
||||||
|
_deliver(changed)
|
||||||
|
return cid, rev
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
_deliver(_apply(body)[0])
|
||||||
|
return state()
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(body):
|
||||||
|
"""save()'s change, kept here and waiting to send. Returns (changed, contact id, rev)."""
|
||||||
|
email = str(body.get('email') or '').strip()
|
||||||
|
updates, followup = flag(body, 'updates'), flag(body, '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
|
||||||
|
return changed, s['id'], s['rev']
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver(changed):
|
||||||
|
if changed and not _send_pending(block=False):
|
||||||
|
_wake.set() # offline, or a send under way that will take this change with it
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
+36
-8
@@ -241,8 +241,7 @@ def _write_config(path, text, expected):
|
|||||||
try:
|
try:
|
||||||
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
with os.fdopen(fd_, "w", encoding="utf-8") as fh:
|
||||||
fh.write(text)
|
fh.write(text)
|
||||||
if not frame_host.WINDOWS:
|
frame_host.make_private(tmp) # best effort: an edit still beats none (repair_permissions insists)
|
||||||
tmp.chmod(0o600)
|
|
||||||
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
|
||||||
if read_config(path) != expected:
|
if read_config(path) != expected:
|
||||||
return False
|
return False
|
||||||
@@ -271,6 +270,37 @@ def _edit_config(path, change):
|
|||||||
raise OSError(f"{path} kept changing while Frame Control tried to update it")
|
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):
|
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
|
"""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
|
file alone. -> True if the file changed. Does nothing if there's no such block, or
|
||||||
@@ -333,7 +363,7 @@ def remove_block(alias, path=None):
|
|||||||
def effective_port(alias, config):
|
def effective_port(alias, config):
|
||||||
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
"""The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
out = frame_host.run_ssh(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=10).stdout
|
stdin=subprocess.DEVNULL, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return 22
|
return 22
|
||||||
@@ -346,7 +376,7 @@ def effective_port(alias, config):
|
|||||||
|
|
||||||
def _keygen(*args):
|
def _keygen(*args):
|
||||||
try:
|
try:
|
||||||
return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
return frame_host.run_ssh(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10)
|
timeout=10)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
@@ -623,9 +653,7 @@ class Registry:
|
|||||||
return a
|
return a
|
||||||
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
raise DeviceError(f"{host} isn't one of this headset's addresses")
|
||||||
|
|
||||||
def add_address(self, device_id, host, kind=None, label="", first=False):
|
def add_address(self, device_id, host, kind=None, label=""):
|
||||||
"""Add an address at the end of the list, or at the front (first=True), where the
|
|
||||||
user's order makes it win over the others that work on the same network."""
|
|
||||||
with self._changing():
|
with self._changing():
|
||||||
d = self._find(device_id)
|
d = self._find(device_id)
|
||||||
a = new_address(host, kind, label)
|
a = new_address(host, kind, label)
|
||||||
@@ -633,7 +661,7 @@ class Registry:
|
|||||||
raise DeviceError(f"{a['host']} is already on the list")
|
raise DeviceError(f"{a['host']} is already on the list")
|
||||||
if len(d["addresses"]) >= 32:
|
if len(d["addresses"]) >= 32:
|
||||||
raise DeviceError("That's enough addresses for one headset")
|
raise DeviceError("That's enough addresses for one headset")
|
||||||
d["addresses"].insert(0 if first else len(d["addresses"]), a)
|
d["addresses"].append(a)
|
||||||
self.save()
|
self.save()
|
||||||
return copy.deepcopy(a)
|
return copy.deepcopy(a)
|
||||||
|
|
||||||
|
|||||||
+141
-8
@@ -5,12 +5,16 @@ Everything here runs on your computer, not the Frame. Python stdlib only.
|
|||||||
CLI (used by the Electron app, so terminal handling lives in one place):
|
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
|
python3 ui/frame_host.py terminal -- CMD [ARG...] # open CMD in a terminal window
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
MAC = sys.platform == "darwin"
|
MAC = sys.platform == "darwin"
|
||||||
@@ -32,6 +36,45 @@ class HostError(RuntimeError):
|
|||||||
pass
|
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):
|
def data_dir(*parts):
|
||||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
"""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)."""
|
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||||
@@ -153,6 +196,19 @@ def open_path(path):
|
|||||||
stderr=subprocess.DEVNULL, **DETACHED)
|
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
|
open_url = open_path # the same openers hand URLs to the default browser
|
||||||
|
|
||||||
|
|
||||||
@@ -228,10 +284,46 @@ def clipboard_text():
|
|||||||
raise HostError("Can't read the clipboard")
|
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):
|
def ssh_hostname(alias):
|
||||||
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
"""The real host name an ssh alias points at (`ssh -G`), for non-SSH clients like RDP."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
out = run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return alias
|
return alias
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
@@ -264,24 +356,65 @@ def open_steam_link():
|
|||||||
return "Steam Link isn't installed; opened its download page"
|
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):
|
def open_rdp(alias, host=None):
|
||||||
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
"""Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
|
||||||
host = host or ssh_hostname(alias)
|
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 MAC:
|
||||||
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
|
||||||
return "Opened Windows App"
|
return f"Opened Windows App: connect to {host} and {RDP_LOGIN}"
|
||||||
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
open_url("https://apps.apple.com/app/windows-app/id1295203466")
|
||||||
return "Windows App isn't installed; opened its App Store page"
|
return "Windows App isn't installed; opened its App Store page"
|
||||||
if WINDOWS:
|
if WINDOWS:
|
||||||
_spawn(["mstsc.exe", f"/v:{host}"])
|
_spawn(["mstsc.exe", str(rdp_file(host))])
|
||||||
return f"Opened Remote Desktop to {host}"
|
# Windows asks about the unsigned connection file first.
|
||||||
|
return f"Opened Remote Desktop to {host}: choose Connect, {RDP_LOGIN}"
|
||||||
if which("remmina"):
|
if which("remmina"):
|
||||||
_spawn(["remmina", "-c", f"rdp://steamos@{host}"])
|
_spawn(["remmina", "-c", f"rdp://{RDP_USER}@{host}"])
|
||||||
return f"Opened Remmina to {host}"
|
return f"Opened Remmina to {host}: {RDP_LOGIN}"
|
||||||
for name in ("xfreerdp3", "xfreerdp"):
|
for name in ("xfreerdp3", "xfreerdp"):
|
||||||
if which(name):
|
if which(name):
|
||||||
_spawn([name, f"/v:{host}", "/u:steamos", "/dynamic-resolution"])
|
_spawn([name, f"/v:{host}", f"/u:{RDP_USER}", "/dynamic-resolution"])
|
||||||
return f"Opened FreeRDP to {host}"
|
return f"Opened FreeRDP to {host}: {RDP_LOGIN}"
|
||||||
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
raise HostError("No RDP client found: install Remmina or FreeRDP")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+7
-12
@@ -74,7 +74,7 @@ def ssh_g(alias):
|
|||||||
"""(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
|
"""(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."""
|
ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
|
||||||
try:
|
try:
|
||||||
out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
out = frame_host.run_ssh(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
timeout=10).stdout
|
timeout=10).stdout
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
out = ""
|
out = ""
|
||||||
@@ -108,7 +108,8 @@ def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
|
|||||||
ip = addr[0]
|
ip = addr[0]
|
||||||
if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
|
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
|
try: # a link-local IPv6 address only works with its interface
|
||||||
ip = f"{ip}%{socket.if_indextoname(addr[3])}"
|
# Windows' ssh takes only the number: its names ("wireless_32768") don't resolve.
|
||||||
|
ip = f"{ip}%{addr[3] if frame_host.WINDOWS else socket.if_indextoname(addr[3])}"
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
pass
|
pass
|
||||||
left = deadline - now()
|
left = deadline - now()
|
||||||
@@ -169,7 +170,6 @@ class Link:
|
|||||||
self.version = 0
|
self.version = 0
|
||||||
self.stopped = False
|
self.stopped = False
|
||||||
self.kicks = [] # reasons someone asked for a (re)connect
|
self.kicks = [] # reasons someone asked for a (re)connect
|
||||||
self.test_gen = {} # device id -> its newest test of the addresses (see test())
|
|
||||||
self.busy = False # the loop is handling kicks
|
self.busy = False # the loop is handling kicks
|
||||||
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
|
||||||
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
"probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
|
||||||
@@ -766,7 +766,7 @@ class Link:
|
|||||||
if not self.control:
|
if not self.control:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
|
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
|
stdin=subprocess.DEVNULL, timeout=5).returncode == 0
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
return False
|
return False
|
||||||
@@ -778,7 +778,7 @@ class Link:
|
|||||||
pending.kill()
|
pending.kill()
|
||||||
if self.control and self.alias:
|
if self.control and self.alias:
|
||||||
try:
|
try:
|
||||||
subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
frame_host.run_ssh([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
|
||||||
stdin=subprocess.DEVNULL, timeout=5)
|
stdin=subprocess.DEVNULL, timeout=5)
|
||||||
except (OSError, subprocess.TimeoutExpired):
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
pass
|
pass
|
||||||
@@ -956,13 +956,9 @@ class Link:
|
|||||||
started = now()
|
started = now()
|
||||||
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
|
||||||
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
"rtt_ms": None, "ssh": None} for a in device["addresses"]]
|
||||||
with self.cond:
|
|
||||||
gen = self.test_gen[device_id] = self.test_gen.get(device_id, 0) + 1
|
|
||||||
|
|
||||||
def put(**fields):
|
def put(**fields):
|
||||||
with self.cond:
|
with self.cond:
|
||||||
if gen != self.test_gen[device_id]:
|
|
||||||
return # a newer test has started: its results are the ones to show
|
|
||||||
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
|
||||||
self.version += 1
|
self.version += 1
|
||||||
self.cond.notify_all()
|
self.cond.notify_all()
|
||||||
@@ -988,7 +984,7 @@ class Link:
|
|||||||
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
*self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
|
||||||
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
"-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
r = frame_host.run_ssh(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||||
errors="replace", timeout=20)
|
errors="replace", timeout=20)
|
||||||
err = r.stderr.strip()
|
err = r.stderr.strip()
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
@@ -1116,8 +1112,7 @@ def devices_action(link, body, open_setup, busy=lambda: 0):
|
|||||||
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
|
||||||
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
|
||||||
elif action == "address-add":
|
elif action == "address-add":
|
||||||
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "",
|
a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
|
||||||
first=body.get("first") is True)
|
|
||||||
if is_active and link.state["phase"] == "failed":
|
if is_active and link.state["phase"] == "failed":
|
||||||
link.kick("retry")
|
link.kick("retry")
|
||||||
msg = f"Added {a['host']}"
|
msg = f"Added {a['host']}"
|
||||||
|
|||||||
+109
-10
@@ -6,6 +6,10 @@ 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
|
nothing is published. It is sent whatever the analytics settings are, because
|
||||||
the person sends it deliberately. Diagnostics are scrubbed first
|
the person sends it deliberately. Diagnostics are scrubbed first
|
||||||
(frame_telemetry.scrub); the person's own words are sent as written.
|
(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 os
|
||||||
import platform
|
import platform
|
||||||
@@ -13,6 +17,7 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import frame_contact
|
||||||
import frame_host
|
import frame_host
|
||||||
import frame_telemetry
|
import frame_telemetry
|
||||||
|
|
||||||
@@ -107,9 +112,18 @@ def send(body):
|
|||||||
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
|
||||||
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
|
||||||
title, text, diag = compose(body)
|
title, text, diag = compose(body)
|
||||||
|
followup = frame_contact.flag(body, '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')
|
||||||
|
started = time.time() # a removal from now on (even while saving the address) is redacted from the log
|
||||||
|
# It becomes the contact email in Settings, where it's changed or removed like any other.
|
||||||
|
contact_id, contact_rev = frame_contact.from_report(contact) if followup else ('', 0)
|
||||||
ref = uuid.uuid4().hex[:8].upper()
|
ref = uuid.uuid4().hex[:8].upper()
|
||||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||||
'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
|
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
|
||||||
|
# Only with an address: a later change from this copy (higher rev) can take it back.
|
||||||
|
'contact_id': contact_id, 'contact_rev': contact_rev,
|
||||||
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
|
'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.
|
# 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()),
|
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
|
||||||
@@ -119,6 +133,8 @@ def send(body):
|
|||||||
except frame_telemetry.SendError as e:
|
except frame_telemetry.SendError as e:
|
||||||
raise ReportError(str(e))
|
raise ReportError(str(e))
|
||||||
try:
|
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:
|
except OSError:
|
||||||
pass # it was sent; failing to log it here mustn't make the person send it again
|
pass # it was sent; failing to log it here mustn't make the person send it again
|
||||||
@@ -131,26 +147,109 @@ class ReportError(RuntimeError):
|
|||||||
|
|
||||||
def inbox(days=30):
|
def inbox(days=30):
|
||||||
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
|
||||||
frame_compat_db.sync uses)."""
|
frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
|
||||||
|
questions now: 'withdrawn' when a later choice from the same copy took it back."""
|
||||||
import frame_compat_db
|
import frame_compat_db
|
||||||
|
days = int(days)
|
||||||
res = frame_compat_db._posthog_query(
|
res = frame_compat_db._posthog_query(
|
||||||
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
|
||||||
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
|
"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 "
|
"properties.contact_followup, properties.contact_id, properties.contact_rev "
|
||||||
|
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {days} DAY "
|
||||||
"ORDER BY timestamp DESC LIMIT 200")
|
"ORDER BY timestamp DESC LIMIT 200")
|
||||||
return res.get('results') or []
|
rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 13]
|
||||||
|
if any(r[11] and _yes(r[10]) for r in rows):
|
||||||
|
later = frame_compat_db._posthog_query(
|
||||||
|
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0) "
|
||||||
|
"FROM events WHERE event = 'contact_consent' LIMIT 100000")
|
||||||
|
mark_withdrawn(rows, later.get('results') or [])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def mark_withdrawn(reports, consents):
|
||||||
|
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
|
||||||
|
the same copy made after the report (a higher rev than it carries, not a later clock) no
|
||||||
|
longer agrees to follow-up questions at that address."""
|
||||||
|
newest = {}
|
||||||
|
for c in consents:
|
||||||
|
if not isinstance(c, list) or len(c) != 4:
|
||||||
|
continue
|
||||||
|
cid, email, followup, rev = c
|
||||||
|
try:
|
||||||
|
rev = int(rev or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if rev > newest.get(str(cid), (-1,))[0]:
|
||||||
|
newest[str(cid)] = (rev, str(email or ''), followup)
|
||||||
|
for r in reports:
|
||||||
|
if not (r[11] and _yes(r[10])):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sent_at = int(r[12] or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sent_at = 0
|
||||||
|
rev, email, followup = newest.get(str(r[11]), (-1, '', None))
|
||||||
|
if rev > sent_at and not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
|
||||||
|
r[10] = 'withdrawn'
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def main():
|
||||||
cmd, *args = sys.argv[1:] or ['inbox']
|
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':
|
if cmd != 'inbox':
|
||||||
sys.exit('usage: frame_report.py inbox [days]')
|
sys.exit(USAGE)
|
||||||
for row in inbox(*(args[:1] or [30])):
|
for row in inbox(*(args[:1] or [30])):
|
||||||
if not isinstance(row, list) or len(row) != 10:
|
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
|
||||||
continue
|
# Reports from before contact_followup existed only carried an address given for a reply.
|
||||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
reply = contact and (row[10] is None or _yes(row[10]))
|
||||||
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
|
||||||
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
|
print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
|
||||||
|
f"{', may follow up at ' + contact if reply else ''}"
|
||||||
|
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
|
||||||
print(' ' + text.replace('\n', '\n '))
|
print(' ' + text.replace('\n', '\n '))
|
||||||
if diag:
|
if diag:
|
||||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||||
|
|||||||
+550
-572
File diff suppressed because it is too large.
Load diff
+69
-6
@@ -50,6 +50,7 @@ import frame_catalog # noqa: E402
|
|||||||
import frame_devices # noqa: E402
|
import frame_devices # noqa: E402
|
||||||
import frame_steamgriddb
|
import frame_steamgriddb
|
||||||
import frame_comfort # noqa: E402
|
import frame_comfort # noqa: E402
|
||||||
|
import frame_contact # noqa: E402
|
||||||
import frame_host # noqa: E402
|
import frame_host # noqa: E402
|
||||||
import frame_link # noqa: E402
|
import frame_link # noqa: E402
|
||||||
import frame_macview # noqa: E402
|
import frame_macview # noqa: E402
|
||||||
@@ -133,6 +134,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
|||||||
# install's clean-up) to the other headset.
|
# install's clean-up) to the other headset.
|
||||||
_work_lock = threading.Lock()
|
_work_lock = threading.Lock()
|
||||||
_work = [0]
|
_work = [0]
|
||||||
|
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
@@ -332,12 +334,14 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
# Never let ssh inherit our stdin: under the app it's the pipe held open for
|
# 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.
|
# --exit-on-eof, and Windows' ssh.exe waits on it forever.
|
||||||
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
feed = {"input": stdin} if stdin is not None else {"stdin": subprocess.DEVNULL}
|
||||||
r = subprocess.run([*SSH, FRAME, remote], capture_output=True, **feed,
|
r = frame_host.run_ssh([*SSH, FRAME, remote], capture_output=True, **feed,
|
||||||
text=text, errors="replace" if text else None, timeout=timeout)
|
text=text, errors="replace" if text else None, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Timed out talking to {FRAME}")
|
raise Failure(f"Timed out talking to {FRAME}")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
err = (r.stderr or r.stdout) if text else (r.stderr or r.stdout).decode(errors="replace")
|
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):
|
if r.returncode == 255 and LINK and unreachable(err):
|
||||||
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
LINK.lost(err, route_gen) # ssh itself failed: the connector reconnects
|
||||||
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
failure = Failure(strip_ansi(err).strip() or f"ssh exited {r.returncode}")
|
||||||
@@ -346,6 +350,30 @@ def ssh(remote, *, stdin=None, timeout=30, text=True):
|
|||||||
return r.stdout
|
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):
|
def strip_ansi(s):
|
||||||
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|\r", "", s)
|
||||||
|
|
||||||
@@ -503,7 +531,7 @@ def save_shots(body):
|
|||||||
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
incoming = Path(tempfile.mkdtemp(prefix=".incoming-", dir=SHOTS_DIR))
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(["scp", "-p", *SSH[1:], *(f"{FRAME}:{p}" for p in todo), str(incoming)],
|
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)
|
capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=300)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure("Copying screenshots timed out")
|
raise Failure("Copying screenshots timed out")
|
||||||
@@ -1192,6 +1220,14 @@ def open_thing(body):
|
|||||||
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
SHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
frame_host.open_path(SHOTS_DIR)
|
frame_host.open_path(SHOTS_DIR)
|
||||||
return {"message": f"Opened {SHOTS_DIR} in {frame_host.FILE_MANAGER}"}
|
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:
|
except frame_host.HostError as e:
|
||||||
raise Failure(str(e), 500)
|
raise Failure(str(e), 500)
|
||||||
raise Failure("unknown target", 400)
|
raise Failure("unknown target", 400)
|
||||||
@@ -2151,6 +2187,7 @@ POST = {
|
|||||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||||
"/api/webinstall/cancel": webinstall_cancel,
|
"/api/webinstall/cancel": webinstall_cancel,
|
||||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
"/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/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action, "/api/panels": panels_action,
|
||||||
"/api/devices": lambda body: devices_post(body)}
|
"/api/devices": lambda body: devices_post(body)}
|
||||||
|
|
||||||
@@ -2246,7 +2283,7 @@ def push_file(path, dest="Downloads/"):
|
|||||||
else:
|
else:
|
||||||
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
# Modern scp uses SFTP, so the remote path isn't parsed by a shell.
|
||||||
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
cmd = ["scp", *SSH[1:], "-r", str(path), f"{FRAME}:{dest}"]
|
||||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
r = frame_host.run_ssh(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors="replace", timeout=3600)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Failure(f"Copying {name} timed out")
|
raise Failure(f"Copying {name} timed out")
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -2254,6 +2291,11 @@ def push_file(path, dest="Downloads/"):
|
|||||||
return f"Sent {name} to ~/{dest}"
|
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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "FrameControl/1"
|
server_version = "FrameControl/1"
|
||||||
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
timeout = 60 # per socket operation, so a stalled client can't hold a thread
|
||||||
@@ -2286,8 +2328,11 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
# Nobody may frame the UI (clickjacking).
|
# Nobody may frame the UI (clickjacking).
|
||||||
self.send_header("X-Frame-Options", "DENY")
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
self.send_header("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
try:
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(data)
|
self.wfile.write(data)
|
||||||
|
except ConnectionError as e: # Windows says ConnectionAbortedError, others BrokenPipeError
|
||||||
|
raise ClientGone() from e
|
||||||
|
|
||||||
def send_json(self, obj, status=200):
|
def send_json(self, obj, status=200):
|
||||||
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
self.send_bytes(json.dumps(obj).encode(), "application/json", status)
|
||||||
@@ -2330,6 +2375,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
from apk_sources import _images
|
from apk_sources import _images
|
||||||
try:
|
try:
|
||||||
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
self.send_bytes(*_images.image(path.rsplit("/", 1)[-1]))
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
self.send_json({"error": "Artwork unavailable"}, 404)
|
self.send_json({"error": "Artwork unavailable"}, 404)
|
||||||
elif path == "/api/sources/details":
|
elif path == "/api/sources/details":
|
||||||
@@ -2380,6 +2427,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_json(macview_state(parse_qs(url.query)))
|
self.send_json(macview_state(parse_qs(url.query)))
|
||||||
elif path == "/api/telemetry":
|
elif path == "/api/telemetry":
|
||||||
self.send_json(frame_telemetry.state())
|
self.send_json(frame_telemetry.state())
|
||||||
|
elif path == "/api/contact":
|
||||||
|
self.send_json(frame_contact.state())
|
||||||
elif path == "/api/computer/state":
|
elif path == "/api/computer/state":
|
||||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||||
elif path == "/api/status":
|
elif path == "/api/status":
|
||||||
@@ -2405,6 +2454,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
headers=[("X-Capture-Source", "gamescope")])
|
headers=[("X-Capture-Source", "gamescope")])
|
||||||
else:
|
else:
|
||||||
self.send_json({"error": "not found"}, 404)
|
self.send_json({"error": "not found"}, 404)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
self.send_error_json(str(e), e.status, e.apk)
|
self.send_error_json(str(e), e.status, e.apk)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -2436,9 +2487,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
body = json.loads(self.rfile.read(length) or b"{}")
|
body = json.loads(self.rfile.read(length) or b"{}")
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
raise Failure("request body must be a JSON object", 400)
|
raise Failure("request body must be a JSON object", 400)
|
||||||
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)):
|
# Not headset work: switching headsets mustn't wait for (or refuse) these.
|
||||||
|
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
|
||||||
result = handler(body)
|
result = handler(body)
|
||||||
self.send_json(result)
|
self.send_json(result)
|
||||||
|
except ClientGone:
|
||||||
|
raise
|
||||||
except Failure as e:
|
except Failure as e:
|
||||||
if e.status >= 500:
|
if e.status >= 500:
|
||||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||||
@@ -2614,6 +2668,10 @@ class LoopbackServer(ThreadingHTTPServer):
|
|||||||
socketserver.TCPServer.server_bind(self)
|
socketserver.TCPServer.server_bind(self)
|
||||||
self.server_name, self.server_port = "127.0.0.1", self.server_address[1]
|
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
|
_ONE_SERVER = None
|
||||||
|
|
||||||
@@ -2644,6 +2702,7 @@ def main():
|
|||||||
sweep_tmp()
|
sweep_tmp()
|
||||||
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
threading.Thread(target=apk_search.warm, daemon=True).start() # big indexes download before the first search
|
||||||
frame_telemetry.start()
|
frame_telemetry.start()
|
||||||
|
frame_contact.start()
|
||||||
global LINK, _ONE_SERVER
|
global LINK, _ONE_SERVER
|
||||||
if not LOCAL:
|
if not LOCAL:
|
||||||
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
if not PRIVATE: # a private server only uses the headsets (see one_server)
|
||||||
@@ -2656,12 +2715,16 @@ def main():
|
|||||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||||
if args.exit_on_eof:
|
if args.exit_on_eof:
|
||||||
def watch_stdin():
|
def watch_stdin():
|
||||||
sys.stdin.buffer.read()
|
# os.read, not sys.stdin.buffer.read: a buffered read holds stdin's lock,
|
||||||
|
# and if a signal stops the server first, Python aborts (SIGABRT) at exit
|
||||||
|
# when it can't take that lock back from this thread.
|
||||||
|
while os.read(0, 4096):
|
||||||
|
pass
|
||||||
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
threading.Thread(target=watch_stdin, daemon=True).start()
|
threading.Thread(target=watch_stdin, daemon=True).start()
|
||||||
|
try:
|
||||||
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
|
# 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)
|
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
|
||||||
try:
|
|
||||||
httpd.serve_forever()
|
httpd.serve_forever()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in new issue
Block a user