mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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>
271 lines
10 KiB
Python
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()
|