mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-14 08:01:24 +02:00
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:
1 parent
6ea20a2806
commit
7faf80fc2b
8 files changed
+1332
-2
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user