From 7faf80fc2bdbba2b945e77dcc3b112af02cae888 Mon Sep 17 00:00:00 2001 From: saphid <4596216+saphid@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:17:11 +1100 Subject: [PATCH] Import from my Quest: copy games you own off a USB-connected Quest New ui/frame_quest.py (CLI: devices | list | import), GET/POST /api/quest and an Import from my Quest panel on the Android page. It finds a Quest on this computer's adb (never Frame Control's own tunnelled serials), lists third-party apps with split, OBB and Android/data details, pulls the APKs into a per-user cache and checks them before copying anything else: Meta's Platform SDK licence check (libovrplatformloader.so) and 32-bit-only builds are refused, VrApi-only builds flagged. It then installs through the normal APK path, adds OBBs with the existing helper and, if asked, Android/data files, and always removes the cache. No licence-check bypass is bundled, called or linked. Tests use a fake adb (tests/fakeadb.py). Docs: docs/quest-import.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + docs/quest-import.md | 144 ++++++++++++ docs/vr-apks.md | 3 +- tests/fakeadb.py | 100 +++++++++ tests/test_quest.py | 515 +++++++++++++++++++++++++++++++++++++++++++ ui/frame_quest.py | 473 +++++++++++++++++++++++++++++++++++++++ ui/index.html | 52 +++++ ui/server.py | 46 +++- 8 files changed, 1332 insertions(+), 2 deletions(-) create mode 100644 docs/quest-import.md create mode 100644 tests/fakeadb.py create mode 100644 tests/test_quest.py create mode 100644 ui/frame_quest.py diff --git a/README.md b/README.md index fb40d32..f97b44f 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ Frame's software fits together, all checked against a real headset and labelled | [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels | | [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging | | [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances | +| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games don't | | [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection | | [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste | | [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build | diff --git a/docs/quest-import.md b/docs/quest-import.md new file mode 100644 index 0000000..319ffdc --- /dev/null +++ b/docs/quest-import.md @@ -0,0 +1,144 @@ +# Import from my Quest + +Move games you own from your own Meta Quest onto the Steam Frame. Plug the +Quest into your computer by USB; Frame Control copies the game off it with +adb, checks it can run in Lepton, and installs it on the Frame like any other +APK, with its own Lepton instance and Steam library entry +([apks.md](apks.md), [vr-apks.md](vr-apks.md)). + +There is no licence-check bypass, and there won't be. A game that checks its +Meta store licence is refused, with the reason. See +[What won't run](#what-wont-run-and-why) below. + +## Before you start: developer mode on the Quest + +adb only reaches a Quest with developer mode on: + +1. Make a free Meta developer organisation at + [developers.meta.com](https://developers.meta.com/horizon/) on a **verified** + Meta account (Meta asks for a phone number or payment card to verify). +2. In the Meta Horizon phone app, open the headset's settings and switch + **Developer Mode** on. Restart the Quest. +3. Plug the Quest into the computer that runs Frame Control with a USB cable + that carries data (some charging cables don't). +4. Put the headset on. Accept **Allow USB debugging**, and tick **Always allow + from this computer**. + +Frame Control uses the adb it already uses for the Frame (an adb you +installed, else the one it bundles). On Linux, adb also needs permission to +open the Quest: add a udev rule for Meta's USB vendor id `2833`, for example +`SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0660", GROUP="plugdev"`, and +be in that group. Then unplug and replug. + +The Frame's own adb connections (Frame Control's SSH tunnels to Lepton, +`127.0.0.1:`) are never touched. Only a device that reports itself as +Oculus or Meta, or a Quest model, counts as a Quest. + +## Using it + +In the app: **Android → On your Frame → Import from my Quest**. Choose **Look +for my Quest**, then **Import** next to a game. Tick **Also copy each game's +files in Android/data** to bring the game's external files too (some games +keep saves there). Progress shows in the panel; the result and any reason it +stopped appear in the message log. + +From a terminal: + +```sh +python3 ui/frame_quest.py devices # what adb sees, and which is the Quest +python3 ui/frame_quest.py list # apps on the Quest: split or not, OBB and data sizes +python3 ui/frame_quest.py import com.example.game # copy one to the Frame +python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX +``` + +The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and +its apps) and `POST /api/quest` with +`{"action": "import", "package": "...", "serial": "...", "data": false}`, +which returns a background job (`/api/job`) with a stage and percentage. + +## What an import does + +1. Finds the Quest (`adb devices -l`, then `ro.product.manufacturer` and + `ro.product.model`). If none is ready it says why: nothing connected, not + authorised yet (the **Allow USB debugging** prompt), no USB permission + (Linux), offline, another Android device, or several Quests. +2. Lists third-party apps (`pm list packages -3`), each with `pm path` (one + line is a single APK, several a split APK) and the size of + `/sdcard/Android/obb/` and `/sdcard/Android/data/`. + Labels aren't shown before import: reading them means copying the APK. +3. Checks there is room on the computer, then copies the APK and its splits + into a per-user cache folder (`Frame Control/quest-import` under the + system's cache folder). +4. **Checks the APK before anything else is copied**, refusing: + - Meta's Platform SDK licence check (`libovrplatformloader.so`, in the base + or any split); + - 32-bit-only builds (native code but no `arm64-v8a`); + - apps that need an Android newer than Lepton's (API 30); + - split APKs Frame Control can't merge (below). + VrApi-only builds (`libvrapi.so`, no OpenXR loader) are flagged, as for any + APK: they install, but won't run. +5. Copies the OBB folder (and `Android/data` if ticked). +6. Installs through the normal APK install (`frame_android.install`): VR + detection, the launcher fix, the OpenXR compatibility layer, artwork. +7. With game data to add, starts the game's Lepton instance, waits for Android + to finish booting, copies standard `main|patch...obb` + files with the existing OBB helper (checked by SHA-256 inside Android, + [sidequest.md](sidequest.md#obb-files)), streams the `Android/data` files + in, then stops the game so its next start reads them. Other files in the + OBB folder are listed as not copied. +8. Deletes the cache folder, whether the import worked or not. A folder left + by a crash is removed by a later import after six hours. + +Only one import runs at a time. If copying game data fails after the install, +the game stays installed and the message says so; run Import again. + +### Split APKs + +Lepton runs one APK per instance, so the shared install path now merges a +split APK into one (`frame_android.install(base, splits=[...])`, or +`python3 ui/frame_android.py install base.apk split1.apk ...`). Merging copies +each split's native libraries and assets into the base, drops the base +manifest's `isSplitRequired` flag, and re-signs with Frame Control's key, as +the launcher fix already does. Splits with their own resource table (screen +density or language splits) would need the tables merged, which Frame Control +doesn't do; those are refused with that reason before anything reaches the +Frame. A merged, re-signed APK can't update a copy signed by the publisher. + +## What won't run, and why + +- **Games that check their Meta store licence.** Store games call Meta's + Platform SDK (`libovrplatformloader.so`) to confirm you bought them. That + needs Meta's services, which only exist on Meta headsets, so on the Frame + the game quits. Frame Control refuses these and **won't remove or work + around the check**, and doesn't bundle, call or link to any tool that does. + Ask the developer for a build without it, or get the game from Steam or + itch.io. Most Meta Store games have it; most SideQuest, itch.io and + open-source games don't. +- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only. +- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi. +- **Anything else that needs Meta**: avatars, Quest-only device checks, + passthrough and other `XR_FB_*` features SteamVR lacks + ([vr-apks.md](vr-apks.md#what-steamvrs-android-runtime-supports-verified)). +- **Private app data** (`/data/data/`) can't be read over adb without + root, so only saves in `Android/data` come across. + +What usually works: OpenXR games without Meta's licence check, for example +open-source and itch.io builds you sideloaded onto the Quest. + +Only import games you own, for your own use. + +## Evidence and limits + +**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`): +device detection (Quest, the Frame's tunnel, the Frame over USB, other +Android devices, unauthorised, no permissions, offline, none, several), +package listing and split detection, the refusals, the order of copies +(APK checked before the OBB is copied), the install, OBB and data sequencing, +split merging and signing, and cache clean-up after success and failure. + +**Not verified:** a real Quest (none was available), Quest system apps in +`pm list packages -3`, real split APKs from the Meta Store, OBB and +`Android/data` writes into a running Lepton instance on a real Frame +(the OBB helper itself is also unverified there), and the Linux udev rule. +Large games are copied into memory while being merged or patched, as every +patched APK already is. diff --git a/docs/vr-apks.md b/docs/vr-apks.md index e88613c..b1c8555 100644 --- a/docs/vr-apks.md +++ b/docs/vr-apks.md @@ -214,7 +214,8 @@ source-search endpoint remain unverified (the public install seam is tested). - **Meta entitlement.** Apps that call the Oculus Platform SDK (`libovrplatformloader.so`) to check the Quest store licence need Meta's - services. Frame Control won't work around that. + services. Frame Control won't work around that. [Import from my + Quest](quest-import.md) refuses these before copying anything to the Frame. - **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator, not a patch. diff --git a/tests/fakeadb.py b/tests/fakeadb.py new file mode 100644 index 0000000..395fbf1 --- /dev/null +++ b/tests/fakeadb.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""A stand-in for Android's adb, for tests/test_quest.py: just enough of `devices -l`, +`shell`, and `pull` for frame_quest.py, run as a real subprocess. + +$FAKEADB_STATE is a JSON file: + devices [{"serial", "state", "extra" (the rest of the `devices -l` line), "props" {getprop name: value}}] + root a folder standing in for each device's file system (remote /x/y is root/x/y) + packages {package: [APK paths, as `pm path` prints them]} + fail_pull a remote path whose pull fails +Every call is appended to $FAKEADB_LOG as a JSON line. Shell commands are the simple +`; `-separated ones frame_quest sends, each answered here: no real shell runs. +""" +import json +import os +import shutil +import sys + +args = sys.argv[1:] +with open(os.environ['FAKEADB_LOG'], 'a') as f: + f.write(json.dumps(args) + '\n') +with open(os.environ['FAKEADB_STATE']) as f: + state = json.load(f) +root = state.get('root', '') +serial = None +if args[:1] == ['-s']: + serial, args = args[1], args[2:] + + +def fail(message, code=1): + sys.stderr.write(message + '\n') + sys.exit(code) + + +def local(remote): + return os.path.join(root, remote.lstrip('/')) + + +def size_kb(path): + total = 0 + for dirpath, _, files in os.walk(path): + total += sum(os.path.getsize(os.path.join(dirpath, n)) for n in files) + return max(4, total // 1024) + + +if args == ['devices', '-l']: + print('List of devices attached') + for d in state.get('devices', []): + print(f"{d['serial']:<22} {d['state']} {d.get('extra', '')}".rstrip()) + sys.exit(0) + +device = next((d for d in state.get('devices', []) if d['serial'] == serial), None) +if serial is None or device is None: + fail(f"adb: device '{serial}' not found") +if device['state'] == 'unauthorized': + fail('adb: device unauthorized.\nThis adb server\'s $ADB_VENDOR_KEYS is not set') +if device['state'] != 'device': + fail(f"adb: device {device['state']}") + +if args[:1] == ['shell']: + out = [] + for command in ' '.join(args[1:]).split('; '): + command = command.replace(' 2>/dev/null', '').strip() + words = command.split() + if not words or command == 'true': + continue + if words[0] == 'getprop': + out.append(device.get('props', {}).get(words[1], '')) + elif words[0] == 'echo': + out.append(' '.join(words[1:])) + elif command == 'pm list packages -3': + out += ['package:' + p for p in state.get('packages', {})] + elif words[:2] == ['pm', 'path']: + out += ['package:' + p for p in state.get('packages', {}).get(words[2], [])] + elif words[:2] == ['du', '-sk']: + if os.path.isdir(local(words[2])): + out.append(f'{size_kb(local(words[2]))}\t{words[2]}') + elif words[:3] == ['stat', '-c', '%s']: + out += [str(os.path.getsize(local(p.strip("'")))) for p in words[3:]] + else: + fail(f'/system/bin/sh: {words[0]}: inaccessible or not found', 127) + print('\n'.join(out)) + sys.exit(0) + +if args[:1] == ['pull'] and len(args) == 3: + remote, dest = args[1], args[2] + if remote == state.get('fail_pull'): + fail(f"adb: error: failed to copy '{remote}' to '{dest}': remote Connection reset") + src = local(remote) + if not os.path.exists(src): + fail(f"adb: error: failed to stat remote object '{remote}': No such file or directory") + if os.path.isdir(dest): + dest = os.path.join(dest, os.path.basename(remote.rstrip('/'))) + if os.path.isdir(src): + shutil.copytree(src, dest) + else: + shutil.copyfile(src, dest) + print(f'{remote}: 1 file pulled.') + sys.exit(0) + +fail('adb: unknown command ' + ' '.join(args)) diff --git a/tests/test_quest.py b/tests/test_quest.py new file mode 100644 index 0000000..84aa10c --- /dev/null +++ b/tests/test_quest.py @@ -0,0 +1,515 @@ +"""Import from my Quest (ui/frame_quest.py) against a fake adb (tests/fakeadb.py), and +split APK merging in the shared install path (frame_android.install(splits=...)).""" +import sandbox # noqa: F401 (first: keeps tests off real data and services) +import io +import json +import os +from pathlib import Path +from types import SimpleNamespace +import struct +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'ui')) +import frame_android as android # noqa: E402 +import frame_apk # noqa: E402 +import frame_apk_sign # noqa: E402 +import frame_apk_vr # noqa: E402 +import frame_quest as quest # noqa: E402 +from apk_fixtures import pool # noqa: E402 + +FAKEADB = str(Path(__file__).resolve().parent / 'fakeadb.py') +PKG = 'com.example.questgame' +QUEST = {'serial': '1WMHH000ABCDEF', 'state': 'device', 'extra': 'usb:1-1 product:eureka model:Quest_3 transport_id:2', + 'props': {'ro.product.manufacturer': 'Oculus', 'ro.product.model': 'Quest 3'}} +TUNNEL = {'serial': '127.0.0.1:40123', 'state': 'device', 'extra': 'product:lepton model:Lepton transport_id:1', + 'props': {'ro.product.manufacturer': 'Valve', 'ro.product.model': 'Lepton'}} +PHONE = {'serial': 'R58M000PHONE', 'state': 'device', 'extra': 'usb:1-2 model:SM_G990 transport_id:3', + 'props': {'ro.product.manufacturer': 'samsung', 'ro.product.model': 'SM-G990'}} + + +def axml(package, split=None, split_required=False, resmap=False): + """A minimal binary AndroidManifest.xml: . + + resmap: name isSplitRequired by its android resource id (as real builds do), not only by name.""" + strings = ['isSplitRequired', 'package', 'split', 'manifest', 'application', 'label', package, + split or '', 'Quest Game'] + none = 0xffffffff + + def start(tag, attrs): + body = struct.pack(', server.AdbTunnel) and emulators. +TUNNEL_RE = re.compile(r'^(127\.0\.0\.1|localhost|\[::1\]):\d+$') +QUEST_MAKERS = {'oculus', 'meta'} +META_APPS = ('com.oculus.', 'com.meta.', 'com.facebook.') +CACHE_TTL = 6 * 3600 # an import left behind by a crash is swept after this +ADB_TIMEOUT = 30 +PULL_TIMEOUT = 3 * 3600 +READY_TIMEOUT = 150 +BATCH = 15 # packages per adb shell command while listing + +ENTITLEMENT = ("{label} checks your Meta Quest store licence with Meta's Platform SDK " + "(libovrplatformloader.so). That check needs Meta's services, which only exist on Meta " + "headsets, so the game would quit on the Frame. Frame Control won't remove or work around " + "the check. Ask the developer for a build without it, or look for the game on Steam or itch.io.") +NO_DEVICE = ("No Quest found on this computer's USB. Connect the Quest with a cable that carries data, " + "put the headset on and allow USB debugging. Developer mode must be on first: it needs a Meta " + "developer organisation on a verified Meta account (developers.meta.com), then Developer Mode " + "switched on for the headset in the Meta Horizon phone app.") +UNAUTHORISED = ("The Quest hasn't allowed this computer yet. Put the headset on and accept the \"Allow USB " + "debugging\" prompt (tick Always allow from this computer), then try again. No prompt? Unplug " + "and replug the cable, or switch developer mode off and on.") +NO_PERMISSIONS = ("This computer sees the Quest but isn't allowed to open it over USB. On Linux, add a udev " + "rule for Meta's USB vendor id 2833 (see docs/quest-import.md), then replug it.") +OFFLINE = "adb reports the Quest offline. Unplug and replug the cable, or restart the headset." +GONE = "The Quest was disconnected. Plug it back in, put it on, and try again." + + +class QuestError(android.FrameError): + pass + + +def _adb_cmd(): + try: + return [frame_host.adb()] + except frame_host.HostError as e: + raise QuestError(str(e)) from e + + +def _friendly(out): + low = out.lower() + if 'unauthorized' in low: + return UNAUTHORISED + if 'no permissions' in low or 'insufficient permissions' in low: + return NO_PERMISSIONS + if 'device offline' in low: + return OFFLINE + if 'no devices/emulators found' in low or re.search(r"device '[^']*' not found", low): + return GONE + return None + + +def adb(*args, serial=None, timeout=ADB_TIMEOUT): + """Run this computer's adb (the one Frame Control already finds) and return stdout.""" + cmd = _adb_cmd() + (['-s', serial] if serial else []) + list(args) + try: + r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors='replace', + timeout=timeout) + except subprocess.TimeoutExpired: + raise QuestError(f'adb {args[0]} took more than {timeout} s; check the Quest is still connected') + except OSError as e: + raise QuestError(f'could not run adb: {e}') + if r.returncode != 0: + out = (r.stderr or '').strip() or (r.stdout or '').strip() + raise QuestError(_friendly(out) or out[-600:] or f'adb exited {r.returncode}') + return r.stdout + + +# ---- Finding the Quest ------------------------------------------------------ + +def _parse_devices(out): + found = [] + for line in out.splitlines(): + line = line.strip() + if not line or line.startswith('List of devices') or line.startswith('*'): + continue + parts = line.split(None, 1) + serial, rest = parts[0], (parts[1] if len(parts) > 1 else '') + state = 'no permissions' if rest.startswith('no permissions') else (rest.split() or ['unknown'])[0] + fields = dict(t.split(':', 1) for t in rest.split() if re.match(r'^[a-z_]+:', t)) + found.append({'serial': serial, 'state': state, 'usb': 'usb' in fields, + 'model': fields.get('model', '').replace('_', ' '), 'manufacturer': ''}) + return found + + +def devices(): + """Every adb device on this computer, each with a kind: quest, frame, android (another + Android device), frame-tunnel (Frame Control's own SSH-tunnelled connection to a Lepton + instance, never touched here), emulator, or unknown (not authorised, offline...).""" + found = _parse_devices(adb('devices', '-l', timeout=15)) + for d in found: + if TUNNEL_RE.match(d['serial']): + d['kind'] = 'frame-tunnel' + elif d['serial'].startswith('emulator-'): + d['kind'] = 'emulator' + elif d['state'] != 'device': + d['kind'] = 'unknown' + else: + try: + props = adb('shell', 'getprop ro.product.manufacturer; getprop ro.product.model', + serial=d['serial'], timeout=15).splitlines() + ['', ''] + except QuestError: + d['kind'] = 'unknown' + continue + d['manufacturer'], d['model'] = props[0].strip(), props[1].strip() or d['model'] + maker = d['manufacturer'].lower() + d['kind'] = ('quest' if maker in QUEST_MAKERS or 'quest' in d['model'].lower() + else 'frame' if maker == 'valve' else 'android') + return found + + +def find_quest(serial=None): + """The one connected, authorised Quest, or a QuestError saying what to do.""" + found = devices() + if serial: + d = next((d for d in found if d['serial'] == serial), None) + if not d: + raise QuestError(f'No adb device {serial} on this computer. ' + NO_DEVICE) + if d['kind'] == 'quest': + return d + state = {'unauthorized': UNAUTHORISED, 'no permissions': NO_PERMISSIONS, 'offline': OFFLINE}.get(d['state']) + what = ' '.join(x for x in (d['manufacturer'] or d['kind'], d['model']) if x) + raise QuestError(state or f"{serial} isn't a Meta Quest ({what}).") + quests = [d for d in found if d['kind'] == 'quest'] + if len(quests) == 1: + return quests[0] + if quests: + raise QuestError('Several Quests are connected: ' + ', '.join(f"{d['model']} ({d['serial']})" for d in quests) + + '. Choose one, or unplug the others.') + states = {d['state'] for d in found if d['kind'] == 'unknown'} + for state, message in (('unauthorized', UNAUTHORISED), ('no permissions', NO_PERMISSIONS), ('offline', OFFLINE)): + if state in states: + raise QuestError(message) + others = [d for d in found if d['kind'] in ('android', 'frame')] + if others: + raise QuestError(NO_DEVICE + ' (adb sees ' + ', '.join( + f"{d['manufacturer']} {d['model']}".strip() for d in others) + ", which isn't a Quest.)") + raise QuestError(NO_DEVICE) + + +# ---- What's on it ------------------------------------------------------------- + +def _package_script(packages): + parts = [] + for p in packages: + parts += [f'echo @@{p}', f'pm path {p}', f'du -sk {OBB}/{p} 2>/dev/null', f'du -sk {DATA}/{p} 2>/dev/null'] + return '; '.join(parts + ['true']) + + +def _parse_packages(out, packages): + apps, current = {}, None + for line in out.splitlines(): + line = line.strip() + if line.startswith('@@') and line[2:] in packages: + current = apps[line[2:]] = {'package': line[2:], 'apks': [], 'obb': None, 'data': None} + elif current is None or not line: + continue + elif line.startswith('package:'): + current['apks'].append(line[len('package:'):]) + else: + size, _, where = line.partition('\t') if '\t' in line else line.partition(' ') + if size.isdigit(): + key = 'obb' if where.strip().startswith(OBB + '/') else 'data' if where.strip().startswith(DATA + '/') else None + if key: + current[key] = int(size) * 1024 + result = [] + for p in packages: + app = apps.get(p) + if not app or not app['apks']: + continue # removed while listing + app['split'] = len(app['apks']) > 1 + app['meta'] = p.startswith(META_APPS) + result.append(app) + return result + + +def packages(serial): + """Third-party apps on the Quest: APK paths (several means a split APK), and the size + in bytes of the OBB and external data folders (None when there is none).""" + out = adb('shell', 'pm list packages -3', serial=serial) + names = sorted({l.split(':', 1)[1].strip() for l in out.splitlines() if l.startswith('package:')}) + names = [n for n in names if android.PKG_RE.match(n)] + apps = [] + for i in range(0, len(names), BATCH): + batch = names[i:i + BATCH] + apps += _parse_packages(adb('shell', _package_script(batch), serial=serial, timeout=120), batch) + return apps + + +def package(serial, pkg): + apps = _parse_packages(adb('shell', _package_script([pkg]), serial=serial, timeout=60), [pkg]) + if not apps: + raise QuestError(f"{pkg} isn't installed on the Quest") + return apps[0] + + +def status(serial=None, with_apps=True): + """For the page: the Quest (or why there isn't one), what else adb sees, and its apps.""" + try: + found = devices() + except QuestError as e: + return {'quest': None, 'devices': [], 'guidance': str(e), 'apps': []} + try: + quest = find_quest(serial) + except QuestError as e: + return {'quest': None, 'devices': found, 'guidance': str(e), 'apps': []} + return {'quest': quest, 'devices': found, 'guidance': None, + 'apps': packages(quest['serial']) if with_apps else []} + + +# ---- Checking what was copied --------------------------------------------------- + +def inspect(package_name, base, splits=()): + """Refuse what can't or mustn't run on the Frame, before anything is copied to it.""" + names = set() + for path in [base, *splits]: + try: + with zipfile.ZipFile(path) as z: + names |= set(z.namelist()) + except (zipfile.BadZipFile, OSError) as e: + raise QuestError(f'{os.path.basename(path)} copied from the Quest is not a readable APK: {e}') + info = android.apk_info(base) + label = info['label'] + if info['package'] != package_name: + raise QuestError(f"The APK copied for {package_name} says it is {info['package']}; not importing it") + if any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names): + raise QuestError(ENTITLEMENT.format(label=label)) + abis = sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2}) + if abis and 'arm64-v8a' not in abis: + raise QuestError(f"{label} is a 32-bit-only build ({', '.join(abis)}). The Frame runs 64-bit ARM " + "(arm64-v8a) apps only; older Quest games are often 32-bit. Ask the developer " + "whether a 64-bit build exists.") + android.check_installable(dict(info, abis=abis)) # Android version + if splits: + android.split_entries(base, splits) # a split APK Frame Control can't merge fails now + warnings = [] + openxr = 'lib/arm64-v8a/libopenxr_loader.so' in names + if 'lib/arm64-v8a/libvrapi.so' in names and not openxr: + warnings.append("Uses Meta's legacy VrApi and no OpenXR. The Frame doesn't have VrApi, so it " + "will install but won't run.") + return {'package': info['package'], 'label': label, 'version': info['version'], 'abis': abis, + 'vr': bool(info.get('vr') or openxr), 'warnings': warnings} + + +# ---- Importing ------------------------------------------------------------------ + +_import_lock = threading.Lock() +_sleep = time.sleep + + +def cache_root(): + return Path(frame_host.cache_dir('quest-import')) + + +def sweep_cache(now=None): + """Remove imports a crash left behind (older than CACHE_TTL).""" + root, now = cache_root(), now or time.time() + if root.is_dir(): + for d in root.iterdir(): + try: + if now - d.stat().st_mtime > CACHE_TTL: + shutil.rmtree(d, ignore_errors=True) + except OSError: + pass + + +def _safe(name): + return re.sub(r'[^A-Za-z0-9._-]', '_', name)[:120] or 'part.apk' + + +def _remote_sizes(serial, paths): + out = adb('shell', 'stat -c %s ' + ' '.join(shlex.quote(p) for p in paths), serial=serial) + return sum(int(x) for x in out.split() if x.isdigit()) + + +def _pull(serial, remote, local): + adb('pull', remote, str(local), serial=serial, timeout=PULL_TIMEOUT) + + +def _wait_ready(meta): + """Until the app's Lepton instance has finished booting Android.""" + container = f"lepton-steamlaunch-{int(meta['instance'])}" + deadline = time.monotonic() + READY_TIMEOUT + while time.monotonic() < deadline: + out = android.ssh(f"podman exec {container} /system/bin/sh -c 'getprop sys.boot_completed' " + "2>/dev/null || true", timeout=30) + if out.strip() == '1': + return + _sleep(3) + raise QuestError(f"{meta['label']} didn't start on the Frame within {READY_TIMEOUT // 60} minutes, so its " + "game data couldn't be added. Check it opens from the Steam library, then import again.") + + +def push_data(meta, folder, work): + """Copy a pulled /sdcard/Android/data/ folder into the running instance.""" + import frame_android_data + pkg = meta['package'] + archive = Path(work) / 'data.tar' + count = 0 + with tarfile.open(str(archive), 'w', format=tarfile.GNU_FORMAT) as tar: + for path in sorted(Path(folder).rglob('*')): + if path.is_symlink() or not (path.is_file() or path.is_dir()): + continue # regular files and folders only + info = tar.gettarinfo(str(path), arcname=path.relative_to(folder).as_posix()) + info.uid = info.gid = 0 + info.uname = info.gname = '' + if path.is_file(): + with path.open('rb') as f: + tar.addfile(info, f) + count += 1 + else: + tar.addfile(info) + container = f"lepton-steamlaunch-{int(meta['instance'])}" + script = f'set -eu; mkdir -p {DATA}/{pkg}; cd {DATA}/{pkg}; tar -x -o -f -' + with archive.open('rb') as src: + frame_android_data._stream(shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script]), + src=src) + return count + + +def import_game(package_name, serial=None, data=False, progress=None): + """Copy package_name from the Quest to the Frame. progress(stage, percent).""" + if not android.PKG_RE.match(package_name or ''): + raise QuestError('invalid package name') + report = progress or (lambda stage, percent=None: None) + if not _import_lock.acquire(blocking=False): + raise QuestError('Another Quest import is running; wait for it to finish') + try: + report('Looking for your Quest', 1) + quest = find_quest(serial) + app = package(quest['serial'], package_name) + sweep_cache() + work = cache_root() / f'{package_name}-{secrets.token_hex(4)}' + work.mkdir(parents=True) + try: + return _import(quest['serial'], app, data, work, report) + finally: + shutil.rmtree(work, ignore_errors=True) + finally: + _import_lock.release() + + +def _import(serial, app, data, work, report): + pkg = app['package'] + # The APKs, a merged copy of a split APK and the install's patched copy, then the data. + need = _remote_sizes(serial, app['apks']) * 3 + (app['obb'] or 0) + ((app['data'] or 0) if data else 0) + free = shutil.disk_usage(str(work)).free + if need > free: + raise QuestError(f'Importing {pkg} needs about {need / 1024**3:.1f} GB free on this computer ' + f'({free / 1024**3:.1f} GB available in {work.parent})') + # 1. The APK (and its splits), checked before anything else is copied. + local = [] + for i, remote in enumerate(app['apks']): + report(f'Copying the app from your Quest ({i + 1} of {len(app["apks"])})', 5 + 20 * i // len(app['apks'])) + name = 'base.apk' if os.path.basename(remote) == 'base.apk' else f'{i:02d}-{_safe(os.path.basename(remote))}' + _pull(serial, remote, work / name) + if not (work / name).is_file(): + raise QuestError(f'adb did not copy {remote} from the Quest') + local.append(work / name) + base = next((p for p in local if p.name == 'base.apk'), local[0]) + splits = [p for p in local if p != base] + report('Checking it can run on the Frame', 27) + checked = inspect(pkg, str(base), [str(p) for p in splits]) + warnings = list(checked['warnings']) + # 2. Game data. + obb_files = [] + if app['obb'] is not None: + report('Copying game data (OBB) from your Quest', 30) + (work / 'obb').mkdir() + _pull(serial, f'{OBB}/{pkg}', work / 'obb') + folder = work / 'obb' / pkg + files = sorted(p for p in folder.iterdir() if p.is_file()) if folder.is_dir() else [] + obb_files = [p for p in files if re.fullmatch(OBB_NAME.format(re.escape(pkg)), p.name)] + skipped = [p.name for p in files if p not in obb_files] + if skipped: + warnings.append('Not copied, not standard OBB file names: ' + ', '.join(skipped)) + data_folder = None + if data and app['data'] is not None: + report('Copying app files from your Quest', 50) + (work / 'data').mkdir() + _pull(serial, f'{DATA}/{pkg}', work / 'data') + data_folder = work / 'data' / pkg if (work / 'data' / pkg).is_dir() else None + # 3. The Frame. + report('Installing on the Frame', 60) + meta = android.install(str(base), splits=[str(p) for p in splits], source='Meta Quest (USB)') + copied_obb, copied_files = [], 0 + if obb_files or data_folder: + report('Starting it on the Frame to add its game data', 75) + android.launch(pkg) + try: + _wait_ready(meta) + if obb_files: + report('Copying game data (OBB) to the Frame', 80) + copied_obb = android.install_obb(pkg, [str(p) for p in obb_files])['obb'] + if data_folder: + report('Copying app files to the Frame', 92) + copied_files = push_data(meta, data_folder, work) + except android.FrameError as e: + raise QuestError(f"Installed {meta['label']}, but copying its game data failed: {e}. " + "Run Import again to retry.") from e + finally: + try: + android.stop(pkg) # the next start reads the new data from the beginning + except android.FrameError: + pass + report('Done', 100) + message = f"Imported {meta['label']} from your Quest. It's in the Steam library." + if copied_obb: + message += f' Added {len(copied_obb)} OBB file{"s" if len(copied_obb) != 1 else ""}.' + if copied_files: + message += f' Copied {copied_files} app file{"s" if copied_files != 1 else ""}.' + note = android.layer_note(meta) + return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, + 'obb': copied_obb, 'files': copied_files, 'warnings': warnings, 'split': bool(splits)} + + +def main(): + cmd, *args = sys.argv[1:] or ['help'] + serial = args[args.index('--serial') + 1] if '--serial' in args and args.index('--serial') + 1 < len(args) else None + try: + if cmd == 'devices': + r = devices() + elif cmd == 'list': + r = packages(find_quest(serial)['serial']) + elif cmd == 'import': + names = [a for a in args if not a.startswith('--') and a != serial] + if len(names) != 1: + raise QuestError('usage: import PACKAGE [--serial SERIAL] [--data]') + r = import_game(names[0], serial, data='--data' in args, + progress=lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None + else stage, file=sys.stderr)) + else: + sys.exit(__doc__) + except (android.FrameError, OSError) as e: + sys.exit(f'error: {e}') + print(json.dumps(r, indent=1)) + + +if __name__ == '__main__': + main() diff --git a/ui/index.html b/ui/index.html index 8c5a705..349bba5 100644 --- a/ui/index.html +++ b/ui/index.html @@ -951,6 +951,17 @@ library under its own name and opens in its own panel. Test launches it and records whether it stays up; Report records how it worked for you. +
+

Import from my Quest

+
+
Plug your Quest into this computer by USB, put it on, then Look for my Quest.
+ + +
+
Copies games you own from your own Quest (developer mode on) to the Frame. Games that + check their Meta store licence need Meta's services, which only Meta headsets have, so they are refused: + Frame Control won't remove that check. How it works
+

Android display

Lepton
@@ -1427,6 +1438,7 @@ function applyHostWording(h) { // The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos. document.body.classList.toggle("mobile", !!h.mobile); $("shotsFolder").hidden = !!h.mobile; + $("quest").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`; $("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`; $("clipMac").textContent = `Send ${h.computer} clipboard`; @@ -3317,6 +3329,46 @@ async function loadAndroid() { }); } +// ---- Import from my Quest (frame_quest.py): adb on this computer, never on the Frame ---- +const quest = { serial: null }; +function questSize(bytes) { return bytes == null ? "" : bytes >= 1073741824 ? `${(bytes / 1073741824).toFixed(1)} GB` : `${Math.max(1, Math.round(bytes / 1048576))} MB`; } +async function loadQuest() { + $("questScan").disabled = true; $("questState").textContent = "Looking for your Quest…"; $("questApps").innerHTML = ""; + let d; + try { d = await api("/api/quest"); } + catch (e) { $("questState").textContent = e.message; return; } + finally { $("questScan").disabled = false; } + quest.serial = d.quest ? d.quest.serial : null; + $("questCount").textContent = d.quest ? `${d.apps.length} app${d.apps.length === 1 ? "" : "s"}` : ""; + if (!d.quest) { $("questState").textContent = d.guidance || "No Quest found."; return; } + $("questState").textContent = `${d.quest.model || "Quest"} connected (${d.quest.serial}). Games that check their Meta licence are refused when you import them.`; + const installed = new Set(androidApps.map(a => a.package)); + $("questApps").innerHTML = d.apps.length ? d.apps.map(a => { + const bits = [a.split ? `split APK (${a.apks.length} files)` : "single APK"]; + if (a.obb != null) bits.push(`OBB ${questSize(a.obb)}`); + if (a.data != null) bits.push(`Android/data ${questSize(a.data)}`); + if (a.meta) bits.push("Meta app: needs Meta's services"); + return `
${esc(a.package)}
${esc(bits.join(" · "))}
+ ${installed.has(a.package) ? `On the Frame` : ""} +
`; + }).join("") : `
No games or apps you installed are on this Quest.
`; +} +$("questScan").onclick = loadQuest; +$("questApps").addEventListener("click", e => { + const b = e.target.closest("[data-quest-import]"); + if (!b) return; + const pkg = b.dataset.questImport, key = "quest:" + pkg; + if (installing.has(key)) return; + b.disabled = true; + const bar = $("questProgress"); + bar.hidden = false; bar.value = 0; + runJob(`Import ${pkg} from Quest`, key, + () => api("/api/quest", { action: "import", package: pkg, serial: quest.serial, data: $("questData").checked }), + j => { if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; }) + .then(r => { bar.hidden = true; if (r && r.app) { $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); } + else { b.disabled = false; $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } }); +}); + async function loadCatalog() { try { cat.apps = (await api("/api/android/catalog")).apps; } catch (e) { $("catGrid").innerHTML = `
${esc(e.message)}
`; return; } diff --git a/ui/server.py b/ui/server.py index 07ca9f3..c8fdae5 100755 --- a/ui/server.py +++ b/ui/server.py @@ -57,6 +57,7 @@ import frame_link # noqa: E402 import frame_macview # noqa: E402 import frame_media # noqa: E402 import frame_panels # noqa: E402 +import frame_quest # noqa: E402 import frame_report # noqa: E402 import frame_store # noqa: E402 import frame_telemetry # noqa: E402 @@ -1309,6 +1310,47 @@ def android(body): raise Failure("unknown action", 400) +# ---- Import from my Quest (frame_quest.py, docs/quest-import.md) -------------- + +QUEST_ON_FRAME = "Import from my Quest runs on your computer, with the Quest plugged into it by USB." +QUEST_SERIAL = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,128}$") + + +def quest_serial(value): + if value in (None, ""): + return None + if not isinstance(value, str) or not QUEST_SERIAL.match(value): + raise Failure("invalid Quest serial", 400) + return value + + +def quest_status(query): + """The Quest on this computer's USB (or what to do), and the apps on it.""" + if LOCAL: + return {"quest": None, "devices": [], "guidance": QUEST_ON_FRAME, "apps": []} + try: + return frame_quest.status(quest_serial((parse_qs(query).get("serial") or [None])[0])) + except frame_android.FrameError as e: + raise Failure(str(e)) from e + + +def quest(body): + """{"action": "import", "package", "serial"?, "data"?}: a background job with progress.""" + if LOCAL: + raise Failure(QUEST_ON_FRAME, 400) + if body.get("action") != "import": + raise Failure("unknown action", 400) + pkg = str(body.get("package", "")) + if not frame_android.PKG_RE.match(pkg): + raise Failure("invalid Android package id", 400) + serial, data = quest_serial(body.get("serial")), body.get("data", False) + if type(data) is not bool: + raise Failure("data must be true or false", 400) + ensure_master() + return start_job(f"Import {pkg} from Quest", + lambda report: frame_quest.import_game(pkg, serial, data=data, progress=report), progress=True) + + # Errors that are the APK's own fault, so they belong in the compatibility # database as install_failed. Connection trouble and the like don't. APK_FAULTS = {"android_installer", "apk_needs_newer_android", "apk_wrong_abi"} @@ -2221,7 +2263,7 @@ POST = { "/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat, "/api/input": remote_input, "/api/touch": remote_touch, "/api/settings/artwork": frame_steamgriddb.save_settings, - "/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, + "/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard, "/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots, "/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start, "/api/webinstall/cancel": webinstall_cancel, @@ -2436,6 +2478,8 @@ class Handler(BaseHTTPRequestHandler): apps = frame_android.list_apps() backfill_art(apps=apps) self.send_json({"apps": apps}) + elif path == "/api/quest": + self.send_json(quest_status(url.query)) elif path == "/api/titles": ensure_master() titles_list = frame_titles.list_titles()