Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 6abc765e22 App: Try Again also works when the server is up but its page failed to load
The button was accepted only while no server was known. If the server answered
and the page then failed to load, the error page showed with the server still
known, and the button did nothing. It's now accepted from the error page itself
(the window's only data: page).

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 20:17:24 +10:00
saphidandClaude Opus 5.5 2ca0e6924a Contact email tests: pin the in-gap save and same-second report timing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:38:54 +10:00
saphidandClaude Opus 5.5 cf2db32721 Contact email: don't strand a change saved as a send finishes; time reports exactly
Third review follow-ups:
- A sender that found nothing waiting checks again after letting go of the
  send lock, so a change saved in that moment is sent, not left for a retrier.
- A report is compared with a removal using its full-precision start time, so
  a report sent after the address was removed is logged as sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:35:25 +10:00
saphidandClaude Opus 5.5 3f0f09b138 Contact email: don't block Save on a slow send; redact reports still in flight
Second review follow-ups:
- Saving returns once the choice is stored; a send already under way picks up
  the newest change, or the background retry is woken.
- A problem report still being sent when its address is removed is logged as
  <removed>, checked under the same lock the removal holds.
- The prompt re-checks the privacy notice after fetching its state.
- docs/privacy.md: offline contact changes are sent later by themselves; the
  prompt never follows straight after the privacy notice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:29:17 +10:00
saphidandClaude Opus 5.5 b8ed53f2ff Contact email: newest choice wins by rev, removal wipes the local log
Review follow-ups:
- Each contact_consent event carries a rev that goes up with every change,
  sends are serialized, and `contacts` picks every field from the highest
  rev per copy, so a withdrawal can't lose to an earlier event sent in the
  same second or with a skewed clock.
- Removing the address also replaces it with <removed> in the local
  sent log (earlier contact events and problem reports).
- The prompt is rechecked when the Frame connects, not only at page load.
- No thanks hides the bar only once the dismissal is saved.
- docs/privacy.md: say that the analytics switches don't block a report or
  contact change the person sends deliberately.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:22:45 +10:00
saphidandClaude Opus 5.5 19a0d0af18 Ask for an optional contact email, with separate update and follow-up consent
Problem reports arrive with no way to reply. People can now leave an email
address with two separate opt-ins: occasional update notices, and follow-up
questions from the maintainer.

- ui/frame_contact.py keeps the address and choices locally and sends each
  change privately to PostHog as a contact_consent event under its own random
  contact id; removing the address sends a withdrawal without it. Changes made
  offline wait and are retried.
- A one-time, dismissible prompt appears after the Frame first connects; No
  thanks and showing it once are both remembered.
- Privacy & updates gains a Contact email section to add, change or remove it.
- The report form's contact field now goes with a report only when "may
  contact me with follow-up questions" is ticked (contact_followup).
- frame_report.py contacts [updates|followup] lists who agreed to what,
  using the newest event per copy.
- docs/privacy.md says what is collected, why, where and how to remove it.

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

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

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

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c])); const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid; 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);
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+9 -14
View File
@@ -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.
@@ -183,5 +178,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
View File
@@ -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
View File
@@ -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).
+56 -5
View File
@@ -97,14 +97,16 @@ 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
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. your analytics events.
With **Include diagnostics** ticked (the default), the report adds: With **Include diagnostics** ticked (the default), the report adds:
@@ -128,11 +130,60 @@ 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 while or straight after the
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates**
is where you add, change or remove the address and either choice at any time.
**What's sent, and where.** The address and the two choices go privately to
Frame Control's PostHog project, the same place as problem reports, as a
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
or `withdraw`) and the common properties above. Only the maintainer can read
that project, and nothing in it is published or shared. It's sent only when
you save, whatever the analytics settings are, because you chose to. It
carries its own random contact id, not the analytics id, so it isn't linked
to your usage events, and a `rev` number that goes up with each change, so
the newest choice always wins. Like everything else sent, it's listed under
**Show what's been sent**. On this computer the address and choices are kept in
`contact/contact.json` in Frame Control's data folder. An address is only
kept with at least one choice ticked.
**Removing it.** **Remove my email** (or clearing the address and saving)
deletes it from this computer, including from the **Show what's been sent**
log (in earlier contact events and problem reports), and sends a `withdraw`
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
then on the address isn't listed for either choice. Unticking one choice
works the same way for that choice. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event
stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report).
Nothing sends email yet: this only records who agreed to what. The
maintainer lists the addresses with
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
personal API key as `inbox`.
## Turning it all off ## 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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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.
-42
View File
@@ -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)
+280
View File
@@ -0,0 +1,280 @@
"""A contact email (ui/frame_contact.py): kept only with a matching choice, sent privately,
withdrawn when removed, never lost offline, and the one-time prompt stays dismissed.
Run: python3 -m unittest discover -s tests
"""
import sandbox # noqa: F401 (first: keeps tests off real data and services)
import sys
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "ui"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import frame_compat_db as db # noqa: E402
import frame_contact as fc # noqa: E402
import frame_report as fr # noqa: E402
import frame_telemetry as tm # noqa: E402
from test_telemetry import Base, ReportProblem # noqa: E402
REPORT = {"title": "RDP not working", "message": "It never connects on Windows."}
class Contact(Base):
"""Base's temp telemetry state, ReportProblem's PostHog stand-in, and a temp contact file."""
serve = ReportProblem.serve
def setUp(self):
super().setUp()
self.addCleanup(fc._removed.clear)
for name, value in (("STATE", tm.STATE / "contact"), ("FILE", tm.STATE / "contact" / "contact.json")):
p = mock.patch.object(fc, name, value)
p.start()
self.addCleanup(p.stop)
self.got = self.serve()
def events(self):
return [body["batch"][0] for _, body in self.got]
def offline(self):
return mock.patch.object(tm, "post", side_effect=tm.SendError("couldn't reach PostHog"))
# ---- storage and consent flags
def test_nothing_is_kept_or_sent_until_chosen(self):
s = fc.state()
self.assertEqual((s["email"], s["updates"], s["followup"], s["waiting"]), ("", False, False, False))
self.assertFalse(fc.FILE.exists())
self.assertEqual(self.got, [])
def test_an_address_needs_a_choice_and_a_real_address(self):
with self.assertRaisesRegex(ValueError, "tick"):
fc.save({"email": "me@example.com"})
with self.assertRaisesRegex(ValueError, "email address"):
fc.save({"email": "not an address", "updates": True})
self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, [])
def test_each_choice_is_sent_privately_on_its_own(self):
fc.save({"email": " me@example.com ", "updates": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True})
first, second = self.events()
self.assertEqual(first["event"], "contact_consent")
self.assertEqual({k: first["properties"][k] for k in ("email", "updates", "followup", "action")},
{"email": "me@example.com", "updates": True, "followup": False, "action": "set"})
self.assertEqual((second["properties"]["updates"], second["properties"]["followup"]), (False, True))
self.assertEqual(first["distinct_id"], second["distinct_id"]) # one contact id, newest wins
self.assertNotEqual(first["distinct_id"], tm.settings()["id"]) # not the analytics id
self.assertEqual((first["properties"]["$process_person_profile"], first["properties"]["$geoip_disable"]),
(False, True))
self.assertEqual([e["event"] for e in tm._read_lines(tm.SENT)], ["contact_consent"] * 2)
def test_sent_whatever_the_analytics_settings(self):
tm.update_settings({"usage": False})
fc.save({"email": "me@example.com", "followup": True})
self.assertEqual(len(self.got), 1)
def test_saving_the_same_choice_again_sends_nothing(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(len(self.got), 1)
# ---- withdrawal
def test_removing_the_address_sends_a_withdrawal_without_it(self):
fc.save({"email": "me@example.com", "updates": True, "followup": True})
s = fc.save({"email": "", "updates": True, "followup": True})
self.assertEqual((s["email"], s["updates"], s["followup"]), ("", False, False))
withdrawal = self.events()[-1]["properties"]
self.assertEqual((withdrawal["action"], withdrawal["email"], withdrawal["updates"], withdrawal["followup"]),
("withdraw", "", False, False))
self.assertNotIn("me@example.com", fc.FILE.read_text())
def test_an_address_still_waiting_is_withdrawn_too(self):
with self.offline():
fc.save({"email": "me@example.com", "updates": True}) # may already be on its way
with mock.patch.object(tm, "post") as post:
fc.save({"email": ""})
self.assertEqual([c.args[0][0]["properties"]["action"] for c in post.call_args_list], ["withdraw"])
self.assertFalse(fc.state()["waiting"])
def test_offline_the_newest_choice_waits_and_a_withdrawal_is_never_lost(self):
fc.save({"email": "me@example.com", "updates": True})
with self.offline():
s = fc.save({"email": ""})
self.assertTrue(s["waiting"])
self.assertFalse(fc._send_pending())
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
self.assertTrue(fc._send_pending())
self.assertFalse(fc.state()["waiting"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
def test_removing_the_address_wipes_it_from_the_sent_log_too(self):
fc.save({"email": "me@example.com", "followup": True})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text())
fc.save({"email": ""})
self.assertNotIn("me@example.com", tm.SENT.read_text())
self.assertEqual([e["properties"].get("action") for e in tm._read_lines(tm.SENT)
if e["event"] == "contact_consent"], ["set", "withdraw"])
def test_each_change_has_a_higher_rev_so_the_newest_wins_whatever_the_clock(self):
fc.save({"email": "me@example.com", "updates": True})
fc.save({"email": "new@example.com", "updates": True})
fc.save({"email": ""})
self.assertEqual([e["properties"]["rev"] for e in self.events()], [1, 2, 3])
def test_a_withdrawal_during_a_send_goes_after_it(self):
started, release, order = threading.Event(), threading.Event(), []
real = tm.post
def slow(batch, timeout=20):
order.append(batch[0]["properties"]["action"])
if len(order) == 1:
started.set()
release.wait(5)
real(batch, timeout)
with mock.patch.object(tm, "post", side_effect=slow):
t = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
t.start()
self.assertTrue(started.wait(5))
w = threading.Thread(target=fc.save, args=({"email": ""},))
w.start()
for _ in range(500): # the withdrawal is saved while the first send is still out
if fc.load()["rev"] == 2:
break
time.sleep(0.01)
self.assertEqual(fc.load()["pending"]["properties"]["action"], "withdraw")
release.set()
t.join(5)
w.join(5)
self.assertEqual(order, ["set", "withdraw"])
self.assertEqual([e["properties"]["action"] for e in self.events()], ["set", "withdraw"])
self.assertFalse(fc.state()["waiting"])
self.assertNotIn("me@example.com", tm.SENT.read_text())
def test_a_report_still_sending_when_its_address_is_removed_is_logged_without_it(self):
fc.save({"email": "me@example.com", "followup": True})
real = tm.post
def remove_meanwhile(batch, timeout=20):
real(batch, timeout)
fc.save({"email": ""}) # removed while the report is on its way, before it's logged
with mock.patch.object(tm, "post", side_effect=remove_meanwhile):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertNotIn("me@example.com", tm.SENT.read_text())
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
self.assertIn("me@example.com", tm.SENT.read_text()) # sent again after removal: logged as sent
def test_only_reports_started_before_the_removal_are_redacted_even_within_a_second(self):
fc._removed["me@example.com"] = 1790000000.3
event = lambda: {"timestamp": "2026-09-21T12:53:20Z", "properties": {"contact": "me@example.com"}}
before, after = event(), event() # the same whole second as the removal
fc.redact_removed(before, 1790000000.1)
fc.redact_removed(after, 1790000000.6)
self.assertEqual((before["properties"]["contact"], after["properties"]["contact"]),
("<removed>", "me@example.com"))
def test_saving_during_a_slow_send_returns_at_once(self):
busy = fc._send_lock
busy.acquire()
try:
s = fc.save({"email": "me@example.com", "updates": True})
finally:
busy.release()
self.assertTrue(s["waiting"]) # left for the send under way (or the retry) to take
self.assertEqual(self.got, [])
self.assertTrue(fc._send_pending())
self.assertEqual(len(self.got), 1)
def test_a_change_saved_as_a_send_finishes_is_not_left_behind(self):
real = fc._send_lock
class Lock: # a Save lands after the sender found nothing waiting, before it lets go
saved = False
def acquire(self, blocking=True):
return real.acquire(blocking)
def release(self):
if not Lock.saved:
Lock.saved = True
s = threading.Thread(target=fc.save, args=({"email": "me@example.com", "updates": True},))
s.start()
s.join(5)
assert not s.is_alive() # the change is saved while the sender still holds the lock
real.release()
with mock.patch.object(fc, "_send_lock", Lock()):
self.assertTrue(fc._send_pending())
self.assertEqual([e["properties"]["email"] for e in self.events()], ["me@example.com"])
self.assertFalse(fc.state()["waiting"])
# ---- the one-time prompt
def test_the_prompt_waits_for_a_working_setup_then_stays_dismissed(self):
self.assertFalse(fc.state()["showPrompt"]) # a new install: the Frame hasn't connected yet
tm.frame_seen("20260901.1", "3.8")
self.assertTrue(fc.state()["showPrompt"])
fc.prompt({"prompt": "dismissed"})
fc.prompt({"prompt": "shown"}) # a later session can't bring it back
self.assertEqual(fc.load()["prompt"], "dismissed")
self.assertFalse(fc.state()["showPrompt"])
self.assertEqual(self.got, []) # No thanks sends nothing
with self.assertRaises(ValueError):
fc.prompt({"prompt": "reset"})
def test_the_prompt_is_shown_once_and_saving_answers_it(self):
tm.frame_seen("20260901.1", "3.8")
fc.prompt({"prompt": "shown"})
self.assertFalse(fc.state()["showPrompt"])
fc.save({"email": "me@example.com", "followup": True, "fromPrompt": True})
self.assertEqual(fc.load()["prompt"], "answered")
# ---- reports and the maintainer's list
def test_a_report_carries_the_address_only_with_follow_up_consent(self):
fr.send({**REPORT, "contact": "me@example.com"})
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = (e["properties"] for e in self.events())
self.assertEqual((without["contact"], without["contact_followup"]), ("", False))
self.assertEqual((with_["contact"], with_["contact_followup"]), ("me@example.com", True))
with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
["c", "", False, False, "2026-09-03T10:00:00Z"], # withdrawn
["d", "not-an-address", True, True, "2026-09-03T10:00:00Z"], ["short"]]
with mock.patch.object(db, "_posthog_query", return_value={"results": rows}) as q:
found = fr.contacts()
self.assertIn("argMax(properties.email, tuple(ifNull(toInt(properties.rev), 0), timestamp))",
q.call_args.args[0])
self.assertEqual(found, {"updates": [("both@example.com", "2026-09-01"), ("news@example.com", "2026-09-02")],
"followup": [("both@example.com", "2026-09-01")]})
with mock.patch.object(fr, "contacts", return_value=found), \
mock.patch.object(sys, "argv", ["frame_report.py", "contacts", "followup"]), \
mock.patch("builtins.print") as out:
fr.main()
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("both@example.com", printed)
self.assertNotIn("news@example.com", printed)
def test_the_page_can_reach_it(self):
import server
self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
# Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem
if __name__ == "__main__":
unittest.main()
-14
View File
@@ -296,20 +296,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")
-41
View File
@@ -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"})
-55
View File
@@ -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()
+18
View File
@@ -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."""
+5 -3
View File
@@ -392,13 +392,14 @@ class ReportProblem(Base):
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
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.assertIs(props["contact_followup"], True)
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 +422,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]
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],
["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:
+218
View File
@@ -0,0 +1,218 @@
"""An email address the person chooses to leave, and what it may be used for. Python stdlib only.
Two separate opt-in choices, both off until ticked:
- updates: occasional notices about Frame Control releases and updates
- followup: the maintainer may ask follow-up questions, mainly about problem reports
The address and the choices are kept on this computer (frame_host.data_dir('contact')) and
sent privately to Frame Control's PostHog project as a `contact_consent` event, the same way
as problem reports (frame_report.py), so only the maintainer can read them. Every change
sends a new event under this copy's own random contact id (not the analytics id), numbered
by `rev`, and the highest rev for an id is the one that counts, whatever the clocks say:
removing the address sends a withdrawal with no address in it, and wipes the address from
the local log of what was sent. The maintainer lists who agreed to what with
`python3 ui/frame_report.py contacts`. Nothing here sends email.
A change that can't be sent (offline) waits in the state file and is retried in the
background, so a withdrawal is never lost. The page's one-time prompt is remembered here
too: once it has been shown or dismissed it never comes back.
"""
import json
import os
import re
import threading
import time
import uuid
import frame_host
import frame_telemetry
STATE = frame_host.data_dir('contact')
FILE = STATE / 'contact.json'
EMAIL_MAX = 254
EMAIL_RE = re.compile(r'[^@\s]+@[^@\s]+\.[^@\s.]+')
PROMPTS = ('new', 'shown', 'dismissed', 'answered')
RETRY_EVERY = 600
_lock = threading.RLock()
_send_lock = threading.Lock() # one send at a time, so events reach PostHog in rev order
_removed = {} # address (lower case) -> when it was removed, for reports still being sent then
_wake = threading.Event()
_retrier = None
def _defaults():
return {'id': str(uuid.uuid4()), 'email': '', 'updates': False, 'followup': False,
'prompt': 'new', 'pending': None, 'rev': 0}
def load():
with _lock:
s = _defaults()
try:
with open(FILE) as f:
saved = json.load(f)
if isinstance(saved, dict):
s.update({k: v for k, v in saved.items() if k in s})
except (OSError, ValueError):
pass
return s
def _save(s):
STATE.mkdir(parents=True, exist_ok=True)
tmp = FILE.with_suffix('.tmp')
tmp.write_text(json.dumps(s, indent=1))
os.replace(tmp, FILE)
def valid_email(email):
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet,
and the Frame has connected at least once (setup worked), so it never greets a new install."""
s = load()
set_up = bool(frame_telemetry.settings().get('frames_seen'))
return {'email': s['email'], 'updates': s['updates'], 'followup': s['followup'],
'waiting': s['pending'] is not None, 'showPrompt': s['prompt'] == 'new' and set_up}
def _event(s):
email = s['email'] if s['updates'] or s['followup'] else ''
return {'event': 'contact_consent', 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'properties': {**frame_telemetry.common(), 'email': email, 'updates': bool(email and s['updates']),
'followup': bool(email and s['followup']),
'action': 'set' if email else 'withdraw', 'rev': s['rev'], 'level': 'contact'}}
def _send_pending(block=True):
"""Send what's waiting, including changes made while sending. True if nothing is left
waiting. Without block, a send already under way is left to pick up the newest change."""
if not _send_lock.acquire(blocking=block):
return False
try:
while True:
with _lock:
event = load()['pending']
if event is None:
break
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError:
return False
_sent(event)
finally:
_send_lock.release()
# A change saved just as this finished found the lock still held and left it to us.
with _lock:
left = load()['pending'] is not None
return _send_pending(block=False) if left else True
def _sent(event):
with _lock:
s = load()
if s['pending'] and s['pending'].get('uuid') == event['uuid']: # not replaced meanwhile
s['pending'] = None
_save(s)
# A withdrawal, or the address still in use: not an old one removed while this was on its way.
if event['properties']['email'] in ('', s['email']):
try:
frame_telemetry.record_sent([event])
except OSError:
pass
def _forget_locally(email):
"""Take a removed address out of the log of what was sent (contact events and reports)."""
with frame_telemetry._lock:
_removed[email.lower()] = time.time()
rows = frame_telemetry._read_lines(frame_telemetry.SENT)
hit = False
for e in rows:
p = e.get('properties') or {}
for k in ('email', 'contact'):
if p.get(k) and str(p[k]).strip().lower() == email.lower():
p[k], hit = '<removed>', True
if hit:
frame_telemetry._write_lines(frame_telemetry.SENT, rows)
def redact_removed(event, started):
"""Before logging a report (started at time.time() `started`) whose address was removed
while it was being sent: take the address out. Call with frame_telemetry._lock held, so a
removal can't slip between this and the log."""
p = event.get('properties') or {}
removed_at = _removed.get(str(p.get('contact') or '').strip().lower())
if removed_at is not None and started <= removed_at:
p['contact'] = '<removed>'
def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one
choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
email = str(body.get('email') or '').strip()
updates, followup = bool(body.get('updates')), bool(body.get('followup'))
if email and not valid_email(email):
raise ValueError("that doesn't look like an email address")
if email and not (updates or followup):
raise ValueError('tick what the address may be used for, or remove it')
if not email:
updates = followup = False
with _lock:
s = load()
old = s['email']
changed = (email, updates, followup) != (s['email'], s['updates'], s['followup'])
s.update(email=email, updates=updates, followup=followup)
if body.get('fromPrompt') or email:
s['prompt'] = 'answered'
if changed:
# Only the newest choice matters, so it replaces anything still waiting. A withdrawal
# is sent even for an address still waiting here: its send may already be under way.
s['rev'] += 1
s['pending'] = _event(s)
_save(s)
if old and old.lower() != email.lower():
try:
_forget_locally(old)
except OSError:
pass
if changed and not _send_pending(block=False):
_wake.set() # offline, or a send under way that will take this change with it
return state()
def prompt(body):
"""The one-time prompt was shown, or dismissed with No thanks. Either way it stays gone."""
action = body.get('prompt')
if action not in ('shown', 'dismissed'):
raise ValueError('unknown prompt action')
with _lock:
s = load()
if s['prompt'] in ('new', 'shown'):
s['prompt'] = action
_save(s)
return state()
def start():
"""Retry a change that couldn't be sent, from now on in the background."""
global _retrier
if _retrier:
return
def loop():
while True:
try:
_send_pending()
except Exception:
pass
_wake.wait(RETRY_EVERY)
_wake.clear()
_retrier = threading.Thread(target=loop, name='contact', daemon=True)
_retrier.start()
+2 -4
View File
@@ -623,9 +623,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 +631,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)
+13
View File
@@ -153,6 +153,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
+1 -7
View File
@@ -169,7 +169,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,
@@ -956,13 +955,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()
@@ -1116,8 +1111,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']}"
+68 -7
View File
@@ -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,19 +112,26 @@ 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 = bool(body.get('contactFollowup'))
contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box')
ref = uuid.uuid4().hex[:8].upper() 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,
'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()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props} 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
started = time.time()
try: try:
frame_telemetry.post([event], timeout=30) frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError as e: except frame_telemetry.SendError as e:
raise ReportError(str(e)) raise ReportError(str(e))
try: try:
frame_telemetry.record_sent([event]) with frame_telemetry._lock: # the lock a removal holds while wiping its address
frame_contact.redact_removed(event, started)
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
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'} return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
@@ -135,22 +147,71 @@ def inbox(days=30):
import frame_compat_db import frame_compat_db
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, "
"properties.contact_followup "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY " f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
"ORDER BY timestamp DESC LIMIT 200") "ORDER BY timestamp DESC LIMIT 200")
return res.get('results') or [] return res.get('results') or []
def _yes(v):
return v is True or str(v).lower() in ('true', '1')
def contacts():
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
address, replaces what came before, so withdrawn addresses are never listed. "Newest" is
the highest rev from that copy (then time), so every field comes from the same event
whatever order they arrived in or what the clocks said."""
import frame_compat_db
newest = "tuple(ifNull(toInt(properties.rev), 0), timestamp)"
res = frame_compat_db._posthog_query(
f"SELECT distinct_id, argMax(properties.email, {newest}), argMax(properties.updates, {newest}), "
f"argMax(properties.followup, {newest}), argMax(timestamp, {newest}) FROM events "
"WHERE event = 'contact_consent' GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
out = {'updates': [], 'followup': []}
for row in res.get('results') or []:
if not isinstance(row, list) or len(row) != 5:
continue
_, email, updates, followup, ts = row
email = str(email or '').strip()
if not frame_contact.valid_email(email):
continue
for kind, agreed in (('updates', updates), ('followup', followup)):
if _yes(agreed):
out[kind].append((email, str(ts or '')[:10]))
return out
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
def main(): 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: if not isinstance(row, list) or len(row) != 11:
continue continue
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row) ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10])
# Reports from before contact_followup existed only carried an address given for a reply.
reply = contact and (row[10] is None or _yes(row[10]))
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 ''}")
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 '))
+537 -575
View File
File diff suppressed because it is too large. Load diff
+18 -3
View File
@@ -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
@@ -1192,6 +1193,12 @@ 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.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 +2158,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)}
@@ -2380,6 +2388,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":
@@ -2644,6 +2654,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 +2667,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()
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
try: try:
# The real port, which --port 0 leaves to the system (the iPhone app reads it from here).
print(f"Frame Control on http://127.0.0.1:{httpd.server_address[1]} (alias: {FRAME}; Ctrl-C to stop)", flush=True)
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass