From b2485500e17b86cf26cbcd563e15ecd1680c0cbc Mon Sep 17 00:00:00 2001 From: SAPHID Date: Tue, 20 Oct 2026 03:54:17 +0000 Subject: [PATCH] Import Android APK and OBB backups together --- app/package-lock.json | 4 +- app/package.json | 2 +- docs/android-backups.md | 57 ++++++++ docs/evidence/android-import/device.json | 34 +++++ docs/sidequest.md | 5 +- tests/test_android_import.py | 177 +++++++++++++++++++++++ tests/test_webinstall.py | 2 +- ui/frame_android_import.py | 145 +++++++++++++++++++ ui/index.html | 161 +++++++++++++++++++-- ui/server.py | 110 +++++++++++++- 10 files changed, 678 insertions(+), 19 deletions(-) create mode 100644 docs/android-backups.md create mode 100644 docs/evidence/android-import/device.json create mode 100644 tests/test_android_import.py create mode 100644 ui/frame_android_import.py diff --git a/app/package-lock.json b/app/package-lock.json index 8467e4a..ec00c6c 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "frame-control", - "version": "0.4.7", + "version": "0.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frame-control", - "version": "0.4.7", + "version": "0.4.8", "license": "MIT", "devDependencies": { "electron": "^44.4.5", diff --git a/app/package.json b/app/package.json index 93e695c..29e242e 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "frame-control", "productName": "Frame Control", - "version": "0.4.7", + "version": "0.4.8", "description": "Desktop app for managing a Valve Steam Frame over SSH", "private": true, "main": "main.js", diff --git a/docs/android-backups.md b/docs/android-backups.md new file mode 100644 index 0000000..80087eb --- /dev/null +++ b/docs/android-backups.md @@ -0,0 +1,57 @@ +# Import an Android game backup + +A SideQuest backup can contain a small APK and much larger `main.…obb` and +`patch.…obb` files. All of them belong to the same game. Installing only the APK +can leave the game unable to start because its assets are missing. + +In **Android → Import game files…**, choose the APK and its OBB files together. +You can also drop them anywhere in the window. In the desktop app, dropping the +APK alone finds matching OBB files beside it; dropping a folder containing the +APK and OBBs also works. Browsers can read only the files you explicitly choose. + +The import screen shows the game app and its game-data files together. **Import +game & data** installs the APK, starts its own Steam library instance, waits for +Android and the installed package, copies the OBBs, and closes the game. Open it +again from the Steam library to play. The screen explains this before import; +choosing files alone never starts a game. + +Files are matched using the package inside the APK and the standard +`main|patch...obb` name, not the APK's filename. Both main and +patch files are preserved. OBB version numbers need not equal the APK's current +version code: updates can reuse older expansion files. The copy uses the existing +SHA-256 transfer check inside Android and writes to that game's own +`/sdcard/Android/obb/` folder. + +If no data files are selected, the screen reminds you to include any OBBs in the +backup before offering **Install game app**. An APK cannot reliably tell Frame +Control whether separate expansion files are required. Game-data files are never +silently sent to Downloads by this flow. + +If setup fails, the import screen keeps the error and retry instructions visible. +The APK may already be installed, but the game is not reported ready. Import the +same backup again, or choose just its OBB files to add data to an existing install. +The importer checks the installed package and version before writing data and +closes the instance after a failed copy. Unrelated or duplicate files, running +games that would be replaced, and downgrades are rejected. Uploaded staging files +are discarded after use or cancellation; abandoned imports expire after an hour +when the importer is next used, and dead-server staging folders are swept at +startup. + +This flow handles one ordinary APK and standard OBB files for one game at a time. +Split APKs, XAPK/APKS archives, nested Android/data folders and private save +archives need their own import handling. PC ZIP/folder/EXE imports keep their +existing route. Quest USB and Meta library imports keep their existing workflow. + +Validation covers actual APK parsing, the three-file backup layout, package +matching, sibling discovery, browser upload staging and cleanup, instance/version +guards and failure recovery with mocked headset operations. Browser verification +uses the real page and upload/inspection endpoints with simulated installation. +**Verified 2026-10-20 on a real Steam Frame**, BUILD_ID `20260925.6191901`: +F-Droid Basic 2.0.2 with two synthetic main/patch OBBs completed the automatic +install/start/copy/close sequence. Reopening its persistent instance confirmed +both SHA-256 hashes in `/sdcard/Android/obb/org.fdroid.basic`. The test app, +shortcut and instance were removed afterward, the shared device lock was +released, and the battery remained at 99%. See [device evidence](evidence/android-import/device.json). +The headset was unworn; this checks transfer and persistence, not rendering, +Resident Evil 4 gameplay, or a game that exits before data can be copied. This +flow does not change APK compatibility or remove licence checks. diff --git a/docs/evidence/android-import/device.json b/docs/evidence/android-import/device.json new file mode 100644 index 0000000..79f08b2 --- /dev/null +++ b/docs/evidence/android-import/device.json @@ -0,0 +1,34 @@ +{ + "date": "2026-10-20T03:53:25Z", + "build_id": "20260925.6191901", + "app": "F-Droid Basic 2.0.2 (org.fdroid.basic, version code 2000052)", + "input": "One real APK plus synthetic main and patch OBBs named with older version code 2000050", + "sibling_discovery": true, + "automatic_import": "passed", + "closed_after_import": true, + "independent_readback": [ + "5023b41e7ac80f8b26a868db1f66884976bf864fab81a070ad37539f3481b352 /sdcard/Android/obb/org.fdroid.basic/main.2000050.org.fdroid.basic.obb", + "aecbed7433e81aaeef40942069d03ad23130f09d93b7e78bbe224865a9573157 /sdcard/Android/obb/org.fdroid.basic/patch.2000050.org.fdroid.basic.obb" + ], + "cleanup": { + "test_install_removed": true, + "running_instances_before": {}, + "running_instances_after": {}, + "battery_before": [ + "100", + "Full", + "Charging" + ], + "battery_after": [ + "99", + "Discharging", + "Charging" + ], + "shared_lock_released": true + }, + "limits": [ + "No headset rendering or gameplay assertion; headset unworn.", + "Synthetic expansion files verify transport and persistence, not Resident Evil 4 compatibility.", + "A game that exits before its Lepton instance can accept data was not tested." + ] +} diff --git a/docs/sidequest.md b/docs/sidequest.md index 23311d0..196cb85 100644 --- a/docs/sidequest.md +++ b/docs/sidequest.md @@ -24,7 +24,7 @@ No SideQuest implementation code was copied. | 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. | +| Drag-and-drop APK/OBB install | APK drag-and-drop already works | The [Android backup importer](android-backups.md) now groups the APK and matching OBBs, detects desktop siblings and guides installation and data setup. | | 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. | @@ -85,6 +85,9 @@ local SHA-256 alone must not be called publisher verification. ## OBB files +For the app's guided APK and OBB import, see [Import an Android game backup](android-backups.md). +The CLI helper below remains available for a running instance. + ```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 diff --git a/tests/test_android_import.py b/tests/test_android_import.py new file mode 100644 index 0000000..343709c --- /dev/null +++ b/tests/test_android_import.py @@ -0,0 +1,177 @@ +"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup.""" +import sandbox # noqa: F401 +import io +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui')) +import frame_android_import as backup +import server +from apk_fixtures import apk, manifest + +PKG = 'com.Armature.VR4' +META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210, + 'instance': 2800000001, 'shortcut': 123, 'game_id': 456} + + +class BackupTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.apk = self.root / 'VR4-Android-Shipping-arm64.apk' + self.apk.write_bytes(apk({'AndroidManifest.xml': manifest(PKG, 0x7f010000, 0x7f010001, 26)})) + self.obb = [self.root / f'{kind}.203.{PKG}.obb' for kind in ('main', 'patch')] + for p in self.obb: + p.write_bytes(b'game data') + + def test_desktop_apk_finds_matching_siblings_but_browser_requires_selection(self): + (self.root / 'main.1.org.other.game.obb').write_bytes(b'other game') + with patch.object(backup.android, 'ssh') as ssh: + desktop = backup.inspect([str(self.apk)], discover=True) + browser = backup.inspect([str(self.apk)]) + selected = backup.inspect([str(self.apk), *map(str, self.obb)]) + ssh.assert_not_called() + self.assertEqual(desktop['obb'], self.obb) + self.assertEqual(browser['obb'], []) + self.assertEqual(backup.public(selected)['obb'][0]['name'], self.obb[0].name) + self.assertEqual(backup.public(selected)['package'], PKG) + self.assertNotIn(str(self.root), str(backup.public(selected))) + + def test_wrong_data_empty_data_duplicate_names_and_multiple_apks_fail_before_install(self): + other = self.root / 'main.203.org.other.game.obb' + other.write_bytes(b'other game') + with self.assertRaisesRegex(backup.android.FrameError, 'does not belong'): + backup.inspect([str(self.apk), str(other)]) + self.obb[0].write_bytes(b'') + with self.assertRaisesRegex(backup.android.FrameError, 'nonempty'): + backup.inspect([str(self.apk), str(self.obb[0])]) + second = self.root / 'another.apk' + second.write_bytes(self.apk.read_bytes()) + with self.assertRaisesRegex(backup.android.FrameError, 'one game at a time'): + backup.inspect([str(self.apk), str(second)]) + self.obb[0].write_bytes(b'data') + folder = self.root / 'copy' + folder.mkdir() + duplicate = folder / self.obb[0].name + duplicate.write_bytes(b'data') + with self.assertRaisesRegex(backup.android.FrameError, 'unique names'): + backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)]) + + def test_obb_only_repairs_existing_game_and_requests_apk_when_not_installed(self): + with patch.object(backup.android, 'read_meta', return_value=None): + with self.assertRaisesRegex(backup.android.FrameError, 'Add its APK'): + backup.inspect(list(map(str, self.obb))) + with patch.object(backup.android, 'read_meta', return_value=META): + plan = backup.inspect(list(map(str, self.obb))) + self.assertIsNone(plan['apk']) + self.assertEqual(plan['checked']['version_code'], 210) + + def test_apk_and_both_data_files_install_launch_verify_copy_close_in_order(self): + plan = backup.inspect([str(self.apk), *map(str, self.obb)]) + order = [] + def added(pkg, checked, files, *args, **kwargs): + order.append('copy-close') + self.assertEqual(pkg, PKG) + self.assertEqual(files, self.obb) + return {'closed': True, 'message': 'copied', 'app': META, 'needs_game_data': False} + with patch.object(backup.quest, '_running', side_effect=[False, False, True]), \ + patch.object(backup.android, 'read_meta', return_value=None), \ + patch.object(backup.android, 'install', side_effect=lambda *a, **k: order.append('install') or META), \ + patch.object(backup.android, 'launch', side_effect=lambda *a: order.append('launch')), \ + patch.object(backup.quest, '_wait_ready', side_effect=lambda *a: order.append('ready')), \ + patch.object(backup.quest, '_check_android_package', side_effect=lambda *a: order.append('verify')), \ + patch.object(backup.quest, 'add_game_data', side_effect=added): + result = backup.install(plan) + self.assertEqual(order, ['install', 'launch', 'ready', 'verify', 'copy-close']) + self.assertIn('2 game-data files', result['message']) + self.assertIn('Ready to open', result['message']) + + def test_copy_failure_closes_game_and_keeps_retry_instruction(self): + plan = backup.inspect([str(self.apk), *map(str, self.obb)]) + with patch.object(backup.quest, '_running', side_effect=[False, True]), \ + patch.object(backup.android, 'read_meta', return_value=None), \ + patch.object(backup.android, 'install', return_value=META), \ + patch.object(backup.quest, '_wait_ready'), patch.object(backup.quest, '_check_android_package'), \ + patch.object(backup.quest, 'add_game_data', side_effect=backup.android.FrameError('transfer failed')), \ + patch.object(backup.quest, '_close', return_value=(True, None)) as close: + with self.assertRaisesRegex(backup.android.FrameError, 'installed, but.*Import the same backup again'): + backup.install(plan) + close.assert_called_once_with(PKG) + + def test_changed_files_are_rejected_before_frame_access(self): + plan = backup.inspect([str(self.apk), *map(str, self.obb)]) + self.obb[0].write_bytes(b'changed contents') + with patch.object(backup.android, 'ssh') as ssh: + with self.assertRaisesRegex(backup.android.FrameError, 'backup changed'): + backup.install(plan) + ssh.assert_not_called() + + def test_running_game_and_newer_installed_version_are_not_replaced(self): + plan = backup.inspect([str(self.apk), *map(str, self.obb)]) + newer = {**META, 'version_code': plan['checked']['version_code'] + 1} + for running, existing, message in [(True, None, 'Close'), (False, newer, 'newer')]: + with self.subTest(running=running), \ + patch.object(backup.quest, '_running', return_value=running), \ + patch.object(backup.android, 'read_meta', return_value=existing), \ + patch.object(backup.android, 'install') as install: + with self.assertRaisesRegex(backup.android.FrameError, message): + backup.install(plan) + install.assert_not_called() + + def test_apk_without_data_installs_without_launching(self): + plan = backup.inspect([str(self.apk)]) + with patch.object(backup.quest, '_running', return_value=False), \ + patch.object(backup.android, 'read_meta', return_value=None), \ + patch.object(backup.android, 'install', return_value=META), \ + patch.object(backup.android, 'launch') as launch: + result = backup.install(plan) + launch.assert_not_called() + self.assertFalse(result['needs_game_data']) + + +class StagingTests(unittest.TestCase): + setUp = BackupTests.setUp + def tearDown(self): + for entry in server._android_backups.values(): + server._discard_android_backup(entry) + server._android_backups.clear() + + def test_uploaded_batch_is_inspected_together_and_cancel_removes_all_uploads(self): + token = server.android_backup({'action': 'begin'})['token'] + for path in [self.apk, *self.obb]: + class Request: + headers = {'X-Filename': path.name, 'X-Mode': 'android-backup', + 'X-Android-Backup': token, 'Content-Length': str(path.stat().st_size)} + rfile = io.BytesIO(path.read_bytes()) + server.Handler.upload(Request()) + read = server.android_backup({'action': 'inspect', 'token': token}) + self.assertEqual(len(read['plan']['obb']), 2) + folders = list(server._android_backups[token]['dirs']) + server.android_backup({'action': 'discard', 'token': token}) + self.assertTrue(all(not Path(p).exists() for p in folders)) + self.assertTrue(self.apk.exists()) + + def test_headset_change_refuses_import_and_pc_folder_is_left_to_title_importer(self): + class Link: + device = 'a' + def active_device(self): + return {'id': self.device} + link = Link() + with patch.object(server, 'LINK', link): + read = server.android_backup({'action': 'inspect', 'paths': [str(self.apk)]}) + link.device = 'b' + with self.assertRaisesRegex(server.Failure, 'headset changed'): + server.android_backup({'action': 'install', 'token': read['token']}) + pc = self.root / 'pc' + pc.mkdir() + (pc / 'game.exe').write_bytes(b'program') + self.assertEqual(server.android_backup({'action': 'inspect', 'paths': [str(pc)], 'probe': True}), + {'android': False}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_webinstall.py b/tests/test_webinstall.py index ccbb560..6157136 100644 --- a/tests/test_webinstall.py +++ b/tests/test_webinstall.py @@ -394,7 +394,7 @@ class ServerJobs(unittest.TestCase): places = s._tmp_places() self.assertEqual({p for _, p in places}, {s.WEB_TMP_PREFIX, s.frame_titles.TMP_PREFIX, s.frame_android.TMP_PREFIX, s.frame_agent.TMP_PREFIX, - s.apk_fdroid.TMP_PREFIX}) + s.apk_fdroid.TMP_PREFIX, s.ANDROID_BACKUP_TMP}) for folder, prefix in places: folder.mkdir(parents=True, exist_ok=True) gone = tempfile.mkdtemp(prefix=f"{prefix}{dead.pid}-", dir=folder) diff --git a/ui/frame_android_import.py b/ui/frame_android_import.py new file mode 100644 index 0000000..70b23e2 --- /dev/null +++ b/ui/frame_android_import.py @@ -0,0 +1,145 @@ +"""Import a local Android backup: one APK and its matching expansion files.""" +import re +import time +from pathlib import Path + +import frame_android as android +import frame_quest as quest + +OBB = re.compile(r'(main|patch)\.[0-9]+\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)\.obb') + + +def inspect(paths, discover=False): + """Inspect selected files before installing anything. Desktop APK drops can find + sibling OBBs; browser uploads include only files explicitly selected by the user.""" + if not isinstance(paths, list) or not paths or len(paths) > 100: + raise android.FrameError('Choose one game APK and its .obb game-data files together.') + files = [] + for value in paths: + path = Path(value).expanduser().absolute() + if path.is_dir(): + files.extend(p for p in sorted(path.iterdir()) if p.suffix.lower() in ('.apk', '.obb')) + else: + files.append(path) + files = list(dict.fromkeys(files)) + if not files or len(files) > 100: + raise android.FrameError('No Android backup found. Choose a folder containing one APK and its game data.') + for path in files: + if path.suffix.lower() not in ('.apk', '.obb') or not path.is_file() or path.stat().st_size == 0: + raise android.FrameError(f'{path.name}: choose a nonempty APK or .obb game-data file.') + apks = [p for p in files if p.suffix.lower() == '.apk'] + if len(apks) > 1: + raise android.FrameError('Choose one game at a time. Split APK backups are not supported by this importer.') + apk = apks[0] if apks else None + if apk: + info = android.apk_info(str(apk)) + try: + android.check_installable(info) + except android.FrameError as error: + error.apk = {'package': info['package'], 'version_code': info.get('version_code'), + 'blocker': str(error)} + raise + checked = quest.inspect(info['package'], str(apk)) + pkg = checked['package'] + if discover and len(files) == 1: + files.extend(p for p in sorted(apk.parent.iterdir()) + if p not in files and OBB.fullmatch(p.name) and OBB.fullmatch(p.name)[2] == pkg) + else: + packages = {OBB.fullmatch(p.name)[2] for p in files if OBB.fullmatch(p.name)} + if len(packages) != 1: + raise android.FrameError('Choose the APK and game-data files for one game together.') + pkg = packages.pop() + if not android.PKG_RE.fullmatch(pkg): + raise android.FrameError('The game-data filename has an invalid Android package name.') + meta = android.read_meta(pkg) + if not meta: + raise android.FrameError('This game is not installed. Add its APK alongside these game-data files.') + checked = {k: meta.get(k) for k in ('package', 'label', 'version', 'version_code')} + checked['version_code'] = quest._installed_code(pkg, meta) + if checked['version_code'] is None: + raise android.FrameError('Add the matching APK so Frame Control can check which game version this data belongs to.') + checked['warnings'] = [] + obb = [p for p in files if p.suffix.lower() == '.obb'] + if len(obb) > 99: + raise android.FrameError('Choose one APK and at most 99 game-data files.') + if obb and checked['version_code'] is None: + raise android.FrameError('The APK has no readable version code, so its game-data target cannot be checked.') + names = set() + for path in obb: + match = OBB.fullmatch(path.name) + if not match or match[2] != pkg: + raise android.FrameError(f'{path.name} does not belong to {checked["label"]}. Choose matching game data.') + if not path.is_file() or not path.stat().st_size or path.name in names: + raise android.FrameError(f'{path.name}: game-data files must be nonempty and have unique names.') + names.add(path.name) + return {'apk': apk, 'obb': obb, 'checked': checked, + 'signatures': {str(p): (p.stat().st_size, p.stat().st_mtime_ns) for p in [*apks, *obb]}} + + +def public(plan): + checked = plan['checked'] + return {**checked, 'apk': plan['apk'].name if plan['apk'] else None, + 'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']], + 'size': sum(size for size, _ in plan['signatures'].values())} + + +def install(plan, display='auto', progress=None): + """The explicit Import button authorizes starting the game to add its data. + Reuse the Quest import's instance/version checks, verified transfer and close.""" + report = progress or (lambda *_: None) + if display not in ('auto', 'flat', 'vr'): + raise android.FrameError('Invalid Android display mode.') + for path, signature in plan['signatures'].items(): + p = Path(path) + if not p.is_file() or (p.stat().st_size, p.stat().st_mtime_ns) != signature: + raise android.FrameError('The backup changed after it was read. Choose the files again.') + checked = plan['checked'] + pkg, label = checked['package'], checked['label'] + with android._install_lock: + if plan['apk']: + if quest._running(pkg): + raise android.FrameError(f'Close {label} on the Frame, then try importing again.') + quest._check_downgrade(android.read_meta(pkg), checked, False) + report('Installing the game app', 15) + meta = android.install(str(plan['apk']), source=plan['apk'].name, + flatscreen=None if display == 'auto' else display == 'flat') + else: + meta = android._meta_or_fail(pkg) + if not plan['obb']: + report('Done', 100) + warnings = list(checked['warnings']) + list(meta.get('vr_issues') or []) + return {'message': f'Installed {label}. Open it from your Steam library.', 'app': meta, + 'needs_game_data': False, 'warnings': warnings} + try: + if not quest._running(pkg): + report('Starting the game to add its data', 55) + android.launch(pkg) + deadline = time.monotonic() + 90 + while not quest._running(pkg): + if time.monotonic() >= deadline: + raise android.FrameError('The game did not start. Check that Steam is open on the Frame.') + time.sleep(2) + report('Preparing Android for game data', 65) + quest._wait_ready(meta) + # Android can be booted before Lepton has installed the APK. + deadline = time.monotonic() + 90 + while True: + try: + quest._check_android_package(meta, checked['version_code']) + break + except quest.QuestError: + if time.monotonic() >= deadline: + raise + time.sleep(2) + result = quest.add_game_data(pkg, checked, plan['obb'], None, report, + checked['warnings'], source='your backup') + if result['closed']: + result['message'] = (f'Imported {label} and {len(plan["obb"])} game-data ' + f'file{"s" if len(plan["obb"]) != 1 else ""}. ' + 'Ready to open from your Steam library.') + return result + except Exception as error: + closed, why = quest._close(pkg) + detail = ('The game is closed.' if closed else f'Close the game on the Frame ({why}).') + raise android.FrameError(f'{label} is installed, but its game-data setup did not finish: {error}. ' + f'{detail} Import the same backup again to retry.') from error diff --git a/ui/index.html b/ui/index.html index 7642d6d..ea4370c 100644 --- a/ui/index.html +++ b/ui/index.html @@ -420,7 +420,7 @@ .rep-item .s { white-space: normal; } #bugDlg, #repDlg, #titleDlg, #wiDlg, #pwDlg, #apkAltDlg, #aboutDlg, #modDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px; padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); } - #bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); } + #bugDlg::backdrop, #repDlg::backdrop, #titleDlg::backdrop, #wiDlg::backdrop, #pwDlg::backdrop, #apkAltDlg::backdrop, #androidImportDlg::backdrop, #aboutDlg::backdrop, #modDlg::backdrop { background: rgba(0,0,0,.55); } #bugDlg { width: min(640px, calc(100vw - 40px)); } #bugForm label.field { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; } #bugForm label.field input, #bugForm label.field textarea, #bugForm select { margin-top: 5px; } @@ -1088,7 +1088,7 @@
-

Apps and games for the Frame

Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in Games.

+

Apps and games for the Frame

Free Android apps and VR games from open app stores and developers' own pages, each marked with whether it runs on the Frame. Steam games are in Games.

@@ -1110,6 +1110,14 @@
Games from the store, your Quest and your Meta library install one after another. Remove takes a game out before it starts.
+
+

Import Android game

+

Have a SideQuest backup? Choose the game's APK and all its .obb game-data files together. + Frame Control installs the app and puts its data in the right place.

+ + +

In the desktop app, drop the backup folder or its APK. Matching game data in the same folder is found automatically.

+

Import from my Quest

@@ -1282,7 +1290,8 @@

Send to Frame

Drop files here, or click to chooseTap to choose files - Files land in ~/Downloads. .apk files install as their own Android app; + Android backups: choose the .apk and its .obb game data together. + Other files land in ~/Downloads; a game's .zip, folder or .exe becomes a title in the Steam library. You can also drop files anywhere in this window. @@ -1412,7 +1421,8 @@
@@ -1434,6 +1444,19 @@

+ +

Import Android game

+

+
+

+

+

+ +
+ + +
+

Try another APK version

@@ -1738,6 +1761,7 @@ async function api(path, body, device = window.connDevice) { checkGeneration(); if (!r.ok) { const err = new Error(data.error || `HTTP ${r.status}`); + err.apk = data.apk; err.offline = !!data.offline; if (err.offline) setOnline(false, err.message); throw err; @@ -3025,20 +3049,25 @@ window.addEventListener("drop", e => { const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0)); sendFiles([...e.dataTransfer.files], dirs); }); -function upload(file, mode, dev = window.connDevice) { +function upload(file, mode, dev = window.connDevice, backupToken = null, progress = () => {}, signal = null) { return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new Error("Import cancelled")); const xhr = new XMLHttpRequest(); + const abort = () => xhr.abort(); + const cleanup = () => signal?.removeEventListener("abort", abort); xhr.open("POST", "/api/upload"); xhr.setRequestHeader("X-Frame-UI", UI_KEY); if (dev) xhr.setRequestHeader("X-Frame-Device", dev); xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name)); xhr.setRequestHeader("X-Mode", mode); + if (backupToken) xhr.setRequestHeader("X-Android-Backup", backupToken); if (mode === "apk") xhr.setRequestHeader("X-APK-Display", $("apkDisplay").value); const bar = $("prog").firstElementChild; $("prog").style.display = "block"; bar.style.width = "0"; - xhr.upload.onprogress = e => { if (e.lengthComputable) bar.style.width = (100 * e.loaded / e.total) + "%"; }; + xhr.upload.onprogress = e => { if (e.lengthComputable) { bar.style.width = (100 * e.loaded / e.total) + "%"; progress(100 * e.loaded / e.total); } }; const gen = devGen; xhr.onload = () => { + cleanup(); $("prog").style.display = "none"; let data; try { data = JSON.parse(xhr.responseText); } catch { data = { error: `HTTP ${xhr.status}` }; } if (gen !== devGen) { // the app switched headsets meanwhile: nothing to offer about this one here @@ -3049,7 +3078,9 @@ function upload(file, mode, dev = window.connDevice) { if (data.apk?.blocker && xhr.status >= 300) checkApkAlternatives(data.apk, dev); xhr.status < 300 ? resolve(data) : reject(new Error(data.error)); }; - xhr.onerror = () => { $("prog").style.display = "none"; reject(new Error("network error")); }; + xhr.onerror = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("network error")); }; + xhr.onabort = () => { cleanup(); $("prog").style.display = "none"; reject(new Error("Import cancelled")); }; + signal?.addEventListener("abort", abort, {once:true}); xhr.send(file); }); } @@ -3150,29 +3181,135 @@ document.addEventListener("click", async e => { $("aboutClose").onclick = () => $("aboutDlg").close(); const TITLE_EXT = /\.(zip|exe)$/i; -async function sendFiles(files, dirs = new Set()) { - const dev = window.connDevice; // the whole batch is for the headset it was dropped on +let fileSendQueue = Promise.resolve(); +function sendFiles(files, dirs = new Set()) { + const dev = window.connDevice; + const run = fileSendQueue.then(() => sendFilesOne(files, dirs, dev)); + fileSendQueue = run.catch(e => toast(e.message, true)); + return fileSendQueue; +} +async function sendFilesOne(files, dirs, dev) { + const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb)$/i.test(f.name)); + const grouped = androidFiles.some(f => /\.obb$/i.test(f.name)); + if (grouped && window.connDevice === dev) await importAndroidFiles(androidFiles, dev); for (const [i, f] of files.entries()) { if (window.connDevice !== dev) { toast(`Switched headsets: didn't send ${files.length - i} remaining file${files.length - i === 1 ? "" : "s"}`, true); break; } + if (grouped && androidFiles.includes(f)) continue; const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : ""; if (dirs.has(i)) { - if (path) await sideload(f, path, true); + if (path) { + if (!await importAndroidFiles([f], dev, true)) await sideload(f, path, true); + } else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true); continue; } if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; } if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; } const apk = f.name.toLowerCase().endsWith(".apk"); - const res = await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push", dev)); - if (apk && res && res.app) await offerTest(res.app); + if (apk) await importAndroidFiles([f], dev); + else await act(`Copy ${f.name} to ~/Downloads`, () => upload(f, "push", dev)); } $("fileInput").value = ""; refresh(); } +$("androidBackupChoose").onclick = () => $("androidBackupInput").click(); +$("androidBackupOpen").onclick = () => $("androidBackupInput").click(); +$("androidBackupInput").onchange = () => { + sendFiles([...$("androidBackupInput").files]); + $("androidBackupInput").value = ""; +}; +async function importAndroidFiles(files, dev, probe = false) { + const dlg = $("androidImportDlg"); + let token = null, running = false; + const controller = new AbortController(); + const finished = new Promise(resolve => dlg.addEventListener("close", () => { controller.abort(); resolve(); }, {once:true})); + $("androidImportName").textContent = "Reading your backup…"; + for (const id of ["androidImportFiles", "androidImportNote", "androidImportWarnings", "androidImportState"]) $(id).textContent = ""; + $("androidImportGo").hidden = false; + $("androidImportGo").disabled = true; + $("androidImportGo").textContent = "Import game"; + $("androidImportCancel").disabled = false; + $("androidImportCancel").textContent = "Cancel"; + $("androidImportCancel").onclick = () => dlg.close(); + dlg.oncancel = e => { if (running) e.preventDefault(); }; + $("androidImportProgress").hidden = true; + dlg.showModal(); + try { + let read; + const paths = files.map(f => window.frameApp?.pathForFile?.(f) || ""); + if (paths.every(Boolean)) { + read = await api("/api/android/backup", {action:"inspect", paths, probe}, dev); + if (read.android === false) { dlg.close(); await finished; return false; } + token = read.token; + } else { + token = (await api("/api/android/backup", {action:"begin"}, dev)).token; + for (const [i, file] of files.entries()) { + if (!dlg.open) return true; + $("androidImportState").textContent = `Reading file ${i + 1} of ${files.length}: ${file.name}`; + $("androidImportProgress").hidden = false; + await upload(file, "android-backup", dev, token, percent => { $("androidImportProgress").value = percent; }, controller.signal); + } + read = await api("/api/android/backup", {action:"inspect", token}, dev); + } + if (!dlg.open) return true; + const plan = read.plan; + $("androidImportName").textContent = `${plan.label}${plan.version ? " · " + plan.version : ""}`; + $("androidImportFiles").innerHTML = (plan.apk ? `
Game app
${esc(plan.apk)}
` + : `
Game app already installed
`) + + `
Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}
` + + plan.obb.map(f => `
${esc(f.name)} ${gb(f.size)}
`).join(""); + $("androidImportNote").textContent = plan.obb.length + ? "Frame Control will start the game, copy its data to the right place, and close it. Then you can open it from your Steam library." + : "No game-data files selected. If your backup includes .obb files, cancel and choose them with the APK. In the desktop app, matching files beside the APK are found automatically."; + $("androidImportWarnings").textContent = (plan.warnings || []).join(" "); + $("androidImportState").textContent = ""; + $("androidImportProgress").hidden = true; + $("androidImportGo").textContent = plan.obb.length ? "Import game & data" : "Install game app"; + $("androidImportGo").disabled = false; + const chosen = await Promise.race([ + new Promise(resolve => { $("androidImportGo").onclick = () => resolve(true); }), + finished.then(() => false), + ]); + if (!chosen) return true; + if (window.connDevice !== dev) throw new Error("The headset changed. Choose the backup again for this Frame."); + running = true; + $("androidImportCancel").disabled = true; + $("androidImportGo").disabled = true; + $("androidImportProgress").hidden = false; + $("androidImportProgress").value = 0; + const result = await runJob(`Import ${plan.label}`, `backup:${token}`, async () => { + try { + const started = await api("/api/android/backup", {action:"install", token, display:$("apkDisplay").value}, dev); + token = null; // the job owns the uploaded files now + return started; + } catch (e) { $("androidImportState").textContent = e.message; throw e; } + }, j => { + $("androidImportState").textContent = j.error || j.message || j.stage || "Importing…"; + if (j.percent != null) $("androidImportProgress").value = j.percent; + }); + $("androidImportState").textContent = result?.message || $("androidImportState").textContent || "Setup did not finish. Import the backup again to retry."; + if (result?.warnings?.length) $("androidImportWarnings").textContent = result.warnings.join(" "); + } catch (e) { + if (controller.signal.aborted) return true; + $("androidImportState").textContent = e.message; + log(`Import Android game: ${e.message}`, "e"); + if (e.apk?.blocker) { dlg.close(); checkApkAlternatives(e.apk, dev); } + } finally { + running = false; + $("androidImportProgress").hidden = true; + $("androidImportGo").hidden = true; + $("androidImportCancel").disabled = false; + $("androidImportCancel").textContent = "Close"; + if (token) api("/api/android/backup", {action:"discard", token}, dev).catch(() => {}); + } + await finished; + return true; +} + // ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ---- const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard", "devkit-steam"]; function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows diff --git a/ui/server.py b/ui/server.py index b730867..ad67452 100755 --- a/ui/server.py +++ b/ui/server.py @@ -44,6 +44,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import frame_agent # noqa: E402 import frame_assistant # noqa: E402 import frame_android # noqa: E402 +import frame_android_import # noqa: E402 from apk_sources import search as apk_search, SourceError # noqa: E402 from apk_sources import fdroid as apk_fdroid # noqa: E402 import frame_apk_versions # noqa: E402 @@ -1597,6 +1598,93 @@ def apk_installed(info, meta, error, seconds): frame_android.install_hooks.append(apk_installed) +# ---- Local Android backups (one APK and its OBB game data) --------------- + +_android_backups = {} +_android_backups_lock = threading.Lock() +ANDROID_BACKUP_TMP = 'frame-android-backup-' + + +def _discard_android_backup(entry): + for folder in entry['dirs']: + shutil.rmtree(folder, ignore_errors=True) + + +def _android_backup(token=None): + now = time.time() + with _android_backups_lock: + stale = [_android_backups.pop(t) for t, e in list(_android_backups.items()) + if now - e['time'] > 3600] + entry = _android_backups.get(token) + for old in stale: + _discard_android_backup(old) + if not entry: + raise Failure('This import expired. Choose the backup files again.', 400) + device = LINK.active_device()['id'] if LINK else None + if entry['device'] != device: + raise Failure('The headset changed. Choose the backup files again for this Frame.', 409) + entry['time'] = now + return entry + + +def android_backup(body): + action = body.get('action') + if action == 'begin' or (action == 'inspect' and 'paths' in body): + paths = body.get('paths', []) + if not isinstance(paths, list) or len(paths) > 100 or any( + not isinstance(p, str) or not os.path.isabs(p) for p in paths): + raise Failure('Choose local Android backup files.', 400) + # A dropped PC-game folder continues through the existing title importer. + if body.get('probe') and len(paths) == 1 and Path(paths[0]).is_dir(): + if not any(p.suffix.lower() in ('.apk', '.obb') for p in Path(paths[0]).iterdir()): + return {'android': False} + token = secrets.token_hex(12) + entry = {'paths': paths, 'dirs': [], 'plan': None, 'time': time.time(), + 'device': LINK.active_device()['id'] if LINK else None} + with _android_backups_lock: + _android_backups[token] = entry + _android_backup(token) # also expire abandoned imports + if action == 'begin': + return {'token': token} + else: + token = str(body.get('token') or '') + entry = _android_backup(token) + if action == 'inspect': + try: + entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs']) + except (frame_android.FrameError, OSError) as error: + with _android_backups_lock: + _android_backups.pop(token, None) + _discard_android_backup(entry) + raise Failure(str(error), 400, getattr(error, 'apk', None)) + return {'android': True, 'token': token, 'plan': frame_android_import.public(entry['plan'])} + if action not in ('install', 'discard'): + raise Failure('Unknown Android backup action.', 400) + if action == 'install' and not entry['plan']: + raise Failure('Read the backup before importing it.', 400) + display = body.get('display', 'auto') + if display not in ('auto', 'flat', 'vr'): + raise Failure('Invalid Android display mode.', 400) + with _android_backups_lock: + if _android_backups.pop(token, None) is not entry: + raise Failure('This import was already started or cancelled. Choose the files again.', 409) + if action == 'discard': + _discard_android_backup(entry) + return {'message': 'Import cancelled'} + + def work(report): + try: + return frame_android_import.install(entry['plan'], display, report) + finally: + _discard_android_backup(entry) + try: + ensure_master() + return start_job('Import ' + entry['plan']['checked']['label'], work, progress=True) + except BaseException: + _discard_android_backup(entry) + raise + + # ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) -------- # # Installing is two steps: inspect (a dropped file is uploaded and a zip @@ -2212,7 +2300,7 @@ def _pid_alive(pid): def _tmp_places(): """Where each kind of staging folder goes, and its name before the server's PID.""" tmp = Path(tempfile.gettempdir()) - return [(tmp, WEB_TMP_PREFIX), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX), + return [(tmp, WEB_TMP_PREFIX), (tmp, ANDROID_BACKUP_TMP), (tmp, frame_titles.TMP_PREFIX), (tmp, frame_android.TMP_PREFIX), (tmp, frame_agent.TMP_PREFIX), (frame_host.cache_dir("apk-sources"), apk_fdroid.TMP_PREFIX)] @@ -2499,6 +2587,7 @@ def source_manage(body): POST = { + "/api/android/backup": android_backup, "/api/vr": vr, "/api/comfort": comfort, "/api/media": media, "/api/agent/call": agent_call, "/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat, @@ -2937,7 +3026,14 @@ class Handler(BaseHTTPRequestHandler): raise Failure("empty or too-large upload", 400) if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"): raise Failure("APK install needs a .apk file", 400) - tmp = Path(tempfile.mkdtemp(prefix="frame-ui-")) + backup = None + if mode == 'android-backup': + backup = _android_backup(self.headers.get('X-Android-Backup')) + if (not name.lower().endswith(('.apk', '.obb')) or '/' in name or '\\' in name + or len(backup['paths']) >= 100 or any(Path(p).name == name for p in backup['paths'])): + raise Failure('Choose one APK and its game data, with no duplicate filenames.', 400) + prefix = f'{ANDROID_BACKUP_TMP}{os.getpid()}-' if backup is not None else 'frame-ui-' + tmp = Path(tempfile.mkdtemp(prefix=prefix)) keep = False try: dest = tmp / name @@ -2949,6 +3045,16 @@ class Handler(BaseHTTPRequestHandler): raise Failure("upload interrupted", 400) f.write(chunk) remaining -= len(chunk) + if backup is not None: + with _android_backups_lock: + if _android_backups.get(self.headers.get('X-Android-Backup')) is not backup: + raise Failure('This import was cancelled. Choose the backup files again.', 400) + backup['paths'].append(str(dest)) + backup['dirs'].append(str(tmp)) + backup['plan'] = None + backup['time'] = time.time() + keep = True + return {'message': f'Read {name}', 'token': self.headers.get('X-Android-Backup')} if mode == "media": return push_media(dest) if mode == "apkinfo":