Files
saphid--frame-control/ui/frame_report.py
T
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

217 lines
9.3 KiB
Python

"""Report a problem from inside Frame Control. Python stdlib only.
The page's Report a problem dialog shows the diagnostics below before anything
is sent, then this sends the report privately to Frame Control's PostHog
project as a `problem_report` event: only the maintainer can read it, and
nothing is published. It is sent whatever the analytics settings are, because
the person sends it deliberately. Diagnostics are scrubbed first
(frame_telemetry.scrub); the person's own words are sent as written.
An email address goes with a report only when the person ticks "may contact me with
follow-up questions" (contact_followup). Standing choices made in Settings are
frame_contact.py's `contact_consent` events; `contacts` lists them.
"""
import os
import platform
import sys
import time
import uuid
import frame_contact
import frame_host
import frame_telemetry
KINDS = ('bug', 'idea', 'question', 'other')
TEXT_MAX = 5000 # the person's own text, in JavaScript (UTF-16) units like the page's maxlength
DIAG_MAX = 8000 # the diagnostics block
LOG_LINES = 60
ACTIVITY_LINES = 25
frame = {} # the Frame's last known SteamOS build, set by server.status()
def u16(s):
"""Length as the website's validator counts it (JavaScript strings are UTF-16)."""
return len(s.encode('utf-16-le')) // 2
def cut(s, n):
"""s shortened to at most n UTF-16 units, never splitting a character."""
while u16(s) > n:
s = s[:max(0, len(s) - max(1, (u16(s) - n) // 2))]
return s
def _log_tail():
"""The last lines of the server log the app writes (FRAME_CONTROL_LOG), newest first."""
path = os.environ.get('FRAME_CONTROL_LOG')
if not path:
return []
try:
with open(path, 'rb') as f:
f.seek(0, os.SEEK_END)
f.seek(max(0, f.tell() - 64 * 1024))
lines = f.read().decode('utf-8', 'replace').splitlines()
except OSError:
return []
# Request lines ("GET /api/status ...") are noise; keep what went wrong.
keep = [ln for ln in lines if ln.strip() and not ln.startswith(('GET ', 'POST '))]
return list(reversed(keep[-LOG_LINES:]))
def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
"""What a report includes, scrubbed and at most `limit` UTF-16 units. Always the versions
and builds; recent activity and the server log only when asked for, since they can name
files. Sections are filled in order of use, newest lines first, so trimming drops the oldest."""
t = frame_telemetry.state()
levels = ', '.join(f"{name} {'on' if on else 'off'}" for name, on in
(('usage', t['usage']), ('compat', t['compat']), ('error details', t['diagnostics'])))
env = [
f"Frame Control {frame_telemetry.app_version()}"
f"{' (built app)' if os.environ.get('FRAME_CONTROL_PACKAGED') else ' (source checkout)'}",
f"Computer: {frame_host.NAME} {platform.release()} {platform.machine()}, Python {'%d.%d.%d' % sys.version_info[:3]}",
f"SteamOS: {frame.get('build') or 'unknown'} ({frame.get('version') or 'not connected since start'})",
f"Analytics: {levels}",
f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
]
out = frame_telemetry.scrub('\n'.join(env), limit=limit)
if not include_logs:
return cut(out, limit)
sections = [('Recent activity (newest first):', [str(a)[:300] for a in list(activity)[:ACTIVITY_LINES] if isinstance(a, str)]),
('Server log (newest first):', _log_tail())]
for title, lines in sections:
if not lines:
continue
block = '\n\n' + title
if u16(out + block) > limit:
break
out += block
for line in lines:
line = '\n' + frame_telemetry.scrub(line, 300)
if u16(out + line) > limit:
break
out += line
return out
def compose(body):
"""(title, text, diagnostics): the diagnostics exactly as the dialog previewed them (passed
back, scrubbed again and bounded here)."""
title = ' '.join(str(body.get('title') or '').split())
text = str(body.get('message') or '').strip()
if len(title) < 5:
raise ValueError('give it a short title (at least 5 characters)')
if len(text) < 10:
raise ValueError('say a little more about what happened (at least 10 characters)')
diag = body.get('diagnostics')
diag = cut(frame_telemetry.scrub(diag, 40000), DIAG_MAX) if isinstance(diag, str) and diag.strip() else ''
return cut(title, 120), cut(text, TEXT_MAX), diag
def send(body):
"""Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
title, text, diag = compose(body)
followup = bool(body.get('contactFollowup'))
contact = str(body.get('contact') or '').strip() if followup else ''
if followup and not frame_contact.valid_email(contact):
raise ValueError('add your email address for follow-up questions, or untick that box')
ref = uuid.uuid4().hex[:8].upper()
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
'contact': contact, 'contact_followup': followup, 'diagnostics': diag,
'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
# Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
try:
frame_telemetry.post([event], timeout=30)
except frame_telemetry.SendError as e:
raise ReportError(str(e))
try:
frame_telemetry.record_sent([event])
except OSError:
pass # it was sent; failing to log it here mustn't make the person send it again
return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
class ReportError(RuntimeError):
pass
def inbox(days=30):
"""The maintainer's recent reports from PostHog, newest first (needs the personal API key
frame_compat_db.sync uses)."""
import frame_compat_db
res = frame_compat_db._posthog_query(
"SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
"properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics, "
"properties.contact_followup "
f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
"ORDER BY timestamp DESC LIMIT 200")
return res.get('results') or []
def _yes(v):
return v is True or str(v).lower() in ('true', '1')
def contacts():
"""{'updates': [(email, since)], 'followup': [...]}: the addresses whose newest
contact_consent event agrees to each, oldest first. A withdrawal, or a change to another
address, replaces what came before, so withdrawn addresses are never listed."""
import frame_compat_db
res = frame_compat_db._posthog_query(
"SELECT distinct_id, argMax(properties.email, timestamp), argMax(properties.updates, timestamp), "
"argMax(properties.followup, timestamp), max(timestamp) FROM events WHERE event = 'contact_consent' "
"GROUP BY distinct_id ORDER BY max(timestamp) LIMIT 100000")
out = {'updates': [], 'followup': []}
for row in res.get('results') or []:
if not isinstance(row, list) or len(row) != 5:
continue
_, email, updates, followup, ts = row
email = str(email or '').strip()
if not frame_contact.valid_email(email):
continue
for kind, agreed in (('updates', updates), ('followup', followup)):
if _yes(agreed):
out[kind].append((email, str(ts or '')[:10]))
return out
USAGE = 'usage: frame_report.py inbox [days] | contacts [updates|followup]'
def main():
cmd, *args = sys.argv[1:] or ['inbox']
if cmd == 'contacts':
kinds = args[:1] or ['updates', 'followup']
if not set(kinds) <= {'updates', 'followup'}:
sys.exit(USAGE)
found = contacts()
for kind in kinds:
print(f"== {'Release and update notices' if kind == 'updates' else 'Follow-up questions'}"
f" ({len(found[kind])})")
for email, since in found[kind]:
print(f" {email} (since {since})")
print()
return
if cmd != 'inbox':
sys.exit(USAGE)
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])
# 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" {version} on {osname}, SteamOS {steamos or 'unknown'}"
f"{', may follow up at ' + contact if reply else ''}")
print(' ' + text.replace('\n', '\n '))
if diag:
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
print()
if __name__ == '__main__':
main()