Files
saphid--frame-control/ui/frame_compat_db.py
T
saphidandClaude Opus 5.5 60571dbfac Prepare Frame Control 0.2.0 for public testing
- Without the maintainer's key, Android compatibility reports stay on the
  Mac and the UI says so; the shared database is never contacted.
- Remove personal infrastructure details from scripts and docs: the Drive
  folder and gog wrapper now come from the environment, and the Chromium
  build host is required instead of defaulted.
- Add an MIT license, tester instructions in the README, and bump to 0.2.0.

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

254 lines
9.4 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 kept in the macOS Keychain (service frame-control-compat-db,
account app-key).
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
~/Library/Application Support/Frame Control/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
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
STATE = os.path.expanduser('~/Library/Application Support/Frame Control/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()
_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 = subprocess.run(['security', 'find-generic-password', '-s', KEYCHAIN[0], '-a', KEYCHAIN[1], '-w'],
capture_output=True, text=True)
if p.returncode != 0 or not p.stdout.strip():
raise DBError('No compatibility-database key in the Keychain '
f'(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 Mac'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 load():
"""All reports: the database (cached for TTL s), else the offline mirror; plus unsent ones."""
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'
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 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()