Compare commits

..
Author SHA1 Message Date
saphidandClaude Opus 5.5 9697812984 Merge origin/main into feat/quest-import
One conflict, in ui/frame_android.py where main added COPY_MIN_RATE (size-scaled
copy timeouts) beside the install lock this branch made reentrant: both kept,
the lock stays an RLock. ui/index.html and ui/server.py merged on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:22:56 +11:00
Alex Southwell 141b95ddde Merge pull request #89 from saphid/feat/headset-telemetry-id
Frame Control 0.4.5: count headsets, lighter Comfort checks, sturdier APK installs
2026-10-13 17:18:52 +11:00
saphidandClaude Opus 5.5 77c6c286cd Split merge: accept 64-bit data descriptors on small streamed ZIP entries
Streaming writers (Python's unseekable ZipFile, force_zip64) write ZIP64
data descriptors even for small files, so the descriptor width can't be
taken from the final sizes. The reader now accepts the signed or unsigned,
32- or 64-bit layout that matches the central directory and fits before
the next entry, preferring one that ends exactly there, then the width the
local header's ZIP64 metadata implies. Format tests cover streamed ZIP64
stored and deflated entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:55 +11:00
saphidandClaude Opus 5.5 ca53b26701 Quest import: fix round-3 review findings (legacy versions, strict ZIP, closing)
- Installs made before records kept a version code no longer get replaced by
  an older Quest copy: the installed code is read from app.apk on the Frame
  (stdlib parser, only the manifest crosses the link); if it can't be read,
  replacing needs allowDowngrade. The data-copy guard uses the same lookup.
- Split members are checked against both ZIP headers: same local name and
  method, local sizes and checksum (or a validated data descriptor, with
  ZIP64 extra fields understood) equal to the central directory, no overlap
  with the next entry, and no trailing bytes after a deflate stream.
- The game is closed in a finally block, so a local OSError or Ctrl-C part-way
  through a copy still closes it; the original exception is kept and a failed
  close is reported separately.
- running_instances(strict=True) fails closed (a failed `podman ps` raises),
  used by the Quest import; other callers keep the lenient listing.
- The dumpsys version check reads only the package's own block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:03 +11:00
saphidandClaude Opus 5.5 baec984449 Quest import: fix round-2 review findings (versions, locking, strict split reads)
- An older Quest copy no longer replaces a newer install on the Frame: installs
  now record versionCode, and a downgrade is refused unless allowed
  (--allow-downgrade, "allowDowngrade", or the panel's checkbox).
- The game-data copy only targets this package's own install record and
  instance at the Quest's exact version code, and checks Android's own
  dumpsys in that instance before copying.
- After the (possibly long) pulls the target is checked again, and the final
  checks, the transfer and closing the game hold the shared install lock,
  now reentrant so install_obb and install can be called under it.
- Closing the game is confirmed; a failed or incomplete close is reported
  instead of claiming the game was closed.
- Split members are read from their raw bytes: stored members must be exactly
  their declared size, deflated streams must end exactly there, then the
  checksum; forged sizes with prefix checksums are refused.
- A split's resource table counts as empty only if no type chunk defines any
  entry (bags included) and every chunk is understood.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:24:30 +11:00
saphidandClaude Opus 5.5 18e5053b64 Quest import: fix review findings (bounded split merge, no game launch, streamed data)
- Split merging only accepts splits holding lib/<abi>/*.so, assets/, their own
  signature/manifest and an empty resource table. Code (classes*.dex), other
  files, resource splits, feature splits and split manifests declaring
  components or libraries are refused before anything reaches the Frame.
- Merged bytes are bounded (512 MB per file, 1 GB in all) from the headers and
  again by bounded reads with a checksum, so a zip bomb or a forged header
  can't fill memory.
- Import never starts the game. The first import installs it; game data (OBB,
  Android/data) is copied by a second import while the person has the game
  open, never replacing an open game's APK, then the game is closed.
- Android/data is streamed as a tar straight into the SSH transfer, not
  staged; the space check counts split payloads uncompressed and data once.
- The panel shows small sizes in KB rather than "1 MB".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:02:42 +11:00
saphidandClaude Opus 5.5 7faf80fc2b 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>
2026-10-13 13:17:11 +11:00
saphidandClaude Opus 5.5 6ea20a2806 Android installs: accept split APKs by merging them into one APK
Lepton runs one APK per instance, so frame_android.install(base, splits=[...])
(and `frame_android.py install base.apk split.apk ...`) merges splits that add
native libraries or assets into the base, drops the manifest's isSplitRequired
flag, and re-signs with Frame Control's key. Density and language splits,
splits of another package and clashing entries are refused before anything
reaches the Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:17:11 +11:00
11 changed files with 2565 additions and 19 deletions

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 |
+6
View File
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
run at once alongside Lepton Development, each in its own container
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
Split APKs (a base plus `split_*.apk` files) install as one app:
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
APK, so splits that add native libraries or assets are merged into the base
and re-signed; density and language splits are refused. See
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
from the shortcut, stopped, and relaunched with their data intact. ADB and
Lepton Development aren't involved.
+195
View File
@@ -0,0 +1,195 @@
# 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.
A game with game data (an OBB folder, or Android/data when ticked) takes two
imports, because Android can only take that data while the game's Lepton
instance is running, and there is no way to start the instance without the
game. Frame Control never starts the game itself:
1. **Import** installs the game and says it has data to add.
2. Start the game from the Steam library and leave it on its first screen.
3. **Import** again. Frame Control sees the game open with the same version,
copies the data in, and closes the game. Start it again to play.
A game that quits straight away without its data can't take it this way.
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 (the APKs, a merged and a patched copy
of them counted uncompressed, the OBB folder and the Android/data folder
once, since that is streamed to the Frame), 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. If the game isn't open on the Frame: installs it through the normal APK
install (`frame_android.install`: VR detection, the launcher fix, the
OpenXR compatibility layer, artwork), and says whether it has game data
to add. An older Quest copy never replaces a newer version on the Frame
(compared by version code, which installs now record; for installs made
before that, it is read from the installed APK on the Frame) unless you
tick **Allow replacing a newer version** (`--allow-downgrade`,
`"allowDowngrade": true`). If the installed version can't be read at all,
that permission is needed too.
6. If the game is open on the Frame (the second import): refuses unless
Frame Control's install record is for this package, in this package's own
instance, at the Quest's exact version code (an open game is never
replaced), otherwise copies the OBB folder (and `Android/data` if ticked)
from the Quest, waits for Android to have finished 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))
once Android's own `dumpsys package` in that instance shows the same
package at the same version code,
streams the `Android/data` files in as a tar written straight into the SSH
transfer, then closes the game so its next start reads them, and checks
its instance has stopped; if it hasn't, or the check itself fails (a
failed `podman ps` never reads as "stopped"), the result says so. The game
is closed whatever happens during the copy, including a local read error
or Ctrl-C, and a failed close is reported without hiding the original
error. Everything
from the second check of the install record (after the pulls, which can
be long) to closing the game holds the same lock as installs and removals.
Other files in the OBB folder are listed as not copied.
7. 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, the game is
closed and the message says so; start it and 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. Only splits that hold nothing but
`lib/<abi>/*.so`, `assets/` and their own signature, manifest and an empty
resource table are merged. Refused, with the reason, before anything reaches
the Frame: splits with resources (screen density or language), code
(`classes*.dex`), any other file, a manifest that declares components or
libraries (`<service>`, `<uses-library>`...), feature splits, and splits of
another package. The merged files are held in memory while the APK is
repacked and signed (as for every patched APK), so a merge adds at most
512 MB per file and 1 GB in all. Each file is read from its raw bytes and
checked against both of its ZIP headers: the local header must name the same
file with the same method, sizes and checksum as the central directory (or a
data descriptor must carry them; ZIP64 sizes are understood), the data must
end before the next entry, a stored file must be exactly its declared size, a
compressed one must end exactly at the end of its data with nothing after it,
and its checksum must match. So a forged header can't truncate a file or fill
memory. A resource table counts as empty only
if it defines nothing at all (styles and other complex entries included). 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 two-step install then data copy
(the game is never started by Frame Control, never replaced while open, and
closed after its data is copied), split merging and signing, the split
refusals and size limits (including a forged zip bomb), the space estimate,
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), whether games stay open
without their OBB long enough for the second import, 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))
+1129
View File
File diff suppressed because it is too large. Load diff
+353 -17
View File
@@ -7,7 +7,7 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
Python stdlib only. CLI: python3 ui/frame_android.py
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
install APK [SPLIT.apk ...] [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
@@ -18,7 +18,7 @@ import frame_artwork
import frame_host
import tempfile
import zipfile
from frame_apk_vr import add_launcher_category
from frame_apk_vr import add_launcher_category, drop_split_required
from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -142,7 +142,9 @@ def check_installable(info):
"(or arm64, or universal) and install that instead")
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
# Installs are rare; one at a time avoids every race. Reentrant so a caller (a Quest import's
# final checks and data copy) can hold it around install_obb and install, which take it too.
_install_lock = threading.RLock()
# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
COPY_MIN_RATE = 512 * 1024
@@ -190,7 +192,303 @@ def _write_meta(d, meta):
install_hooks = []
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
# Split APK members that belong to the split itself, not the app: never merged.
SPLIT_OWN = re.compile(r'^(AndroidManifest\.xml|stamp-cert-sha256|META-INF/.*)$')
# What a mergeable split may add: native libraries and assets, nothing else.
SPLIT_ADDS = re.compile(r'^(lib/[^/]+/[^/]+\.so|assets/.*[^/])$')
# Manifest elements a config or install-time asset split carries. Anything else (an
# activity, service, uses-library...) belongs to a feature split, which needs its manifest.
SPLIT_TAGS = {'manifest', 'application', 'uses-sdk', 'module', 'delivery', 'install-time', 'fusing'}
# Every merged byte is held in memory while the APK is repacked and signed (as for every
# patched APK), so splits are bounded by what's actually inflated, not only the headers.
MAX_SPLIT_MEMBER = 512 * 1024**2
MAX_SPLIT_TOTAL = 1024**3
def _split_manifest(path, name):
"""(package, split name) from a split APK's manifest, refusing splits that declare
anything the merged app would lose with that manifest."""
with zipfile.ZipFile(path) as z:
elements = frame_apk.manifest_elements(frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
attrs = next((a for tag, a in elements if tag == 'manifest'), {})
split = (attrs.get('split') or (None, None, None))[2]
feature = attrs.get('isFeatureSplit')
if feature and feature[1]:
raise FrameError(f'{name} ({split or "split"}) is a feature split with its own code and manifest; '
'Frame Control can only merge splits that add native libraries or assets')
extra = sorted({tag for tag, _ in elements if tag not in SPLIT_TAGS})
if extra:
raise FrameError(f'{name} ({split or "split"}) declares <{">, <".join(extra)}> in its manifest; '
'Frame Control can only merge splits that add native libraries or assets')
return (attrs.get('package') or (None, None, None))[2], split
def split_entries(base, splits):
"""What merging splits into base adds: {entry name: (split path, entry name, size)}.
Lepton runs one APK per instance, so a split app is merged into one APK. Only splits
that add native libraries (the arm64 ABI split) or install-time assets can be: a split
with code, components or resources (screen density, language) needs its own manifest
or resource table, which Frame Control doesn't merge, so those are refused."""
try:
with zipfile.ZipFile(base) as z:
taken = set(z.namelist())
package = apk_info(base)['package']
plan, total = {}, 0
for path in splits:
name = os.path.basename(path)
split_pkg, split_name = _split_manifest(path, name)
label = f'{name} ({split_name or "split"})'
if split_pkg != package:
raise FrameError(f'{name} is not part of {package} (it says {split_pkg or "nothing"})')
with zipfile.ZipFile(path) as z:
for member in z.infolist():
entry = member.filename
if entry.endswith('/') or SPLIT_OWN.match(entry):
continue
if entry == 'resources.arsc':
# A config split for native code may carry an empty table; any resource needs merging.
if not resources_empty(frame_apk._read(z, entry, frame_apk.MAX_ARSC)):
raise FrameError(f'{label} holds screen-density or language resources; Frame Control '
'can only merge splits that add native libraries or assets')
continue
if entry.startswith('res/'):
raise FrameError(f'{label} holds screen-density or language resources ({entry}); Frame '
'Control can only merge splits that add native libraries or assets')
if re.match(r'^classes\d*\.dex$', entry):
raise FrameError(f'{label} contains code ({entry}); Frame Control can only merge splits '
'that add native libraries or assets')
if not SPLIT_ADDS.match(entry):
raise FrameError(f'{label} contains {entry}; Frame Control can only merge splits that '
'add native libraries or assets')
if entry in taken:
raise FrameError(f'{name} and another part of the app both contain {entry}')
if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise FrameError(f'{entry} in {name} uses a compression Android does not')
if member.file_size > MAX_SPLIT_MEMBER:
raise FrameError(f'{entry} in {name} is {member.file_size / 1024**2:.0f} MB, more than '
f'Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
total += member.file_size
if total > MAX_SPLIT_TOTAL:
raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB, more than '
'Frame Control merges')
taken.add(entry)
plan[entry] = (path, entry, member.file_size)
return plan
except (OSError, zipfile.BadZipFile, struct.error, IndexError, frame_apk.ApkError) as e:
raise FrameError(f'could not read the split APKs: {e}') from e
def resources_empty(data):
"""True only for a resource table that provably defines nothing: every type chunk's
entries absent. Bags (styles, plurals) count, and anything not understood counts too."""
def covered(start, end):
chunks, at = [], start
for chunk in frame_apk._chunks(data, start, end):
if chunk[2] != at:
return None
chunks.append(chunk)
at = chunk[2] + chunk[3]
return chunks if at == end else None
try:
if len(data) < 12 or struct.unpack_from('<H', data, 0)[0] != 0x0002:
return False
top = covered(struct.unpack_from('<H', data, 2)[0], len(data))
if top is None:
return False
for ctype, _, off, size in top:
if ctype == 0x0001: # the global string pool
continue
if ctype != 0x0200:
return False
inner = covered(off + struct.unpack_from('<H', data, off + 2)[0], off + size)
if inner is None:
return False
for kind, hs, t, _ in inner:
if kind in (0x0001, 0x0202): # type and key names, type specs
continue
if kind != 0x0201:
return False
flags, count = struct.unpack_from('<xBxxI', data, t + 8)
if flags & 0x01: # sparse: only present entries are listed
if count:
return False
elif flags & 0x02:
if any(o != 0xffff for o in struct.unpack_from(f'<{count}H', data, t + hs)):
return False
elif any(o != 0xffffffff for o in struct.unpack_from(f'<{count}I', data, t + hs)):
return False
return True
except (struct.error, IndexError):
return False
def _zip64_sizes(extra, usize, csize):
"""(uncompressed, compressed) with 0xffffffff fields taken from a ZIP64 extra field."""
at = 0
while at + 4 <= len(extra):
kind, length = struct.unpack_from('<HH', extra, at)
body = extra[at + 4:at + 4 + length]
if kind == 0x0001:
values, pos = [], 0
for field in (usize, csize):
if field == 0xffffffff:
if pos + 8 > len(body):
raise FrameError('truncated ZIP64 extra field')
values.append(struct.unpack_from('<Q', body, pos)[0])
pos += 8
else:
values.append(field)
return tuple(values)
at += 4 + length
if 0xffffffff in (usize, csize):
raise FrameError('ZIP64 sizes without a ZIP64 extra field')
return usize, csize
def _has_zip64_extra(extra):
at = 0
while at + 4 <= len(extra):
kind, length = struct.unpack_from('<HH', extra, at)
if kind == 0x0001:
return True
at += 4 + length
return False
def read_member(path, member):
"""A split member's bytes, read from its raw data and checked against both headers.
The local header must name the same file, with the same method and (unless a data
descriptor carries them, which is then checked) the same sizes and checksum as the
central directory; the data must end before the next member; a stored member must be
exactly its size and a deflated stream must end exactly at the end of its data, with
nothing after it. Output is bounded by the declared size: never trusts zipfile, which
stops early at a smaller declared size."""
with zipfile.ZipFile(path) as z:
info = z.getinfo(member)
starts = sorted({i.header_offset for i in z.infolist()} | {z.start_dir})
name, size = f'{member} in {os.path.basename(path)}', info.file_size
if info.flag_bits & 1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
raise FrameError(f'{name} is encrypted or uses a compression Android does not')
if size > MAX_SPLIT_MEMBER or info.compress_size > MAX_SPLIT_MEMBER:
raise FrameError(f'{name} is larger than Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
following = next((o for o in starts if o > info.header_offset), None)
with open(path, 'rb') as raw:
raw.seek(info.header_offset)
header = raw.read(30)
if len(header) != 30 or header[:4] != b'PK\3\4':
raise FrameError(f'{name}: invalid local ZIP header')
flags, method, crc, csize, usize, nl, el = struct.unpack_from('<2xHH4xIIIHH', header, 4)
local_name, extra = raw.read(nl), raw.read(el)
if local_name != info.orig_filename.encode('utf-8' if flags & 0x800 else 'cp437', 'surrogateescape'):
raise FrameError(f'{name}: the local header names {local_name.decode("utf-8", "replace")!r}')
if method != info.compress_type or (flags & 0x08) != (info.flag_bits & 0x08):
raise FrameError(f'{name} does not match its header (local and central entries differ)')
start = info.header_offset + 30 + nl + el
compressed = raw.read(info.compress_size)
if len(compressed) != info.compress_size:
raise FrameError(f'{name} is truncated')
if flags & 0x08:
# Sizes and checksum follow the data: (signature,) crc, then sizes of 4 bytes each,
# or 8 for ZIP64. Streaming writers use the ZIP64 form even for small files when
# sizes weren't known up front, so the width isn't implied by the final sizes: take
# the layout that matches the central directory and fits before the next entry,
# preferring one that ends exactly there, then the width the local header implies.
zip64 = 0xffffffff in (csize, usize) or _has_zip64_extra(extra)
tail = raw.read(24)
if len(tail) < 12:
raise FrameError(f'{name}: truncated data descriptor')
end = start + info.compress_size
fits = []
for signed in (True, False):
if signed and tail[:4] != b'PK\7\x08':
continue
for width in (20, 12):
body = tail[4:4 + width] if signed else tail[:width]
if len(body) < width:
continue
values = struct.unpack_from('<IQQ' if width == 20 else '<III', body)
length = width + (4 if signed else 0)
if values == (info.CRC, info.compress_size, size) and \
(following is None or end + length <= following):
exact = following is not None and end + length == following
fits.append((not exact, (width == 20) != zip64, length))
if not fits:
raise FrameError(f'{name} does not match its header (local and central entries differ, '
'or its data descriptor is truncated)')
crc, csize, usize = info.CRC, info.compress_size, size
descriptor = min(fits)[2]
else:
usize, csize = _zip64_sizes(extra, usize, csize)
descriptor = 0
if (crc, csize, usize) != (info.CRC, info.compress_size, size):
raise FrameError(f'{name} does not match its header (local and central entries differ)')
if following is not None and start + info.compress_size + descriptor > following:
raise FrameError(f'{name} overlaps the next ZIP entry')
if info.compress_type == zipfile.ZIP_STORED:
if info.compress_size != size:
raise FrameError(f'{name} does not match its header (stored {info.compress_size} bytes, says {size})')
data = compressed
else:
inflate = zlib.decompressobj(-15)
try:
data = inflate.decompress(compressed, size + 1)
except zlib.error as e:
raise FrameError(f'{name} is corrupt: {e}') from e
if len(data) != size or not inflate.eof or inflate.unconsumed_tail or inflate.unused_data:
raise FrameError(f'{name} does not match its header (its compressed data is not exactly '
f'{size} bytes with nothing after it)')
if zlib.crc32(data) != info.CRC:
raise FrameError(f'{name} does not match its checksum')
return data
def merge_splits(base, splits, dst):
"""One installable APK from a base APK and its splits (see split_entries), re-signed
with Frame Control's key, without the base's isSplitRequired flag."""
plan = split_entries(base, splits)
add, total = {}, 0
try:
for entry, (path, member, size) in plan.items():
data = read_member(path, member)
total += len(data)
if total > MAX_SPLIT_TOTAL:
raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB')
add[entry] = data
with zipfile.ZipFile(base) as z:
original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
manifest = drop_split_required(original)
repack(base, dst, replace={'AndroidManifest.xml': manifest} if manifest != original else None, add=add)
except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
raise FrameError(f'could not merge the split APKs: {e}') from e
return {'merged': sorted(os.path.basename(p) for p in splits), 'entries': len(plan)}
def split_size(splits):
"""Bytes a merge adds, from the split headers (merge_splits enforces the real sizes)."""
total = 0
for path in splits:
with zipfile.ZipFile(path) as z:
total += sum(m.file_size for m in z.infolist() if SPLIT_ADDS.match(m.filename))
return total
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None,
splits=None):
"""splits: the rest of a split APK (base first, as apk_path); merged into one APK first."""
if splits:
start = time.time()
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
merged = os.path.join(tmp, 'merged.apk')
try:
merge_splits(apk_path, list(splits), merged)
except Exception as e: # reported like any other failed install
_after_install(None, None, e, start)
raise
meta = install(merged, flatscreen, name, source or os.path.basename(apk_path), icon_png, xr_compat, artwork)
return meta
start, info = time.time(), None
try:
info = apk_info(apk_path)
@@ -273,6 +571,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
'source': source or os.path.basename(apk_path)})
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'version_code': info.get('version_code'),
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
@@ -359,6 +658,34 @@ except (OSError, ValueError, TypeError, AttributeError):
"""
def _apk_parser_script():
"""Frame-side Python preamble that loads this copy's stdlib APK parser (frame_apk)."""
script = 'import sys, types, json, base64\n'
for module in ('frame_apk', 'frame_apk_vr'):
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
source = f.read()
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
return script
def installed_version_code(pkg):
"""The version code of pkg's installed app.apk, read on the Frame (only its manifest
crosses the link), or None if there is no readable APK. Connection failures raise."""
if not PKG_RE.match(pkg or ''):
raise FrameError(f'bad package name {pkg!r}')
script = _apk_parser_script() + f"""try:
code = sys.modules['frame_apk'].apk_info({(APPS_DIR + '/' + pkg + '/app.apk')!r}).get('version_code')
except Exception:
code = None
print(json.dumps(code if isinstance(code, int) else None))
"""
try:
code = json.loads(ssh('python3 -', input=script, timeout=120).strip().splitlines()[-1])
except (ValueError, IndexError):
return None
return code if isinstance(code, int) and not isinstance(code, bool) else None
def art_missing(m):
"""True when a Frame Control install has no complete Steam artwork on record."""
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
@@ -380,13 +707,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
m = _meta_or_fail(pkg)
d = f'{APPS_DIR}/{pkg}'
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
modules = {}
for module in ('frame_apk', 'frame_apk_vr'):
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
modules[module] = f.read()
script = 'import sys, types, json, base64\n'
for module, source in modules.items():
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
script = _apk_parser_script()
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
@@ -447,10 +768,22 @@ def _clean_meta(m):
return m
def running_instances():
"""Lepton container name -> adb port, for the instances that are running now."""
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
def running_instances(strict=False):
"""Lepton container name -> adb port, for the instances that are running now.
strict: a failed `podman ps` raises FrameError instead of reading as "nothing running",
and containers without an adb port label count too (port None); for callers that must
not mistake an error for a stopped instance."""
if not strict:
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}"')
found = {}
for line in out.splitlines():
cols = line.split()
if cols:
found[cols[0]] = cols[1] if len(cols) > 1 and cols[1] != '<no' else None
return found
def list_apps():
@@ -599,8 +932,11 @@ def main():
r = frame_apk_versions.alternatives(
info['package'] if info else args[0], info.get('version_code') if info else None)
elif cmd == 'install':
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
xr_compat=False if '--no-xr-compat' in args else None)
files = [a for a in args if not a.startswith('--')]
if not files:
raise FrameError('install requires APK [SPLIT.apk ...]')
r = install(files[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
xr_compat=False if '--no-xr-compat' in args else None, splits=files[1:])
elif cmd == 'refresh-art':
if len(args) != 1:
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
+43
View File
@@ -126,3 +126,46 @@ def detection(data, names):
issues.append('Uses OpenXR (good).')
info['vr_issues'] = issues
return info
IS_SPLIT_REQUIRED = 0x01010591 # android:isSplitRequired
def drop_split_required(axml_bytes):
"""The manifest without <application android:isSplitRequired>, for a base APK whose
splits were merged into it: Android refuses a lone base that still says it needs them."""
chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes)))
pool = next((c for c in chunks if c[0] == 1), None)
if pool is None:
raise frame_apk.ApkError('AndroidManifest.xml has no string pool')
strings = frame_apk._string_pool(axml_bytes, pool[2])
resmap = ()
for kind, header_size, offset, chunk_size in chunks:
if kind == 0x180:
resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4), axml_bytes, offset + header_size)
result, changed = bytearray(axml_bytes[:8]), False
for kind, hs, off, size in chunks:
chunk = bytearray(axml_bytes[off:off + size])
if kind == 0x0102 and not changed:
name, astart, asize, count = struct.unpack_from('<4xIHHH', chunk, hs)
if name < len(strings) and strings[name] == 'application':
for i in range(count):
a = hs + astart + i * asize
attr = struct.unpack_from('<I', chunk, a + 4)[0]
known = resmap[attr] if attr < len(resmap) else None
if known == IS_SPLIT_REQUIRED or (known is None and attr < len(strings)
and strings[attr] == 'isSplitRequired'):
del chunk[a:a + asize]
struct.pack_into('<I', chunk, 4, len(chunk))
struct.pack_into('<H', chunk, hs + 12, count - 1)
# id/class/style attribute indexes are 1-based positions in the list.
for at in (hs + 14, hs + 16, hs + 18):
index = struct.unpack_from('<H', chunk, at)[0]
struct.pack_into('<H', chunk, at, 0 if index == i + 1 else index - 1 if index > i + 1 else index)
changed = True
break
result += chunk
if not changed:
return axml_bytes
struct.pack_into('<I', result, 4, len(result))
return bytes(result)
+630
View File
@@ -0,0 +1,630 @@
"""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) 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. Game data (the OBB folder and, if asked, the external
app-data folder) is copied by a second import while the person has the game
open, since only a running Lepton instance can take it; Frame Control never
starts the game itself. The cache is always 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] [--allow-downgrade]
"""
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'],
'version_code': info.get('version_code'), '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):
"""Stream a pulled /sdcard/Android/data/<package> folder into the running instance as a
tar, written straight into the SSH transfer: nothing is staged on this computer."""
import frame_android_data
pkg = meta['package']
container = f"lepton-steamlaunch-{int(meta['instance'])}"
script = f'set -eu; mkdir -p {DATA}/{pkg}; cd {DATA}/{pkg}; tar -x -o -f -'
read_end, write_end = os.pipe()
state = {'count': 0, 'error': None}
def write():
try:
with os.fdopen(write_end, 'wb') as out, \
tarfile.open(fileobj=out, mode='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)
state['count'] += 1
else:
tar.addfile(info)
except (OSError, tarfile.TarError, ValueError) as e: # ValueError: the reader closed first
state['error'] = e
writer = threading.Thread(target=write, daemon=True)
writer.start()
try:
with os.fdopen(read_end, 'rb') as src:
frame_android_data._stream(shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script]),
src=src)
finally:
writer.join(60)
if state['error']:
raise QuestError(f'could not read the app files copied from the Quest: {state["error"]}')
return state['count']
def space_needed(apk_bytes, merged_bytes, obb_bytes, data_bytes):
"""Bytes an import needs free on this computer: the APKs as copied, a merged APK and the
install's patched copy of it (each up to merged_bytes), the OBB folder and the
Android/data folder (streamed to the Frame, never staged twice)."""
return apk_bytes + 2 * merged_bytes + obb_bytes + data_bytes
def _check_space(work, need, pkg):
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})')
def import_game(package_name, serial=None, data=False, progress=None, allow_downgrade=False):
"""Copy package_name from the Quest to the Frame. progress(stage, percent).
Frame Control never starts the game itself. Its game data (OBB, and Android/data if
asked) can only be written into a running Lepton instance, and there is no way to
boot one without its game, so that's a second import: the person starts the game,
imports again, and Frame Control copies the data in and closes the game.
allow_downgrade: replace a newer version on the Frame with the Quest's older one."""
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, allow_downgrade)
finally:
shutil.rmtree(work, ignore_errors=True)
finally:
_import_lock.release()
def _running(pkg):
# strict: a failed `podman ps` raises, never reads as "not running".
return f'lepton-steamlaunch-{android.instance_id(pkg)}' in android.running_instances(strict=True)
def _installed_code(pkg, meta):
"""The installed version code: from the install record, else (installs made before
records kept it) read from the installed app.apk on the Frame; None if unknown."""
code = (meta or {}).get('version_code')
if isinstance(code, int) and not isinstance(code, bool):
return code
return android.installed_version_code(pkg) if meta else None
def _version(name, code):
return ' '.join(x for x in (f'version {name}' if name else '', f'code {code}' if code is not None else 'unknown code') if x)
def _check_downgrade(existing, checked, allow):
"""Refuse to replace a newer install on the Frame with the Quest's older copy."""
if not existing or allow:
return
have, new = _installed_code(existing['package'], existing), checked['version_code']
if have is None or new is None:
raise QuestError(f"Frame Control can't tell which version of {checked['label']} is on the Frame, so it "
"won't replace it in case that one is newer. Allow replacing a newer version (or "
"--allow-downgrade) to install your Quest's copy anyway.")
if int(new) < int(have):
raise QuestError(f"The Frame has a newer {checked['label']} ({_version(existing.get('version'), have)}) than "
f"your Quest ({_version(checked['version'], new)}). Frame Control won't replace it with "
"an older version unless you allow it (Allow replacing a newer version, or "
"--allow-downgrade).")
def _check_target(pkg, meta, checked):
"""The install record of the game open on the Frame, if it is this package, in its own
instance, at the Quest's exact version code; else a QuestError."""
label = checked['label']
if (not meta or meta.get('package') != pkg or android._int(meta.get('instance')) != android.instance_id(pkg)):
raise QuestError(f"{label} is open on the Frame, but Frame Control has no matching install record for "
'it. Close it, then import again to install it.')
have, new = _installed_code(pkg, meta), checked['version_code']
if have is None or new is None or int(have) != int(new):
raise QuestError(f"{label} is open on the Frame with a different version (Frame: "
f"{_version(meta.get('version'), have)}; Quest: {_version(checked['version'], new)}). "
"Close it, then import again to install the Quest's version.")
return meta
def _check_android_package(meta, code):
"""The package Android itself has installed in the instance is this one, at this version."""
pkg = meta['package']
container = f"lepton-steamlaunch-{int(meta['instance'])}"
out = android.ssh(f"podman exec {container} /system/bin/sh -c 'dumpsys package {pkg}' 2>/dev/null || true",
timeout=60)
# Only this package's own block (dumpsys can list other packages too).
block = re.search(r'^\s*Package \[' + re.escape(pkg) + r'\] \(.*?(?=^\s*Package \[|\Z)', out, re.M | re.S)
found = re.search(r'\bversionCode=(\d+)', block.group(0)) if block else None
if not found:
raise QuestError(f'Android in {container} does not have {pkg} installed')
if int(found.group(1)) != int(code):
raise QuestError(f'Android in {container} has {pkg} version code {found.group(1)}, not the '
f"Quest's {code}")
def _close(pkg):
"""Stop the game and confirm its instance is gone: (closed, why not)."""
try:
android.stop(pkg)
for _ in range(6):
if not _running(pkg):
return True, None
_sleep(2)
return False, 'it is still running'
except Exception as e: # never replaces the error that led here; unconfirmed counts as not closed
return False, str(e) or type(e).__name__
def _import(serial, app, data, work, report, allow_downgrade=False):
pkg = app['package']
obb_bytes = app['obb'] or 0
data_bytes = (app['data'] or 0) if data else 0
apk_bytes = _remote_sizes(serial, app['apks'])
_check_space(work, space_needed(apk_bytes, apk_bytes, obb_bytes, data_bytes), pkg)
# 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'])
label = checked['label']
# A merge stores what splits compressed: count it uncompressed, now the files are here.
merged = base.stat().st_size + android.split_size([str(p) for p in splits])
_check_space(work, space_needed(0, merged, obb_bytes, data_bytes), pkg)
wants_data = app['obb'] is not None or bool(data and app['data'] is not None)
if not _running(pkg):
# 2a. Install. The game is never started here: see import_game. Checked and installed
# under the shared install lock, so nothing installs or removes it in between.
report('Installing on the Frame', 60)
with android._install_lock:
if _running(pkg):
raise QuestError(f'{label} was started on the Frame during the import. Close it, then import again.')
_check_downgrade(android.read_meta(pkg), checked, allow_downgrade)
meta = android.install(str(base), splits=[str(p) for p in splits], source='Meta Quest (USB)')
report('Done', 100)
message = f"Imported {meta['label']} from your Quest. It's in the Steam library."
if wants_data:
what = ' and '.join(x for x in ('its OBB game data' if app['obb'] is not None else '',
'its Android/data files' if data and app['data'] is not None else '') if x)
message += (f" To add {what}, start {meta['label']} from the Steam library, leave it on its first "
"screen, then import it again here: Frame Control copies the data in and closes the game.")
note = android.layer_note(meta)
return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, 'obb': [],
'files': 0, 'warnings': warnings, 'split': bool(splits), 'needs_game_data': wants_data,
'closed': None}
# 2b. The game is open on the Frame: add its data to exactly this install, never replacing it.
_check_target(pkg, android.read_meta(pkg), checked) # early, before copying the data off the Quest
if not wants_data:
raise QuestError(f"{label} is already installed and open on the Frame, and has no game data to add. "
'Close it first to install it again.')
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
copied_obb, copied_files, failure = [], 0, None
# The pulls can take a long time: check the target again, and hold the shared install
# lock (install, remove and install_obb take it too) until the game is closed.
with android._install_lock:
if not _running(pkg):
raise QuestError(f'{label} was closed on the Frame during the import. Start it, then import again.')
meta = _check_target(pkg, android.read_meta(pkg), checked)
closed, why = None, None
try:
report('Waiting for Android on the Frame', 70)
_wait_ready(meta)
_check_android_package(meta, checked['version_code'])
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)
except Exception as e: # any failure, local (OSError) or on the Frame, is reported below
failure = e
finally:
# Whatever happened, even Ctrl-C part-way through a write, the game is closed so it
# never runs on half-copied data. A failed close never hides the original error.
interrupted = sys.exc_info()[0] is not None
closed, why = _close(pkg)
if interrupted and not closed:
print(f"Frame Control couldn't close {meta['label']} on the Frame ({why}); close it from Steam "
'before playing.', file=sys.stderr)
if failure:
message = f"Copying {meta['label']}'s game data failed: {failure}."
message += (' The game was closed; start it and import again to retry.' if closed else
f" Frame Control also couldn't close the game ({why}): close it from Steam, then start it "
'and import again.')
raise QuestError(message) from failure
report('Done', 100)
message = f"Added {meta['label']}'s game data from your Quest"
message += ('; the game is closed, start it again from the Steam library.' if closed else
f", but Frame Control couldn't close the game ({why}). Close it from Steam before playing, so it "
'starts with the new data.')
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 ""}.'
return {'message': ' '.join([message, *warnings]), 'app': meta, 'obb': copied_obb, 'files': copied_files,
'warnings': warnings, 'split': bool(splits), 'needs_game_data': False, 'closed': closed}
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] [--allow-downgrade]')
r = import_game(names[0], serial, data='--data' in args, allow_downgrade='--allow-downgrade' 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()
+60
View File
@@ -958,6 +958,20 @@
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>
<label class="sub" style="display:block;margin-top:4px"><input type="checkbox" id="questOlder"> Allow replacing a newer version on the Frame with the Quest's older one</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. A game with
OBB data takes two imports: the first installs it; start it from the Steam library, then import again to
copy its data in. Frame Control never starts the game itself. 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>
@@ -1435,6 +1449,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`;
@@ -3329,6 +3344,51 @@ 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) {
if (bytes == null) return "";
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
if (bytes >= 1048576) return `${Math.round(bytes / 1048576)} MB`;
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
}
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, allowDowngrade: $("questOlder").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; }
+46 -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
@@ -1325,6 +1326,48 @@ 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, older = quest_serial(body.get("serial")), body.get("data", False), body.get("allowDowngrade", False)
if type(data) is not bool or type(older) is not bool:
raise Failure("data and allowDowngrade 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,
allow_downgrade=older), 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"}
@@ -2237,7 +2280,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,
@@ -2456,6 +2499,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()