Frame Control: Mac app, web UI, Android and Steam tooling

Package the Frame Control web UI as an installable Electron Mac app and
bring in the tooling built alongside it.

- app/: Electron wrapper that starts ui/server.py on a free loopback port,
  hardened window (sandbox, no navigation, runAsNode fuse off), login-shell
  PATH so Homebrew tools work from Finder, first-run offer to run
  connect.sh, ad-hoc signed DMG/zip via electron-builder.
- ui/: headset view (OpenVR screenshots), device status, library, Steam
  "Get games" (owned games, install, store search), Android apps as
  persistent Lepton instances with a rated F-Droid catalogue and a private
  compatibility database, Android display controls over ADB, file and
  clipboard transfer, Flatpaks, remote and power actions.
- apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed
  capsule for compatibility reports, Frame-side launchers.
- tests/ and CI: server guard and validation tests plus Steam helper tests,
  run on Python 3.9 with script and app syntax checks.
- Docs: README leads with the Mac app; new Android, panels, Steam games and
  field-notes docs; security notes on LAN-exposed ADB ports.

Screenshot values for the headset's IP and Wi-Fi name are placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-25 22:21:20 +10:00
1 parent 6ccf562756
commit d4486a7681
56 files changed
+19298 -11

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# Android app catalogue and compatibility reports
The data behind Frame Control's **Android apps** section: every app in the
F-Droid main repo, rated for Lepton (the Frame's Android container), plus our
own compatibility reports. No ProtonDB-style database for sideloaded Android
apps on the Frame existed as of 2026-09-25 (Steam Frame Hub and Valve's
"Great on Frame" cover Steam games only), so we keep our own.
```sh
scripts/frame-ui.sh # Frame Control → Android apps: search, Install, Test, Report
scripts/apk-catalog.sh # refresh the F-Droid data (only scans what changed)
```
## Verdicts
| Verdict | Meaning |
|---|---|
| Works on Frame | The latest report says it runs (automated Test or a person's rating) |
| Should work | No known blocker found in the APK |
| Might work | Something uncertain: Compose version unknown, Godot, Qt, Play Services, no launcher icon (widgets, tiles, keyboards), or a report of issues |
| Probably crashes | Compose UI < 1.11, SDL or Kivy |
| Won't work | Needs Android 12+ or has no 64-bit ARM build, or a report says it's broken |
"Should work" means the app opens. Features that need something Lepton lacks
(browser links, file picker, Play Services, camera app) can still fail. The
rules and the evidence behind them are in [docs/apks.md](../docs/apks.md).
## Compatibility reports
Reports live in Frame Control's private database, a Lakebed capsule at
`https://frame-compat.lakebed.app` that only the app can read or write (see
[compat-db/README.md](../compat-db/README.md), including backups). **Test**
records whether an app stays up in its own instance (`result`); **Report**
(on any installed app, catalogue card, or **+ Report an APK** for anything else, e.g. an
APK file or your own build) records `works`, `issues` or `broken`, how it was run
(own instance, Lepton Development, other), where the APK came from, and notes. Each report carries
the SteamOS `BUILD_ID` and the Lepton build id. Newest wins, and a person's
rating beats an automated result (`reports.py`).
## Files
| File | Role |
|---|---|
| `zipcd.py` | Reads an APK's zip directory and single entries with HTTP range requests |
| `scan.py` | Per app: native ABIs, frameworks (from `lib/*.so`), Compose/GMS/Firebase resource names from `resources.arsc`. Writes `data/scan.jsonl` |
| `scan2.py` | Per app: Compose UI version, launcher/IME/feature strings from `AndroidManifest.xml`. Writes `data/scan2.jsonl` |
| `pick.py` | Which version to rate and install: newest with an arm64 build (or no native code) and minSdk ≤ 30 |
| `pins.json` | Versions pinned by hand (F-Droid 1.17.2) |
| `reports.py` | How reports override predictions (the reports are in compat-db) |
| `build.py` | Applies the rules and writes `site/apps.js` (predictions; Frame Control adds reports at runtime) |
Frame Control's `ui/frame_catalog.py` loads `site/apps.js`, applies the
reports from `ui/frame_compat_db.py`, downloads APKs (SHA-256 checked against the
F-Droid index), and installs them with `ui/frame_android.py`.
Both scans skip apps whose version hasn't changed. Compose is detected by its
resource ids (`compose_view_saveable_id_tag`) because many apps strip the
`META-INF` version files; those apps are rated "Might work".
+158
View File
@@ -0,0 +1,158 @@
"""Merge the F-Droid index, both APK scans and the on-device results into
site/apps.js, applying the Lepton compatibility rules in docs/apks.md.
Usage: python3 build.py (run from apk-catalog/, after scan.py and scan2.py)
"""
import json, os, re, time
from pick import pick_version, LEPTON_SDK
import reports
HERE = os.path.dirname(os.path.abspath(__file__))
DATA = os.path.join(HERE, 'data')
REPO = 'https://f-droid.org/repo'
# Compose UI below this crashes on any Compose screen: it casts the missing
# clipboard service to non-null while building AndroidComposeView.
COMPOSE_OK = (1, 11)
RANK = {'works': 0, 'likely': 1, 'maybe': 2, 'unlikely': 3, 'no': 4}
def jsonl(path):
if not os.path.exists(path):
return {}
return {r['pkg']: r for r in map(json.loads, open(path))}
def loc(d):
if not isinstance(d, dict):
return d or ''
return d.get('en-US') or d.get('en') or next(iter(d.values()), '')
def ver_tuple(v):
m = re.match(r'(\d+)\.(\d+)', v or '')
return (int(m[1]), int(m[2])) if m else None
def classify(m, s, s2):
"""Return (verdict, reasons). Hard failures first, then crash signals."""
no, bad, maybe, notes = [], [], [], []
min_sdk = m.get('usesSdk', {}).get('minSdkVersion', 1)
if min_sdk > LEPTON_SDK:
no.append(f'Needs Android API {min_sdk}; Lepton is Android 11 (API 30), so it won\'t install')
native = m.get('nativecode') or s.get('abis') or []
if native and 'arm64-v8a' not in native:
no.append(f'Native code only for {", ".join(native)}; Lepton is 64-bit ARM only, so it won\'t install')
cv = s2.get('compose_ver')
if cv and ver_tuple(cv) and ver_tuple(cv) < COMPOSE_OK:
bad.append(f'Jetpack Compose {cv}: Compose screens crash (no clipboard service); 1.11+ is fine')
elif s.get('compose') and not cv:
maybe.append('Uses Jetpack Compose, version unknown: crashes if older than 1.11')
elif cv:
notes.append(f'Jetpack Compose {cv} (fine)')
fw = set(s.get('frameworks', []))
if 'sdl' in fw or s2.get('sdl3'):
bad.append('SDL app: registers a clipboard listener at start-up and crashes')
if 'kivy' in fw:
bad.append('Kivy (SDL) app: crashes at start-up on the missing clipboard')
if 'godot' in fw:
maybe.append('Godot: 4.3 crashed (clipboard), 4.6 worked')
if 'reactnative' in fw or 'hermes' in fw:
notes.append('React Native: 2 of 3 tested apps worked')
if 'qt' in fw or 'qt6' in fw:
maybe.append('Qt app: the one tested crashed on a missing libc++ symbol')
if 'flutter' in fw:
notes.append('Flutter (tested apps worked)')
if 'gdx' in fw:
notes.append('libGDX (tested games worked)')
if s.get('gms') or s2.get('gms_meta'):
maybe.append('Uses Google Play Services, which Lepton lacks')
if s2 and not s2.get('launcher'):
if s2.get('ime'):
maybe.append('Keyboard (IME), not an app you open; untested in Lepton')
else:
maybe.append('No launcher icon (widget, tile, wallpaper or plug-in)')
feats = s2.get('features', [])
if 'android.hardware.touchscreen.multitouch' in feats:
notes.append('Mentions multi-touch; the Frame pointer is single-touch (inferred)')
if any(f in feats for f in ('android.hardware.telephony', 'android.hardware.nfc')):
notes.append('Mentions telephony or NFC, which Lepton lacks')
if 'android.hardware.type.watch' in feats:
maybe.append('Wear OS watch app')
if no:
return 'no', no + bad + maybe + notes
if bad:
return 'unlikely', bad + maybe + notes
if maybe:
return 'maybe', maybe + notes
if not s or 'error' in s:
return 'maybe', ['APK not scanned'] + notes
return 'likely', notes or ['No known blockers']
def finalize(app, reps):
"""Set the shown verdict ('r', 'why', 't') from the prediction plus any reports."""
rv = reports.verdict(reps)
if rv:
app['r'], lines = rv
app['why'] = lines + ['Rule check: ' + r for r in app['pw'] if not r.startswith('No known')]
else:
app['r'], app['why'] = app['pr'], list(app['pw'])
app['t'] = bool(rv)
return app
def load_catalog(path=None):
"""Read site/apps.js back into a list (for serve.py and Frame Control)."""
src = open(path or os.path.join(HERE, 'site', 'apps.js'), encoding='utf-8').read()
return json.loads(src.split('window.APPS=', 1)[1].rstrip().rstrip(';'))
def main():
idx = json.load(open(os.path.join(DATA, 'index-v2.json')))
s1 = jsonl(os.path.join(DATA, 'scan.jsonl'))
s2 = jsonl(os.path.join(DATA, 'scan2.jsonl'))
pins = json.load(open(os.path.join(HERE, 'pins.json'))) if os.path.exists(os.path.join(HERE, 'pins.json')) else {}
cats = idx.get('repo', {}).get('categories', {})
apps = []
for pkg, p in idx['packages'].items():
if not p.get('versions'):
continue
v = pick_version(p)
md, m = p['metadata'], v['manifest']
verdict, why = classify(m, s1.get(pkg, {}), s2.get(pkg, {}))
apk, sha, shown_ver = REPO + v['file']['name'], v['file'].get('sha256'), m.get('versionName')
pin = pins.get(pkg)
if pin:
apk, sha, shown_ver = pin['apk'], pin['sha256'], pin['version']
why = [pin['why']] + why
icon = loc(md.get('icon'))
apps.append(finalize({
'p': pkg,
'n': loc(md.get('name')) or pkg,
's': loc(md.get('summary')),
'c': [loc(cats.get(c, {}).get('name')) or c for c in md.get('categories', [])],
'i': REPO + icon['name'] if isinstance(icon, dict) and icon.get('name') else '',
'v': shown_ver,
'z': v['file'].get('size'),
'u': md.get('lastUpdated'),
'a': apk,
'h': sha,
'af': sorted(v.get('antiFeatures', {}).keys()),
'pr': verdict,
'pw': why,
}, None))
apps.sort(key=lambda a: (RANK[a['r']], a['n'].lower()))
out = os.path.join(HERE, 'site', 'apps.js')
meta = {'built': time.strftime('%Y-%m-%d'), 'count': len(apps),
'source': 'F-Droid main repo, rated on the newest version each app has that Lepton can install'}
with open(out, 'w') as f:
f.write('window.CATALOG_META=' + json.dumps(meta) + ';\n')
f.write('window.APPS=' + json.dumps(apps, separators=(',', ':'), ensure_ascii=False) + ';\n')
counts = {k: sum(a['r'] == k for a in apps) for k in RANK}
print(out, counts)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
"""Choose which F-Droid version of an app to rate and install.
F-Droid often publishes one APK per ABI under different version codes, and
the highest code is frequently the x86_64 build. Prefer the newest version
Lepton can install (arm64-v8a or no native code, minSdk <= 30), else the newest.
"""
LEPTON_SDK = 30
def installable(v):
m = v['manifest']
native = m.get('nativecode') or []
return (not native or 'arm64-v8a' in native) and \
m.get('usesSdk', {}).get('minSdkVersion', 1) <= LEPTON_SDK
def pick_version(p):
vs = sorted(p['versions'].values(), key=lambda v: v['manifest'].get('versionCode', 0), reverse=True)
return next((v for v in vs if installable(v)), vs[0])
+8
View File
@@ -0,0 +1,8 @@
{
"org.fdroid.fdroid": {
"apk": "https://f-droid.org/archive/org.fdroid.fdroid_1017002.apk",
"sha256": "756b7dfc7fb43ef28c27d276428a2f7826cd482fd794bbd9eeaef24016b2081c",
"version": "1.17.2",
"why": "Pinned to 1.17.2: 1.23.2 crashed (old Compose). 2.0 uses Compose 1.12 but is untested. Don't let it update itself."
}
}
+33
View File
@@ -0,0 +1,33 @@
"""How compatibility reports turn into a verdict. The reports themselves live
in Frame Control's private database (ui/frame_compat_db.py, a Lakebed capsule
in compat-db/); this module is the pure logic shared by the build and the app.
A report: package, version, result (runs | crashes | install_failed |
instance_failed, from an automated test), rating (works | issues | broken, from
a person), notes, via (harness | probe | user), date, steamos, lepton, runtime.
Newest wins, and a person's rating beats an automated result.
"""
def verdict(reports):
"""(verdict, summary lines) for one package's reports, or None."""
if not reports:
return None
rs = sorted(reports, key=lambda r: r.get('date') or '')
people = [r for r in rs if r.get('rating')]
best = people[-1] if people else rs[-1]
kind = best.get('rating') or best.get('result')
v = {'works': 'works', 'runs': 'works', 'issues': 'maybe'}.get(kind, 'no')
n_ok = sum((r.get('rating') or r.get('result')) in ('works', 'runs') for r in rs)
lines = [f"Reported on a Frame {(best.get('date') or '')[:10]} (v{best.get('version')}): "
f"{kind}{' – ' + best['notes'] if best.get('notes') else ''}"]
if len(rs) > 1:
lines.append(f'{len(rs)} reports, {n_ok} working')
return v, lines
def by_package(reports):
out = {}
for r in reports:
out.setdefault(r['package'], []).append(r)
return out
+123
View File
@@ -0,0 +1,123 @@
"""Scan F-Droid APKs (latest version per app) for Steam Frame / Lepton signals.
Reads only the zip central directory and the resources.arsc key-string pool
through HTTP range requests. Output: one JSON line per package (resumable).
"""
import json, os, struct, sys, zlib, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipcd
from pick import pick_version
REPO = 'https://f-droid.org/repo'
HERE = os.path.dirname(os.path.abspath(__file__))
IDX = os.path.join(HERE, 'data', 'index-v2.json')
OUT = os.path.join(HERE, 'data', 'scan.jsonl')
KEYS = {
'compose': [b'compose_view_saveable_id_tag', b'wrapped_composition_tag',
b'androidx_compose_ui_view_compositionlocal_map'],
'gms': [b'common_google_play_services_unknown_issue',
b'common_google_play_services_install_title'],
'firebase': [b'google_app_id', b'gcm_defaultSenderId'],
}
LIBS = {
'unity': 'libunity.so', 'flutter': 'libflutter.so', 'reactnative': 'libreactnative',
'hermes': 'libhermes', 'godot': 'libgodot_android.so', 'gdx': 'libgdx.so',
'sdl': 'libSDL2.so', 'unreal': 'libUE4.so', 'unreal5': 'libUnreal.so',
'xamarin': 'libmonodroid.so', 'qt': 'libQt5Core', 'qt6': 'libQt6Core',
'cocos': 'libcocos', 'love': 'liblove.so', 'renpy': 'librenpy',
'kivy': 'libpython', 'gomobile': 'libgojni.so',
}
def arsc_keys(url, entries):
comp, csize, lho = entries['resources.arsc']
h = zipcd.rng(url, lho, lho + 29)
nl, el = struct.unpack('<HH', h[26:30])
base = lho + 30 + nl + el
if comp == 8:
blob = zlib.decompress(zipcd.rng(url, base, base + csize - 1), -15)
read = lambda o, n: blob[o:o + n]
elif comp == 0:
read = lambda o, n: zipcd.rng(url, base + o, base + o + n - 1)
else:
raise ValueError(f'arsc compression {comp}')
th = read(0, 12)
if struct.unpack('<H', th[:2])[0] != 2:
raise ValueError('not a ResTable')
off = struct.unpack('<H', th[2:4])[0]
pools = []
total = struct.unpack('<I', th[4:8])[0]
while off < total:
ch = read(off, 8)
ctype, chdr, csz = struct.unpack('<HHI', ch)
if ctype == 0x0200: # package
ph = read(off, 288)
key_off = struct.unpack('<I', ph[8 + 4 + 256 + 8:8 + 4 + 256 + 12])[0]
kh = read(off + key_off, 8)
ksz = struct.unpack('<I', kh[4:8])[0]
pools.append(read(off + key_off, ksz))
if csz <= 0:
break
off += csz
return b''.join(pools)
def scan(pkg, meta, ver):
f = ver['file']
url = REPO + f['name']
m = ver['manifest']
r = {'pkg': pkg, 'vc': m.get('versionCode'), 'vn': m.get('versionName'),
'apk': url, 'size': f.get('size')}
try:
names, entries, _ = zipcd.list_names(url, f['size'])
libs = {n for n in names if n.startswith('lib/')}
r['frameworks'] = sorted(k for k, s in LIBS.items() if any(s in n for n in libs))
r['abis'] = sorted({n.split('/')[1] for n in libs if n.count('/') >= 2})
r['metainf_compose'] = any(n.startswith('META-INF/androidx.compose.ui') for n in names)
r['assets_bin_data'] = any(n.startswith('assets/bin/Data/') for n in names)
if 'resources.arsc' in entries:
kp = arsc_keys(url, entries)
for k, pats in KEYS.items():
r[k] = any(p in kp or p.decode().encode('utf-16-le') in kp for p in pats)
else:
r['no_arsc'] = True
except Exception as e: # keep going; record the failure
r['error'] = f'{type(e).__name__}: {e}'[:200]
return r
def main():
idx = json.load(open(IDX))
done = set()
if os.path.exists(OUT):
for line in open(OUT):
try:
r = json.loads(line)
done.add((r['pkg'], r.get('vc')))
except Exception:
pass
jobs = []
for pkg, p in idx['packages'].items():
if not p.get('versions'):
continue
ver = pick_version(p)
if (pkg, ver['manifest'].get('versionCode')) not in done:
jobs.append((pkg, p['metadata'], ver))
print(f'{len(done)} done, {len(jobs)} to scan', flush=True)
lock = threading.Lock()
n = 0
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '12'))) as ex:
futs = [ex.submit(scan, *j) for j in jobs]
for fu in as_completed(futs):
r = fu.result()
with lock:
out.write(json.dumps(r) + '\n'); out.flush()
n += 1
if n % 100 == 0:
print(f'{n}/{len(jobs)}', flush=True)
print('finished', flush=True)
if __name__ == '__main__':
main()
+80
View File
@@ -0,0 +1,80 @@
"""Second pass: Compose UI version, launcher activity, GMS meta-data, uses-feature
strings from AndroidManifest.xml (binary XML string pool). Resumable JSONL."""
import json, os, struct, sys, threading
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipcd
HERE = os.path.dirname(os.path.abspath(__file__))
IN = os.path.join(HERE, 'data', 'scan.jsonl')
OUT = os.path.join(HERE, 'data', 'scan2.jsonl')
FEATURES = ['android.hardware.touchscreen.multitouch', 'android.hardware.telephony',
'android.hardware.nfc', 'android.hardware.bluetooth_le', 'android.hardware.usb.host',
'android.hardware.camera', 'android.hardware.vr.high_performance',
'android.software.leanback', 'android.hardware.type.watch']
def axml_strings(b):
# ResXMLTree_header (8) then string pool chunk
off = struct.unpack('<H', b[2:4])[0]
t, hs, sz, cnt, _styles, flags, sstart, _ = struct.unpack('<HHIIIIII', b[off:off + 28])
utf8 = flags & 0x100
offs = struct.unpack(f'<{cnt}I', b[off + hs:off + hs + 4 * cnt])
base = off + sstart
out = []
for o in offs:
p = base + o
if utf8:
n = b[p]; p += 2 if n & 0x80 else 1
n = b[p]; hi = n & 0x80
if hi:
n = ((n & 0x7f) << 8) | b[p + 1]; p += 2
else:
p += 1
out.append(b[p:p + n].decode('utf-8', 'replace'))
else:
n = struct.unpack('<H', b[p:p + 2])[0]; p += 2
if n & 0x8000:
n = ((n & 0x7fff) << 16) | struct.unpack('<H', b[p:p + 2])[0]; p += 2
out.append(b[p:p + 2 * n].decode('utf-16-le', 'replace'))
return out
def scan(r):
o = {'pkg': r['pkg'], 'vc': r.get('vc')}
try:
names, ent, _ = zipcd.list_names(r['apk'], r['size'])
for n in ('META-INF/androidx.compose.ui_ui.version', 'META-INF/androidx.compose.ui_ui-android.version'):
if n in ent:
o['compose_ver'] = zipcd.read_entry(r['apk'], ent, n).decode().strip()
break
o['sdl3'] = any(n.endswith('/libSDL3.so') for n in names)
s = set(axml_strings(zipcd.read_entry(r['apk'], ent, 'AndroidManifest.xml')))
o['launcher'] = 'android.intent.category.LAUNCHER' in s
o['leanback_launcher'] = 'android.intent.category.LEANBACK_LAUNCHER' in s
o['gms_meta'] = 'com.google.android.gms.version' in s
o['ime'] = 'android.view.InputMethod' in s
o['features'] = [f for f in FEATURES if f in s]
except Exception as e:
o['error2'] = f'{type(e).__name__}: {e}'[:200]
return o
def main():
rows = list({r['pkg']: r for r in map(json.loads, open(IN))}.values()) # latest per app
done = set()
if os.path.exists(OUT):
done = {(o['pkg'], o.get('vc')) for o in map(json.loads, open(OUT))}
rows = [r for r in rows if (r['pkg'], r.get('vc')) not in done and 'error' not in r]
print(len(done), 'done', len(rows), 'todo', flush=True)
lock = threading.Lock(); n = 0
with open(OUT, 'a') as out, ThreadPoolExecutor(int(os.environ.get('WORKERS', '40'))) as ex:
for fu in as_completed([ex.submit(scan, r) for r in rows]):
with lock:
out.write(json.dumps(fu.result()) + '\n'); out.flush(); n += 1
if n % 200 == 0:
print(n, flush=True)
print('finished', flush=True)
if __name__ == '__main__':
main()
+2
View File
@@ -0,0 +1,2 @@
window.CATALOG_META={"built": "2026-09-25", "count": 4455, "source": "F-Droid main repo, rated on the newest version each app has that Lepton can install"};
window.APPS=[{"p":"com.terokarvinen.x54ask","n":"0x54ask","s":"Todo.txt manager. Offline, works with Syncthing. Fork of SimpleTask Cloudless","c":["Calendar & Agenda","Note","Task"],"i":"https://f-droid.org/repo/com.terokarvinen.x54ask/en-US/icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png","v":"1.1.2 (fork of Simpletask)","z":13037003,"u":1788427366142,"a":"https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk","h":"f05781226bb84205caa5b5aa6a511afcb8df86de8bc4b53e33b7de34c2940e8a","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.github.ashutoshgngwr.tenbitclockwidget","n":"10-bit Clock Widget","s":"A beautiful BCD clock for your home screen","c":["Clock"],"i":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget/en-US/icon_TrUyJLRoXZGniCc2uQM3OnsVmlOokr_KZk0ZQaPrtjY=.png","v":"2.2-1","z":1281564,"u":1696789501000,"a":"https://f-droid.org/repo/com.github.ashutoshgngwr.tenbitclockwidget_221.apk","h":"35ff9940fd3d73acd1099f3640be6367c311ec9f6c34fa17e4748e874ecfe763","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"dev.lonami.klooni","n":"1010! Klooni","s":"A libGDX game based on 1010","c":["Puzzle Game"],"i":"https://f-droid.org/repo/icons/dev.lonami.klooni.860.png","v":"0.8.6","z":2735506,"u":1598918400000,"a":"https://f-droid.org/repo/dev.lonami.klooni_860.apk","h":"55641cdb5dba7f30c1d229cf8a34f390a8ff6b3f60cdff9b45d277919f33ce24","af":[],"pr":"likely","pw":["libGDX (tested games worked)"],"r":"likely","why":["libGDX (tested games worked)"],"t":false},{"p":"eu.quelltext.counting","n":"12345 - Learn Counting","s":"Learn counting in different languages with pictures","c":["Educational Game","Science & Education"],"i":"https://f-droid.org/repo/eu.quelltext.counting/en-US/icon_30ymRTCTMZiTzSNXPRLEOukBSubDfmp1CV_cpbGudKw=.png","v":"1.3","z":2413060,"u":1646352000000,"a":"https://f-droid.org/repo/eu.quelltext.counting_3.apk","h":"98fe65f21ff8e51918b94e80d25d99d52f5527d24a69dcd8dd9ca1a5da9b7a02","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.roufsyed.onekey","n":"1Key Password Manager","s":"Offline password manager. 2FA + notes. No account, no network, no telemetry.","c":["Password & 2FA","Security"],"i":"https://f-droid.org/repo/com.roufsyed.onekey/en-US/icon_7Oq_UnE5rGthf-UdC05ENbWiZZe00b9J8cKU2qrdVMQ=.png","v":"1.1.1","z":4738420,"u":1784362608829,"a":"https://f-droid.org/repo/com.roufsyed.onekey_3.apk","h":"690a58bb75780d9f835183ae6deb563e06db659218a4275ddd40ba15353d66ce","af":[],"pr":"likely","pw":["Jetpack Compose 1.11.2 (fine)"],"r":"likely","why":["Jetpack Compose 1.11.2 (fine)"],"t":false},{"p":"org.og8.a1tox","n":"1toX","s":"Remember numbers quick to train your brain","c":["Educational Game"],"i":"https://f-droid.org/repo/icons/org.og8.a1tox.1.png","v":"1.00","z":639637,"u":1567641600000,"a":"https://f-droid.org/repo/org.og8.a1tox_1.apk","h":"34895a84a638d53bd5ed57d134511eee9468f5461cb0e41874a1968ac256e4c8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"com.dasp.worldcup2026","n":"2026 Football Fixtures Widget","s":"2026 football fixtures and widgets.","c":["Sports & Health"],"i":"","v":"0.1.0","z":33934,"u":1780506857489,"a":"https://f-droid.org/repo/com.dasp.worldcup2026_1.apk","h":"8c7b60c9cef5a6343f000f12ff0a0714bc6f3de72ced17c57f1ce4a147bc4a67","af":["NonFreeNet"],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.secuso.privacyfriendly2048","n":"2048 (Privacy Friendly)","s":"(SECUSO) Try to reach 2048 in this puzzle game","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.secuso.privacyfriendly2048/en-US/icon__EtkwPp725lQQYnzjkzDUiOqD2X5nnY1CiZSIYN9TVU=.png","v":"1.4.2","z":9294779,"u":1753701498000,"a":"https://f-droid.org/repo/org.secuso.privacyfriendly2048_100.apk","h":"02c799d3d582669daf2acf920093c68d2933f60aa937bb72fa2a805557233fe8","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"org.mattvchandler.a2050","n":"2050","s":"A game loosely based on 2048, but with circles instead of squares","c":["Puzzle Game"],"i":"https://f-droid.org/repo/org.mattvchandler.a2050/en-US/icon_3BMQD76YZDYHbtVP8WR8CTKi6E7pd6L82YveKdLHjR4=.png","v":"1.0.10","z":5079962,"u":1693608133000,"a":"https://f-droid.org/repo/org.mattvchandler.a2050_190010010.apk","h":"98a0e75e589c319093db56cf98bfa32d920b9436a9cbe7c30b32dcf7a4a6d284","af":[],"pr":"likely","pw":["No known blockers"],"r":"likely","why":["No known blockers"],"t":false},{"p":"nl.eventinfra.wifisetup","n":"37C3 Wifi Setup","s":"Official NOC application for connecting to the 36C3 Wi-Fi","c":["Connectivity"],"i":"","v":"0.37","z":2405866,"u":1729155289000,"a":"https://f-droid.org/repo/nl.eventinfra.wifisetup_20231222.apk","h":"aa0ca052e9e48ad7945f9aa535f5fd691018a5356a8ff95b0e5bf94662a54a10"Line truncated
+31
View File
@@ -0,0 +1,31 @@
import struct, urllib.request, zlib
UA={'User-Agent':'steam-frame-compat-scan/1.0'}
def rng(url, start, end=None):
h=dict(UA); h['Range']=f'bytes={start}-' if end is None else f'bytes={start}-{end}'
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
return r.read()
def tail(url, n):
h=dict(UA); h['Range']=f'bytes=-{n}'
with urllib.request.urlopen(urllib.request.Request(url,headers=h),timeout=60) as r:
return r.read()
def list_names(url, size):
t=tail(url, min(size, 65557))
i=t.rfind(b'PK\x05\x06')
if i<0: raise ValueError('no EOCD')
cd_size, cd_off = struct.unpack('<II', t[i+12:i+20])
base=size-len(t)
cd = t[cd_off-base:cd_off-base+cd_size] if cd_off>=base else rng(url, cd_off, cd_off+cd_size-1)
names=[]; p=0; entries={}
while p+46<=len(cd) and cd[p:p+4]==b'PK\x01\x02':
comp,=struct.unpack('<H',cd[p+10:p+12])
csize,usize=struct.unpack('<II',cd[p+20:p+28])
nl,el,cl=struct.unpack('<HHH',cd[p+28:p+34]); lho,=struct.unpack('<I',cd[p+42:p+46])
n=cd[p+46:p+46+nl].decode('utf-8','replace'); names.append(n); entries[n]=(comp,csize,lho)
p+=46+nl+el+cl
return names, entries, cd_size
def read_entry(url, entries, name):
comp,csize,lho=entries[name]
h=rng(url, lho, lho+29)
nl,el=struct.unpack('<HH',h[26:30])
data=rng(url, lho+30+nl+el, lho+30+nl+el+csize-1)
return zlib.decompress(data,-15) if comp==8 else data