From a1bc6522c3e77dc41c8900f826ce8a0a2a5239f7 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:16:56 +1000 Subject: [PATCH] Detect VR APKs and repair launchers with pure Python v2 signing --- docs/vr-apks.md | 28 +++ tests/test_frame_apk_vr.py | 187 +++++++++++++++++++ ui/frame_android.py | 60 ++++++- ui/frame_apk.py | 8 +- ui/frame_apk_sign.py | 356 +++++++++++++++++++++++++++++++++++++ ui/frame_apk_vr.py | 113 ++++++++++++ ui/index.html | 7 + ui/server.py | 10 +- 8 files changed, 761 insertions(+), 8 deletions(-) create mode 100644 tests/test_frame_apk_vr.py create mode 100644 ui/frame_apk_sign.py create mode 100644 ui/frame_apk_vr.py diff --git a/docs/vr-apks.md b/docs/vr-apks.md index 7b913ff..45ecd56 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -87,3 +87,31 @@ Khronos-style loader and found SteamVR through `/vendor`. services. Frame Control won't work around that. - **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator, not a patch. + +## Frame Control does this for you + +APK uploads and `python3 ui/frame_android.py install app.apk` detect VR +manifest categories, Samsung's `vr_only` flag and the arm64 OpenXR loader. +VR apps default to immersive mode without the flatscreen marker. The upload +selector or CLI `--flat` / `--vr` overrides that choice. Compatibility notes +identify legacy VrApi, Meta platform SDK and OpenXR libraries. + +If a VR MAIN intent filter lacks LAUNCHER, Frame Control inserts it, repacks +and v2-signs the APK locally before copying it; `meta.json` records +`"patched": ["launcher"]`. Unchanged ZIP members retain their compressed +bytes; stored libraries are aligned to 16 KiB. The RSA signing identity lives +in Frame Control's per-user app-data directory as `apk-signing-key.json` +(mode 0600). Keep this key to preserve the signer on subsequent patched +updates. A re-signed APK cannot update an installation signed by its original +publisher; Android also treats it as a different signer for signature checks. + +Inspect or prepare an APK without contacting the headset: + +```sh +python3 ui/frame_android.py info app.apk +python3 ui/frame_android.py patch app.apk patched.apk +python3 ui/frame_android.py patch app.apk patched.apk --add assets/openxr/1/api_layers/implicit.d/X.json=X.json --add lib/arm64-v8a/libX.so=libX.so +``` + +The patch fixes Lepton's launch-category requirement. It does not supply an +OpenXR 1.1 translation layer, Meta services or a VrApi implementation. diff --git a/tests/test_frame_apk_vr.py b/tests/test_frame_apk_vr.py new file mode 100644 index 0000000..c13e856 --- /dev/null +++ b/tests/test_frame_apk_vr.py @@ -0,0 +1,187 @@ +"""Local-only VR manifest, raw ZIP and independent signature checks.""" +import io +import os +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_apk +import frame_apk_vr as vr +import frame_apk_sign as signing +import frame_android +from test_frame_apk import pool + + +def manifest(utf8=False, launcher=False, category=None, samsung=False, split=False): + strings = ['manifest', 'package', 'org.test.vr', 'application', 'activity', 'intent-filter', + 'action', 'category', 'name', vr.MAIN, category or next(iter(sorted(vr.VR))), + vr.LAUNCHER, 'http://schemas.android.com/apk/res/android', 'meta-data', 'value', + 'com.samsung.android.vr.application.mode', 'vr_only'] + def start(tag, attrs=()): + body = struct.pack(' {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1)) -def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None): +def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None): info = apk_info(apk_path) if icon_png: info['icon_png'] = icon_png @@ -114,7 +120,16 @@ def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None): pkg = info['package'] if not PKG_RE.match(pkg): raise FrameError(f'unexpected package name {pkg!r}') + if flatscreen is None: + flatscreen = not info['vr'] with _install_lock: + if not info['launchable'] and info['vr_activity']: + with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp: + patched = os.path.join(tmp, 'app.apk') + patch(apk_path, patched) + info['patched'] = ['launcher'] + info['launchable'] = True + return _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path)) return _install(apk_path, info, pkg, flatscreen, name, source) @@ -143,6 +158,8 @@ def _install(apk_path, info, pkg, flatscreen, name, source): raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})') meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'], 'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut), + 'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []), + 'launchable': info.get('launchable', False), 'patched': info.get('patched', []), 'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'), 'source': source or os.path.basename(apk_path)} _write_meta(d, meta) @@ -273,11 +290,46 @@ def probe(pkg, wait=20): 'container_up': ctr in running_instances()} +def patch(src, dst, add=None): + try: + info = apk_info(src) + with zipfile.ZipFile(src) as z: + original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST) + manifest = add_launcher_category(original) if info['vr_activity'] else original + if not info['launchable'] and not info['vr_activity']: + raise FrameError('APK has no MAIN/LAUNCHER or patchable VR activity') + repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add) + result = apk_info(dst) + result.pop('icon_png', None) + result['patched'] = ['launcher'] if manifest != original else [] + return result + except (OSError, ValueError, zipfile.BadZipFile, frame_apk.ApkError) as e: + raise FrameError(str(e)) from e + + def main(): cmd, *args = sys.argv[1:] or ['help'] try: if cmd == 'install': - r = install(args[0], flatscreen='--vr' not in args) + r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None) + elif cmd in ('info', 'describe'): + r = apk_info(args[0]) + r.pop('icon_png', None) + elif cmd == 'patch': + import argparse + parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally') + parser.add_argument('src') + parser.add_argument('dst') + parser.add_argument('--add', action='append', default=[], metavar='NAME=PATH') + opts = parser.parse_args(args) + additions = {} + for item in opts.add: + if '=' not in item: + raise FrameError('--add requires NAME=PATH') + entry, path = item.split('=', 1) + with open(path, 'rb') as f: + additions[entry] = f.read() + r = patch(opts.src, opts.dst, additions) elif cmd == 'list': r = list_apps() elif cmd in ('launch', 'stop', 'probe'): @@ -286,7 +338,7 @@ def main(): r = remove(args[0], keep_data='--keep-data' in args) else: sys.exit(__doc__) - except FrameError as e: + except (FrameError, OSError) as e: sys.exit(f'error: {e}') print(json.dumps(r, indent=1)) diff --git a/ui/frame_apk.py b/ui/frame_apk.py index 943af2a..541b321 100644 --- a/ui/frame_apk.py +++ b/ui/frame_apk.py @@ -10,7 +10,7 @@ import zipfile # android: attribute resource ids; names can be stripped by shrinkers, ids can't. ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name', - 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'} + 0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'} T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11 # APKs can come from websites (install links), so nothing read from one may be # unbounded. zipfile stops at a member's declared size, so checking it is enough. @@ -215,8 +215,11 @@ def apk_info(path): if 'AndroidManifest.xml' not in names: raise ApkError('not an APK: no AndroidManifest.xml') try: - elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST)) + manifest_data = _read(z, 'AndroidManifest.xml', MAX_MANIFEST) + elements = manifest_elements(manifest_data) res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'') + from frame_apk_vr import detection + vr_info = detection(manifest_data, names) except (struct.error, IndexError, zipfile.BadZipFile) as e: raise ApkError(f'could not read the APK manifest: {e}') tags = {} @@ -235,6 +238,7 @@ def apk_info(path): 'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None, 'icon_png': None, } + info.update(vr_info) try: info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res)) except Exception: # noqa: BLE001 - any unreadable icon just means no icon diff --git a/ui/frame_apk_sign.py b/ui/frame_apk_sign.py new file mode 100644 index 0000000..091820d --- /dev/null +++ b/ui/frame_apk_sign.py @@ -0,0 +1,356 @@ +"""Lossless ZIP repacking and APK v2 RSA/SHA-256 signing, Python 3.9 stdlib. + +Spec: https://source.android.com/docs/security/features/apksigning/v2 +Sections: APK Signing Block; APK Signature Scheme v2 Block; Integrity-protected +contents; Verification. No verity algorithm is used, so no verity padding. +""" +import hashlib +import io +import json +import math +import os +from pathlib import Path +import re +import secrets +import struct +import tempfile +import zipfile +import zlib + +import frame_host + +MAGIC = b'APK Sig Block 42' +V2 = 0x7109871a +ALG = 0x0103 +SHA256_DER = bytes.fromhex('3031300d060960864801650304020105000420') + + +def u32(n): + return struct.pack('= n: + raise ValueError('invalid RSA signature size/value') + actual = pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') + if actual != encoded_hash(data, size): + raise ValueError('RSA signature mismatch') + + +def certificate(key): + name = sequence(der(0x31, sequence(bytes.fromhex('0603550403'), der(12, b'Frame Control APK signer')))) + validity = sequence(der(0x17, b'200101000000Z'), der(0x18, b'21200101000000Z')) + tbs = sequence(der(0xa0, integer(2)), integer(1), CERT_ALG, name, validity, name, public_key(key)) + return sequence(tbs, CERT_ALG, der(3, b'\0' + rsa_sign(tbs, key))) + + +def _prime(bits): + small = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47) + while True: + n = secrets.randbits(bits) | (3 << (bits - 2)) | 1 + if any(n % p == 0 for p in small) or (n - 1) % 65537 == 0: + continue + d, s = n - 1, 0 + while d % 2 == 0: + d //= 2 + s += 1 + for _ in range(40): # Miller-Rabin error bound <= 2^-80 + x = pow(secrets.randbelow(n - 3) + 2, d, n) + if x in (1, n - 1): + continue + for _ in range(s - 1): + x = pow(x, 2, n) + if x == n - 1: + break + else: + break + else: + return n + + +def signing_key(path=None): + """Persistent identity in app data, never an evictable cache. Atomic publication. + + Hard-linking a fully written private temp file prevents concurrent first-use + callers from selecting different identities or reading a partial key. + """ + path = Path(path) if path is not None else frame_host.data_dir('apk-signing-key.json') + if not path.exists(): + p, q = _prime(1024), _prime(1024) + while q == p: + q = _prime(1024) + key = {'n': p * q, 'e': 65537, 'd': pow(65537, -1, math.lcm(p - 1, q - 1))} + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix='.apk-key-', dir=str(path.parent)) + try: + with os.fdopen(fd, 'w') as f: + json.dump(key, f) + f.flush() + os.fsync(f.fileno()) + try: + os.link(tmp, path) + except FileExistsError: + pass + finally: + os.unlink(tmp) + os.chmod(path, 0o600) + key = json.loads(path.read_text()) + if key['n'].bit_length() != 2048 or key['e'] != 65537: + raise ValueError('invalid cached APK signing key') + rsa_verify(b'key check', rsa_sign(b'key check', key), key['n'], key['e']) + return key + + +def _eocd(data): + # ZIP comments can contain the EOCD signature; accept only an exact EOF fit. + for at in range(len(data) - 22, max(-1, len(data) - 65558), -1): + if data[at:at + 4] == b'PK\5\6' and at + 22 + struct.unpack_from(' len(data): + raise ValueError('truncated length prefix') + size = struct.unpack_from(' len(data): + raise ValueError('length prefix outside block') + result.append(data[off:off + size]) + off += size + return result + + +def _der_parts(data): + result, off = [], 0 + while off < len(data): + start = off + tag, size = data[off:off + 2] + off += 2 + if size & 128: + count = size & 127 + if not count or count > 4: + raise ValueError('invalid DER length') + size = int.from_bytes(data[off:off + count], 'big') + off += count + if off + size > len(data): + raise ValueError('truncated DER') + result.append((tag, data[off:off + size], data[start:off + size])) + off += size + return result + + +def _cert_key(cert): + outer = _der_parts(cert) + if len(outer) != 1 or outer[0][0] != 0x30: + raise ValueError('invalid certificate') + fields = _der_parts(outer[0][1]) + tbs = _der_parts(fields[0][1]) + spki = tbs[6 if tbs[0][0] == 0xa0 else 5][2] + pub = _der_parts(_der_parts(spki)[0][1]) + if pub[0][2] != RSA_ALG or pub[1][1][:1] != b'\0': + raise ValueError('certificate is not RSA') + numbers = _der_parts(_der_parts(pub[1][1][1:])[0][1]) + n, e = [int.from_bytes(item[1], 'big') for item in numbers] + return n, e, spki + + +def verify(path): + """Verify this v2-only format; return True or raise ValueError on corruption. + + A valid signature establishes integrity, not trust in the APK publisher. + """ + data = Path(path).read_bytes() + try: + eo, cd = _eocd(data) + if data[cd - 16:cd] != MAGIC: + raise ValueError('no APK signing block') + size = struct.unpack_from(' cd - 24: + raise ValueError('invalid signing pair') + ident = struct.unpack_from('= 0xffffffff: + raise ValueError('ZIP64 APKs are unsupported') + output.write(struct.pack('= 65535 or cd + len(directory) >= 0xffffffff: + raise ValueError('ZIP64 APKs are unsupported') + output.write(directory) + output.write(struct.pack('.zip, folder or .exe becomes a title in the Steam library. + +

