mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +02:00
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:
1 parent
6ccf562756
commit
d4486a7681
56 files changed
+19298
-11
No files matched your search
@@ -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".
|
||||
@@ -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
@@ -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])
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user