mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 08:00:32 +02:00
Merge main into mac-in-headset: the Mac view alongside analytics and Report a problem
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
d970107716
31 files changed
+2466
-48
No files matched your search
+29
-9
@@ -106,16 +106,36 @@ def _write_meta(d, meta):
|
||||
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
|
||||
|
||||
|
||||
# Called after every install, worked or not, as fn(info, meta, error, seconds):
|
||||
# info is None if the APK couldn't be read, meta None and error set if it failed.
|
||||
install_hooks = []
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
check_installable(info)
|
||||
pkg = info['package']
|
||||
if not PKG_RE.match(pkg):
|
||||
raise FrameError(f'unexpected package name {pkg!r}')
|
||||
with _install_lock:
|
||||
return _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
start, info = time.time(), None
|
||||
try:
|
||||
info = apk_info(apk_path)
|
||||
if icon_png:
|
||||
info['icon_png'] = icon_png
|
||||
check_installable(info)
|
||||
pkg = info['package']
|
||||
if not PKG_RE.match(pkg):
|
||||
raise FrameError(f'unexpected package name {pkg!r}')
|
||||
with _install_lock:
|
||||
meta = _install(apk_path, info, pkg, flatscreen, name, source)
|
||||
except FrameError as e:
|
||||
_after_install(info, None, e, start)
|
||||
raise
|
||||
_after_install(info, meta, None, start)
|
||||
return meta
|
||||
|
||||
|
||||
def _after_install(info, meta, error, start):
|
||||
for hook in install_hooks:
|
||||
try:
|
||||
hook(info, meta, error, time.time() - start)
|
||||
except Exception:
|
||||
pass # reporting must never change an install's outcome
|
||||
|
||||
|
||||
def _install(apk_path, info, pkg, flatscreen, name, source):
|
||||
|
||||
+155
-1
@@ -8,12 +8,18 @@ New reports go to a local outbox first and are sent from there, so nothing is
|
||||
lost offline. A mirror of every report is kept for offline reads. Both live in
|
||||
frame_host.data_dir('compat-db'). Python stdlib only.
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
|
||||
Everyone else can opt in to sharing (the Privacy panel): their reports then
|
||||
also go to PostHog as compat_report events (frame_telemetry.py), and the
|
||||
maintainer's `sync` pulls them into the database, at most SYNC_DAILY_CAP per
|
||||
reporter per day, marked via=community[-probe|-install].
|
||||
|
||||
CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush|sync}
|
||||
(import restores a backup; reports already in the database are skipped.)
|
||||
"""
|
||||
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
|
||||
|
||||
import frame_host
|
||||
import frame_telemetry
|
||||
|
||||
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
|
||||
KEYCHAIN = ('frame-control-compat-db', 'app-key')
|
||||
@@ -228,11 +234,153 @@ def add(report):
|
||||
if shared():
|
||||
flush()
|
||||
_mem['at'] = 0 # refetch on next load
|
||||
else:
|
||||
frame_telemetry.compat_report(r) # only if this person opted in to sharing
|
||||
except Exception:
|
||||
pass # stays queued; load() shows it and a later call sends it
|
||||
return r
|
||||
|
||||
|
||||
# ---- community reports: PostHog -> the database (maintainer only) ---------------
|
||||
|
||||
POSTHOG_KEYCHAIN = ('frame-control-posthog', 'personal-api-key')
|
||||
SYNC_STATE = os.path.join(STATE, 'posthog-sync.json')
|
||||
SYNC_DAILY_CAP = 30
|
||||
COMMUNITY_VIA = {'user': 'community', 'probe': 'community-probe', 'install': 'community-install'}
|
||||
|
||||
|
||||
def posthog_personal_key():
|
||||
k = os.environ.get('POSTHOG_PERSONAL_API_KEY')
|
||||
if k:
|
||||
return k
|
||||
if frame_host.MAC:
|
||||
p = subprocess.run(['security', 'find-generic-password', '-s', POSTHOG_KEYCHAIN[0], '-a',
|
||||
POSTHOG_KEYCHAIN[1], '-w'], capture_output=True, text=True)
|
||||
if p.returncode == 0 and p.stdout.strip():
|
||||
return p.stdout.strip()
|
||||
raise DBError('No PostHog personal API key (set POSTHOG_PERSONAL_API_KEY, or on macOS the Keychain '
|
||||
f'item service {POSTHOG_KEYCHAIN[0]}, account {POSTHOG_KEYCHAIN[1]})')
|
||||
|
||||
|
||||
def _posthog_query(sql):
|
||||
cfg = frame_telemetry.config()
|
||||
project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or cfg.get('project')
|
||||
if not project:
|
||||
raise DBError('No PostHog project id (ui/telemetry.json "project", or FRAME_CONTROL_POSTHOG_PROJECT)')
|
||||
# The query API lives on the app host (us.posthog.com), not the ingestion host (us.i.posthog.com).
|
||||
host = cfg['host'].replace('.i.posthog.com', '.posthog.com')
|
||||
req = urllib.request.Request(f'{host}/api/projects/{urllib.parse.quote(str(project))}/query/', method='POST',
|
||||
data=json.dumps({'query': {'kind': 'HogQLQuery', 'query': sql}}).encode(),
|
||||
headers={'authorization': 'Bearer ' + posthog_personal_key(),
|
||||
'content-type': 'application/json'})
|
||||
try:
|
||||
with _opener.open(req, timeout=60) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
raise DBError(f'PostHog said HTTP {e.code}: {e.read()[:300]!r}')
|
||||
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
|
||||
raise DBError(f"can't reach PostHog: {e}")
|
||||
|
||||
|
||||
SYNC_OVERLAP_DAYS = 30 # re-read this far back: offline copies send late, with their original time
|
||||
SYNC_PAGE = 5000
|
||||
|
||||
|
||||
def community_rows(events, state, cap=SYNC_DAILY_CAP):
|
||||
"""(reports, skipped): compat_report events as database rows. `state` ({"seen": {id: day},
|
||||
"counts": {"reporter|day": n}}) persists between syncs, so an event read twice is handled
|
||||
once and each reporter gets at most `cap` reports a day in total."""
|
||||
seen, counts = state.setdefault('seen', {}), state.setdefault('counts', {})
|
||||
out, skipped = [], []
|
||||
for props, reporter, ts in events:
|
||||
if isinstance(props, str):
|
||||
try:
|
||||
props = json.loads(props)
|
||||
except ValueError:
|
||||
props = None
|
||||
if not isinstance(props, dict):
|
||||
skipped.append((None, 'unreadable properties'))
|
||||
continue
|
||||
bad = [k for k in (*FIELDS, 'id') if props.get(k) is not None and not isinstance(props[k], (str, int, float))]
|
||||
if bad:
|
||||
skipped.append((str(props.get('id'))[:60], f'bad field {bad[0]}'))
|
||||
continue
|
||||
r = {k: (str(props[k]) if props.get(k) is not None else None) for k in FIELDS}
|
||||
r['id'] = str(props['id']) if props.get('id') is not None else None
|
||||
if r['id'] in seen:
|
||||
continue # handled in an earlier sync (or earlier in this one)
|
||||
r['via'] = COMMUNITY_VIA.get(r.get('via') or 'user', 'community')
|
||||
why = problem(r)
|
||||
if why:
|
||||
skipped.append((r.get('id'), why))
|
||||
continue
|
||||
day = str(ts)[:10]
|
||||
seen[r['id']] = day
|
||||
key_ = f'{reporter}|{day}'
|
||||
if counts.get(key_, 0) >= cap:
|
||||
skipped.append((r['id'], 'over the daily limit for one reporter'))
|
||||
continue
|
||||
counts[key_] = counts.get(key_, 0) + 1
|
||||
out.append(r)
|
||||
return out, skipped
|
||||
|
||||
|
||||
def _sync_state():
|
||||
try:
|
||||
with open(SYNC_STATE) as f:
|
||||
s = json.load(f)
|
||||
return s if isinstance(s, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_sync_state(s):
|
||||
"""Forget ids and counts older than the overlap window (plus a margin)."""
|
||||
cutoff = time.strftime('%Y-%m-%d', time.gmtime(time.time() - (SYNC_OVERLAP_DAYS + 15) * 86400))
|
||||
s['seen'] = {k: d for k, d in s.get('seen', {}).items() if d >= cutoff}
|
||||
s['counts'] = {k: n for k, n in s.get('counts', {}).items() if k.rsplit('|', 1)[-1] >= cutoff}
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with open(SYNC_STATE + '.tmp', 'w') as f:
|
||||
json.dump(s, f)
|
||||
os.replace(SYNC_STATE + '.tmp', SYNC_STATE)
|
||||
|
||||
|
||||
def sync(dry_run=False):
|
||||
"""Pull community reports from PostHog into the database. Returns (added, skipped).
|
||||
Reads the last SYNC_OVERLAP_DAYS each time, since events carry the time they were
|
||||
made, not when they arrived; the saved state keeps that from adding anything twice."""
|
||||
key() # the maintainer's copy only
|
||||
state = _sync_state()
|
||||
since = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(time.time() - SYNC_OVERLAP_DAYS * 86400))
|
||||
events, after = [], f"timestamp >= toDateTime('{since}', 'UTC')"
|
||||
for _ in range(40):
|
||||
# Keyset paging: PostHog refuses OFFSET with a personal API key. The cursor is in UTC,
|
||||
# since a local time is ambiguous in the hour clocks go back.
|
||||
res = _posthog_query("SELECT properties, distinct_id, timestamp, toString(uuid), "
|
||||
"formatDateTime(timestamp, '%Y-%m-%d %H:%i:%S.%f', 'UTC') FROM events "
|
||||
f"WHERE event = 'compat_report' AND {after} "
|
||||
f"ORDER BY timestamp, toString(uuid) LIMIT {SYNC_PAGE}")
|
||||
rows = res.get('results') or []
|
||||
events += [row[:3] for row in rows]
|
||||
if len(rows) < SYNC_PAGE:
|
||||
break
|
||||
last_uuid, last_ts = rows[-1][3], rows[-1][4]
|
||||
after = (f"(timestamp > toDateTime64('{last_ts}', 6, 'UTC') OR "
|
||||
f"(timestamp = toDateTime64('{last_ts}', 6, 'UTC') AND toString(uuid) > '{last_uuid}'))")
|
||||
rows, skipped = community_rows(events, state)
|
||||
if dry_run:
|
||||
return rows, skipped
|
||||
if rows:
|
||||
os.makedirs(STATE, exist_ok=True)
|
||||
with _lock, open(OUTBOX, 'a') as f:
|
||||
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
|
||||
# Saved before sending: the rows are in the outbox now, and flush retries them if sending fails.
|
||||
_save_sync_state(state)
|
||||
flush() # also retries rows a failed earlier sync left in the outbox
|
||||
_mem['at'] = 0
|
||||
return rows, skipped
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['count']
|
||||
try:
|
||||
@@ -260,6 +408,12 @@ def main():
|
||||
'reports already in the database were not duplicated')
|
||||
elif cmd == 'flush':
|
||||
print(f'{flush()} still queued')
|
||||
elif cmd == 'sync':
|
||||
rows, skipped = sync(dry_run='--dry-run' in args)
|
||||
for rid, why in skipped:
|
||||
print(f'skipped {rid!r}: {why}', file=sys.stderr)
|
||||
print(f"{len(rows)} community reports {'found' if '--dry-run' in args else 'added'}, "
|
||||
f'{len(skipped)} skipped')
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except DBError as e:
|
||||
|
||||
+5
-2
@@ -33,8 +33,11 @@ class HostError(RuntimeError):
|
||||
|
||||
|
||||
def data_dir(*parts):
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
|
||||
if MAC:
|
||||
"""Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
|
||||
(or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
|
||||
if os.environ.get("FRAME_CONTROL_DATA_DIR"):
|
||||
base = Path(os.environ["FRAME_CONTROL_DATA_DIR"])
|
||||
elif MAC:
|
||||
base = Path.home() / "Library" / "Application Support" / "Frame Control"
|
||||
elif WINDOWS:
|
||||
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""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.
|
||||
"""
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
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)
|
||||
ref = uuid.uuid4().hex[:8].upper()
|
||||
props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
|
||||
'contact': str(body.get('contact') or '').strip()[:120], '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 "
|
||||
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 main():
|
||||
cmd, *args = sys.argv[1:] or ['inbox']
|
||||
if cmd != 'inbox':
|
||||
sys.exit('usage: frame_report.py inbox [days]')
|
||||
for row in inbox(*(args[:1] or [30])):
|
||||
if not isinstance(row, list) or len(row) != 10:
|
||||
continue
|
||||
ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
|
||||
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(' ' + text.replace('\n', '\n '))
|
||||
if diag:
|
||||
print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Anonymous analytics for Frame Control, sent to PostHog. Python stdlib only.
|
||||
|
||||
Three levels, each chosen in the page's Privacy panel (docs/privacy.md lists
|
||||
every event and property):
|
||||
|
||||
- usage (on by default, after the first-run notice has been shown): installs of
|
||||
Frame Control, daily opens, updates, which tabs are used, and whether installs
|
||||
on the Frame worked, with an error category from a fixed list. Never file
|
||||
names, paths, hostnames, IP addresses, window titles or account data.
|
||||
- compat (opt-in): Android compatibility reports, the same fields the Report
|
||||
dialog shows, so they reach the shared database (frame_compat_db.py). The
|
||||
maintainer's sync (python3 ui/frame_compat_db.py sync) moves them there.
|
||||
- diagnostics (opt-in): error messages and Python tracebacks, scrubbed of
|
||||
home folders, user names, addresses and keys.
|
||||
|
||||
The first-run notice offers compat and diagnostics together, and the page's
|
||||
Report a problem dialog (frame_report.py) sends bug reports privately to the
|
||||
same project whatever is chosen here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
|
||||
Events wait in an outbox file and are sent in batches from a background thread,
|
||||
so going offline loses nothing. The last SENT_KEEP sent events are kept on this
|
||||
computer so the page can show exactly what left it.
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
SETTINGS = STATE / 'settings.json'
|
||||
OUTBOX = STATE / 'outbox.jsonl'
|
||||
SENT = STATE / 'sent.jsonl'
|
||||
SENT_KEEP = 200
|
||||
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
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
TABS = {'home', 'games', 'android', 'tools'}
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # held while sending; consent changes wait for it
|
||||
_seen_errors = {}
|
||||
_flusher = None
|
||||
_wake = threading.Event()
|
||||
|
||||
|
||||
# ---- configuration and settings -------------------------------------------------
|
||||
|
||||
def config():
|
||||
"""PostHog host and project key: the environment, else ui/telemetry.json."""
|
||||
try:
|
||||
with open(HERE / 'telemetry.json') as f:
|
||||
c = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
c = {}
|
||||
host = os.environ.get('FRAME_CONTROL_POSTHOG_HOST') or c.get('host') or DEFAULT_HOST
|
||||
key = os.environ.get('FRAME_CONTROL_POSTHOG_KEY') or c.get('key') or ''
|
||||
project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or c.get('project') or ''
|
||||
return {'host': host.rstrip('/'), 'key': key, 'project': str(project)}
|
||||
|
||||
|
||||
def blocked():
|
||||
"""Why nothing may be sent at all, whatever the settings say, or None."""
|
||||
if os.environ.get('DO_NOT_TRACK') == '1' or os.environ.get('FRAME_CONTROL_TELEMETRY') == '0':
|
||||
return 'turned off by DO_NOT_TRACK or FRAME_CONTROL_TELEMETRY=0'
|
||||
if not config()['key']:
|
||||
return 'no PostHog project key in this build'
|
||||
if not os.environ.get('FRAME_CONTROL_PACKAGED') and os.environ.get('FRAME_CONTROL_TELEMETRY') != '1':
|
||||
return 'running from a source checkout (set FRAME_CONTROL_TELEMETRY=1 to send)'
|
||||
return None
|
||||
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
def settings():
|
||||
with _lock:
|
||||
s = _defaults()
|
||||
try:
|
||||
with open(SETTINGS) 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
|
||||
if not SETTINGS.exists():
|
||||
_save(s) # keep the id stable from the first call
|
||||
return s
|
||||
|
||||
|
||||
def _save(s):
|
||||
try:
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = SETTINGS.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(s, indent=1))
|
||||
os.replace(tmp, SETTINGS)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def enabled(level):
|
||||
"""Whether events of this level are collected: never when sending is blocked, so a
|
||||
source checkout or a test run leaves nothing behind."""
|
||||
if blocked():
|
||||
return False
|
||||
return bool(settings().get(level))
|
||||
|
||||
|
||||
def update_settings(changes):
|
||||
"""Apply the page's choices. Turning a level off drops its unsent events; a send already
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
backfill_compat()
|
||||
_wake.set()
|
||||
return state()
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
|
||||
|
||||
# ---- scrubbing and error categories ---------------------------------------------
|
||||
|
||||
def _user_names():
|
||||
names = set()
|
||||
for v in (os.environ.get('USER'), os.environ.get('USERNAME'), Path.home().name):
|
||||
if v and len(v) > 2:
|
||||
names.add(v)
|
||||
return names
|
||||
|
||||
|
||||
URL_RE = re.compile(r'[A-Za-z][A-Za-z0-9+.-]*://[^\s\'"<>]+')
|
||||
SCRUBS = [
|
||||
(re.compile(r'ssh-(?:rsa|ed25519|dss)\s+\S+'), '<ssh-key>'),
|
||||
(re.compile(r'-----BEGIN [^-]+-----.*?-----END [^-]+-----', re.S), '<pem>'),
|
||||
(re.compile(r'\b(?:phc|phx|ghp|gho|ghu|ghs|github_pat|sk|pk|rk|xox[abpr])[_-][A-Za-z0-9_-]{12,}'), '<token>'),
|
||||
(re.compile(r'(?i)\b(token|key|secret|password|passwd|pwd|auth|signature|sig)=[^\s&]+'), r'\1=<redacted>'),
|
||||
(re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+'), '<email>'),
|
||||
(re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'), '<ip>'),
|
||||
(re.compile(r'\b(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\b'), '<mac>'),
|
||||
(re.compile(r'\b7656119\d{10}\b'), '<steamid>'),
|
||||
(re.compile(r'\b(?:[\w-]+\.)+(?:local|lan|home|internal|localdomain|ts\.net)\b'), '<host>'),
|
||||
(re.compile(r'\b[0-9a-fA-F]{32,}\b'), '<hex>'),
|
||||
]
|
||||
IPV6_RE = re.compile(r'(?<![\w:])[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:%\w+)?(?![\w:])')
|
||||
|
||||
|
||||
def _ipv6(m):
|
||||
try:
|
||||
ipaddress.IPv6Address(m.group(0).split('%')[0])
|
||||
return '<ip>'
|
||||
except ValueError:
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def public_host(host):
|
||||
"""A host name that's safe to send: not an address, not a private or single-label name."""
|
||||
host = (host or '').lower().rstrip('.')
|
||||
if not host or '.' not in host:
|
||||
return None
|
||||
try:
|
||||
ipaddress.ip_address(host.strip('[]'))
|
||||
return None
|
||||
except ValueError:
|
||||
pass
|
||||
if re.search(r'\.(?:local|lan|home|internal|localdomain|ts\.net|arpa)$', host) or not re.fullmatch(r'[a-z0-9.-]+', host):
|
||||
return None
|
||||
return host
|
||||
|
||||
|
||||
def _scrub_url(u):
|
||||
"""Only the scheme and a public host name of a URL; never user names, passwords, ports,
|
||||
paths or queries."""
|
||||
try:
|
||||
parts = urlsplit(u)
|
||||
host = public_host(parts.hostname)
|
||||
except ValueError:
|
||||
host = None
|
||||
return f'{parts.scheme}://{host}/…' if host else '<url>'
|
||||
|
||||
|
||||
def scrub(text, limit=2000):
|
||||
"""Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
|
||||
if text is None:
|
||||
return None
|
||||
t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
|
||||
home = str(Path.home())
|
||||
if len(home) > 3:
|
||||
t = t.replace(home, '~')
|
||||
t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1<user>', t)
|
||||
for pattern, repl in SCRUBS:
|
||||
t = pattern.sub(repl, t)
|
||||
t = IPV6_RE.sub(_ipv6, t)
|
||||
for name in _user_names():
|
||||
t = re.sub(r'\b%s\b' % re.escape(name), '<user>', t)
|
||||
return t[:limit]
|
||||
|
||||
|
||||
# From the most to the least specific; the first match wins.
|
||||
CATEGORIES = [
|
||||
('android_installer', re.compile(r'INSTALL_(?:FAILED|PARSE_FAILED)_[A-Z_]+')),
|
||||
('apk_needs_newer_android', re.compile(r'needs Android API')),
|
||||
('apk_wrong_abi', re.compile(r'no arm64-v8a build')),
|
||||
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
|
||||
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
|
||||
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
|
||||
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
|
||||
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed')),
|
||||
('frame_unreachable', re.compile(r'(?i)timed out|Connection (?:refused|reset|closed)|No route to host|'
|
||||
r'Network is unreachable|Operation timed out|asleep|kex_exchange')),
|
||||
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
|
||||
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
|
||||
('flatpak', re.compile(r'(?i)flatpak|flathub')),
|
||||
('cancelled', re.compile(r'(?i)cancel')),
|
||||
('lepton', re.compile(r'(?i)lepton|podman|instance')),
|
||||
]
|
||||
|
||||
|
||||
def categorize(message):
|
||||
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
|
||||
text = str(message or '')
|
||||
for name, pattern in CATEGORIES:
|
||||
m = pattern.search(text)
|
||||
if m:
|
||||
return name, (m.group(0) if name == 'android_installer' else None)
|
||||
return 'other', None
|
||||
|
||||
|
||||
# ---- capturing ------------------------------------------------------------------
|
||||
|
||||
def common():
|
||||
return {'app_version': app_version(), 'os': frame_host.NAME, 'arch': platform.machine().lower(),
|
||||
'python': '%d.%d' % sys.version_info[:2], '$lib': 'frame-control',
|
||||
# Anonymous events: no person profile, no location lookup, and a placeholder address,
|
||||
# since PostHog stores the sender's IP unless an event gives one.
|
||||
'$process_person_profile': False, '$geoip_disable': True, '$ip': '0.0.0.0'}
|
||||
|
||||
|
||||
def app_version():
|
||||
v = os.environ.get('FRAME_CONTROL_VERSION')
|
||||
if v:
|
||||
return v
|
||||
try:
|
||||
with open(HERE.parent / 'app' / 'package.json') as f:
|
||||
return json.load(f).get('version') or 'dev'
|
||||
except (OSError, ValueError):
|
||||
return 'dev'
|
||||
|
||||
|
||||
def capture(event, props=None, level='usage'):
|
||||
"""Queue an event if its level is on. Never raises."""
|
||||
try:
|
||||
if level not in LEVELS or not enabled(level):
|
||||
return False
|
||||
s = settings()
|
||||
e = {'event': event, 'distinct_id': s['id'], 'uuid': str(uuid.uuid4()),
|
||||
'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'properties': {**common(), **(props or {}), 'level': level}}
|
||||
with _lock:
|
||||
lines = _read_lines(OUTBOX) + [e]
|
||||
_write_lines(OUTBOX, lines[-OUTBOX_MAX:])
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def page_event(body):
|
||||
"""An event from the page, checked against PAGE_EVENTS."""
|
||||
name = body.get('event')
|
||||
allowed = PAGE_EVENTS.get(name)
|
||||
if allowed is None:
|
||||
raise ValueError('unknown event')
|
||||
props = {k: str(v)[:40] for k, v in (body.get('properties') or {}).items() if k in allowed}
|
||||
if name == 'tab_viewed' and props.get('tab') not in TABS:
|
||||
raise ValueError('unknown tab')
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed')
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened')
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_save(s)
|
||||
|
||||
|
||||
def frame_seen(build, version):
|
||||
"""The Frame's SteamOS build, once per build (public build numbers)."""
|
||||
key = f'{build}/{version}'
|
||||
with _lock:
|
||||
s = settings()
|
||||
if not build or key in s['frames_seen']:
|
||||
return
|
||||
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
|
||||
_save(s)
|
||||
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
|
||||
|
||||
|
||||
def install_finished(kind, ok, seconds=None, error=None, **props):
|
||||
"""kind: apk, flatpak, steam, title or web. props must already be public (no file names)."""
|
||||
p = {'kind': kind, 'ok': bool(ok), **{k: v for k, v in props.items() if v is not None}}
|
||||
if seconds is not None:
|
||||
p['seconds'] = round(seconds, 1)
|
||||
if error is not None:
|
||||
p['error_category'], code = categorize(error)
|
||||
if code:
|
||||
p['installer_code'] = code
|
||||
capture('install_finished', p)
|
||||
if error is not None and not ok:
|
||||
diagnostic(f'{kind} install failed', error)
|
||||
|
||||
|
||||
def diagnostic(where, error, tb=None):
|
||||
"""An error for the opt-in diagnostics level: scrubbed text, and a traceback if there is one."""
|
||||
if not enabled('diagnostics'):
|
||||
return
|
||||
message = scrub(error)
|
||||
fingerprint = f'{where}|{message[:120]}'
|
||||
now = time.time()
|
||||
with _lock:
|
||||
if now - _seen_errors.get(fingerprint, 0) < REPEAT_WINDOW:
|
||||
return
|
||||
_seen_errors[fingerprint] = now
|
||||
exc_type = type(error).__name__ if isinstance(error, BaseException) else 'Error'
|
||||
frames = []
|
||||
if tb is None and isinstance(error, BaseException):
|
||||
tb = error.__traceback__
|
||||
for fs in traceback.extract_tb(tb) if tb else []:
|
||||
frames.append({'filename': os.path.basename(fs.filename), 'lineno': fs.lineno, 'function': fs.name,
|
||||
'in_app': True, 'platform': 'python'})
|
||||
capture('$exception', {'$exception_list': [{'type': exc_type, 'value': message,
|
||||
'mechanism': {'handled': True, 'type': 'generic'},
|
||||
'stacktrace': {'type': 'raw', 'frames': frames[-30:]}}],
|
||||
'$exception_type': exc_type, '$exception_message': message,
|
||||
'where': scrub(where, 200), 'error_category': categorize(error)[0]},
|
||||
level='diagnostics')
|
||||
|
||||
|
||||
COMPAT_FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton',
|
||||
'runtime', 'label', 'source', 'id')
|
||||
|
||||
|
||||
def compat_report(report):
|
||||
"""A compatibility report for the shared database (compat level only). Free text is
|
||||
scrubbed; the source is kept only as F-Droid or a public download host."""
|
||||
if not report.get('id') or not enabled('compat'):
|
||||
return False
|
||||
p = {k: report.get(k) for k in COMPAT_FIELDS if report.get(k) not in (None, '')}
|
||||
for k, n in (('notes', 1000), ('label', 120), ('version', 80)):
|
||||
if k in p:
|
||||
p[k] = scrub(p[k], n)
|
||||
src = str(p.pop('source', '') or '')
|
||||
if src == 'F-Droid':
|
||||
p['source'] = src
|
||||
elif src.startswith(('http://', 'https://')) and _scrub_url(src) != '<url>':
|
||||
p['source'] = _scrub_url(src)
|
||||
return capture('compat_report', p, level='compat')
|
||||
|
||||
|
||||
def backfill_compat():
|
||||
"""On opting in, share the reports this computer kept before (not ones already sent or queued)."""
|
||||
try:
|
||||
import frame_compat_db
|
||||
if frame_compat_db.shared():
|
||||
return 0 # the maintainer's copy writes to the database directly
|
||||
done = set(settings()['compat_sent'])
|
||||
done |= {e['properties'].get('id') for e in _read_lines(OUTBOX) if e.get('event') == 'compat_report'}
|
||||
n = 0
|
||||
for r in frame_compat_db._outbox():
|
||||
if r.get('id') not in done and compat_report(r):
|
||||
n += 1
|
||||
return n
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
# ---- the outbox -----------------------------------------------------------------
|
||||
|
||||
def _read_lines(path):
|
||||
try:
|
||||
with open(path) as f:
|
||||
out = []
|
||||
for line in f:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except ValueError:
|
||||
pass
|
||||
return out
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _write_lines(path, rows):
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = Path(str(path) + '.tmp')
|
||||
with open(tmp, 'w') as f:
|
||||
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _drop_unwanted(s):
|
||||
"""Unsent events whose level is now off never leave the computer."""
|
||||
keep = {level: s[level] for level in LEVELS}
|
||||
rows = _read_lines(OUTBOX)
|
||||
kept = [e for e in rows if keep.get(e.get('properties', {}).get('level'), False)]
|
||||
if len(kept) != len(rows):
|
||||
_write_lines(OUTBOX, kept)
|
||||
|
||||
|
||||
def post(batch, timeout=20):
|
||||
"""Send events to PostHog now. Raises SendError if they weren't accepted."""
|
||||
cfg = config()
|
||||
if not cfg['key']:
|
||||
raise SendError('no PostHog project key in this build')
|
||||
for e in batch: # also events queued by versions that didn't add the placeholder address
|
||||
e.setdefault('properties', {})['$ip'] = '0.0.0.0'
|
||||
body = json.dumps({'api_key': cfg['key'], 'batch': batch}).encode()
|
||||
req = urllib.request.Request(cfg['host'] + '/batch/', data=body, method='POST',
|
||||
headers={'content-type': 'application/json',
|
||||
'user-agent': f'FrameControl/{app_version()}'})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
e.close()
|
||||
raise SendError(f'PostHog said HTTP {e.code}')
|
||||
except (urllib.error.URLError, OSError, ValueError) as e:
|
||||
raise SendError(f"couldn't reach PostHog: {e}")
|
||||
|
||||
|
||||
def record_sent(events):
|
||||
"""Add events sent outside the outbox to the log the page shows."""
|
||||
with _lock:
|
||||
_write_lines(SENT, (_read_lines(SENT) + list(events))[-SENT_KEEP:])
|
||||
|
||||
|
||||
class SendError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def flush(timeout=20):
|
||||
"""Send what's queued. Returns how many were sent; on failure they stay queued."""
|
||||
with _send_lock:
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
with _lock:
|
||||
_drop_unwanted(settings())
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
try:
|
||||
post(batch, timeout)
|
||||
except SendError:
|
||||
return 0
|
||||
sent_ids = {e['uuid'] for e in batch}
|
||||
with _lock:
|
||||
_write_lines(OUTBOX, [e for e in _read_lines(OUTBOX) if e.get('uuid') not in sent_ids])
|
||||
_write_lines(SENT, (_read_lines(SENT) + batch)[-SENT_KEEP:])
|
||||
compat = [e['properties'].get('id') for e in batch if e.get('event') == 'compat_report']
|
||||
if compat: # remembered only once PostHog has them, so an opt-out before sending can't lose them
|
||||
s = settings()
|
||||
s['compat_sent'] = (s['compat_sent'] + compat)[-5000:]
|
||||
_save(s)
|
||||
return len(batch)
|
||||
|
||||
|
||||
def start():
|
||||
"""Record this start and send in the background from now on."""
|
||||
global _flusher
|
||||
try:
|
||||
app_started()
|
||||
except Exception:
|
||||
pass
|
||||
if _flusher:
|
||||
return
|
||||
|
||||
def loop():
|
||||
while True:
|
||||
try:
|
||||
while flush() == 100: # a full batch: there may be more
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
_wake.wait(FLUSH_EVERY)
|
||||
_wake.clear()
|
||||
|
||||
_flusher = threading.Thread(target=loop, name='telemetry', daemon=True)
|
||||
_flusher.start()
|
||||
|
||||
|
||||
def wake():
|
||||
_wake.set()
|
||||
+246
-9
@@ -226,6 +226,17 @@
|
||||
.actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.actions button { justify-content: flex-start; height: 40px; }
|
||||
.actions svg { width: 16px; height: 16px; flex: none; opacity: .85; }
|
||||
.notice { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-radius: 4px;
|
||||
background: rgba(26,159,255,.12); border-left: 3px solid var(--blue); font-size: 13.5px; line-height: 1.5; }
|
||||
.notice .grow { flex: 1; min-width: 260px; }
|
||||
.notice .progress { width: 160px; margin-top: 0; display: block; }
|
||||
.popt { display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; align-items: start; margin: 0 0 14px; cursor: pointer; }
|
||||
.popt input { margin: 3px 0 0; width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.popt b { font-weight: 600; color: var(--text); }
|
||||
.popt .sub { grid-column: 2; line-height: 1.45; }
|
||||
.sentlog { max-height: 260px; overflow: auto; background: rgba(0,0,0,.3); border-radius: 3px; padding: 10px;
|
||||
font: 11.5px ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-all; margin: 8px 0 0; }
|
||||
details summary { cursor: pointer; color: var(--link); font-size: 13px; margin-top: 12px; }
|
||||
.links { margin-top: 16px; padding-top: 14px; border-top: 1px solid rgba(255,255,255,.06); font-size: 13px; color: var(--muted); }
|
||||
.links a { color: var(--link); text-decoration: none; } .links a:hover { color: #fff; }
|
||||
.links div { margin: 5px 0; }
|
||||
@@ -254,10 +265,18 @@
|
||||
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
|
||||
.and-col { display: grid; gap: 22px; align-content: start; }
|
||||
.rep-item .s { white-space: normal; }
|
||||
#repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
#bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
#repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
#bugDlg { width: min(640px, calc(100vw - 40px)); }
|
||||
#bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; }
|
||||
#bugForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
border-radius: 3px; padding: 8px 10px; font: inherit; }
|
||||
#bugForm .popt { margin: 14px 0 0; }
|
||||
#bugForm .sentlog { max-height: 200px; }
|
||||
#bugWarn { color: var(--muted); font-size: 12.5px; line-height: 1.45; margin: 12px 0 0; }
|
||||
#bugDlg h2, #repDlg h2, #titleDlg h2, #wiDlg h2, #pwDlg h2, #apkAltDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
|
||||
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
|
||||
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
|
||||
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
|
||||
@@ -390,12 +409,31 @@
|
||||
<div class="spacer"></div>
|
||||
<span class="chip" id="conn" role="status"><span class="dot"></span><span>Connecting…</span></span>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
<button id="reportBtn" data-report title="Report a problem, with diagnostics" aria-label="Report a problem">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path d="M12 3l10 18H2z"/><path d="M12 10v5M12 18v.5"/></svg>
|
||||
</button>
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="notice" id="updateBar" hidden>
|
||||
<div class="grow" id="updateText"></div>
|
||||
<div class="progress" id="updateProg" hidden><i></i></div>
|
||||
<button class="small" id="updateNotes">What's new</button>
|
||||
<button class="action small" id="updateGo">Update and restart</button>
|
||||
<button class="small" id="updateLater">Later</button>
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||||
<button class="small" id="noticeMore" title="Also share compatibility results and error details (you can turn either off later)">Share more to help fix problems</button>
|
||||
<button class="action small" id="noticeOk">OK</button>
|
||||
</div>
|
||||
<div class="banner" id="offline" role="alert" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 8.5a15 15 0 0 1 20 0M5.5 12a10 10 0 0 1 13 0M9 15.5a5 5 0 0 1 6 0"/><circle cx="12" cy="19" r="1.2" fill="currentColor"/><path d="M3 3l18 18"/></svg>
|
||||
<div class="grow"><div class="t" id="offMsg">Can't reach the Frame</div>
|
||||
@@ -671,6 +709,24 @@
|
||||
<div class="hint">Each window or screen becomes its own panel in the headset. Place it with the SteamVR dashboard
|
||||
(Float in World, Move, Size). Point and click with the laser, scroll with the thumbstick, and type on the Mac's keyboard.</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" id="privacy">
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tDiag"><b>Send error details</b>
|
||||
<span class="sub">Error messages and where in Frame Control they happened, with your home
|
||||
folder, user name, addresses and keys removed.</span></label>
|
||||
<div class="hint" id="tStatus"></div>
|
||||
<details id="tSentBox"><summary>Show what's been sent</summary><div class="sentlog" id="tSent"></div></details>
|
||||
<div class="row" style="margin-top:14px"><button class="small action" data-report>Report a problem</button>
|
||||
<button class="small" id="updateCheck" hidden>Check for updates</button>
|
||||
<a class="sub" href="https://github.com/saphid/frame-control/blob/main/docs/privacy.md" target="_blank">What's collected, exactly</a></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -724,6 +780,29 @@
|
||||
<button type="submit" class="action small" id="repSave">Save report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="bugDlg" aria-labelledby="bugTitle">
|
||||
<form method="dialog" id="bugForm">
|
||||
<h2 id="bugTitle">Report a problem</h2>
|
||||
<label class="field">What kind of report?<select id="bugKind">
|
||||
<option value="bug">Something's broken</option><option value="idea">An idea</option>
|
||||
<option value="question">A question</option><option value="other">Something else</option></select></label>
|
||||
<label class="field">Title<input type="text" id="bugTitleIn" maxlength="120" required minlength="5"
|
||||
placeholder="e.g. Installing an APK stops at 'copying to the Frame'"></label>
|
||||
<label class="field">What happened?<textarea id="bugText" maxlength="5000" required minlength="10"
|
||||
placeholder="What you did, what happened, and what you expected."></textarea></label>
|
||||
<label class="field">How can we reach you? (optional, for a reply)<input type="text" id="bugContact" maxlength="120" placeholder="Email, GitHub or Discord name"></label>
|
||||
<label class="popt"><input type="checkbox" id="bugDiag" checked><b>Include diagnostics</b>
|
||||
<span class="sub">Frame Control's version, your OS and the Frame's SteamOS build.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="bugLogs"><b>Also include recent activity and the server log</b>
|
||||
<span class="sub">Often shows what went wrong, but can contain file and app names. Check it below before sending.</span></label>
|
||||
<details id="bugDiagBox"><summary>Show exactly what's included</summary><div class="sentlog" id="bugDiagText">Loading…</div></details>
|
||||
<p id="bugWarn">Sent privately to the Frame Control developer. Nothing is published.</p>
|
||||
<div class="row rep-actions"><span class="sub" id="bugMsg"></span><span class="spacer"></span>
|
||||
<button type="button" class="small" id="bugCancel">Cancel</button>
|
||||
<button type="button" class="small" id="bugCopy">Copy report</button>
|
||||
<button type="submit" class="action small" id="bugSend">Send report</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="titleDlg" aria-labelledby="titleTitle">
|
||||
<form method="dialog" id="titleForm">
|
||||
<h2 id="titleTitle">Add to the Steam library</h2>
|
||||
@@ -1494,6 +1573,7 @@ function showApkAlternatives(reason, result) {
|
||||
if (res) $("apkAltDlg").close(); else { b.disabled = false; b.textContent = "Install"; }
|
||||
await loadAndroid();
|
||||
if (cat.apps) { const y = window.scrollY; filterCatalog(); window.scrollTo(0, y); }
|
||||
if (res && res.app) await offerTest(res.app);
|
||||
};
|
||||
if (!$("apkAltDlg").open) $("apkAltDlg").showModal();
|
||||
}
|
||||
@@ -1511,7 +1591,8 @@ async function sendFiles(files, dirs = new Set()) {
|
||||
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
|
||||
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
|
||||
const apk = f.name.toLowerCase().endsWith(".apk");
|
||||
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
|
||||
if (apk && res && res.app) await offerTest(res.app);
|
||||
}
|
||||
$("fileInput").value = "";
|
||||
refresh();
|
||||
@@ -1900,7 +1981,8 @@ document.body.addEventListener("click", async e => {
|
||||
toast(`Installing ${b.dataset.name}. The first time takes a minute…`);
|
||||
const done = runJob(`Install ${b.dataset.name}`, pkg, () => api("/api/android", { action, package: pkg }));
|
||||
b.outerHTML = `<span class="tag">Installing…</span>`;
|
||||
await done;
|
||||
const res = await done;
|
||||
if (res && res.app) await offerTest(res.app);
|
||||
} else if (action === "remove") {
|
||||
if (!confirm(`Remove ${b.dataset.name} and its data from the Frame?`)) return;
|
||||
await act(`Remove ${b.dataset.name}`, () => api("/api/android", { action, package: pkg }), b);
|
||||
@@ -2028,6 +2110,14 @@ loadDisplays();
|
||||
const RLABEL = { works: "Works", issues: "Problems", broken: "Doesn't work", runs: "Runs (test)",
|
||||
crashes: "Crashed (test)", install_failed: "Won't install", instance_failed: "Didn't start (test)" };
|
||||
const RCLASS = { works: "works", runs: "works", issues: "maybe" };
|
||||
let repShared = false;
|
||||
function setRepHint() {
|
||||
$("repHint").textContent = repShared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: telemetry.compat && !telemetry.blocked
|
||||
? "Reports change the verdicts you see and are shared with Frame Control's compatibility database (Privacy settings). Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. Turn on Share compatibility results in Privacy settings to add them to the shared database. Any APK can be reported, including ones not on F-Droid.";
|
||||
}
|
||||
const REPORTS_SHOWN = 5;
|
||||
let repsAll = false;
|
||||
$("repMore").onclick = () => { repsAll = true; loadReports(); };
|
||||
@@ -2035,9 +2125,7 @@ async function loadReports() {
|
||||
let reps, shared;
|
||||
try { ({ reports: reps, shared } = await api("/api/android/reports")); }
|
||||
catch (e) { $("repList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||
$("repHint").textContent = shared
|
||||
? "Reports go to Frame Control's shared compatibility database and change the verdicts in the catalogue. Any APK can be reported, including ones not on F-Droid."
|
||||
: "Reports are saved on this computer and change the verdicts you see. They aren't uploaded: the shared database is maintainer-only for now. Any APK can be reported, including ones not on F-Droid.";
|
||||
repShared = shared; setRepHint();
|
||||
$("repCount").textContent = reps.length ? `${reps.length} newest` : "";
|
||||
$("repMore").hidden = reps.length <= REPORTS_SHOWN || repsAll;
|
||||
$("repMore").textContent = `Show all ${reps.length}`;
|
||||
@@ -2269,7 +2357,7 @@ $("macview").onclick = async e => {
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools" };
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -2295,6 +2383,155 @@ document.addEventListener("keydown", e => {
|
||||
if (n) location.hash = n;
|
||||
});
|
||||
|
||||
// ---- after an APK install: test it, so the compatibility database learns whether it runs ----
|
||||
async function offerTest(m) {
|
||||
if (!m.package || !confirm(`${m.label || m.package} is installed. Test it now?\n\nIt opens in the headset for about 20 seconds `
|
||||
+ "and records whether it stays up.")) return;
|
||||
toast(`Testing ${m.label || m.package}: launching it and watching for 20 s…`);
|
||||
await act(`Test ${m.label || m.package}`, () => api("/api/android", { action: "probe", package: m.package }));
|
||||
loadReports();
|
||||
}
|
||||
|
||||
// ---- privacy: anonymous analytics levels (ui/frame_telemetry.py, docs/privacy.md) ----
|
||||
const telemetry = { usage: false, compat: false, blocked: "not loaded" };
|
||||
function renderTelemetry(s) {
|
||||
Object.assign(telemetry, s);
|
||||
setRepHint();
|
||||
$("tUsage").checked = s.usage; $("tCompat").checked = s.compat; $("tDiag").checked = s.diagnostics;
|
||||
$("tStatus").textContent = s.blocked ? `Nothing is being sent: ${s.blocked}.`
|
||||
: `${s.queued ? s.queued + " waiting to send. " : ""}Your anonymous id is ${s.id.slice(0, 8)}…; it isn't linked to you or this computer.`;
|
||||
$("tSent").textContent = s.sent.length ? s.sent.map(e => JSON.stringify({ event: e.event, time: e.timestamp, ...e.properties })).join("\n\n")
|
||||
: "Nothing sent yet.";
|
||||
const showNotice = !s.blocked && !s.noticeShown && s.usage;
|
||||
$("privacyNotice").hidden = !showNotice;
|
||||
if (showNotice) api("/api/telemetry", { noticeShown: true }).catch(() => {});
|
||||
}
|
||||
async function loadTelemetry() {
|
||||
try { renderTelemetry(await api("/api/telemetry")); } catch {}
|
||||
}
|
||||
async function setTelemetry(change) {
|
||||
try { renderTelemetry(await api("/api/telemetry", change)); }
|
||||
catch (e) { toast(`Couldn't save: ${e.message}`, true); loadTelemetry(); }
|
||||
}
|
||||
$("tUsage").onchange = e => setTelemetry({ usage: e.target.checked });
|
||||
$("tCompat").onchange = e => setTelemetry({ compat: e.target.checked });
|
||||
$("tDiag").onchange = e => setTelemetry({ diagnostics: e.target.checked });
|
||||
$("tSentBox").ontoggle = () => { if ($("tSentBox").open) loadTelemetry(); };
|
||||
$("noticeOk").onclick = () => { $("privacyNotice").hidden = true; };
|
||||
$("noticeMore").onclick = async () => {
|
||||
$("privacyNotice").hidden = true;
|
||||
await setTelemetry({ compat: true, diagnostics: true });
|
||||
toast("Thanks! Compatibility results and error details will be shared too. Change it any time in Privacy.");
|
||||
};
|
||||
$("noticeSettings").onclick = () => { $("privacyNotice").hidden = true; location.hash = "#privacy"; };
|
||||
loadTelemetry();
|
||||
function pageEvent(event, properties) {
|
||||
if (telemetry.usage && !telemetry.blocked) api("/api/telemetry/event", { event, properties }).catch(() => {});
|
||||
}
|
||||
// Which tabs get used: once per tab per session.
|
||||
const tabsSeen = new Set();
|
||||
document.querySelectorAll("nav a").forEach(a => a.addEventListener("click", () => {
|
||||
const tab = a.getAttribute("href").slice(1);
|
||||
if (!tabsSeen.has(tab)) { tabsSeen.add(tab); pageEvent("tab_viewed", { tab }); }
|
||||
}));
|
||||
|
||||
// ---- report a problem (ui/frame_report.py): sent privately to PostHog, with diagnostics ----
|
||||
const bug = { preview: "" };
|
||||
const activityLines = () => [...$("log").children].slice(0, 25).map(el => el.textContent.trim());
|
||||
function bugReportText() {
|
||||
const contact = $("bugContact").value.trim();
|
||||
const body = `Kind: ${$("bugKind").value}${contact ? `\nContact: ${contact}` : ""}\n\n${$("bugText").value.trim()}${bug.preview ? "\n\n---\nDiagnostics:\n```\n" + bug.preview + "\n```" : ""}`;
|
||||
return { title: $("bugTitleIn").value.trim(), body };
|
||||
}
|
||||
// The preview is a snapshot: exactly this text is sent, even if more activity happens meanwhile.
|
||||
async function loadBugPreview() {
|
||||
if (!$("bugDiag").checked) { bug.preview = ""; $("bugDiagText").textContent = "Nothing: diagnostics are off."; return; }
|
||||
$("bugDiagText").textContent = "Loading…";
|
||||
try { bug.preview = (await api("/api/report/preview", { activity: activityLines(), includeLogs: $("bugLogs").checked })).text; }
|
||||
catch (e) { bug.preview = ""; $("bugDiagText").textContent = `Couldn't collect diagnostics: ${e.message}`; return; }
|
||||
$("bugDiagText").textContent = bug.preview;
|
||||
}
|
||||
function openBugReport() {
|
||||
$("bugForm").reset();
|
||||
$("bugMsg").textContent = ""; $("bugSend").disabled = false;
|
||||
$("bugCancel").textContent = "Cancel";
|
||||
$("bugDiagBox").open = false; $("bugLogs").disabled = false;
|
||||
$("bugDlg").showModal();
|
||||
loadBugPreview();
|
||||
}
|
||||
document.body.addEventListener("click", e => { if (e.target.closest("[data-report]")) openBugReport(); });
|
||||
$("bugDiag").onchange = () => { $("bugLogs").disabled = !$("bugDiag").checked; loadBugPreview(); };
|
||||
$("bugLogs").onchange = loadBugPreview;
|
||||
$("bugCancel").onclick = () => $("bugDlg").close();
|
||||
$("bugCopy").onclick = async () => {
|
||||
const { title, body } = bugReportText();
|
||||
try { await navigator.clipboard.writeText(`${title}\n\n${body}`); $("bugMsg").textContent = "Copied."; }
|
||||
catch { $("bugMsg").textContent = "Couldn't copy; select the text under Show exactly what's included."; }
|
||||
};
|
||||
$("bugForm").onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
if (!$("bugForm").reportValidity()) return;
|
||||
$("bugSend").disabled = true; $("bugMsg").textContent = "Sending…";
|
||||
try {
|
||||
const res = await api("/api/report", {
|
||||
kind: $("bugKind").value, title: $("bugTitleIn").value, message: $("bugText").value,
|
||||
contact: $("bugContact").value, diagnostics: $("bugDiag").checked ? bug.preview : "" });
|
||||
$("bugMsg").textContent = `Sent, thank you. Your reference is ${res.id}.`;
|
||||
$("bugCancel").textContent = "Close";
|
||||
log(res.message, "ok");
|
||||
} catch (err) {
|
||||
$("bugMsg").textContent = `Couldn't send it: ${err.message}. Try again later, or use Copy report.`;
|
||||
$("bugSend").disabled = false;
|
||||
}
|
||||
};
|
||||
if (window.frameApp && window.frameApp.onReportProblem) window.frameApp.onReportProblem(openBugReport);
|
||||
|
||||
// ---- updates (the desktop app only: app/updater.js) ----
|
||||
const upd = { dismissed: false, offered: null };
|
||||
function renderUpdate(s) {
|
||||
if (!s) return;
|
||||
$("appVersion").textContent = `Version ${s.current}`;
|
||||
const r = s.latest;
|
||||
const show = r && ["available", "downloading", "ready", "error"].includes(s.status) && !upd.dismissed
|
||||
&& !(s.status === "error" && !r);
|
||||
$("updateBar").hidden = !show;
|
||||
if (!show) return;
|
||||
if (s.status === "available" && upd.offered !== r.version) { upd.offered = r.version; pageEvent("update_offered", { to_version: r.version }); }
|
||||
const busy = s.status === "downloading" || s.status === "ready";
|
||||
$("updateText").innerHTML = s.status === "error"
|
||||
? `Updating to ${esc(r.version)} didn't work: ${esc(s.error || "unknown error")}`
|
||||
: busy ? `Downloading Frame Control ${esc(r.version)}… It restarts when it's ready.`
|
||||
: `<b>Frame Control ${esc(r.version)} is available.</b> You have ${esc(s.current)}.`
|
||||
+ (s.canInstall ? "" : ` ${esc(s.why ? "It can't update itself here (" + s.why + ")," : "")} download it from the release page.`);
|
||||
$("updateProg").hidden = !busy;
|
||||
$("updateProg").firstElementChild.style.width = Math.round((s.progress || 0) * 100) + "%";
|
||||
$("updateGo").textContent = s.canInstall ? (s.status === "error" ? "Try again" : "Update and restart") : "Open release page";
|
||||
$("updateGo").disabled = busy; $("updateLater").hidden = busy;
|
||||
}
|
||||
if (window.frameApp && window.frameApp.update) {
|
||||
window.frameApp.update.onState(renderUpdate);
|
||||
window.frameApp.update.get().then(renderUpdate);
|
||||
$("updateCheck").hidden = false;
|
||||
$("updateCheck").onclick = async () => {
|
||||
const btn = $("updateCheck");
|
||||
btn.disabled = true;
|
||||
let s;
|
||||
try { s = await window.frameApp.update.check(); } finally { btn.disabled = false; }
|
||||
upd.dismissed = false; renderUpdate(s);
|
||||
if (s && s.status === "none") toast(`You have the newest version (${s.current})`);
|
||||
if (s && s.status === "check-failed") toast(`Couldn't check for updates: ${s.error}`, true);
|
||||
};
|
||||
$("updateGo").onclick = () => {
|
||||
pageEvent("update_started", { to_version: upd.offered || "" });
|
||||
window.frameApp.update.install();
|
||||
};
|
||||
$("updateLater").onclick = () => { upd.dismissed = true; $("updateBar").hidden = true; };
|
||||
$("updateNotes").onclick = async () => {
|
||||
const s = await window.frameApp.update.get();
|
||||
if (s && s.latest) window.open(s.latest.page, "_blank");
|
||||
};
|
||||
}
|
||||
|
||||
// ---- install links from websites (frame-control://install, docs/web-install.md) ----
|
||||
// The app passes each link here. The server checks it and reads the manifest;
|
||||
// nothing downloads until the user clicks Install in this dialog.
|
||||
|
||||
+100
-8
@@ -44,7 +44,9 @@ import frame_apk_versions # noqa: E402
|
||||
import frame_catalog # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
import frame_titles # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
|
||||
@@ -166,8 +168,10 @@ def start_job(label, work):
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
@@ -253,7 +257,12 @@ def terminal(argv):
|
||||
# ---- actions ---------------------------------------------------------------
|
||||
|
||||
def status(_body):
|
||||
return json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
|
||||
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"))
|
||||
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
|
||||
return s
|
||||
|
||||
|
||||
def headset_view():
|
||||
@@ -435,7 +444,16 @@ def steam(body):
|
||||
raise Failure("bad appid", 400)
|
||||
if action not in ("install", "store"):
|
||||
raise Failure("action must be install or store", 400)
|
||||
return steam_frame(action, appid)
|
||||
if action == "store":
|
||||
return steam_frame(action, appid)
|
||||
# Starts Steam's download; Steam reports the rest in the headset.
|
||||
try:
|
||||
res = steam_frame(action, appid)
|
||||
except Failure as e:
|
||||
frame_telemetry.install_finished("steam", False, error=e, steam_appid=appid)
|
||||
raise
|
||||
frame_telemetry.install_finished("steam", True, steam_appid=appid)
|
||||
return res
|
||||
|
||||
|
||||
def steam_search(query):
|
||||
@@ -509,10 +527,16 @@ def flatpak(body):
|
||||
raise Failure("bad Flatpak app ID", 400)
|
||||
if action == "install":
|
||||
def work():
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
|
||||
start = time.time()
|
||||
try:
|
||||
# Per-user, so it survives SteamOS updates and needs no sudo (as install-apps.sh).
|
||||
ssh("flatpak remote-add --user --if-not-exists flathub "
|
||||
"https://dl.flathub.org/repo/flathub.flatpakrepo && "
|
||||
f"flatpak install --user -y --noninteractive flathub {shlex.quote(app)}", timeout=1800)
|
||||
except Failure as e:
|
||||
frame_telemetry.install_finished("flatpak", False, time.time() - start, e, flatpak_id=app)
|
||||
raise
|
||||
frame_telemetry.install_finished("flatpak", True, time.time() - start, flatpak_id=app)
|
||||
return {"message": f"Installed {app}"}
|
||||
return start_job(f"Install {app}", work)
|
||||
if action == "uninstall":
|
||||
@@ -609,13 +633,37 @@ def android(body):
|
||||
runtime=body.get("runtime") or "instance",
|
||||
label=body.get("label"), source=body.get("source"))
|
||||
name = r.get("label") or pkg
|
||||
where = "" if frame_catalog.compat_db.shared() else " on this computer"
|
||||
where = ("" if frame_catalog.compat_db.shared() else
|
||||
" and shared it" if frame_telemetry.enabled("compat") else " on this computer")
|
||||
return {"message": f"Saved your report for {name}{where}", "report": r}
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e))
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
# Errors that are the APK's own fault, so they belong in the compatibility
|
||||
# database as install_failed. Connection trouble and the like don't.
|
||||
APK_FAULTS = {"android_installer", "apk_needs_newer_android", "apk_wrong_abi"}
|
||||
|
||||
|
||||
def apk_installed(info, meta, error, seconds):
|
||||
"""Every APK install (catalogue, dropped file, web link): usage analytics, and an
|
||||
install_failed report when the APK itself wouldn't install."""
|
||||
pkg = (info or {}).get("package")
|
||||
by_pkg = frame_catalog._cache.get("by_pkg") or {}
|
||||
in_catalog = bool(pkg) and pkg in by_pkg
|
||||
# Package names only for catalogue apps, which are public; a private APK's name stays here.
|
||||
# No version: a local rebuild can share a catalogue app's package name but carry anything in its version.
|
||||
frame_telemetry.install_finished("apk", error is None, seconds, error, catalog=in_catalog,
|
||||
package=pkg if in_catalog else None)
|
||||
if error is not None and pkg and frame_telemetry.categorize(error)[0] in APK_FAULTS:
|
||||
frame_catalog.add_report(pkg, info.get("version"), result="install_failed", notes=str(error)[:300],
|
||||
via="install", label=info.get("label"))
|
||||
|
||||
|
||||
frame_android.install_hooks.append(apk_installed)
|
||||
|
||||
|
||||
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
|
||||
#
|
||||
# Installing is two steps: inspect (a dropped file is uploaded and a zip
|
||||
@@ -669,14 +717,18 @@ def _run_title_install(token, entry, name, exe, runtime):
|
||||
with _titles_lock:
|
||||
_title_jobs[token].update(fields)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
m = frame_titles.install_plan(entry["plan"], name=name, exe=exe, runtime=runtime,
|
||||
progress=lambda stage, fraction: update(stage=stage, fraction=fraction))
|
||||
update(title=m, message=f"Installed {m['id']} in the Steam library ({m['runtime_label']})")
|
||||
frame_telemetry.install_finished("title", True, time.time() - start, runtime=m.get("runtime"))
|
||||
except frame_android.FrameError as e:
|
||||
update(error=str(e))
|
||||
frame_telemetry.install_finished("title", False, time.time() - start, e)
|
||||
except Exception as e:
|
||||
update(error=f"{type(e).__name__}: {e}")
|
||||
frame_telemetry.install_finished("title", False, time.time() - start, e)
|
||||
finally:
|
||||
_drop_staged(entry)
|
||||
update(done=True, time=time.time())
|
||||
@@ -1129,10 +1181,16 @@ def _webinstall_run(plan, job):
|
||||
ensure_master()
|
||||
res = frame_webinstall.dispatch(path, name=plan["name"], exe=plan["exe"], progress=detail, source=plan["url"])
|
||||
job["message"], job["phase"] = res["message"], "done"
|
||||
if res.get("kind") != "apk": # APKs are counted by apk_installed
|
||||
frame_telemetry.install_finished("web", True, kind_detail=res.get("kind"))
|
||||
except Exception as e:
|
||||
stage = job.get("phase") # download or install, before it becomes "error"
|
||||
known = (frame_webinstall.WebInstallError, Failure, frame_android.FrameError)
|
||||
job["error"] = str(e) if isinstance(e, known) else f"{type(e).__name__}: {e}"
|
||||
job["phase"] = "error"
|
||||
# An APK that failed to install was counted by apk_installed.
|
||||
if not isinstance(e, frame_webinstall.Cancelled) and not (stage == "install" and plan.get("kind") == "apk"):
|
||||
frame_telemetry.install_finished("web", False, error=e, stage=stage, kind_detail=plan.get("kind"))
|
||||
finally:
|
||||
with _web_lock:
|
||||
job.pop("_conn", None)
|
||||
@@ -1269,6 +1327,18 @@ def macview_action(body):
|
||||
except frame_macview.MacViewError as e:
|
||||
raise Failure(str(e), 502)
|
||||
raise Failure("unknown action", 400)
|
||||
# ---- Report a problem (frame_report.py) --------------------------------------
|
||||
|
||||
def report_preview(body):
|
||||
"""Exactly the diagnostics a report would include, for the dialog to show first."""
|
||||
return {"text": frame_report.diagnostics(body.get("activity") or (), include_logs=bool(body.get("includeLogs")))}
|
||||
|
||||
|
||||
def report_send(body):
|
||||
try:
|
||||
return frame_report.send(body)
|
||||
except frame_report.ReportError as e:
|
||||
raise Failure(str(e))
|
||||
|
||||
|
||||
def agent_call(body):
|
||||
@@ -1287,11 +1357,19 @@ POST = {"/api/agent/call": agent_call, "/api/agent/approval": agent_approval,
|
||||
"/api/assistant/chat": assistant_chat, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel, "/api/macview": macview_action}
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
"/api/telemetry": frame_telemetry.update_settings, "/api/telemetry/event": frame_telemetry.page_event,
|
||||
"/api/report/preview": report_preview, "/api/report": report_send, "/api/macview": macview_action}
|
||||
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
def action_of(body):
|
||||
"""The action a request asked for, for diagnostics: a short word, never user data."""
|
||||
a = body.get("action") if isinstance(body, dict) else None
|
||||
return a if isinstance(a, str) and re.fullmatch(r"[a-z]{1,20}", a) else ""
|
||||
|
||||
|
||||
def _pipe_reader(pipe):
|
||||
"""Chunks from a pipe via a thread; select() can't wait on pipes on Windows."""
|
||||
chunks = queue.Queue() # unbounded: the pump never blocks, so it ends at EOF
|
||||
@@ -1419,6 +1497,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json({"apps": frame_catalog.catalog()})
|
||||
elif path == "/api/macview":
|
||||
self.send_json(macview_state(parse_qs(url.query)))
|
||||
elif path == "/api/telemetry":
|
||||
self.send_json(frame_telemetry.state())
|
||||
elif path == "/api/computer/state":
|
||||
self.send_json(json.loads(ssh("python3 -", stdin=(HERE / "frame_computer.py").read_text(), timeout=20)))
|
||||
elif path == "/api/status":
|
||||
@@ -1449,12 +1529,14 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except frame_android.FrameError as e:
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
frame_telemetry.diagnostic(f"GET {path}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def do_POST(self):
|
||||
if not self.local_request():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
body = None
|
||||
try:
|
||||
if path == "/api/upload":
|
||||
self.send_json(self.upload())
|
||||
@@ -1471,12 +1553,16 @@ class Handler(BaseHTTPRequestHandler):
|
||||
raise Failure("request body must be a JSON object", 400)
|
||||
self.send_json(handler(body))
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except (ValueError, TypeError) as e:
|
||||
self.send_json({"error": f"bad request: {e}"}, 400)
|
||||
except frame_android.FrameError as e:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_error_json(str(e), 502)
|
||||
except Exception as e:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_json({"error": f"{type(e).__name__}: {e}"}, 500)
|
||||
|
||||
def stream_video(self, query):
|
||||
@@ -1576,13 +1662,18 @@ class Handler(BaseHTTPRequestHandler):
|
||||
keep = True # stage_title owns tmp now, and removes it on failure
|
||||
return stage_title(str(dest), temp_dir=str(tmp))
|
||||
if mode == "apk":
|
||||
# Checked here, before install(), to hand the page a blocker it can offer
|
||||
# alternatives for; report these failures the way install() would have.
|
||||
start = time.time()
|
||||
try:
|
||||
info = frame_android.apk_info(str(dest))
|
||||
except frame_android.FrameError as e:
|
||||
frame_android._after_install(None, None, e, start)
|
||||
raise Failure(str(e), 400)
|
||||
try:
|
||||
frame_android.check_installable(info)
|
||||
except frame_android.FrameError as e:
|
||||
frame_android._after_install(info, None, e, start)
|
||||
raise Failure(str(e), 400, {"package": info["package"], "version_code": info.get("version_code"), "blocker": str(e)})
|
||||
ensure_master()
|
||||
try:
|
||||
@@ -1614,6 +1705,7 @@ def main():
|
||||
args = ap.parse_args()
|
||||
httpd = LoopbackServer(("127.0.0.1", args.port), Handler)
|
||||
sweep_tmp()
|
||||
frame_telemetry.start()
|
||||
if not frame_host.WINDOWS:
|
||||
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
|
||||
if args.exit_on_eof:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"host": "https://us.i.posthog.com",
|
||||
"key": "phc_qkmbgQBvl2oBXGUVzfV6gG52EpmJdeaQyaRIxHRoQoL",
|
||||
"project": "343535"
|
||||
}
|
||||
Reference in new issue
Block a user