mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-20 18:02:23 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a3e52f708 | ||
|
|
b2485500e1 |
No files matched your search
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.<version>.<package>.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/<package>` 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.
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -323,6 +323,15 @@ class Pins(Base):
|
||||
|
||||
|
||||
class Registry(Base):
|
||||
def test_replaced_registry_is_read_even_when_timestamp_and_size_match(self):
|
||||
d = self.reg.add_device('frame', name='First')
|
||||
before = self.reg.path.stat()
|
||||
other = fd.Registry(self.reg.path)
|
||||
other.update_device(d['id'], name='Other')
|
||||
os.utime(self.reg.path, ns=(before.st_atime_ns, before.st_mtime_ns))
|
||||
self.assertEqual(self.reg.path.stat().st_size, before.st_size)
|
||||
self.assertEqual(self.reg.get(d['id'])['name'], 'Other')
|
||||
|
||||
def test_an_address_added_first_wins_on_its_own_network(self):
|
||||
# The page's "Add 192.168.x.x" offer: the headset is reached over Tailscale, which has
|
||||
# worked here before. The LAN address has to go ahead of it to be used at home.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
+10
-4
@@ -558,15 +558,21 @@ class Registry:
|
||||
self.config = Path(config) if config else None # None: ssh_config() at call time
|
||||
self.lock = threading.RLock()
|
||||
self._depth = 0 # nested _changing() calls
|
||||
self._mtime = None # devices.json as last loaded or saved
|
||||
self._stamp = None # devices.json as last loaded or saved
|
||||
self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
|
||||
self.load()
|
||||
|
||||
# -- storage --
|
||||
def _file_stamp(self):
|
||||
stat = self.path.stat()
|
||||
# Saves atomically replace the file. Fast consecutive saves can have the
|
||||
# same timestamp and size; its identity still changes in that case.
|
||||
return stat.st_dev, stat.st_ino, stat.st_mtime_ns, stat.st_size
|
||||
|
||||
def load(self):
|
||||
with self.lock:
|
||||
try:
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
self._stamp = self._file_stamp()
|
||||
data = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
@@ -607,14 +613,14 @@ class Registry:
|
||||
tmp = self.path.with_name(self.path.name + ".tmp")
|
||||
tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, self.path)
|
||||
self._mtime = self.path.stat().st_mtime_ns
|
||||
self._stamp = self._file_stamp()
|
||||
|
||||
def _refresh(self):
|
||||
"""Pick up what another Frame Control server saved (the app and a standalone
|
||||
server can share devices.json)."""
|
||||
with self.lock:
|
||||
try:
|
||||
if self.path.stat().st_mtime_ns != self._mtime:
|
||||
if self._file_stamp() != self._stamp:
|
||||
self.load()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
+149
-12
@@ -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 @@
|
||||
<section id="android">
|
||||
<div class="store-tabs" aria-label="Android sections"><button class="on" data-store-tab="browse" aria-pressed="true">Discover</button><button data-store-tab="library" aria-pressed="false">On your Frame <span id="andCount"></span></button><span class="spacer"></span><button id="sourcesOpen" class="small" title="Choose where Discover looks for apps">Sources…</button><button class="small" id="repNew" hidden>+ Report an APK</button></div>
|
||||
<section id="appStore" aria-label="Discover apps">
|
||||
<div class="store-toolbar"><div class="store-heading"><div><h1>Apps and games for the Frame</h1><p>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 <a href="#games">Games</a>.</p></div></div>
|
||||
<div class="store-toolbar"><div class="store-heading"><div><h1>Apps and games for the Frame</h1><p>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 <a href="#games">Games</a>.</p><button class="small" id="androidBackupOpen">Import game files…</button></div></div>
|
||||
<form id="sourceSearch" class="store-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></svg><input id="sourceQ" type="search" aria-label="Search games and apps" placeholder="Search games and apps"><button type="submit" class="small">Search</button></form></div>
|
||||
<div class="store-filters"><div id="searchFilters" class="store-chips"><button data-filter="all" aria-pressed="true">All</button><button data-filter="vr" aria-pressed="false">VR</button><button data-filter="flat" aria-pressed="false">Flat-screen</button><button data-filter="installable" aria-pressed="false">Installs on the Frame</button></div></div>
|
||||
<div id="sourceStatus" class="store-notice" role="status" aria-live="polite" hidden></div>
|
||||
@@ -1110,6 +1110,14 @@
|
||||
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
|
||||
Remove takes a game out before it starts.</div>
|
||||
</section>
|
||||
<section class="panel" id="androidBackup">
|
||||
<div class="shelf-head"><h2>Import Android game</h2></div>
|
||||
<p class="sub">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.</p>
|
||||
<button class="action small" id="androidBackupChoose">Choose game files…</button>
|
||||
<input type="file" id="androidBackupInput" accept=".apk,.obb" multiple hidden>
|
||||
<p class="hint desk-only">In the desktop app, drop the backup folder or its APK. Matching game data in the same folder is found automatically.</p>
|
||||
</section>
|
||||
<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>
|
||||
@@ -1282,7 +1290,8 @@
|
||||
<div class="shelf-head"><h2>Send to Frame</h2></div>
|
||||
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
|
||||
<b><span class="desk-only">Drop files here, or click to choose</span><span class="mobile-only">Tap to choose files</span></b>
|
||||
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
|
||||
Android backups: choose the <code>.apk</code> and its <code>.obb</code> game data together.
|
||||
Other files land in <code>~/Downloads</code>;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
|
||||
<span class="desk-only">You can also drop files anywhere in this window.</span>
|
||||
<input type="file" id="fileInput" multiple hidden>
|
||||
@@ -1412,7 +1421,8 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Files go to <code>~/Downloads</code>; <code>.apk</code> files install as Android apps;
|
||||
<span class="sub">Android backups: drop the <code>.apk</code> and its <code>.obb</code> game data together.
|
||||
Other files go to <code>~/Downloads</code>;
|
||||
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a Steam library title.</span></div></div>
|
||||
|
||||
<div class="drawer" id="drawer"><div class="log" id="log"></div></div>
|
||||
@@ -1434,6 +1444,19 @@
|
||||
<p id="sourceError" class="store-notice" role="status" aria-live="polite"></p>
|
||||
</dialog>
|
||||
|
||||
<dialog id="androidImportDlg" aria-labelledby="androidImportTitle" style="background:#1e2329;color:var(--text);border:1px solid var(--muted);border-radius:4px;padding:24px;width:480px;max-width:calc(100vw - 32px)">
|
||||
<h2 id="androidImportTitle" style="margin-top:0">Import Android game</h2>
|
||||
<p id="androidImportName"></p>
|
||||
<div id="androidImportFiles" class="list" style="overflow-wrap:anywhere"></div>
|
||||
<p class="sub" id="androidImportNote"></p>
|
||||
<p class="sub" id="androidImportWarnings"></p>
|
||||
<p id="androidImportState" role="status" aria-live="polite"></p>
|
||||
<progress id="androidImportProgress" max="100" hidden style="width:100%" aria-label="Game import progress"></progress>
|
||||
<div class="actions">
|
||||
<button id="androidImportCancel">Cancel</button>
|
||||
<button id="androidImportGo" class="action">Import game</button>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="apkAltDlg" aria-labelledby="apkAltTitle">
|
||||
<h2 id="apkAltTitle">Try another APK version</h2>
|
||||
<p id="apkAltReason"></p>
|
||||
@@ -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 ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>`
|
||||
: `<div class="sub">Game app already installed</div>`) +
|
||||
`<div class="sub" style="margin-top:12px">Game data · ${plan.obb.length} file${plan.obb.length === 1 ? "" : "s"}</div>` +
|
||||
plan.obb.map(f => `<div>${esc(f.name)} <span class="sub">${gb(f.size)}</span></div>`).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
|
||||
|
||||
+108
-2
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user