Compare commits

...
12 changed files with 697 additions and 23 deletions

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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",
+57
View File
@@ -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.
+34
View File
@@ -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
View File
@@ -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
+177
View File
@@ -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()
+9
View File
@@ -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.
+1 -1
View File
@@ -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)
+145
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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":