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 <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-10-13 13:17:11 +11:00
1 parent 6ea20a2806
commit 7faf80fc2b
8 files changed
+1332 -2

No files matched your search

+1
View File
@@ -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 |
+144
View File
@@ -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:<port>`) 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/<package>` and `/sdcard/Android/data/<package>`.
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.<version>.<package>.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/<package>`) 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.
+2 -1
View File
@@ -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.
+100
View File
@@ -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))
+515
View File
@@ -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: <manifest package split?><application label isSplitRequired?/>.
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('<IIHHHHHH', none, strings.index(tag), 20, 20, len(attrs), 0, 0, 0)
for name, dtype, value in attrs:
raw = strings.index(value) if dtype == frame_apk.T_STRING else none
data = strings.index(value) if dtype == frame_apk.T_STRING else value
body += struct.pack('<IIIHBBI', none, strings.index(name), raw, 8, 0, dtype, data)
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, none) + body
def end(tag):
return struct.pack('<HHIIIII', 0x0103, 16, 24, 1, none, none, strings.index(tag))
chunks = pool(strings)
if resmap:
chunks += struct.pack('<HHII', 0x0180, 8, 12, frame_apk_vr.IS_SPLIT_REQUIRED)
mattrs = [('package', frame_apk.T_STRING, package)] + ([('split', frame_apk.T_STRING, split)] if split else [])
aattrs = ([('isSplitRequired', 0x12, 0xffffffff)] if split_required else []) + [('label', frame_apk.T_STRING, 'Quest Game')]
chunks += start('manifest', mattrs) + start('application', aattrs) + end('application') + end('manifest')
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
def apk_bytes(package=PKG, files=None, **manifest):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z:
z.writestr('AndroidManifest.xml', axml(package, **manifest))
for name, data in (files or {}).items():
z.writestr(name, data)
return buf.getvalue()
OPENXR = {'lib/arm64-v8a/libopenxr_loader.so': b'loader', 'lib/arm64-v8a/libgame.so': b'game'}
class FakeQuest:
"""A fake adb with a Quest file system in a temporary folder."""
def __init__(self, test, devices=(QUEST,), apps=None):
self.tmp = tempfile.TemporaryDirectory()
test.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name) / 'quest'
self.log = Path(self.tmp.name) / 'adb.log'
self.log.write_text('')
self.state = {'devices': list(devices), 'root': str(self.root), 'packages': {}}
for pkg, parts in (apps or {}).items():
self.add(pkg, parts)
self.save()
for p in (patch.object(quest, '_adb_cmd', return_value=[sys.executable, FAKEADB]),
patch.dict(os.environ, {'FAKEADB_STATE': str(Path(self.tmp.name) / 'state.json'),
'FAKEADB_LOG': str(self.log)})):
p.start()
test.addCleanup(p.stop)
def add(self, pkg, parts, obb=None, data=None):
paths = []
for i, content in enumerate(parts):
name = 'base.apk' if i == 0 else f'split_config.part{i}.apk'
remote = f'/data/app/~~x==/{pkg}-y==/{name}'
self.write(remote, content)
paths.append(remote)
self.state['packages'][pkg] = paths
for name, content in (obb or {}).items():
self.write(f'/sdcard/Android/obb/{pkg}/{name}', content)
for name, content in (data or {}).items():
self.write(f'/sdcard/Android/data/{pkg}/{name}', content)
self.save()
def write(self, remote, content):
path = self.root / remote.lstrip('/')
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def save(self):
(Path(self.tmp.name) / 'state.json').write_text(json.dumps(self.state))
def calls(self):
return [json.loads(l) for l in self.log.read_text().splitlines()]
class Detection(unittest.TestCase):
def test_quest_found_and_frame_tunnel_left_alone(self):
fake = FakeQuest(self, devices=(TUNNEL, QUEST))
found = {d['serial']: d for d in quest.devices()}
self.assertEqual(found[QUEST['serial']]['kind'], 'quest')
self.assertEqual(found[QUEST['serial']]['model'], 'Quest 3')
self.assertTrue(found[QUEST['serial']]['usb'])
self.assertEqual(found[TUNNEL['serial']]['kind'], 'frame-tunnel')
self.assertEqual(quest.find_quest()['serial'], QUEST['serial'])
# Frame Control's own tunnelled adb connection is never queried.
self.assertFalse([c for c in fake.calls() if TUNNEL['serial'] in c])
def test_quest_recognised_by_model_and_the_frame_on_usb(self):
meta_branded = dict(QUEST, props={'ro.product.manufacturer': 'Meta', 'ro.product.model': 'Quest 3S'})
frame_usb = dict(PHONE, serial='FRAME0USB', props={'ro.product.manufacturer': 'Valve', 'ro.product.model': 'Frame'})
FakeQuest(self, devices=(meta_branded, frame_usb))
kinds = {d['serial']: d['kind'] for d in quest.devices()}
self.assertEqual(kinds, {QUEST['serial']: 'quest', 'FRAME0USB': 'frame'})
def test_nothing_connected(self):
for devices in ((), (TUNNEL,), ({'serial': 'emulator-5554', 'state': 'device'},)):
FakeQuest(self, devices=devices)
with self.assertRaises(quest.QuestError) as e:
quest.find_quest()
self.assertIn('No Quest found', str(e.exception))
self.assertIn('developer organisation', str(e.exception))
self.assertIn('verified Meta account', str(e.exception))
def test_unauthorised(self):
FakeQuest(self, devices=(dict(QUEST, state='unauthorized', extra='usb:1-1 transport_id:2'),))
with self.assertRaisesRegex(quest.QuestError, 'Allow USB debugging'):
quest.find_quest()
status = quest.status()
self.assertIsNone(status['quest'])
self.assertIn('Allow USB debugging', status['guidance'])
self.assertEqual(status['devices'][0]['kind'], 'unknown')
def test_no_permissions_and_offline(self):
FakeQuest(self, devices=(dict(QUEST, state='no', extra='permissions (user in plugdev group; are your udev '
'rules wrong?); see [http://developer.android.com/tools/device.html]'),))
with self.assertRaisesRegex(quest.QuestError, 'udev'):
quest.find_quest()
FakeQuest(self, devices=(dict(QUEST, state='offline'),))
with self.assertRaisesRegex(quest.QuestError, 'offline'):
quest.find_quest()
def test_other_android_device_and_several_quests(self):
FakeQuest(self, devices=(PHONE,))
with self.assertRaisesRegex(quest.QuestError, "samsung SM-G990, which isn't a Quest"):
quest.find_quest()
with self.assertRaisesRegex(quest.QuestError, "isn't a Meta Quest"):
quest.find_quest(PHONE['serial'])
FakeQuest(self, devices=(QUEST, dict(QUEST, serial='2G0000SECOND')))
with self.assertRaisesRegex(quest.QuestError, 'Several Quests'):
quest.find_quest()
self.assertEqual(quest.find_quest('2G0000SECOND')['serial'], '2G0000SECOND')
def test_adb_missing(self):
with patch.object(quest.frame_host, 'adb', side_effect=quest.frame_host.HostError("adb isn't installed")):
status = quest.status()
self.assertIn("adb isn't installed", status['guidance'])
class Listing(unittest.TestCase):
def test_packages_splits_obb_and_data(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes()], obb={f'main.3.{PKG}.obb': b'x' * 5000}, data={'files/save.dat': b'save'})
fake.add('org.example.split', [apk_bytes('org.example.split'), b'split'])
fake.add('com.oculus.extra', [b'apk'])
apps = {a['package']: a for a in quest.packages(QUEST['serial'])}
self.assertEqual(set(apps), {PKG, 'org.example.split', 'com.oculus.extra'})
game = apps[PKG]
self.assertFalse(game['split'])
self.assertEqual(len(game['apks']), 1)
self.assertGreater(game['obb'], 0)
self.assertGreater(game['data'], 0)
self.assertFalse(game['meta'])
self.assertTrue(apps['org.example.split']['split'])
self.assertEqual(len(apps['org.example.split']['apks']), 2)
self.assertIsNone(apps['org.example.split']['obb'])
self.assertIsNone(apps['org.example.split']['data'])
self.assertTrue(apps['com.oculus.extra']['meta'])
status = quest.status()
self.assertEqual(status['quest']['serial'], QUEST['serial'])
self.assertEqual(len(status['apps']), 3)
class Inspection(unittest.TestCase):
def write(self, data):
tmp = tempfile.NamedTemporaryFile(suffix='.apk', delete=False)
tmp.write(data)
tmp.close()
self.addCleanup(os.unlink, tmp.name)
return tmp.name
def test_meta_licence_check_refused_in_base_or_split(self):
base = self.write(apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''})))
with self.assertRaises(quest.QuestError) as e:
quest.inspect(PKG, base)
self.assertIn('libovrplatformloader.so', str(e.exception))
self.assertIn("won't remove or work around", str(e.exception))
self.assertIn('only exist on Meta headsets', str(e.exception))
base = self.write(apk_bytes(split_required=True))
split = self.write(apk_bytes(split='config.arm64_v8a', files={'lib/arm64-v8a/libovrplatformloader.so': b''}))
with self.assertRaisesRegex(quest.QuestError, 'licence'):
quest.inspect(PKG, base, [split])
def test_32_bit_only_refused(self):
base = self.write(apk_bytes(files={'lib/armeabi-v7a/libgame.so': b''}))
with self.assertRaisesRegex(quest.QuestError, '32-bit-only build \\(armeabi-v7a\\)'):
quest.inspect(PKG, base)
def test_vrapi_only_flagged_openxr_fine(self):
base = self.write(apk_bytes(files={'lib/arm64-v8a/libvrapi.so': b'', 'lib/arm64-v8a/libgame.so': b''}))
result = quest.inspect(PKG, base)
self.assertIn('VrApi', result['warnings'][0])
result = quest.inspect(PKG, self.write(apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libvrapi.so': b''}))))
self.assertEqual(result['warnings'], [])
self.assertTrue(result['vr'])
self.assertEqual(result['label'], 'Quest Game')
def test_wrong_package_and_unreadable(self):
with self.assertRaisesRegex(quest.QuestError, 'says it is org.other'):
quest.inspect(PKG, self.write(apk_bytes('org.other.game')))
with self.assertRaisesRegex(quest.QuestError, 'not a readable APK'):
quest.inspect(PKG, self.write(b'not a zip'))
class SplitInstall(unittest.TestCase):
"""frame_android.install(base, splits=[...]): Lepton runs one APK, so splits are merged into it."""
def files(self, *parts):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
paths = []
for i, data in enumerate(parts):
p = os.path.join(tmp.name, f'part{i}.apk')
with open(p, 'wb') as f:
f.write(data)
paths.append(p)
return paths
def test_merge_adds_split_files_drops_split_required_and_signs(self):
for resmap in (False, True):
base, abi, assets = self.files(
apk_bytes(split_required=True, resmap=resmap, files={'assets/base.txt': b'base'}),
apk_bytes(split='config.arm64_v8a', files=dict(OPENXR, **{'META-INF/CERT.RSA': b'sig'})),
apk_bytes(split='install_time_pack', files={'assets/level1.pak': b'level'}))
installed = {}
def fake_install(apk_path, info, pkg, *rest):
with zipfile.ZipFile(apk_path) as z:
installed.update(names=set(z.namelist()), so=z.read('lib/arm64-v8a/libgame.so'),
manifest=z.read('AndroidManifest.xml'))
installed.update(info=info, signed=frame_apk_sign.verify(apk_path))
return {'package': pkg, 'label': info['label']}
with patch.object(android, '_install', side_effect=fake_install), \
patch.object(android, 'xr_compat_files', return_value={}):
meta = android.install(base, splits=[abi, assets])
self.assertEqual(meta['package'], PKG)
self.assertTrue({'lib/arm64-v8a/libgame.so', 'lib/arm64-v8a/libopenxr_loader.so',
'assets/level1.pak', 'assets/base.txt'} <= installed['names'])
self.assertNotIn('META-INF/CERT.RSA', installed['names'])
self.assertEqual(installed['so'], b'game')
self.assertTrue(installed['signed'])
self.assertTrue(installed['info']['vr']) # the OpenXR loader came from the split
self.assertEqual(installed['info']['label'], 'Quest Game') # other attributes intact
app = dict(frame_apk.manifest_elements(installed['manifest']))['application']
self.assertNotIn('isSplitRequired', app)
self.assertEqual(frame_apk.apk_info(base)['package'], PKG) # the inputs are left alone
def test_resource_splits_and_strangers_refused_before_the_frame(self):
base, density, other = self.files(
apk_bytes(split_required=True),
apk_bytes(split='config.xxhdpi', files={'resources.arsc': b'', 'res/drawable/icon.png': b''}),
apk_bytes('org.someone.else', split='config.arm64_v8a', files=OPENXR))
reported = []
with patch.object(android, '_install') as frame, \
patch.object(android, 'install_hooks', [lambda *a: reported.append(a)]):
with self.assertRaisesRegex(android.FrameError, 'density or language'):
android.install(base, splits=[density])
with self.assertRaisesRegex(android.FrameError, 'not part of ' + PKG):
android.install(base, splits=[other])
frame.assert_not_called()
self.assertEqual(len(reported), 2)
self.assertTrue(all(r[2] is not None for r in reported))
def test_duplicate_entries_refused(self):
base, split = self.files(apk_bytes(files=OPENXR), apk_bytes(split='config.arm64_v8a', files=OPENXR))
with self.assertRaisesRegex(android.FrameError, 'both contain'):
android.split_entries(base, [split])
def test_cli_passes_splits(self):
with patch.object(android, 'install', return_value={}) as install, \
patch.object(sys, 'argv', ['frame_android.py', 'install', 'base.apk', 'split.apk', '--vr']):
with patch('builtins.print'):
android.main()
install.assert_called_once_with('base.apk', flatscreen=False, xr_compat=None, splits=['split.apk'])
class Import(unittest.TestCase):
def setUp(self):
self.order = []
meta = {'package': PKG, 'label': 'Quest Game', 'instance': 2800000001, 'vr_issues': []}
def install(base, splits=(), source=None):
self.order.append(('install', os.path.basename(base), [os.path.basename(s) for s in splits]))
self.assertTrue(os.path.isfile(base))
self.assertTrue(all(os.path.isfile(s) for s in splits))
self.assertEqual(source, 'Meta Quest (USB)')
return dict(meta)
def install_obb(pkg, paths):
self.order.append(('obb', sorted(os.path.basename(p) for p in paths)))
self.assertTrue(all(os.path.isfile(p) for p in paths))
return {'obb': [{'name': os.path.basename(p)} for p in paths]}
def push_data(m, folder, work):
self.order.append(('data', sorted(p.relative_to(folder).as_posix() for p in Path(folder).rglob('*') if p.is_file())))
return 1
for target, name, effect in ((android, 'install', install), (android, 'install_obb', install_obb),
(android, 'launch', lambda pkg: self.order.append(('launch',))),
(android, 'stop', lambda pkg: self.order.append(('stop',))),
(quest, '_wait_ready', lambda m: self.order.append(('ready',))),
(quest, 'push_data', push_data)):
p = patch.object(target, name, side_effect=effect)
p.start()
self.addCleanup(p.stop)
def cache_left(self):
root = quest.cache_root()
return list(root.iterdir()) if root.is_dir() else []
def pulls(self, fake):
return [c[3] for c in fake.calls() if c[2:3] == ['pull']]
def test_single_apk_with_obb_and_data(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(files=OPENXR)],
obb={f'main.12.{PKG}.obb': b'main', f'patch.12.{PKG}.obb': b'patch', 'notes.txt': b'?'},
data={'files/save.dat': b'save'})
stages = []
result = quest.import_game(PKG, data=True, progress=lambda stage, percent=None: stages.append(percent))
self.assertEqual(self.order, [('install', 'base.apk', []), ('launch',), ('ready',),
('obb', [f'main.12.{PKG}.obb', f'patch.12.{PKG}.obb']),
('data', ['files/save.dat']), ('stop',)])
pulls = self.pulls(fake)
self.assertTrue(pulls[0].endswith('/base.apk')) # the APK is checked before the OBB is copied
self.assertEqual(pulls[1:], [f'/sdcard/Android/obb/{PKG}', f'/sdcard/Android/data/{PKG}'])
self.assertIn('notes.txt', ' '.join(result['warnings']))
self.assertEqual(len(result['obb']), 2)
self.assertEqual(stages, sorted(stages))
self.assertEqual(stages[-1], 100)
self.assertEqual(self.cache_left(), [])
def test_data_only_copied_when_asked_and_no_launch_without_data(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(files=OPENXR)], data={'files/save.dat': b'save'})
quest.import_game(PKG)
self.assertEqual(self.order, [('install', 'base.apk', [])])
self.assertEqual(len(self.pulls(fake)), 1)
def test_split_apk(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(split_required=True), apk_bytes(split='config.arm64_v8a', files=OPENXR)])
result = quest.import_game(PKG)
self.assertTrue(result['split'])
self.assertEqual(self.order, [('install', 'base.apk', ['01-split_config.part1.apk'])])
self.assertEqual(len(self.pulls(fake)), 2)
self.assertEqual(self.cache_left(), [])
def test_refusals_copy_nothing_more_and_clean_up(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''}))],
obb={f'main.1.{PKG}.obb': b'big'})
with self.assertRaisesRegex(quest.QuestError, 'licence'):
quest.import_game(PKG)
fake.add('org.example.old', [apk_bytes('org.example.old', files={'lib/armeabi-v7a/libgame.so': b''})],
obb={'main.1.org.example.old.obb': b'big'})
with self.assertRaisesRegex(quest.QuestError, '32-bit'):
quest.import_game('org.example.old')
self.assertEqual(self.order, [])
self.assertFalse([p for p in self.pulls(fake) if '/obb/' in p])
self.assertEqual(self.cache_left(), [])
def test_failures_clean_up(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(files=OPENXR)], obb={f'main.1.{PKG}.obb': b'main'})
fake.state['fail_pull'] = f'/sdcard/Android/obb/{PKG}'
fake.save()
with self.assertRaisesRegex(quest.QuestError, 'Connection reset'):
quest.import_game(PKG)
self.assertEqual(self.cache_left(), [])
fake.state.pop('fail_pull')
fake.save()
android.install_obb.side_effect = android.FrameError('checksum mismatch')
with self.assertRaisesRegex(quest.QuestError, 'Installed Quest Game, but copying its game data failed'):
quest.import_game(PKG)
self.assertEqual(self.order[-1], ('stop',)) # never left running half-set-up
self.assertEqual(self.cache_left(), [])
def test_not_on_quest_bad_names_and_one_at_a_time(self):
FakeQuest(self)
with self.assertRaisesRegex(quest.QuestError, "isn't installed on the Quest"):
quest.import_game(PKG)
with self.assertRaisesRegex(quest.QuestError, 'invalid package'):
quest.import_game('../etc; rm -rf')
with quest._import_lock:
with self.assertRaisesRegex(quest.QuestError, 'Another Quest import'):
quest.import_game(PKG)
def test_not_enough_space(self):
fake = FakeQuest(self)
fake.add(PKG, [apk_bytes(files=OPENXR)])
with patch.object(quest.shutil, 'disk_usage', return_value=SimpleNamespace(total=0, used=0, free=10)):
with self.assertRaisesRegex(quest.QuestError, 'GB free'):
quest.import_game(PKG)
self.assertEqual(self.cache_left(), [])
def test_leftovers_from_a_crash_are_swept(self):
old = quest.cache_root() / 'org.example.crashed-0000'
old.mkdir(parents=True)
os.utime(str(old), (1, 1))
fresh = quest.cache_root() / 'org.example.running-0000'
fresh.mkdir()
quest.sweep_cache()
self.assertFalse(old.exists())
self.assertTrue(fresh.exists())
fresh.rmdir()
class FrameSide(unittest.TestCase):
META = {'package': PKG, 'label': 'Quest Game', 'instance': 2800000001}
@unittest.skipUnless(os.name == 'posix', 'symlinks')
def test_push_data_streams_regular_files_into_the_instance(self):
import frame_android_data
import tarfile
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp) / PKG
(folder / 'files').mkdir(parents=True)
(folder / 'files/save.dat').write_bytes(b'save')
os.symlink('/etc/passwd', str(folder / 'files/link'))
got = {}
def stream(command, src=None, dst=None):
got['command'] = command
with tarfile.open(fileobj=io.BytesIO(src.read())) as tar:
got['members'] = {m.name: (m.type, m.uid) for m in tar.getmembers()}
with patch.object(frame_android_data, '_stream', side_effect=stream):
self.assertEqual(quest.push_data(self.META, folder, tmp), 1)
self.assertIn('podman exec -i lepton-steamlaunch-2800000001 /system/bin/sh -c', got['command'])
self.assertIn(f'cd /sdcard/Android/data/{PKG}', got['command'])
self.assertEqual(set(got['members']), {'files', 'files/save.dat'})
self.assertEqual(got['members']['files/save.dat'], (tarfile.REGTYPE, 0))
def test_wait_ready(self):
answers = iter(['', '0\n', '1\n'])
with patch.object(android, 'ssh', side_effect=lambda cmd, timeout=None: next(answers)) as ssh, \
patch.object(quest, '_sleep'):
quest._wait_ready(self.META)
self.assertEqual(ssh.call_count, 3)
self.assertIn('podman exec lepton-steamlaunch-2800000001', ssh.call_args[0][0])
with patch.object(android, 'ssh', return_value=''), patch.object(quest, '_sleep'), \
patch.object(quest, 'READY_TIMEOUT', 0):
with self.assertRaisesRegex(quest.QuestError, "didn't start on the Frame"):
quest._wait_ready(self.META)
class Server(unittest.TestCase):
@classmethod
def setUpClass(cls):
import server
cls.server = server
def test_import_request_validation(self):
s = self.server
for body in ({'action': 'nope'}, {'action': 'import', 'package': '../x'},
{'action': 'import', 'package': PKG, 'data': 'yes'},
{'action': 'import', 'package': PKG, 'serial': 'a b; rm'}):
with self.assertRaises(s.Failure) as e:
s.quest(body)
self.assertEqual(e.exception.status, 400, body)
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
self.assertEqual(s.quest({'action': 'import', 'package': PKG, 'serial': QUEST['serial'], 'data': True}),
{'job': 'j'})
self.assertTrue(job.call_args.kwargs['progress'])
with patch.object(s, 'LOCAL', True):
with self.assertRaisesRegex(s.Failure, 'runs on your computer'):
s.quest({'action': 'import', 'package': PKG})
self.assertIn('runs on your computer', s.quest_status('')['guidance'])
if __name__ == '__main__':
unittest.main()
+473
View File
@@ -0,0 +1,473 @@
"""Import from my Quest: copy games you own off your own Meta Quest onto the Frame.
The Quest is plugged into this computer by USB with developer mode on; its own
adb copies the APK (and split APKs), the OBB folder and, if asked, the external
app-data folder into a per-user cache. The APK is checked before anything goes
to the Frame, then installed through frame_android.install like any other APK,
and the cache is removed. Games that check their Meta store licence
(libovrplatformloader.so) are refused: that check needs Meta's services, and
Frame Control does not remove it or work around it. See docs/quest-import.md.
Python stdlib only. CLI: python3 ui/frame_quest.py
devices | list [--serial SERIAL] | import PACKAGE [--serial SERIAL] [--data]
"""
import json
import os
from pathlib import Path
import re
import secrets
import shlex
import shutil
import subprocess
import sys
import tarfile
import threading
import time
import zipfile
import frame_android as android
import frame_host
OBB = '/sdcard/Android/obb'
DATA = '/sdcard/Android/data'
OBB_NAME = r'(main|patch)\.[0-9]+\.{}\.obb'
# Serials of adb connections that aren't a USB headset: Frame Control's own SSH tunnels
# to the Frame's Lepton instances (127.0.0.1:<port>, 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/<package> 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()
+52
View File
@@ -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.</div>
</div>
<section class="panel" id="quest">
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
<div class="sub" id="questState" role="status" aria-live="polite">Plug your Quest into this computer by USB, put it on, then Look for my Quest.</div>
<label class="sub" style="display:block;margin-top:8px"><input type="checkbox" id="questData"> Also copy each game's files in Android/data (some games keep saves there)</label>
<progress id="questProgress" max="100" hidden aria-label="Import progress" style="width:100%;margin-top:8px"></progress>
<div class="list" id="questApps" style="margin-top:8px"></div>
<div class="hint">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. <a href="https://github.com/saphid/frame-control/blob/main/docs/quest-import.md" target="_blank" rel="noopener noreferrer">How it works</a></div>
</section>
<section class="panel disp" id="display">
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
@@ -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 `<div class="item"><div class="grow"><div class="t">${esc(a.package)}</div><div class="s">${esc(bits.join(" · "))}</div></div>
${installed.has(a.package) ? `<span class="tag">On the Frame</span>` : ""}
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
}).join("") : `<div class="sub">No games or apps you installed are on this Quest.</div>`;
}
$("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 = `<div class="sub">${esc(e.message)}</div>`; return; }
+45 -1
View File
@@ -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()