Contact email: withdrawal covers earlier reports; consent is a real true

- A report with follow-up ticked carries this copy's contact id, and the
  inbox marks its permission withdrawn when a later choice from that copy
  no longer agrees to follow-up questions at that address.
- The one-time prompt never appears in a visit that showed the privacy
  notice, even if the Frame connects just after it's dismissed.
- Saving contact details isn't headset work: it can't hold up switching
  headsets or be refused after a switch.
- Consent flags must be JSON true/false; "false" is no longer consent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-10-01 20:59:23 +10:00
1 parent 2ca0e6924a
commit 01d5c612c0
7 files changed
+160 -20

No files matched your search

+9 -4
View File
@@ -107,7 +107,9 @@ wrote, a short reference shown after sending, and the diagnostics below. Your
email address goes with it only if you tick **The maintainer may contact me 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`); 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 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 that box ticked it also carries this copy's contact
id (`contact_id`, see below), so removing or changing the address later takes
back the follow-up permission given with the report too.
With **Include diagnostics** ticked (the default), the report adds: With **Include diagnostics** ticked (the default), the report adds:
@@ -141,8 +143,8 @@ are two separate choices, both off until you tick them:
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly | | **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 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 connected for the first time, and never in the same visit as the first-run
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't privacy notice. **No thanks** hides it for good, and it isn't
shown again even if you ignore it. **Contact email** in **Privacy & updates** 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. is where you add, change or remove the address and either choice at any time.
@@ -164,7 +166,10 @@ 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` 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 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 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 works the same way for that choice. This also covers problem reports you sent
from this copy with follow-up questions ticked: if your newest choice no longer
agrees to follow-up questions at that address, the maintainer's inbox shows the
permission as withdrawn and leaves the address out. If you're offline, the change waits on
this computer and is sent when PostHog can be reached. The earlier event 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 stays in PostHog until its data retention removes it; to have it deleted
sooner, ask the maintainer (for example in a problem report). sooner, ask the maintainer (for example in a problem report).
+83
View File
@@ -59,6 +59,16 @@ class Contact(Base):
self.assertEqual(fc.load()["email"], "") self.assertEqual(fc.load()["email"], "")
self.assertEqual(self.got, []) self.assertEqual(self.got, [])
def test_only_a_real_true_counts_as_consent(self):
for wrong in ("false", "true", 1, 0, [], {}):
with self.assertRaisesRegex(ValueError, "true or false"):
fc.save({"email": "me@example.com", "updates": wrong, "followup": True})
with self.assertRaisesRegex(ValueError, "true or false"):
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": wrong})
self.assertEqual((fc.load()["email"], self.got), ("", []))
fc.save({"email": "me@example.com", "updates": True}) # left out is no
self.assertEqual((fc.load()["updates"], fc.load()["followup"]), (True, False))
def test_each_choice_is_sent_privately_on_its_own(self): 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": True})
fc.save({"email": "me@example.com", "updates": False, "followup": True}) fc.save({"email": "me@example.com", "updates": False, "followup": True})
@@ -248,6 +258,63 @@ class Contact(Base):
with self.assertRaisesRegex(ValueError, "email address"): with self.assertRaisesRegex(ValueError, "email address"):
fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True}) fr.send({**REPORT, "contact": "discord:me", "contactFollowup": True})
def test_a_report_with_follow_up_carries_the_kept_contact_id(self):
fr.send({**REPORT, "contact": "me@example.com"})
self.assertFalse(fc.FILE.exists()) # no follow-up, nothing kept or linked
fr.send({**REPORT, "contact": "me@example.com", "contactFollowup": True})
without, with_ = (e["properties"] for e in self.events())
self.assertEqual(without["contact_id"], "")
self.assertEqual(with_["contact_id"], fc.load()["id"])
self.assertNotEqual(with_["contact_id"], tm.settings()["id"]) # not the analytics id
fc.save({"email": "me@example.com", "updates": True})
self.assertEqual(self.events()[-1]["distinct_id"], with_["contact_id"]) # same copy, same id
def report_row(self, ts, contact="me@example.com", followup=True, cid="copy"):
return [ts, "AB12CD34", "bug", "RDP", "It never connects.", contact, "0.4.0", "Windows", "", "", followup, cid]
def test_a_later_withdrawal_takes_back_a_reports_follow_up_permission(self):
reports = [self.report_row("2026-09-10T10:00:00Z"), # removed later
self.report_row("2026-09-12T10:00:00Z", cid="other"), # another copy
self.report_row("2026-09-20T10:00:00Z"), # after the withdrawal
self.report_row("2026-09-11T10:00:00Z", cid="", followup=True), # sent before contact_id
self.report_row("2026-09-15T10:00:00Z", cid="same-second")]
consents = [["copy", "me@example.com", True, 1, "2026-09-01T10:00:00Z"],
["copy", "", False, 2, "2026-09-14T10:00:00Z"],
["other", "me@example.com", True, 1, "2026-09-13T10:00:00Z"], # still agrees
["same-second", "", False, 1, "2026-09-15T10:00:00Z"], ["short"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports], ["withdrawn", True, True, True, "withdrawn"])
def test_changing_the_address_or_unticking_follow_up_takes_it_back_too(self):
reports = [self.report_row("2026-09-10T10:00:00Z", cid="moved"),
self.report_row("2026-09-10T10:00:00Z", cid="news-only"),
self.report_row("2026-09-10T10:00:00Z", contact="Me@Example.com", cid="case")]
consents = [["moved", "new@example.com", True, 1, "2026-09-11T10:00:00Z"],
["news-only", "me@example.com", False, 1, "2026-09-11T10:00:00Z"],
["case", "me@example.com", True, 1, "2026-09-11T10:00:00Z"],
# rev, not the clock, decides which later choice is newest
["case", "", False, 2, "2026-09-11T09:59:00Z"]]
fr.mark_withdrawn(reports, consents)
self.assertEqual([r[10] for r in reports], ["withdrawn", "withdrawn", "withdrawn"])
reports[2][10] = True
fr.mark_withdrawn(reports[2:], consents[2:3])
self.assertIs(reports[2][10], True) # same address, any case, still agrees
def test_the_inbox_shows_withdrawn_follow_up_without_the_address(self):
reports = [self.report_row("2026-09-10T10:00:00Z"), ["short"]]
consents = [["copy", "", False, 2, "2026-09-14T10:00:00Z"]]
with mock.patch.object(db, "_posthog_query", side_effect=[{"results": reports}, {"results": consents}]) as q, \
mock.patch.object(sys, "argv", ["frame_report.py", "inbox", "30"]), \
mock.patch("builtins.print") as out:
fr.main()
self.assertIn("event = 'contact_consent'", q.call_args_list[1].args[0])
printed = " ".join(str(c.args[0]) for c in out.call_args_list if c.args)
self.assertIn("follow-up permission since withdrawn", printed)
self.assertNotIn("me@example.com", printed)
with mock.patch.object(db, "_posthog_query", return_value={"results": [self.report_row("x", followup=False)]}) as q:
fr.inbox()
self.assertEqual(q.call_count, 1) # nothing to reconcile, no second query
def test_contacts_lists_the_newest_choice_per_copy_by_consent(self): def test_contacts_lists_the_newest_choice_per_copy_by_consent(self):
rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"], rows = [["a", "both@example.com", True, "true", "2026-09-01T10:00:00Z"],
["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"], ["b", "news@example.com", "true", False, "2026-09-02T10:00:00Z"],
@@ -272,6 +339,22 @@ class Contact(Base):
self.assertIs(server.POST["/api/contact"], fc.save) self.assertIs(server.POST["/api/contact"], fc.save)
self.assertIs(server.POST["/api/contact/prompt"], fc.prompt) self.assertIs(server.POST["/api/contact/prompt"], fc.prompt)
def test_saving_is_not_headset_work(self):
"""A slow send mustn't hold up switching headsets, nor be refused after a switch."""
import io
import server
seen = []
for path in ("/api/contact", "/api/contact/prompt"):
h = server.Handler.__new__(server.Handler)
body = b'{"prompt": "shown"}' if path.endswith("prompt") else b'{"email": "me@example.com", "updates": true}'
h.path, h.rfile = path, io.BytesIO(body)
h.headers = {"Content-Length": str(len(body)), "X-Frame-Device": "a-headset-switched-away-from"}
h.local_request = lambda: True
h.send_json = lambda obj, status=200: seen.append((status, server._work[0]))
with mock.patch.object(fc, "_send_pending", side_effect=lambda block=True: seen.append(("send", server._work[0]))):
h.do_POST()
self.assertEqual(seen, [("send", 0), (200, 0), (200, 0)])
# Run these once, in test_telemetry, not again through the import above. # Run these once, in test_telemetry, not again through the import above.
del Base, ReportProblem del Base, ReportProblem
+2 -2
View File
@@ -422,8 +422,8 @@ 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", "", "", None] "0.4.0", "macOS", "", "", None, None]
rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None], rows = [["2026-09-28T10:00:00Z", "X", "bug", "Hand-made", None, None, None, None, None, None, None, None],
["short"], good] ["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"]), \
+19 -1
View File
@@ -71,6 +71,24 @@ def valid_email(email):
return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email)) return len(email) <= EMAIL_MAX and bool(EMAIL_RE.fullmatch(email))
def flag(body, key):
"""A consent choice: true only when it really is true (not "false" or 1), left out is no."""
v = body.get(key)
if v is not None and not isinstance(v, bool):
raise ValueError(f'{key} must be true or false')
return v is True
def contact_id():
"""This copy's contact id, kept from now on. A report with follow-up consent carries it, so
removing the address later takes back the follow-up permission given with the report too."""
with _lock:
s = load()
if not FILE.exists():
_save(s)
return s['id']
def state(): def state():
"""What the page shows. showPrompt: the one-time prompt hasn't been shown or answered yet, """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.""" and the Frame has connected at least once (setup worked), so it never greets a new install."""
@@ -156,7 +174,7 @@ def save(body):
"""Set, change or remove the address and the two choices. An address needs at least one """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.""" choice ticked; an empty address (or neither ticked) removes it and withdraws both."""
email = str(body.get('email') or '').strip() email = str(body.get('email') or '').strip()
updates, followup = bool(body.get('updates')), bool(body.get('followup')) updates, followup = flag(body, 'updates'), flag(body, 'followup')
if email and not valid_email(email): if email and not valid_email(email):
raise ValueError("that doesn't look like an email address") raise ValueError("that doesn't look like an email address")
if email and not (updates or followup): if email and not (updates or followup):
+37 -8
View File
@@ -112,13 +112,15 @@ 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')) followup = frame_contact.flag(body, 'contactFollowup')
contact = str(body.get('contact') or '').strip() if followup else '' contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact): if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box') 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': contact, 'contact_followup': followup, 'diagnostics': diag, 'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
# Only with an address: so removing it later (Settings) also takes this permission back.
'contact_id': frame_contact.contact_id() if followup else '',
'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()),
@@ -143,15 +145,43 @@ class ReportError(RuntimeError):
def inbox(days=30): def inbox(days=30):
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key """The maintainer's recent reports from PostHog, newest first (needs the personal API key
frame_compat_db.sync uses).""" frame_compat_db.sync uses). Column 10 is whether the person may be asked follow-up
questions now: 'withdrawn' when a later choice from the same copy took it back."""
import frame_compat_db import frame_compat_db
days = int(days)
res = frame_compat_db._posthog_query( res = frame_compat_db._posthog_query(
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, " "SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, " "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
"properties.contact_followup " "properties.contact_followup, properties.contact_id "
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 {days} DAY "
"ORDER BY timestamp DESC LIMIT 200") "ORDER BY timestamp DESC LIMIT 200")
return res.get('results') or [] rows = [r for r in res.get('results') or [] if isinstance(r, list) and len(r) == 12]
if any(r[11] and _yes(r[10]) for r in rows):
later = frame_compat_db._posthog_query(
"SELECT distinct_id, properties.email, properties.followup, ifNull(toInt(properties.rev), 0), timestamp "
f"FROM events WHERE event = 'contact_consent' AND timestamp > now() - INTERVAL {days} DAY LIMIT 100000")
mark_withdrawn(rows, later.get('results') or [])
return rows
def mark_withdrawn(reports, consents):
"""Mark reports whose follow-up permission was taken back: the newest contact choice from
the same copy made at or after the report (same second counts, so a withdrawal wins) no
longer agrees to follow-up questions at that address."""
newest = {}
for c in consents:
if not isinstance(c, list) or len(c) != 5:
continue
cid, email, followup, rev, ts = c
newest.setdefault(str(cid), []).append(((int(rev or 0), str(ts or '')), str(email or ''), followup))
for r in reports:
if not (r[11] and _yes(r[10])):
continue
after = [c for c in newest.get(str(r[11]), []) if c[0][1] >= str(r[0] or '')]
if after:
_, email, followup = max(after, key=lambda c: c[0])
if not (_yes(followup) and email.strip().lower() == str(r[5] or '').strip().lower()):
r[10] = 'withdrawn'
def _yes(v): def _yes(v):
@@ -204,14 +234,13 @@ def main():
if cmd != 'inbox': if cmd != 'inbox':
sys.exit(USAGE) 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) != 11:
continue
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row[:10]) 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. # 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])) 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'}" print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}"
f"{', may follow up at ' + contact if reply else ''}") f"{', may follow up at ' + contact if reply else ''}"
f"{', follow-up permission since withdrawn' if row[10] == 'withdrawn' else ''}")
print(' ' + text.replace('\n', '\n ')) print(' ' + text.replace('\n', '\n '))
if diag: if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n ')) print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
+7 -4
View File
@@ -3963,6 +3963,7 @@ async function offerTest(m) {
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ---- // ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
const telemetry = { usage: false, compat: false, blocked: "not loaded" }; const telemetry = { usage: false, compat: false, blocked: "not loaded" };
let privacyNoticeShown = false; // this visit: then the contact prompt waits for another one
function renderTelemetry(s) { function renderTelemetry(s) {
Object.assign(telemetry, s); Object.assign(telemetry, s);
setRepHint(); setRepHint();
@@ -3973,6 +3974,7 @@ function renderTelemetry(s) {
: "Nothing sent yet."; : "Nothing sent yet.";
const showNotice = !s.blocked && !s.noticeShown && s.usage; const showNotice = !s.blocked && !s.noticeShown && s.usage;
$("privacyNotice").hidden = !showNotice; $("privacyNotice").hidden = !showNotice;
if (showNotice) privacyNoticeShown = true;
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {}); if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
} }
async function loadTelemetry() { async function loadTelemetry() {
@@ -4015,13 +4017,14 @@ async function loadContact() {
try { renderContact(await api("/api/contact")); } catch { return; } try { renderContact(await api("/api/contact")); } catch { return; }
checkContactPrompt(); checkContactPrompt();
} }
// One time only, only once the Frame has connected, and never on top of the privacy notice or // One time only, only once the Frame has connected, and never in a visit that showed the privacy
// straight after it (two asks in a row is nagging): checked at load and whenever the Frame connects. // notice (two asks in a row is nagging): checked at load and whenever the Frame connects.
async function checkContactPrompt() { async function checkContactPrompt() {
if (!$("contactNotice").hidden || !$("privacyNotice").hidden) return; await telemetryLoaded;
if (privacyNoticeShown || !$("contactNotice").hidden) return;
let s; let s;
try { s = await api("/api/contact"); } catch { return; } try { s = await api("/api/contact"); } catch { return; }
if (!s.showPrompt || !$("contactNotice").hidden || !$("privacyNotice").hidden) return; if (!s.showPrompt || privacyNoticeShown || !$("contactNotice").hidden) return;
$("contactNotice").hidden = false; $("contactNotice").hidden = false;
api("/api/contact/prompt", { prompt: "shown" }).catch(() => {}); api("/api/contact/prompt", { prompt: "shown" }).catch(() => {});
} }
+3 -1
View File
@@ -134,6 +134,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
# install's clean-up) to the other headset. # install's clean-up) to the other headset.
_work_lock = threading.Lock() _work_lock = threading.Lock()
_work = [0] _work = [0]
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
@contextlib.contextmanager @contextlib.contextmanager
@@ -2440,7 +2441,8 @@ class Handler(BaseHTTPRequestHandler):
body = json.loads(self.rfile.read(length) or b"{}") body = json.loads(self.rfile.read(length) or b"{}")
if not isinstance(body, dict): if not isinstance(body, dict):
raise Failure("request body must be a JSON object", 400) raise Failure("request body must be a JSON object", 400)
with (contextlib.nullcontext() if path == "/api/devices" else working(meant)): # Not headset work: switching headsets mustn't wait for (or refuse) these.
with (contextlib.nullcontext() if path in NOT_HEADSET_WORK else working(meant)):
result = handler(body) result = handler(body)
self.send_json(result) self.send_json(result)
except Failure as e: except Failure as e: