Files
saphid--frame-control/ui/frame_compat_db.py
T
saphidandClaude Opus 5.5 0f770dc88a Tabs, one offline banner, background installs, drop anywhere
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.

When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.

Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.

Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".

Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 10:23:24 +10:00

271 lines
10 KiB
Python

"""Frame Control's compatibility database: a private Lakebed capsule
(compat-db/, https://frame-compat.lakebed.app) that only this app can read or
write, using a key from $FRAME_CONTROL_KEY or the macOS Keychain (service
frame-control-compat-db, account app-key). Without one (anyone but the
maintainer), reports stay local.
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}
(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
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
STATE = str(frame_host.data_dir('compat-db'))
OUTBOX = os.path.join(STATE, 'compat-outbox.jsonl')
MIRROR = os.path.join(STATE, 'compat-mirror.json')
FIELDS = ('package', 'version', 'result', 'rating', 'notes', 'via', 'date', 'steamos', 'lepton', 'runtime',
'label', 'source')
TTL = 60 # seconds a fetched copy is reused
_lock = threading.Lock()
_load_lock = threading.Lock() # refreshing the cached reports (load); separate from _lock, which flush takes
_mem = {'at': 0, 'reports': None, 'source': None}
class DBError(RuntimeError):
pass
def key():
k = os.environ.get('FRAME_CONTROL_KEY')
if k:
return k
p = None
if frame_host.MAC:
p = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p is None or p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key (set FRAME_CONTROL_KEY, or on macOS the Keychain '
f'item service {KEYCHAIN[0]}, account {KEYCHAIN[1]})')
return p.stdout.strip()
def shared():
"""Whether reports reach the shared database. Without the key (anyone but the
maintainer), reports stay in this computer's outbox and ratings come from the catalogue."""
try:
key()
return True
except DBError:
return False
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never follow redirects: urllib would copy the key header to the new host."""
def redirect_request(self, *args, **kwargs):
return None
_opener = urllib.request.build_opener(_NoRedirect)
def _request(path, body=None, timeout=20):
req = urllib.request.Request(URL + path, method='POST' if body is not None else 'GET',
data=json.dumps(body).encode() if body is not None else None,
headers={'x-frame-control-key': key(), 'content-type': 'application/json',
'user-agent': 'FrameControl/1'})
try:
with _opener.open(req, timeout=timeout) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
raise DBError(f'compatibility database said HTTP {e.code}')
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
raise DBError(f"can't reach the compatibility database: {e}")
def _from_row(row):
r = {k: row.get(k) for k in FIELDS if k != 'date'}
r['date'] = row.get('reportedAt')
r['id'] = row.get('clientId') or row.get('id')
return r
def fetch_all():
"""Every report from the database (paged), deduplicated."""
seen, out, since = set(), [], ''
for _ in range(200):
page = _request('/v1/reports?since=' + urllib.parse.quote(since))
for row in page.get('reports', []):
if row.get('id') and row['id'] not in seen:
seen.add(row['id'])
out.append(_from_row(row))
if not page.get('next') or page['next'] == since:
break
since = page['next']
return out
RESULTS = ('runs', 'crashes', 'install_failed', 'instance_failed')
RATINGS = ('works', 'issues', 'broken')
def problem(r):
"""Why the server would reject this report, or None. Mirrors compat-db/server/index.ts."""
if not isinstance(r, dict):
return 'not an object'
for k in ('package', 'id', 'date'):
if not r.get(k) or not isinstance(r[k], str):
return f'missing {k}'
if r.get('result') not in (None, '', *RESULTS):
return f"bad result {r['result']!r}"
if r.get('rating') not in (None, '', *RATINGS):
return f"bad rating {r['rating']!r}"
return None
def _quarantine(lines, why):
"""Keep what can't be sent, with the reason, instead of dropping it."""
os.makedirs(STATE, exist_ok=True)
with open(OUTBOX + '.rejected', 'a') as f:
for line in lines:
f.write(json.dumps({'why': why, 'at': time.strftime('%Y-%m-%dT%H:%M:%S'), 'line': line}) + '\n')
def _outbox():
"""Queued reports. Unreadable or invalid lines move to the .rejected file."""
if not os.path.exists(OUTBOX):
return []
good, bad = [], []
with open(OUTBOX) as f:
for line in f:
if not line.strip():
continue
try:
r = json.loads(line)
except ValueError:
bad.append((line.rstrip('\n'), 'unreadable JSON'))
continue
why = problem(r)
(bad.append((line.rstrip('\n'), why)) if why else good.append(r))
if bad:
for line, why in bad:
_quarantine([line], why)
_write_outbox(good)
return good
def _write_outbox(rows):
with open(OUTBOX + '.tmp', 'w') as f:
f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
os.replace(OUTBOX + '.tmp', OUTBOX)
def flush():
"""Send queued reports. Sent ones leave the outbox; ones the server rejects go to
the .rejected file; on a network error the rest stay queued. Returns how many are left."""
with _lock:
pending = _outbox()
while pending:
batch = pending[:100]
res = _request('/v1/reports', {'reports': [{**r, 'clientId': r['id']} for r in batch]})
rejected = set(res.get('rejected') or [])
if rejected:
_quarantine([json.dumps(r) for r in batch if r['id'] in rejected], 'rejected by the server')
pending = pending[100:]
_write_outbox(pending)
return len(pending)
def _read_mirror():
try:
with open(MIRROR) as f:
return [r for r in json.load(f).get('reports', []) if isinstance(r, dict) and r.get('package')]
except (OSError, ValueError, AttributeError):
return []
def _save_mirror(reports):
"""Keep a copy for offline use. Failing to write it mustn't fail the read."""
try:
os.makedirs(STATE, exist_ok=True)
with open(MIRROR + '.tmp', 'w') as f:
json.dump({'fetched': time.strftime('%Y-%m-%dT%H:%M:%S'), 'reports': reports}, f)
os.replace(MIRROR + '.tmp', MIRROR)
except OSError:
pass
def load():
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
# The page asks for the catalogue and the reports at once: one refresh at a
# time, so they share a fetch and never write the mirror's .tmp together.
with _load_lock:
now = time.time()
if _mem['reports'] is None or now - _mem['at'] > TTL:
try:
if not shared():
raise DBError('no key')
try:
flush()
except Exception:
pass # sending can fail for any reason; reading must still work
reports, source = fetch_all(), 'lakebed'
_save_mirror(reports)
except DBError:
reports, source = _read_mirror(), 'mirror'
_mem.update(at=now, reports=reports, source=source)
sent = {r.get('id') for r in _mem['reports']}
return _mem['reports'] + [r for r in _outbox() if r['id'] not in sent]
def add(report):
"""Validate, queue, then try to send. Never raises once the report is queued."""
r = {k: report.get(k) for k in FIELDS}
r['id'] = report.get('id') or str(uuid.uuid4())
why = problem(r)
if why:
raise ValueError(f'report not saved: {why}')
os.makedirs(STATE, exist_ok=True)
with _lock, open(OUTBOX, 'a') as f:
f.write(json.dumps(r, ensure_ascii=False) + '\n')
try:
if shared():
flush()
_mem['at'] = 0 # refetch on next load
except Exception:
pass # stays queued; load() shows it and a later call sends it
return r
def main():
cmd, *args = sys.argv[1:] or ['count']
try:
if cmd == 'count':
print(len(fetch_all()))
elif cmd == 'export':
reports = fetch_all()
with open(args[0], 'w') as f:
json.dump({'exported': time.strftime('%Y-%m-%dT%H:%M:%S%z'), 'source': URL,
'count': len(reports), 'reports': reports}, f, indent=1)
print(f'{len(reports)} reports -> {args[0]}')
elif cmd == 'import':
with open(args[0]) as f:
backup = json.load(f)
rows = [{**{k: r.get(k) for k in FIELDS}, 'id': r.get('id')} for r in backup['reports']]
bad = [(r, problem(r)) for r in rows if problem(r)]
ok = [r for r in rows if not problem(r)]
for r, why in bad:
print(f"skipped {r.get('package')!r}: {why}", file=sys.stderr)
os.makedirs(STATE, exist_ok=True)
with _lock, open(OUTBOX, 'a') as f:
f.writelines(json.dumps(r) + '\n' for r in ok)
left = flush()
print(f'{len(ok)} reports sent, {len(bad)} invalid skipped, {left} still queued; '
'reports already in the database were not duplicated')
elif cmd == 'flush':
print(f'{flush()} still queued')
else:
sys.exit(__doc__)
except DBError as e:
sys.exit(f'error: {e}')
if __name__ == '__main__':
main()