From 9b26c907ce277b8fb23357811214067b42091820 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:42:03 +1100 Subject: [PATCH 01/13] Telemetry: count headsets, not just installs frame_connected now carries headset_id, a one-way hash of the headset's /etc/machine-id worked out on the headset (frame_status.py), and headsets_saved, how many headsets this copy of the app has set up. It is sent once per build for each headset. The hash goes to usage statistics only: the status endpoint and MCP clients don't get it. docs/privacy.md says what is sent. Co-Authored-By: Claude Opus 5.5 --- docs/privacy.md | 8 +++++++- tests/test_telemetry.py | 32 ++++++++++++++++++++++++++++++++ ui/frame_status.py | 12 +++++++++++- ui/frame_telemetry.py | 19 ++++++++++++++----- ui/server.py | 4 +++- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index 3412850..24df6e7 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -33,6 +33,12 @@ computer, exactly as they were sent. yours. - Every event includes the app version, OS name (macOS, Windows or Linux), CPU architecture and Python version. +- `frame_connected` carries `headset_id`: the first 16 hex digits of a + SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset, + so the machine-id itself never leaves it. It is the same for one headset on + every computer, which lets us count headsets rather than installs; it can't + be turned back into the machine-id or a serial number. `headsets_saved` is how + many headsets this copy of Frame Control has set up. ## Usage events @@ -41,7 +47,7 @@ computer, exactly as they were sent. | `app_installed` | First run | | | `app_updated` | First run of a new version | `from_version` | | `app_opened` | At most once a day | | -| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` | +| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` | | `tab_viewed` | The first click on each tab in a session | `tab` | | `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below | | `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` | diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 9f5cc28..689acf4 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -164,6 +164,38 @@ class Lifecycle(Base): tm.frame_seen("20260922.1", "3.8") self.assertEqual(len(self.queued()), 1) + def test_frame_once_per_headset_with_saved_count(self): + tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2) + tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2) + tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2) + props = [e["properties"] for e in self.queued()] + self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props], + [("0123456789abcdef", 2), ("fedcba9876543210", 2)]) + + def test_frame_many_headsets_are_not_sent_twice(self): + ids = ["%016x" % n for n in range(25)] + for _ in range(2): + for h in ids: + tm.frame_seen("20260922.1", "3.8", h, 25) + self.assertEqual(len(self.queued()), 25) + + def test_frame_headset_id_must_be_the_hash(self): + tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None) + p = self.queued()[0]["properties"] + self.assertNotIn("headset_id", p) + self.assertNotIn("headsets_saved", p) + + +class HeadsetId(unittest.TestCase): + def test_hash_of_machine_id_and_none_without_one(self): + import hashlib + import frame_status + with mock.patch.object(frame_status, "read", return_value="abc123"): + self.assertEqual(frame_status.headset_id(), + hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16]) + with mock.patch.object(frame_status, "read", return_value=None): + self.assertIsNone(frame_status.headset_id()) + class Sending(Base): def serve(self, status=200): diff --git a/ui/frame_status.py b/ui/frame_status.py index 92ef7a1..0d6a1a5 100644 --- a/ui/frame_status.py +++ b/ui/frame_status.py @@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1 """ import ctypes as C import glob +import hashlib import math import json import os @@ -121,7 +122,16 @@ def os_release(): k, _, v = line.partition("=") out[k] = v.strip('"') return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"), - "variant": out.get("VARIANT_ID")} + "variant": out.get("VARIANT_ID"), "headset": headset_id()} + + +def headset_id(): + """A one-way hash of the headset's /etc/machine-id: the same for this headset on every + computer, and the machine-id itself never leaves the headset. None without one.""" + mid = read("/etc/machine-id") + if not mid: + return None + return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16] def volume(): diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py index 10f7843..68c06e0 100644 --- a/ui/frame_telemetry.py +++ b/ui/frame_telemetry.py @@ -50,6 +50,7 @@ SETTINGS = STATE / 'settings.json' OUTBOX = STATE / 'outbox.jsonl' SENT = STATE / 'sent.jsonl' SENT_KEEP = 200 +FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates OUTBOX_MAX = 2000 # events kept while offline; the oldest go first FLUSH_EVERY = 60 REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds @@ -366,16 +367,24 @@ def app_started(): _save(s) -def frame_seen(build, version): - """The Frame's SteamOS build, once per build (public build numbers).""" - key = f'{build}/{version}' +def frame_seen(build, version, headset=None, saved=None): + """The Frame's SteamOS build (public build numbers), once per build for each headset. + headset: the anonymous hash from frame_status.headset_id; saved: how many headsets + this copy of the app has set up.""" + headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None + key = f'{build}/{version}' + (f'/{headset}' if headset else '') with _lock: s = settings() if not build or key in s['frames_seen']: return - s['frames_seen'] = (s['frames_seen'] + [key])[-20:] + s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:] _save(s) - capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}) + p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]} + if headset: + p['headset_id'] = headset + if isinstance(saved, int) and saved >= 0: + p['headsets_saved'] = saved + capture('frame_connected', p) def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props): diff --git a/ui/server.py b/ui/server.py index 07ca9f3..fb4552a 100755 --- a/ui/server.py +++ b/ui/server.py @@ -403,7 +403,9 @@ def status(_body): s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20)) osr = s.get("os") if isinstance(s, dict) else None if isinstance(osr, dict): - frame_telemetry.frame_seen(osr.get("build"), osr.get("version")) + # The headset's hash is for usage statistics only; the page and MCP clients don't get it. + frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None), + len(LINK.reg.devices()) if LINK else None) frame_report.frame.update(build=osr.get("build"), version=osr.get("version")) return s From b6406a90a4b80671a16b9aa9729a3053ac70b761 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:27:44 +1100 Subject: [PATCH 02/13] Log scrubbing: find names and ssh operands in the original text (#81) Follow-up to #79. Known names and the hosts ssh names are now located as spans in the text as it came, overlapping spans merged, and replaced in one pass. A display name containing a scrubber token (" Jane Doe headset") now goes whole, and a name like "to host" can no longer hide ssh's "connect to host X" operand. Tokens on their own are still left as they are. Co-Authored-By: Claude Opus 5.5 --- tests/test_link.py | 20 ++++++++++++ ui/frame_link.py | 79 +++++++++++++++++++++++++++++++++++++--------- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/tests/test_link.py b/tests/test_link.py index e389e9e..ad80c2d 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -1177,6 +1177,26 @@ class FailureLog(unittest.TestCase): "Timed out talking to ") self.assertEqual(fl.hide_hosts("@: ", ["user", "host", "ip"]), "@: ") + def test_a_whole_name_can_cross_a_scrubber_token(self): # #81, first case + self.assertEqual(fl.scrub_failure(" Jane Doe headset stopped answering", [" Jane Doe headset"]), + " stopped answering") + self.assertEqual(fl.hide_hosts("seen Jane Doe headset and ", [" Jane Doe headset", "ip"]), + "seen and ") # the standalone token stays + self.assertEqual(fl.hide_hosts(" frame", ["ip> frame"]), "") # never half a token left + + def test_a_name_cant_swallow_sshs_operand_marker(self): # #81, second case + said = "ssh: connect to host bastion-jane port 22: Connection refused" + out = fl.scrub_failure(said, ["to host"]) + self.assertNotIn("bastion-jane", out) + self.assertEqual(out, "ssh: connect port 22: Connection refused") + err = quiet_log(self) + fl.log_failure("ssh", "ssh stopped", said, hosts=["to host", "host bastion"]) + self.assertNotIn("bastion", err.getvalue()) + self.assertNotIn("jane", err.getvalue()) + # a name overlapping the operand merges with it into one + self.assertEqual(fl.scrub_failure("Could not resolve hostname jane-office now", ["jane-office now"]), + "Could not resolve hostname ") + def test_windows_user_names_with_spaces_go_whole(self): err = quiet_log(self) with mock.patch.object(fl.frame_telemetry, "_user_names", return_value=set()): diff --git a/ui/frame_link.py b/ui/frame_link.py index d4833c8..1207c90 100644 --- a/ui/frame_link.py +++ b/ui/frame_link.py @@ -203,17 +203,58 @@ def probes_summary(probes): PLACEHOLDER = re.compile(r"(<(?:host|user|email|ip|mac|steamid|hex|token|ssh-key|pem|url|redacted)>)") +def _host_spans(text, hosts, keep=()): + """Where each of `hosts` is in text, longest first and whatever the case: (start, end) pairs. + + A name may cross a scrubber's (the whole token then goes with it), but one wholly + inside a token is the token's own word and is left. A name next to one already found sees + it as , as if that one had been replaced first.""" + keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording + names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True) + tokens = [m.span() for m in PLACEHOLDER.finditer(text)] + spans = [] + + def at(i, inside): # the character at i, with names already found standing in as + if not 0 <= i < len(text): + return "" + return inside if any(a <= i < b for a, b in spans) else text[i] + + for h in names: + pat = re.compile(re.escape(h), re.I) + m = pat.search(text) + while m: + s, e = m.span() + before, after, then = at(s - 1, ">"), at(e, "<"), at(e + 1, "<") + ok = (not re.match(r"[\w.:-]", before) and not re.match(r"[\w-]", after) + and not (after in (":", ".", "%") and re.match(r"\w", then))) + if ok and any(a <= s and e <= b for a, b in tokens): + ok = False # "ip" in "": a token already there stays as it is + if ok: + for a, b in tokens: # never cut a token in half: the whole of it goes + if a < e and s < b: + s, e = min(s, a), max(e, b) + spans.append((s, e)) + m = pat.search(text, m.end() if ok else m.start() + 1) + return spans + + +def _hide_spans(text, spans): + """text with each span replaced by , overlapping ones merged into one, in one pass.""" + out, last = [], 0 + for s, e in sorted(spans): + if s < last: # overlaps the one before: that covers this too + last = max(last, e) + continue + out += [text[last:s], ""] + last = e + return "".join(out) + text[last:] + + def hide_hosts(text, hosts, keep=()): """text with each of `hosts` (the headset's own addresses and names) replaced by , longest first and whatever the case, so a bare name like "steamdeck" that the scrubber can't recognise goes too.""" - keep = {k.lower() for k in keep if k} | KEYWORDS # a headset called "host" mustn't eat ssh's wording - names = sorted({h for h in hosts if h and h.lower() not in keep}, key=len, reverse=True) - parts = PLACEHOLDER.split(text) # never inside a scrubber's own already there - for i in range(0, len(parts), 2): - for h in names: - parts[i] = re.sub(r"(?", parts[i], flags=re.I) - return "".join(parts) + return _hide_spans(text, _host_spans(text, hosts, keep)) # ssh names the host it was going to after these words ("Could not resolve hostname X", @@ -223,19 +264,27 @@ PLAIN_WORDS = {"answer", "the", "a", "an", "this", "it", "its", "be", "connect", KEYWORDS = {"host", "hostname", "to", "port"} | PLAIN_WORDS -def hide_operands(text): - def one(m): +def _operand_spans(text): + """Where the hosts ssh names are in text, ssh's own words and any trailing dots left out.""" + spans = [] + for m in OPERAND.finditer(text): word = m.group(2).rstrip(".") - dots = m.group(2)[len(word):] - return m.group(0) if word.lower() in PLAIN_WORDS else f"{m.group(1)} {dots}" - return OPERAND.sub(one, text) + if word.lower() not in PLAIN_WORDS: + spans.append((m.start(2), m.start(2) + len(word))) + return spans + + +def hide_operands(text): + return _hide_spans(text, _operand_spans(text)) def scrub_failure(text, hosts=()): """Free text about a failed attempt, for the log: the attempt's own names, whole and - longest first (case-insensitively, never ssh's own words), then anything ssh names as a - host, then the shared scrubber (addresses, paths, user names).""" - return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600) + longest first (case-insensitively, never ssh's own words), and anything ssh names as a + host, both found in the text as it came so neither can hide the other, then the shared + scrubber (addresses, paths, user names).""" + text = str(text or "") + return frame_telemetry.scrub(_hide_spans(text, _host_spans(text, hosts) + _operand_spans(text)), 600) def failure_category(message, raw=""): From 7c1d6984007325fb8b38275e2090444210254b2b Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:29:32 +1100 Subject: [PATCH 03/13] Comfort: don't report the poll meeting an unreachable Frame, or poll while it is The page polled POST /api/comfort status every 5 s whatever the connection, and every POST Failure >= 500 went to diagnostics. ssh already marks link failures, but those are still sent once per session per category, so each session with a sleeping, away or re-keyed headset sent up to three reports from this poll (most of 0.4.2's error reports). GET /api/status never reports Failures at all. - server: a polled POST read (POLLS: comfort status) that couldn't reach the headset isn't a diagnostic, as with GET /api/status. Real comfort errors, and link failures from starting or cancelling a session, still are. - page: the comfort poll starts after the first status check and skips its ssh while the Frame is offline (online === false); status checks bring it back. Co-Authored-By: Claude Opus 5.5 --- tests/test_comfort_ui.py | 29 +++++++++++++++++++++++++++++ tests/test_telemetry.py | 40 +++++++++++++++++++++++++++++++++++----- ui/index.html | 12 ++++++++---- ui/server.py | 9 ++++++++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py index ef008d6..1866540 100644 --- a/tests/test_comfort_ui.py +++ b/tests/test_comfort_ui.py @@ -59,3 +59,32 @@ const log = ()=>{}, toast = ()=>{}; ''' result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True) self.assertEqual(result.returncode, 0, result.stderr) + + def test_poll_waits_while_the_frame_is_unreachable(self): + page = (ROOT / 'ui/index.html').read_text(encoding='utf-8') + start = page.index('async function pollComfort()') + code = page[start:page.index('$("comfortForm").onsubmit', start)] + js = r''' +const assert = require('node:assert/strict'); +const el = {textContent: 'Checking session…'}; +const $ = () => el; +let online = false, comfortBusy = false, calls = 0, timers = 0; +const api = async () => { calls++; return {}; }; +const renderComfort = () => {}; +const setTimeout = () => { timers++; }; +''' + code + r''' +(async () => { + await pollComfort(); + assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame + assert.match(el.textContent, /when the Frame is connected/); + assert.equal(timers, 1); // still scheduled, to resume when it's back + online = true; + await pollComfort(); + assert.equal(calls, 1); + online = null; // a status check that failed for another reason: keep polling + await pollComfort(); + assert.equal(calls, 2); +})().catch(e => { console.error(e); process.exitCode = 1; }); +''' + result = subprocess.run(['node', '-e', js], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 689acf4..221cd42 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -408,14 +408,17 @@ class LinkFailureProvenance(Base): c.close() return result - def test_routes_that_rewrap_a_link_failure_keep_its_mark(self): - # Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a - # Failure; after the status poll's first connection failure, theirs are held back too, - # also past the 10-minute window. + def serve(self): self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler) threading.Thread(target=self.httpd.serve_forever, daemon=True).start() self.addCleanup(self.httpd.server_close) self.addCleanup(self.httpd.shutdown) + + def test_routes_that_rewrap_a_link_failure_keep_its_mark(self): + # Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a + # Failure; after the first connection failure, theirs are held back too, also past the + # 10-minute window. + self.serve() down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n") meta = {"label": "Test App", "game_id": 5, "shortcut": None} with mock.patch.object(frame_host, "run_ssh", return_value=down), \ @@ -431,7 +434,34 @@ class LinkFailureProvenance(Base): self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502) sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued() if e["event"] == "$exception"] - self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")]) + # The comfort poll's own failure isn't one (see the next test). + self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")]) + + def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self): + # 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or + # refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled + # by hand still is, once, and a real failure of the poll is too. + self.serve() + for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n", + "banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n", + "ssh: Could not resolve hostname frame: nodename nor servname provided\r\n", + "@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n", + "No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict " + "checking.\r\nHost key verification failed.\r\n", + "steamos@192.168.1.9: Permission denied (publickey,password).\r\n"): + down = subprocess.CompletedProcess(["ssh"], 255, "", stderr) + with mock.patch.object(frame_host, "run_ssh", return_value=down): + self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr) + self.assertEqual(self.queued(), []) + down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n") + with mock.patch.object(frame_host, "run_ssh", return_value=down): + self.request("/api/comfort", {"action": "cancel"}) + broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n") + with mock.patch.object(frame_host, "run_ssh", return_value=broken): + self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502) + sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued() + if e["event"] == "$exception"] + self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")]) def test_download_failures_after_a_poll_failure_are_still_sent(self): # Review round 3: these file names, in the real checksum message through the web-link worker, diff --git a/ui/index.html b/ui/index.html index 8c5a705..2b00dfe 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1587,7 +1587,7 @@ $("vrUtilities").onclick = async e => { await loadUtilities(); }; -// The Frame owns the clock. Poll independently of status and the selected tab. +// The Frame owns the clock. Poll independently of the selected tab, from the first status check on. let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false; async function localNotification(message, request = false) { try { @@ -1646,7 +1646,12 @@ function renderComfort(s) { if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history; } async function pollComfort() { - if (!comfortBusy) { + // Not while the Frame can't be reached: each poll would be another ssh attempt waiting to + // time out, and the banner already says why. Status checks bring it back (setOnline). + if (online === false) { + const message = "Session status appears when the Frame is connected."; + if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message; + } else if (!comfortBusy) { comfortBusy = true; try { renderComfort(await api("/api/comfort", {action: "status"})); } catch (e) { @@ -1680,7 +1685,6 @@ $("castBtn").onclick = () => { // Fullscreen is a direct user gesture; iPhone falls back to its inline viewer. $("viewer").requestFullscreen?.().catch(() => {}); }; -pollComfort(); // ---- battery ---- function battery(b, power) { @@ -4495,7 +4499,7 @@ if (window.frameApp && window.frameApp.onInstallLink) { } setView("headset"); -refresh().then(() => loadShots()); // after status, so app names resolve +refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });