Add OBB transfers, private app-data backups and SideQuest source policy

Borrow expansion-file and save-management features with offline verification. Keep SideQuest page-only under its current access terms; document research, integration limits and device acceptance gaps.

Co-Authored-By: GPT-6 Astra <noreply@openai.com>
This commit is contained in:
saphidandGPT-6 Astra committed 2026-09-28 21:05:59 +10:00
1 parent 268afccf05
commit f4dbb1180c
10 files changed
+809

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# SideQuest implementation notes
2026-09-28. Worktree steam-frame-sidequest, branch sidequest. No delegation,
Frame mutations, installs, launches, pushes or issue edits.
- Read shared source interface, APK/VR docs, catalogue README and Python backend.
- SideQuest robots: crawl delay 3; disallow /search/, /user/*, /sideload/*.
- Current /terms Angular text (main-4MMXZRXL.js): Prohibited Activities (i)
prohibits scraping; (xi) limits access to provided/authorised technologies;
(xii) forbids bypass. Public API address is not permission for a third-party
integration. Page-only source; no automated store downloads or metadata crawl.
- api.sidequestvr.com/robots.txt returned HTTP 403. First shared JS chunk also
returned 403. No attempt to bypass either response.
- Public SideQuest desktop source cloned inside .claude/research for inspection.
/install-from-key takes a website-issued token and returns apps[].urls[] with
provider APK/OBB/Github Release/Mod and link_url. Do not reproduce token flow.
- Two SSH read-only attempts to frame timed out (exit 255). Exact host-side
/sdcard mapping cannot be claimed. internal/<package> is private app data,
not evidence of an OBB mapping. Use the running container's /sdcard path for
OBB writes, without launching it; backup only documented internal/<package>.
- Scope: OBB helper/CLI, private-data backup/restore helper/CLI, compliant
SideQuest page-only source. No search UI, artwork installer or install edits.
- Cross-provider review not launched: task explicitly forbids delegation;
parent brief reserves integration and review for the parent.
## Implementation and verification
- Added ui/frame_android_data.py and frame/android/app-data.py; additive wrappers
and CLI branches only in frame_android.py (install/_install unchanged).
- OBB transfers to an already-running named container, SHA-256 check before
per-file rename. No claimed host sdcard mapping or device persistence.
- Backup/restore covers private internal/<package> only, requires a stopped
instance, uses podman unshare, validates archive paths/types/package/instance,
preserves numeric owners/modes, retains the prior data directory on restore.
- SideQuest adapter intentionally raises a page-only SourceError on search and
download; details gives a numeric listing page with unknown facts, images
schema and downloadable=False. Needs aggregate search to surface the error.
- docs/sidequest.md contains source links, feature comparison, command examples,
terms/robots findings and outstanding device/API questions.
- Fixture tests/fixtures/sidequest-policy.json records observed policy excerpts;
no API response is fabricated.
- `python3 -m unittest discover -s tests`: final run 181 tests, OK (exit 0).
Includes real local shell execution of the OBB checksum/publish sequence,
rejecting a changed input without replacing the previous file; archive
round-trip/retained previous save, 0600 backup, malformed archive rejection.
No test contacts the network or Frame.
- `ast.parse(..., feature_version=(3,9))`: four implementation files passed.
Runtime python3 is Xcode Python 3.9.
- `python3 ui/frame_android.py install-obb` and `... backup-data`: both exit 1
with the intended required-arguments error, without contacting Frame.
- `git diff --check`: passed before commit.
- No SideQuest game downloaded and no `info <download>` run: terms blocked that
requested E2E. No Frame app was installed or launched; no live OBB, namespace
ownership or restore acceptance test. Independent review reserved for parent,
per this task's explicit no-delegation instruction.
+8
View File
@@ -325,3 +325,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably
don't.
## Expansion files and save backups
SideQuest-inspired CLI helpers install local OBB files into an already-running
app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
+142
View File
@@ -0,0 +1,142 @@
# SideQuest and Frame Control
Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop
sideloading/device-management app. Its Quest labels are **not** evidence that a
game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements,
VrApi and Meta services (see [VR APKs](vr-apks.md)).
## Features worth borrowing
Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb),
especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts),
[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts),
and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts).
Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular
bundle `main-4MMXZRXL.js`, inspected locally without browser automation.
No SideQuest implementation code was copied.
| SideQuest feature | Frame Control before this change | Borrow? / effort |
|---|---|---|
| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. |
| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. |
| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. |
| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. |
| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
Priority: expansion files, then save backup/restore. Rich discovery and update
notices follow once a permitted metadata source and source/version persistence
are available. This patch deliberately exposes CLI/backend operations, leaving
shared UI, install(), Steam artwork and launch behavior to sibling work.
## SideQuest as a source: page-only
[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits
copying/distributing/disclosing the Service including automated or non-automated
“scraping”; (xi) prohibits content access through means other than those provided
or authorised by the Service; (xii) prohibits bypassing access restrictions.
The terms describe downloading developer-posted games through the Service, but
do not establish permission for this third-party API integration.
[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl
delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission
would not override the terms. The API host's robots request returned HTTP 403;
a request for the first shared website JS chunk also returned 403. No bypass,
account token, cookies, browser session or private endpoint was used.
The homepage publishes `https://api.sidequestvr.com` and
`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and
`getApp(id, null)`; their actual HTTP search/detail routes could not be established
from the retrieved bundle. Do not invent endpoints. The open-source desktop
[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts)
POSTs `{token: ...}` to `/install-from-key`. It consumes
`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`,
`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key
flow, not evidence of an anonymous download API. It is not implemented here.
`ui/apk_sources/sidequest.py` implements the shared interface conservatively:
- `sources()` marks SideQuest `page_only` and explains why.
- `search()` raises a user-readable `SourceError` with the browse URL (zero
limit returns no rows). It does not invent app results or report a false
“no matching games”. The aggregate search UI should surface this source error.
- `details()` accepts a numeric listing id and returns its canonical page link,
`downloadable: False`, empty versions/tags/headsets and the `images` shape
`{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
unknown facts, including free/VR status, stay `None`.
- `download()` refuses with that page link. Paid/external listings cannot be
downloaded by this adapter either. No downloads means no verification claim.
The JSON fixture records policy evidence, **not a purported live app response**.
No listing metadata, artwork URLs, or OBB download URLs were scraped.
The requested real SideQuest → OpenXR APK → `frame_android.py info` test is
**blocked by the terms**, and was not performed. No alternate source is silently
substituted. A future integration needs SideQuest's permission or an expressly
supported third-party API, plus recorded search/detail/download fixtures,
free/direct-download classification, and size/hash verification. A calculated
local SHA-256 alone must not be called publisher verification.
## OBB files
```sh
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
```
Install the APK first. The named instance must already be running; the helper
never launches an app or uses Lepton Development. It requires standard
`main|patch.<versionCode>.<package>.obb` filenames and nonempty files, validates
the entire batch before transfer, streams each file through SSH into that
instance, checks its SHA-256 **inside Android**, then renames it into
`/sdcard/Android/obb/<package>/`. `verified: True` here means transfer integrity
against the local input, not publisher authentication. Publication is atomic per
file, not for the whole batch; retry after a partial batch failure. Existing OBBs
with different version codes remain. The filename version must match the game;
the current install metadata does not expose its version code for comparison.
Restart the game yourself after the transfer if it cached missing expansion data.
Both read-only SSH attempts to the Frame timed out. Therefore the exact
host-side `/sdcard` mapping and persistence of expansion data were **not verified**.
`compatdata/<instance>/internal/<package>` is documented as `/data/data/<package>`;
it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a
host layout, but device verification across restart/update is still required.
No OBB file was installed on the Frame during this work.
## Private app-data backups
```sh
python3 ui/frame_android.py stop org.example.game
python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz
python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz
```
Keep the instance stopped throughout either operation; do not launch it from
Steam concurrently. The remote guard fails if Podman cannot enumerate containers
or reports that instance running. The helpers use `podman unshare` to read/write
Android's mapped ownership without changing the live data's permissions.
The archive covers **only** `compatdata/<instance>/internal/<package>`, not the
APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot.
It contains a package/instance manifest and regular files/directories. Backups
are private (0600), validated before publication, and never overwrite an existing
backup. Keep them safe: app data can contain credentials and is not encrypted.
Restore checks the package and instance, rejects absolute/traversing/duplicate
paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates
again on the Frame. It extracts into a separate directory, preserves numeric
ownership, ordinary modes and timestamps, then swaps the private-data directory.
Setuid/setgid bits are not restored. The previous directory remains beside it as
`.<package>.before-restore-<timestamp>`; the returned `previous` path identifies
it. This is an additional recovery copy, not an automatic deletion policy.
Locally verified: archive round trip including recovery copy, malformed archive
rejection, transfer command construction and failure handling. Not verified:
real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB
writes or persistence. Backups reject symlinks/special files; an app requiring
those needs a separately designed backup format. These CLI features still need
a real-device acceptance pass before being exposed as a polished UI workflow.
+139
View File
@@ -0,0 +1,139 @@
"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
import json
import os
from pathlib import Path, PurePosixPath
import shutil
import sys
import tarfile
import tempfile
import time
MAX_BYTES = 20 * 1024 ** 3
MAX_FILES = 100000
def inspect_archive(path, package, instance):
names, total, manifest = set(), 0, None
with tarfile.open(path, 'r:gz') as archive:
for member in archive:
name = member.name
parts = PurePosixPath(name).parts
if (not parts or name.startswith('/') or '..' in parts or
name != '/'.join(parts) or name in names or '\\' in name):
raise ValueError('unsafe or duplicate archive path')
if name == 'data' and not member.isdir():
raise ValueError('data root must be a directory')
names.add(name)
if len(names) > MAX_FILES or not (member.isdir() or member.isfile()):
raise ValueError('archive has too many files, links or special files')
if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535:
raise ValueError('archive owner outside Android user namespace')
total += member.size
if total > MAX_BYTES:
raise ValueError('archive exceeds 20 GiB')
if name == 'manifest.json' and member.isfile() and member.size <= 4096:
manifest = json.load(archive.extractfile(member))
elif parts[0] != 'data':
raise ValueError('unexpected archive member')
if (not isinstance(manifest, dict) or manifest.get('format') != 1 or
manifest.get('package') != package or manifest.get('instance') != instance or
'data' not in names):
raise ValueError('backup does not match this package and instance')
return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance}
def backup(root, package, instance, output):
import io
source = root / package
if source.is_symlink() or not source.is_dir():
raise ValueError('private app data does not exist or is a symlink')
count, total = 0, 0
def checked(member):
nonlocal count, total
count += 1
total += member.size
if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES:
raise ValueError('private data contains links/special files or exceeds backup limits')
return member
manifest = json.dumps({'format': 1, 'package': package, 'instance': instance}).encode()
with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive:
member = tarfile.TarInfo('manifest.json')
member.size, member.mode = len(manifest), 0o600
archive.addfile(member, io.BytesIO(manifest))
archive.add(str(source), arcname='data', filter=checked)
def restore(root, package, instance, input_stream):
source = root / package
if source.is_symlink() or not source.is_dir():
raise ValueError('private app data does not exist or is a symlink')
with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work:
work = Path(work)
archive_path = work / 'backup.tar.gz'
with archive_path.open('wb') as output:
size = 0
while True:
chunk = input_stream.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
if size > MAX_BYTES:
raise ValueError('compressed backup exceeds 20 GiB')
output.write(chunk)
result = inspect_archive(archive_path, package, instance)
stage = work / 'stage'
stage.mkdir(mode=0o700)
with tarfile.open(archive_path, 'r:gz') as archive:
directories = []
for member in archive:
if member.name == 'manifest.json':
continue
target = stage / member.name
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
directories.append((target, member))
else:
target.parent.mkdir(parents=True, exist_ok=True)
with archive.extractfile(member) as src, target.open('xb') as dst:
shutil.copyfileobj(src, dst, 1024 * 1024)
apply_metadata(target, member)
for target, member in reversed(directories):
apply_metadata(target, member)
previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
source.rename(previous)
try:
(stage / 'data').rename(source)
except BaseException:
previous.rename(source)
raise
result['previous'] = str(previous)
return result
def apply_metadata(path, member):
os.chown(str(path), member.uid, member.gid)
os.chmod(str(path), member.mode & 0o777)
os.utime(str(path), (member.mtime, member.mtime))
def main():
action, package, instance = sys.argv[1:]
instance = int(instance)
root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal'
if root.is_symlink() or root.resolve() != root.absolute():
raise ValueError('private-data directory traverses a symlink')
if action == 'backup':
backup(root, package, instance, sys.stdout.buffer)
elif action == 'restore':
print(json.dumps(restore(root, package, instance, sys.stdin.buffer)))
else:
raise ValueError('unknown app-data action')
if __name__ == '__main__':
try:
main()
except (OSError, ValueError, tarfile.TarError) as error:
sys.exit(str(error))
+18
View File
@@ -0,0 +1,18 @@
{
"recorded": "2026-09-28",
"robots": {
"url": "https://sidequestvr.com/robots.txt",
"user_agent": "*",
"crawl_delay": 3,
"disallow": ["/search/", "/user/*", "/sideload/*"],
"sitemap": "https://sidequestvr.com/sitemap_index.xml"
},
"api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403},
"terms": {
"url": "https://sidequestvr.com/terms",
"bundle": "https://sidequestvr.com/main-4MMXZRXL.js",
"prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping",
"prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service"
},
"note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction."
}
+213
View File
@@ -0,0 +1,213 @@
import io
import json
import os
from pathlib import Path
import runpy
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'ui'))
import frame_android as android
import frame_android_data as data
REMOTE = runpy.run_path(str(data.REMOTE))
PKG = 'org.example.game'
META = {'package': PKG, 'instance': 2800000001}
class ObbTests(unittest.TestCase):
def test_invalid_files_never_contact_frame(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(android, 'ssh') as ssh:
for name in ('game.obb', 'main.1.org.other.game.obb', 'main.x.' + PKG + '.obb'):
path = Path(tmp) / name
path.write_bytes(b'content')
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [path])
with self.assertRaises(android.FrameError):
data.install_obb('../game', [])
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [])
ssh.assert_not_called()
def test_streams_to_correct_instance_and_checks_hash_before_rename(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('main.7.' + PKG + '.obb')
path.write_bytes(b'expansion payload')
calls = []
def stream(command, src=None, dst=None):
calls.append(command)
self.assertEqual(src.read(), b'expansion payload')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001\n'), \
patch.object(data, '_stream', side_effect=stream):
result = data.install_obb(PKG, [path])
self.assertTrue(result['verified'])
self.assertIn('podman exec -i lepton-steamlaunch-2800000001', calls[0])
self.assertIn('/sdcard/Android/obb/' + PKG, calls[0])
self.assertLess(calls[0].index('sha256sum'), calls[0].index('; mv'))
self.assertIn(result['obb'][0]['sha256'], calls[0])
def test_stopped_instance_and_failed_transfer(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / ('patch.7.' + PKG + '.obb')
path.write_bytes(b'patch')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value=''), patch.object(data, '_stream') as stream:
with self.assertRaisesRegex(android.FrameError, 'start this app'):
data.install_obb(PKG, [path])
stream.assert_not_called()
with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
with self.assertRaisesRegex(android.FrameError, 'bad hash'):
data._stream('command')
@unittest.skipUnless(shutil.which("sh") and shutil.which("shasum"), "shell checksum tools unavailable")
def test_android_shell_publish_and_hash_failure(self):
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / ('main.7.' + PKG + '.obb')
source.write_bytes(b'good expansion')
output = Path(tmp) / 'sdcard/Android/obb' / PKG / source.name
tools_dir = Path(tmp) / 'bin'
tools_dir.mkdir()
checksum = tools_dir / 'sha256sum'
checksum.write_text('#!/bin/sh\nexec shasum -a 256 "$@"\n')
checksum.chmod(0o700)
corrupt = False
def stream(command, src=None, dst=None):
script = shlex.split(command)[-1].replace('/sdcard/', tmp + '/sdcard/')
if corrupt:
source.write_bytes(b'corrupt expansion')
result = subprocess.run(['sh', '-c', script], stdin=src, capture_output=True,
env=dict(os.environ, PATH=str(tools_dir) + ':' + os.environ['PATH']))
if result.returncode:
raise android.FrameError('checksum failed')
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001'), \
patch.object(data, '_stream', side_effect=stream):
data.install_obb(PKG, [source])
self.assertEqual(output.read_bytes(), b'good expansion')
corrupt = True
with self.assertRaises(android.FrameError):
data.install_obb(PKG, [source])
self.assertEqual(output.read_bytes(), b'good expansion')
self.assertEqual(list(output.parent.glob('*.part')), [])
class BackupTests(unittest.TestCase):
def test_roundtrip_and_retains_previous_data(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
source = root / PKG
(source / 'files').mkdir(parents=True)
(source / 'files/save').write_bytes(b'original save')
archive = io.BytesIO()
REMOTE['backup'](root, PKG, META['instance'], archive)
(source / 'files/save').write_bytes(b'new save')
archive.seek(0)
# Current user's uid/gid in this local test; no elevated execution.
result = REMOTE['restore'](root, PKG, META['instance'], archive)
self.assertEqual((source / 'files/save').read_bytes(), b'original save')
self.assertEqual((Path(result['previous']) / 'files/save').read_bytes(), b'new save')
def make_archive(self, path, members, package=PKG):
with tarfile.open(path, 'w:gz') as archive:
payload = json.dumps({'format': 1, 'package': package, 'instance': META['instance']}).encode()
member = tarfile.TarInfo('manifest.json')
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
root = tarfile.TarInfo('data')
root.type = tarfile.DIRTYPE
archive.addfile(root)
for name, kind in members:
member = tarfile.TarInfo(name)
member.type = kind
member.linkname = '/tmp/escape'
archive.addfile(member)
def test_rejects_wrong_package_traversal_links_devices_duplicates(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
cases = [('../escape', tarfile.REGTYPE), ('/absolute', tarfile.REGTYPE),
('data/link', tarfile.SYMTYPE), ('data/link', tarfile.LNKTYPE),
('data/device', tarfile.CHRTYPE), ('data', tarfile.DIRTYPE),
('other/file', tarfile.REGTYPE), ('data/../escape', tarfile.REGTYPE)]
for member in cases:
self.make_archive(path, [member])
with self.assertRaises(ValueError, msg=str(member)):
REMOTE['inspect_archive'](path, PKG, META['instance'])
self.make_archive(path, [], package='org.other.game')
with self.assertRaisesRegex(ValueError, 'does not match'):
REMOTE['inspect_archive'](path, PKG, META['instance'])
def test_failed_backup_leaves_no_archive_and_existing_is_preserved(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'backup.tar.gz'
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(data, '_stream', side_effect=android.FrameError('offline')):
with self.assertRaises(android.FrameError):
data.backup_data(PKG, path)
self.assertEqual(list(Path(tmp).iterdir()), [])
path.write_bytes(b'keep')
with self.assertRaisesRegex(android.FrameError, 'already exists'):
data.backup_data(PKG, path)
self.assertEqual(path.read_bytes(), b'keep')
def test_guard_does_not_hide_podman_failure(self):
command = data._data_command('backup', META)
self.assertIn('|| exit 1', command)
self.assertIn('stop the app', command)
self.assertIn('podman unshare python3', command)
self.assertNotIn('|| true', command)
def test_bad_restore_is_rejected_before_transfer(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'bad.tar.gz'
self.make_archive(path, [('../escape', tarfile.REGTYPE)])
with patch.object(android, '_meta_or_fail', return_value=META), patch.object(data, '_stream') as stream:
with self.assertRaises(android.FrameError):
data.restore_data(PKG, path)
stream.assert_not_called()
def test_successful_backup_is_private_and_inspectable(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'checkpoint')
destination = root / 'backup.tar.gz'
def stream(command, src=None, dst=None):
REMOTE['backup'](root, PKG, META['instance'], dst)
with patch.object(android, '_meta_or_fail', return_value=META), \
patch.object(data, '_stream', side_effect=stream):
result = data.backup_data(PKG, destination)
self.assertEqual(destination.stat().st_mode & 0o777, 0o600)
self.assertEqual(result['sha256'], data._sha256(destination))
self.assertEqual(result['files'], 2)
def test_rejected_restore_keeps_existing_data(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / PKG).mkdir()
(root / PKG / 'save').write_bytes(b'keep')
archive = root / 'bad.tar.gz'
self.make_archive(archive, [('data/link', tarfile.SYMTYPE)])
with archive.open('rb') as source, self.assertRaises(ValueError):
REMOTE['restore'](root, PKG, META['instance'], source)
self.assertEqual((root / PKG / 'save').read_bytes(), b'keep')
self.assertFalse(list(root.glob('.frame-restore-*')))
self.assertFalse(list(root.glob('.*.before-restore-*')))
def test_archive_root_must_be_a_directory(self):
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / 'bad.tar.gz'
with tarfile.open(archive, 'w:gz') as target:
target.addfile(tarfile.TarInfo('data'))
with self.assertRaisesRegex(ValueError, 'directory'):
REMOTE['inspect_archive'](archive, PKG, META['instance'])
+36
View File
@@ -0,0 +1,36 @@
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
from apk_sources import SourceError, sidequest
class SideQuestTests(unittest.TestCase):
def test_policy_is_recorded_and_source_is_page_only(self):
fixture = json.loads((Path(__file__).parent / 'fixtures/sidequest-policy.json').read_text())
self.assertIn('/search/', fixture['robots']['disallow'])
self.assertIn('scraping', fixture['terms']['prohibited_activities_i'])
self.assertTrue(sidequest.sources()[0]['page_only'])
@patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
def test_unknown_listing_never_claims_free_or_downloadable(self, _urlopen):
source = sidequest.sources()[0]
entry = sidequest.details(source, '123')
self.assertEqual(entry['page'], 'https://sidequestvr.com/app/123')
self.assertFalse(entry['downloadable'])
self.assertIsNone(entry['free'])
self.assertIsNone(entry['vr'])
self.assertEqual(entry['images']['screenshots'], [])
with self.assertRaisesRegex(SourceError, 'page-only'):
sidequest.download(source, '123')
with self.assertRaisesRegex(SourceError, 'page-only'):
sidequest.search(source, 'open saber')
self.assertEqual(sidequest.search(source, 'open saber', 0), [])
def test_invalid_ids(self):
for value in ('../123', '1?paid=false', '1', '', '1/2', '1' * 13):
with self.assertRaises(SourceError):
sidequest.details(sidequest.sources()[0], value)
+41
View File
@@ -0,0 +1,41 @@
"""SideQuest page links only: its terms do not authorise third-party scraping.
No API calls, cached listings or automated downloads. See docs/sidequest.md.
"""
from . import SourceError
KIND = 'sidequest'
URL = 'https://sidequestvr.com'
REASON = ('SideQuest is page-only: its terms restrict scraping and unauthorised '
'access. Browse and download with SideQuest, then import a developer-provided APK.')
def sources():
return [{'id': KIND, 'kind': KIND, 'name': 'SideQuest', 'url': URL,
'builtin': True, 'enabled': True, 'trust': 'community',
'page_only': True, 'reason': REASON}]
def search(source, query, limit=50):
# Do not invent catalogue results or interpret a query as a verified free app.
if limit <= 0:
return []
raise SourceError(REASON + ' ' + URL + '/apps')
def details(source, entry_id):
entry_id = str(entry_id)
if not entry_id.isascii() or not entry_id.isdecimal() or len(entry_id) > 12:
raise SourceError('SideQuest listing ids must be numeric')
return {'source': source['id'], 'id': entry_id, 'package': None,
'name': 'SideQuest listing ' + entry_id, 'summary': REASON,
'icon': None, 'page': URL + '/app/' + entry_id, 'version': None,
'version_code': None, 'min_sdk': None, 'abis': None, 'vr': None,
'size': None, 'free': None, 'license': None, 'updated': None,
'downloadable': False, 'versions': [], 'tags': [], 'headsets': [],
'images': {'icon': None, 'banner': None, 'screenshots': []}}
def download(source, entry_id, version_code=None):
entry = details(source, entry_id)
raise SourceError(REASON + ' ' + entry['page'])
+24
View File
@@ -8,6 +8,7 @@ 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-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
import json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
@@ -333,6 +334,21 @@ def patch(src, dst, add=None):
raise FrameError(str(e)) from e
def install_obb(pkg, paths):
import frame_android_data
return frame_android_data.install_obb(pkg, paths)
def backup_data(pkg, destination):
import frame_android_data
return frame_android_data.backup_data(pkg, destination)
def restore_data(pkg, archive):
import frame_android_data
return frame_android_data.restore_data(pkg, archive)
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
@@ -368,6 +384,14 @@ def main():
with open(path, 'rb') as f:
additions[entry] = f.read()
r = patch(opts.src, opts.dst, additions)
elif cmd == 'install-obb':
if len(args) < 2:
raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
r = install_obb(args[0], args[1:])
elif cmd in ('backup-data', 'restore-data'):
if len(args) != 2:
raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
elif cmd == 'list':
r = list_apps()
elif cmd in ('launch', 'stop', 'probe'):
+133
View File
@@ -0,0 +1,133 @@
"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
import hashlib
import json
import os
from pathlib import Path
import re
import runpy
import shlex
import subprocess
import tempfile
import uuid
import frame_android as android
REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
def _stream(command, src=None, dst=None):
try:
result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
stdin=src if src else subprocess.DEVNULL,
stdout=dst if dst else subprocess.PIPE,
stderr=subprocess.PIPE, timeout=1800)
except subprocess.TimeoutExpired:
raise android.FrameError('app-data transfer timed out')
except OSError as error:
raise android.FrameError('app-data transfer failed: ' + str(error))
if result.returncode:
raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
'app-data transfer failed')
return result.stdout
def _sha256(path):
digest = hashlib.sha256()
with open(path, 'rb') as src:
for chunk in iter(lambda: src.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def _meta(package):
if not android.PKG_RE.fullmatch(package or ''):
raise android.FrameError('invalid package name')
meta = android._meta_or_fail(package)
if meta['package'] != package:
raise android.FrameError('installed app metadata has a different package')
return meta
def install_obb(package, paths):
if not android.PKG_RE.fullmatch(package or ''):
raise android.FrameError('invalid package name')
paths = [Path(p).resolve() for p in paths]
if not paths:
raise android.FrameError('select at least one OBB file')
names = set()
for path in paths:
if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
or not path.is_file() or path.stat().st_size == 0 or path.name in names):
raise android.FrameError('OBB must be a nonempty main/patch.<version>.' + package + '.obb file')
names.add(path.name)
with android._install_lock:
meta = _meta(package)
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
# No implicit launch. Android resolves /sdcard, never an assumed host path.
running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
if container not in running:
raise android.FrameError('start this app instance before installing OBB data')
dest = '/sdcard/Android/obb/' + package
results = []
for path in paths:
digest = _sha256(path)
part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
target = dest + '/' + path.name
script = (f'set -eu; mkdir -p {dest}; umask 002; '
f'trap "rm -f {part}" EXIT; cat > {part}; '
f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
f'chmod 664 {part}; mv {part} {target}')
command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
with path.open('rb') as src:
_stream(command, src=src)
results.append({'name': path.name, 'path': target, 'sha256': digest})
return {'package': package, 'instance': meta['instance'], 'obb': results,
'verified': True}
def _data_command(action, meta):
container = 'lepton-steamlaunch-' + str(int(meta['instance']))
# Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
action, meta['package'], str(int(meta['instance']))])
def backup_data(package, destination):
destination = Path(destination).expanduser().absolute()
if destination.exists():
raise android.FrameError('backup destination already exists')
with android._install_lock:
meta = _meta(package)
fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
try:
with os.fdopen(fd, 'wb') as dst:
_stream(_data_command('backup', meta), dst=dst)
result = _inspect(temporary, meta)
# Exclusive publication: a concurrently created backup is never overwritten.
os.link(temporary, str(destination))
return dict(result, path=str(destination), sha256=_sha256(destination))
finally:
os.unlink(temporary)
def _inspect(path, meta):
import tarfile
try:
return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
except (OSError, EOFError, ValueError, tarfile.TarError) as error:
raise android.FrameError('invalid app-data backup: ' + str(error))
def restore_data(package, archive):
with android._install_lock:
meta = _meta(package)
_inspect(archive, meta)
with open(archive, 'rb') as src:
result = _stream(_data_command('restore', meta), src=src)
try:
return json.loads(result)
except (ValueError, TypeError):
raise android.FrameError('could not read restore result; inspect app data before retrying')