Compare commits

...
Author SHA1 Message Date
Alex Southwell 1d4c57d648 Merge pull request #137 from saphid/release/0.4.13
Bump Frame Control to 0.4.13
2026-10-24 05:11:37 +00:00
SAPHID c6215712fe Bump Frame Control to 0.4.13 2026-10-24 05:02:27 +00:00
Alex SouthwellandSAPHID cd7ab2d9f7 Import standalone APK/OBB bundles through the current backup flow (#136)
* feat(android): inspect standalone APK and OBB bundles safely

* test: decode Android page fixture as UTF-8

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 05:01:32 +00:00
9 changed files with 364 additions and 12 deletions

No files matched your search

+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Import standalone APK/OBB backups from XAPK/APKS/APKM archives through the
existing preview and game-data setup flow. Unsafe archives, split sets and
Android/data restores are refused with guidance. This does not establish RE4
compatibility or diagnose its reported failure (issue #102).
- Add `scripts/frame-power-sample.sh`, a bounded read-only SSH sampler for
battery, CPU, named temperatures, fan and available power rails (issue #52).
JSON samples/summaries and CSV exports retain explicit unavailable states;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frame-control",
"version": "0.4.12",
"version": "0.4.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
"version": "0.4.12",
"version": "0.4.13",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
"version": "0.4.12",
"version": "0.4.13",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
+46
View File
@@ -431,3 +431,49 @@ app instance and back up/restore a stopped instance's private app data. See
[SideQuest features and limits](sidequest.md) for commands, archive scope and
verification status. These paths have offline coverage; real Frame storage and
permissions remain unverified. They do not change APK install or launch behavior.
## Local APK/OBB backups and Android bundles
In **Android apps → Import Android game → Choose game files…** (or **Send to Frame**), choose one
standalone APK and its matching `main.<version>.<package>.obb` and/or
`patch.<version>.<package>.obb` files together. The desktop app also discovers
matching OBB files beside a lone selected APK. Browser uploads use only the
files you selected. Installed apps can receive OBB-only imports.
You can instead select **one `.xapk`, `.apks`, or `.apkm` archive** containing
one standalone APK and optional matching OBBs. These extensions describe
containers, not a guarantee that their contents are supported. OBBs may be
at the archive root, under `<package>/`, or under `Android/obb/<package>/`;
one enclosing backup folder is allowed. Known manifest/icon/signature
sidecars are ignored; the APK manifest determines the package. Unknown files,
unsafe paths, links, encrypted entries, duplicate paths (including case
collisions), more than 4,096 entries, or more than 16 GiB unpacked are refused.
Staging also keeps 64 MiB of free disk space. Payloads still have the regular
one-APK/99-OBB limit. Ordinary `.zip` files keep the PC-title import route;
extract an Android ZIP yourself and select its APK/OBBs.
The importer previews the app and files before **Import game & data**.
Confirming authorizes installing the app, starting it to prepare Android,
checking the running package/version, copying OBBs through the existing
verified data helper, and closing the game. Open it from Steam afterwards.
If setup fails, read the error and retry the same backup; do not uninstall
first, since that deletes saves. An app that cannot start may also prevent
data setup. Uploaded and extracted staging is removed on cancellation,
inspection failure, or job completion/failure; abandoned imports expire
through the existing one-hour staging lifecycle. Original local files stay intact.
**Limits:** this backup importer refuses multiple APKs, split-only APKs and
bases marked as requiring splits. It does not select device variants or restore
`Android/data`, private app data, saves, or entitlements. For the separate
restricted native-library/asset split workflow see
[Quest split APKs](quest-import.md#split-apks) and the CLI above. Resource,
code and feature splits are not supported by that merger.
For issue #102, the RE4 report only establishes that an APK attempt failed.
Missing OBBs are a hypothesis; the edition/build, exact files, failing step,
and runtime compatibility remain unknown. Record those details, Frame Control
and SteamOS versions, and sanitized server/crash errors before diagnosing it.
`python3 ui/frame_android.py info /path/to/game.apk` inspects the APK locally.
Successful installation or fixture tests do not establish RE4 compatibility;
OBBs cannot fix ABI/API, VrApi/OpenXR, entitlement or Android-service failures.
+152
View File
@@ -1,6 +1,11 @@
"""Local APK/OBB backups: inspect as a unit, verify the target, and finish setup."""
import sandbox # noqa: F401
import io
import json
import shutil
import stat
import subprocess
import zipfile
from pathlib import Path
import sys
import tempfile
@@ -173,5 +178,152 @@ class StagingTests(unittest.TestCase):
{'android': False})
class BundleTests(unittest.TestCase):
setUp = BackupTests.setUp
tearDown = StagingTests.tearDown
def bundle(self, entries=None, suffix='.xapk'):
path = self.root / ('backup' + suffix)
path.write_bytes(apk(entries if entries is not None else {
'backup/game.apk': self.apk.read_bytes(),
f'backup/Android/obb/{PKG}/{self.obb[0].name}': b'game data',
'backup/manifest.json': b'{"package_name":"untrusted.metadata"}',
}))
return path
def test_all_explicit_extensions_use_current_preview_and_cancel_cleanup(self):
for suffix in ('.xapk', '.apks', '.apkm'):
with self.subTest(suffix=suffix):
source = self.bundle(suffix=suffix)
with patch.object(backup.android, 'ssh') as ssh:
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
ssh.assert_not_called()
self.assertEqual(read['plan']['package'], PKG)
self.assertEqual(read['plan']['obb'][0]['name'], self.obb[0].name)
folders = list(server._android_backups[read['token']]['dirs'])
server.android_backup({'action': 'discard', 'token': read['token']})
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
source = self.bundle()
token = server.android_backup({'action': 'begin'})['token']
class Request:
headers = {'X-Filename': source.name, 'X-Mode': 'android-backup',
'X-Android-Backup': token, 'Content-Length': str(source.stat().st_size)}
rfile = io.BytesIO(source.read_bytes())
server.Handler.upload(Request())
read = server.android_backup({'action': 'inspect', 'token': token})
self.assertEqual(read['plan']['package'], PKG)
self.assertEqual(len(server._android_backups[token]['dirs']), 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))
def test_reject_layouts_before_any_headset_access_and_cleanup_failed_stage(self):
cases = [
({'base.apk': self.apk.read_bytes(), 'split.apk': self.apk.read_bytes()}, 'Split APK'),
({'base.apk': self.apk.read_bytes(), f'Android/data/{PKG}/save': b'private'}, 'restore'),
({'base.apk': self.apk.read_bytes(), 'main.1.org.other.game.obb': b'wrong'}, 'does not belong'),
({'base.apk': self.apk.read_bytes(), '../outside.obb': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), '/outside.obb': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'CON.apk': b'bad'}, 'unsafe'),
({'base.apk': self.apk.read_bytes(), 'other.exe': b'bad'}, 'Unsupported'),
({'base.apk': self.apk.read_bytes(), 'BASE.apk': b'bad'}, 'duplicate'),
({'manifest.json': b'{}'}, 'standalone APK'),
]
for entries, message in cases:
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
source = self.bundle(entries)
with self.assertRaisesRegex(server.Failure, message):
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
self.assertFalse(server._android_backups)
ssh.assert_not_called()
self.assertTrue(source.exists())
def test_links_limits_bad_zip_and_mixed_selection_are_refused(self):
source = self.bundle({'base.apk': self.apk.read_bytes()})
link = zipfile.ZipInfo('link.apk')
link.external_attr = (stat.S_IFLNK | 0o777) << 16
with zipfile.ZipFile(source, 'a') as archive:
archive.writestr(link, 'somewhere')
destination = self.root / 'extract'
destination.mkdir()
with self.assertRaisesRegex(backup.android.FrameError, 'links'):
backup.unpack_bundle(source, destination)
self.assertEqual(list(destination.iterdir()), [])
source = self.bundle()
for limit, value, message in [('MAX_FILES', 1, 'too many'), ('MAX_BYTES', 1, 'size limit')]:
with patch.object(backup, limit, value):
with self.assertRaisesRegex(backup.android.FrameError, message):
backup.unpack_bundle(source, destination)
with patch.object(backup.shutil, 'disk_usage', return_value=type('Disk', (), {'free': 0})()):
with self.assertRaisesRegex(backup.android.FrameError, 'staging space'):
backup.unpack_bundle(source, destination)
with self.assertRaisesRegex(server.Failure, 'one complete'):
server.android_backup({'action': 'inspect', 'paths': [str(source), str(self.apk)]})
source.write_bytes(b'not a zip')
with self.assertRaisesRegex(server.Failure, 'Could not read'):
server.android_backup({'action': 'inspect', 'paths': [str(source)]})
self.assertFalse(server._android_backups)
def test_manifest_split_and_required_split_are_refused(self):
from test_quest import axml
for attrs in ({'split': 'config.arm64'}, {'split_required': True},
{'split_required': True, 'resmap': True}):
data = axml(PKG, **attrs)
if attrs.get('resmap'):
# Strip the attribute name, retaining the Android resource ID.
data = data.replace('isSplitRequired'.encode('utf-16-le'),
('x' * len('isSplitRequired')).encode('utf-16-le'))
self.apk.write_bytes(apk({'AndroidManifest.xml': data}))
with self.subTest(attrs=attrs):
with self.assertRaisesRegex(backup.android.FrameError, 'needs split APKs'):
backup.inspect([str(self.apk)])
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
source = self.bundle()
read = server.android_backup({'action': 'inspect', 'paths': [str(source)]})
entry = server._android_backups[read['token']]
folders = list(entry['dirs'])
def job(label, work, **kwargs):
return work(lambda *args: None)
with patch.object(server, 'ensure_master'), patch.object(server, 'start_job', side_effect=job), \
patch.object(backup, 'install', side_effect=backup.android.FrameError('fixture install failure')) as install:
with self.assertRaisesRegex(backup.android.FrameError, 'fixture install failure'):
server.android_backup({'action': 'install', 'token': read['token']})
install.assert_called_once_with(entry['plan'], 'auto', unittest.mock.ANY)
self.assertFalse(server._android_backups)
self.assertTrue(all(not Path(p).exists() for p in folders))
self.assertTrue(source.exists())
@unittest.skipUnless(shutil.which('node'), 'needs node')
def test_actual_send_handler_routes_bundle_to_importer_and_pc_zip_to_titles(self):
page = (Path(__file__).resolve().parents[1] / 'ui/index.html').read_text(encoding='utf-8')
function = page[page.index('async function sendFilesOne('):page.index('$("androidBackupChoose").onclick')]
script = r"""
const calls = [];
global.window = {connDevice:'fixture'};
global.$ = () => ({});
global.refresh = () => {};
global.toast = msg => {throw new Error(msg)};
global.TITLE_EXT = /\.(zip|exe)$/i;
global.importAndroidFiles = async files => calls.push(['android', files.map(f => f.name)]);
global.sideload = async file => calls.push(['title', file.name]);
global.act = () => {throw new Error('unexpected copy')};
(async () => {
for (const name of ['game.xapk', 'game.apks', 'game.apkm', 'pc.zip']) {
await sendFilesOne([{name, size:123}], new Set(), 'fixture');
}
await sendFilesOne([{name:'base.apk',size:123},{name:'split.apk',size:123}], new Set(), 'fixture');
process.stdout.write(JSON.stringify(calls));
})().catch(e => {console.error(e); process.exitCode = 1});
"""
result = subprocess.run(['node', '-e', function + script], capture_output=True, text=True, check=True)
self.assertEqual(json.loads(result.stdout), [
['android', ['game.xapk']], ['android', ['game.apks']],
['android', ['game.apkm']], ['title', 'pc.zip'],
['android', ['base.apk', 'split.apk']]])
if __name__ == '__main__':
unittest.main()
+142 -1
View File
@@ -1,13 +1,153 @@
"""Import a local Android backup: one APK and its matching expansion files."""
import re
import time
from pathlib import Path
from pathlib import Path, PurePosixPath
import shutil
import stat
import struct
import zipfile
import zlib
import frame_apk
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')
ARCHIVES = {'.xapk', '.apks', '.apkm'}
MAX_BYTES = 16 * 1024**3
MAX_FILES = 4096
METADATA = {'manifest.json', 'info.json', 'icon.png', 'toc.pb', 'meta.sai_v1.json', 'meta.sai_v2.json'}
WINDOWS_DEVICE = re.compile(r'(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\..*)?', re.I)
def _finder_metadata(name):
parts = PurePosixPath(name).parts
return bool(parts) and (parts[0] == '__MACOSX' or parts[-1] == '.DS_Store' or parts[-1].startswith('._'))
def _members(archive):
"""Ignore Finder sidecars and strip at most one shared enclosing folder.
Original member names are still checked by _unpack before extraction.
Android itself is a payload root, never an enclosing folder.
"""
members = archive.infolist()
names = [m.filename for m in members if not m.is_dir() and not _finder_metadata(m.filename)]
first = names[0].split('/')[0] if names else ''
prefix = first + '/' if first and first != 'Android' and all(n.startswith(first + '/') for n in names) else ''
return [(m, m.filename[len(prefix):] if prefix and m.filename.startswith(prefix) else m.filename)
for m in members]
def _path(name):
parts = PurePosixPath(name.rstrip('/')).parts
if (not parts or len(name) > 512 or len(parts) > 32 or name.startswith('/') or '\\' in name or
any(p in ('.', '..') or ':' in p or p.rstrip(' .') != p or WINDOWS_DEVICE.fullmatch(p)
for p in parts) or
'/'.join(parts) != name.rstrip('/')):
raise android.FrameError('unsafe path in Android bundle: ' + name[:200])
return parts
def _kind(name):
parts = _path(name)
if len(parts) >= 4 and parts[:2] == ('Android', 'data') and android.PKG_RE.fullmatch(parts[2]):
raise android.FrameError('Android/data restore is not supported by this importer; choose an APK/OBB-only backup')
if name.lower().endswith('.apk'):
return 'apk', None, None
if name.lower().endswith('.obb'):
match = OBB.fullmatch(parts[-1])
if not match or not (len(parts) == 1 or
len(parts) == 2 and parts[0] == match[2] or
len(parts) == 4 and parts[:2] == ('Android', 'obb') and parts[2] == match[2]):
raise android.FrameError('OBB needs its standard main/patch.<version>.<package>.obb name and Android/obb/<package> location')
return 'obb', match[2], None
if (len(parts) == 1 and name in METADATA) or name.lower().endswith('.apk.idsig'):
return 'metadata', None, None
raise android.FrameError('Unsupported Android bundle file: ' + name + '; no files were installed')
def _unpack(path, root, budget):
output, seen = [], set()
with zipfile.ZipFile(path) as archive:
members = _members(archive)
if len(members) + budget['files'] > MAX_FILES:
raise android.FrameError('Android import has too many files')
declared = sum(m.file_size for m, _ in members)
if declared + budget['bytes'] > MAX_BYTES or declared > max(0, shutil.disk_usage(root).free - 64 * 1024**2):
raise android.FrameError('Android bundle exceeds the size limit or available staging space')
# Validate the entire directory before creating any file.
for member, name in members:
_path(member.filename)
key = member.filename.rstrip('/').casefold()
mode = stat.S_IFMT(member.external_attr >> 16)
if key in seen or member.flag_bits & 1 or mode not in (0, stat.S_IFREG, stat.S_IFDIR):
raise android.FrameError('Android bundle contains duplicate paths, encrypted files, links or special files')
seen.add(key)
if not member.is_dir() and not _finder_metadata(member.filename):
_kind(name)
for member, name in members:
if member.is_dir():
continue
if _finder_metadata(member.filename):
budget['bytes'] += member.file_size
continue
kind, pkg, rel = _kind(name)
target = root.joinpath(*_path(member.filename))
target.parent.mkdir(parents=True, exist_ok=True)
size = 0
with archive.open(member) as src, target.open('xb') as dst:
while True:
chunk = src.read(1024 * 1024)
if not chunk:
break
size += len(chunk)
budget['bytes'] += len(chunk)
if budget['bytes'] > MAX_BYTES or size > member.file_size:
raise android.FrameError('Android bundle exceeds the size limit')
dst.write(chunk)
if size != member.file_size:
raise android.FrameError('Incomplete file in Android bundle: ' + member.filename)
if kind != 'metadata':
output.append((target, kind, pkg, rel))
budget['files'] += len(members)
return output
def unpack_bundle(path, root):
"""Extract a single explicit Android bundle into caller-owned staging.
Reuse the regular backup inspector and installer; no archive metadata is
trusted as package identity, and ordinary PC ZIPs keep their existing route.
"""
try:
entries = _unpack(Path(path), Path(root), {'bytes': 0, 'files': 0})
except android.FrameError:
raise
except (OSError, ValueError, RuntimeError, EOFError, struct.error, zlib.error,
zipfile.BadZipFile) as error:
raise android.FrameError('Could not read Android bundle: ' + str(error)) from error
paths = [str(local) for local, _, _, _ in entries]
if not any(Path(p).suffix.lower() == '.apk' for p in paths):
raise android.FrameError('Android bundles must contain one standalone APK and optional OBB files.')
return paths
def _check_standalone(path):
try:
with zipfile.ZipFile(path) as archive:
elements = frame_apk.manifest_elements(
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
if any((tag == 'manifest' and attrs.get('split', (None, None, None))[2]) or
(tag == 'application' and attrs.get('isSplitRequired', (None, 0, None))[1])
for tag, attrs in elements):
raise android.FrameError('This backup needs split APKs. This importer accepts only a standalone APK; '
'use the documented Quest/CLI split workflow for supported library/asset splits.')
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, ValueError, struct.error, IndexError) as error:
raise android.FrameError('Could not read APK manifest: ' + str(error)) from error
def inspect(paths, discover=False):
"""Inspect selected files before installing anything. Desktop APK drops can find
@@ -32,6 +172,7 @@ def inspect(paths, discover=False):
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:
_check_standalone(apk)
info = android.apk_info(str(apk))
try:
android.check_installable(info)
+1 -1
View File
@@ -12,7 +12,7 @@ import zlib
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
0x01010202: 'targetActivity'}
0x01010202: 'targetActivity', 0x01010591: 'isSplitRequired'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
+5 -5
View File
@@ -1367,10 +1367,10 @@
<div class="store-owned" id="storeOwned">
<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.
<p class="sub">Have a backup? Choose the game's APK and all its .obb files together, or one XAPK/APKS/APKM bundle containing a standalone APK. Split sets and Android/data restore are not supported here.
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>
<input type="file" id="androidBackupInput" accept=".apk,.obb,.xapk,.apks,.apkm" 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">
@@ -1821,7 +1821,7 @@
</main>
</div>
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
<span class="sub">Android backups: drop the <code>.apk</code> and its <code>.obb</code> game data together.
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
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>
@@ -3657,8 +3657,8 @@ function sendFiles(files, dirs = new Set()) {
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));
const androidFiles = files.filter((f, i) => !dirs.has(i) && /\.(apk|obb|xapk|apks|apkm)$/i.test(f.name));
const grouped = androidFiles.length > 1 || androidFiles.some(f => /\.(obb|xapk|apks|apkm)$/i.test(f.name));
if (grouped && window.connDevice === dev) await importAndroidFiles(androidFiles, dev);
for (const [i, f] of files.entries()) {
if (window.connDevice !== dev) {
+10 -2
View File
@@ -1990,7 +1990,15 @@ def android_backup(body):
entry = _android_backup(token)
if action == 'inspect':
try:
entry['plan'] = frame_android_import.inspect(entry['paths'], discover=not entry['dirs'])
paths = entry['paths']
bundles = [p for p in paths if Path(p).suffix.lower() in frame_android_import.ARCHIVES]
if bundles:
if len(paths) != 1:
raise frame_android.FrameError('Choose one complete Android bundle, or loose APK/OBB files together.')
folder = tempfile.mkdtemp(prefix=f'{ANDROID_BACKUP_TMP}{os.getpid()}-')
entry['dirs'].append(folder)
paths = frame_android_import.unpack_bundle(bundles[0], folder)
entry['plan'] = frame_android_import.inspect(paths, discover=not entry['dirs'])
except (frame_android.FrameError, OSError) as error:
with _android_backups_lock:
_android_backups.pop(token, None)
@@ -3477,7 +3485,7 @@ class Handler(BaseHTTPRequestHandler):
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
if (not name.lower().endswith(('.apk', '.obb', '.xapk', '.apks', '.apkm')) 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-'