Sideloaded titles

Loading…
@@ -1138,6 +1144,7 @@ function upload(file, mode) { xhr.setRequestHeader("X-Frame-UI", "1"); xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name)); xhr.setRequestHeader("X-Mode", mode); + if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value); const bar = $("prog").firstElementChild; $("prog").style.display = "block"; bar.style.width = "0"; xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; }; diff --git a/ui/server.py b/ui/server.py index 05661e3..cff2ed9 100755 --- a/ui/server.py +++ b/ui/server.py @@ -1337,10 +1337,16 @@ class Handler(BaseHTTPRequestHandler): if mode == "apk": ensure_master() try: - m = frame_android.install(str(dest), source=name) + display = self.headers.get("X-APK-Display", "auto") + if display not in ("auto", "flat", "vr"): + raise frame_android.FrameError("invalid APK display mode") + m = frame_android.install(str(dest), source=name, + flatscreen=None if display == "auto" else display == "flat") except frame_android.FrameError as e: raise Failure(str(e), 400) - return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m} + kind = "VR app" if not m['flatscreen'] else "app" + notes = " ".join(m.get("vr_issues", [])) + return {"message": f"Installed {m['label']} as its own {kind} in the Steam library. {notes}".strip(), "app": m} return {"message": push_file(dest)} finally: if not keep: