mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-25 10:02:21 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79efb4730 | ||
|
|
95643b7ea0 | ||
|
|
9dab523af9 |
No files matched your search
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Import a base APK with supported native-library or asset splits from a local
|
||||
Android backup or XAPK/APKS/APKM bundle. Check package and version before
|
||||
install; refuse resource, code and feature splits and device-variant sets.
|
||||
This route has local fixture coverage, not a real split-game headset test.
|
||||
|
||||
- Save Steam screenshots under a separate folder for each headset, Steam
|
||||
account and game. Equal screenshot filenames no longer overwrite one another;
|
||||
existing files in the old flat folder are left in place.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.30",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.29",
|
||||
"version": "0.4.30",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
+10
-4
@@ -4,7 +4,7 @@ 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 **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the APK and its OBB files together.
|
||||
In **Add / Install → Import Android game** (or **Apps → Browse → Import Android game**), choose the base APK, any supported splits, 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.
|
||||
@@ -37,9 +37,13 @@ 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
|
||||
This flow handles one base APK, supported native-library or asset splits, and
|
||||
standard OBB files for one game at a time. It checks the package and version in
|
||||
each split manifest before touching the headset. The existing Quest merger
|
||||
rejects resource, code and feature splits and sets with multiple device
|
||||
variants. A merged APK is re-signed; that can affect a game's licence or login.
|
||||
XAPK/APKS/APKM archives use the same checks. Nested Android/data folders and
|
||||
private save archives remain unsupported. 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
|
||||
@@ -55,3 +59,5 @@ released, and the battery remained at 99%. See [device evidence](evidence/androi
|
||||
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.
|
||||
The supported-split backup route was checked with generated local fixtures and
|
||||
existing merger tests, not a real split game on the Frame.
|
||||
+11
-9
@@ -436,13 +436,13 @@ 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
|
||||
base APK, any supported splits, 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
|
||||
one base APK, optional supported splits and 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
|
||||
@@ -450,7 +450,7 @@ 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;
|
||||
100-file/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**.
|
||||
@@ -463,12 +463,14 @@ 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.
|
||||
**Limits:** this backup importer requires exactly one base APK. For split sets,
|
||||
each split must identify the same package and version, and pass the existing
|
||||
[Quest split merger](quest-import.md#split-apks). It refuses resource, code and
|
||||
feature splits and does not choose among device variants. Merging re-signs the
|
||||
APK, which can affect licences. It does not restore `Android/data`, private app
|
||||
data, saves or entitlements. These split imports have local fixture and merger
|
||||
tests, but no real split game has been installed with this backup route on the
|
||||
Frame.
|
||||
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# What we have tested
|
||||
|
||||
Current for Frame Control **v0.4.29**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
Current for Frame Control **v0.4.30**, 25 October 2026. “Real Frame + physical PC” means an actual headset and a non-virtual computer. “VM only” means a Windows virtual machine, sometimes connected to the real Frame. A CI test or a simulated device is **not** a physical hardware test. The date/version column is the last relevant check, not a promise that every new release was retested on that platform. See each link for the exact procedure and limits.
|
||||
|
||||
| Feature | Real Frame + physical PC tested | VM only | Untested or limited | Last check and proof |
|
||||
|---|---|---|---|---|
|
||||
@@ -17,6 +17,7 @@ Current for Frame Control **v0.4.29**, 25 October 2026. “Real Frame + physical
|
||||
| Switch PC windows | No physical Windows PC test | No real VM-to-Frame Alt+Tab check recorded | Explicit chord is covered by tests; actual window focus, keyboard layouts and controller interaction are unverified | 25 Oct, v0.4.24: [PR #181](https://github.com/saphid/frame-control/pull/181), [PC guide](pc-in-headset.md) |
|
||||
| Keyboard and trackpad from Frame Control | A Linux PC sent a click and ASCII keys to a temporary focused window on the real Frame | No | Worn-headset interaction, Unicode delivery and pixel-accurate pointing | 24 Oct, v0.4.19: [received-event log](evidence/pad-gamescope-2026-10-24.md), [PR #66](https://github.com/saphid/frame-control/pull/66) |
|
||||
| Backup and restore after reset | Same-headset backup, no-op save restore and Return to Game Mode checks | No | Restore **after a factory reset**, replacement restore and Android data restore have not been tested on a headset. No headset reset was done | 24 Oct, v0.4.17: [backup guide](backup-and-recovery.md), [Return to Game Mode log](evidence/return-game-mode-packaged-2026-10-24.json) |
|
||||
| Android game backup import | F-Droid Basic APK with synthetic main and patch OBBs installed, transferred and read back on a real Frame | No | v0.4.30 restricted split-APK backup route has generated-fixture and existing merger tests only; no real split game or RE4 gameplay test | 20 Oct OBB headset check; 25 Oct, v0.4.30 split fixtures: [OBB device evidence](evidence/android-import/device.json), [split PR #206](https://github.com/saphid/frame-control/pull/206), [guide](android-backups.md) |
|
||||
| Passthrough in recordings | No active-passthrough mirror check | No | Standby frame was uniform; whether visible room pixels enter `/dev/video99` is **unknown**. Raw cameras are never captured by Frame Control | 25 Oct, v0.4.21: [headset log](evidence/round3-headset-2026-10-25.md), [recording guide](recording.md#does-record-include-passthrough) |
|
||||
| Open at login | No Frame needed; Linux autostart file toggled on a physical Linux host with an isolated config directory | No | A full login session and physical Windows/macOS host login have not been checked; OS API calls were unit tested | 25 Oct, v0.4.26: [PR #189](https://github.com/saphid/frame-control/pull/189) |
|
||||
| Local Frame Control extensions | Example panel displayed live Frame status in a browser preview; the real Frame served a read-only example in the experimental headset panel | No | No wearer-visible extension view; third-party extensions and physical Windows/macOS extension folders untested | 25 Oct, v0.4.29: [PR #194](https://github.com/saphid/frame-control/pull/194), [PR #201](https://github.com/saphid/frame-control/pull/201), [extension guide](extensions.md) |
|
||||
|
||||
+30
-9
@@ -1,10 +1,29 @@
|
||||
# Open questions and on-device checks
|
||||
|
||||
Research as of 2026-09-25, eight days after the Frame's retail release
|
||||
(2026-09-18). Most first-party detail comes from Valve's Steamworks developer
|
||||
pages. Searches of Reddit and the Steam forums turned up **almost no
|
||||
end-user reports** about SSH, desktop streaming, or macOS. Treat that as
|
||||
"not documented yet", not "doesn't work".
|
||||
The September sections below record early checks after the Frame's retail
|
||||
release (2026-09-18). They are a historical test log, not a current setup
|
||||
guide. For current connection steps, see [SteamOS October 2026
|
||||
compatibility](steamos-2026-10-compat.md). For the charge-limit result, see
|
||||
[charge-limit.md](charge-limit.md).
|
||||
|
||||
## Current Frame OS 0.5.6 status (2026-10-25)
|
||||
|
||||
- `frame.local` no longer resolves on the checked 0.5.6 beta build
|
||||
(`20261022.6153909`). Avahi was active but did not publish addresses. The
|
||||
configured Tailscale SSH alias worked. Frame Control can use a saved IP on
|
||||
the same network after verifying the pinned SSH key, or a manual IP/SSH
|
||||
alias. Same-LAN fallback scanning was **not** tested on this Tailscale route.
|
||||
See the [live compatibility check](evidence/steamos-compat-live-2026-10-25.md).
|
||||
- Read-only checks on that build found no SteamOS Manager
|
||||
`BatteryChargeLimit1` interface or `charge_control_*` attribute. The vendor
|
||||
charging switches are root-owned and managed by Valve's charger service.
|
||||
No charging control was written. An 80% limit is **not supported** by Frame
|
||||
Control; see [charge-limit.md](charge-limit.md) and the
|
||||
[headset check](evidence/round3-headset-2026-10-25.md).
|
||||
- Active passthrough in the recording mirror remains **unknown**: the only
|
||||
captured mirror frame was uniform while the headset was unworn in standby.
|
||||
A worn, active passthrough check is still needed; see the
|
||||
[headset check](evidence/round3-headset-2026-10-25.md).
|
||||
|
||||
## Verified on device (2026-09-25)
|
||||
|
||||
@@ -14,7 +33,9 @@ build 20260922.6101926, kernel 6.18, aarch64):
|
||||
- **1–2.** Developer Mode + Set User Password gave working SSH with no terminal
|
||||
steps. `sshd` is enabled and active. The user is `steamos` (in `wheel`) and
|
||||
the hostname is `frame`.
|
||||
- **3.** `frame.local` resolves from the Mac; `avahi-daemon` is active.
|
||||
- **3.** `frame.local` resolved from the Mac on the September build;
|
||||
`avahi-daemon` was active. This result does not apply to the checked 0.5.6
|
||||
beta build; see the current status above.
|
||||
- **5.** `/etc/ssh/sshd_config` has `Include /etc/ssh/sshd_config.d/*.conf`.
|
||||
The existing drop-ins are `20-systemd-userdb.conf` and `99-archlinux.conf`, so
|
||||
`01-frame-keys-only.conf` would sort first as intended. (`--harden` itself
|
||||
@@ -148,9 +169,9 @@ check in the headset:
|
||||
Can an 80% charge limit be set over SSH or adb? **Not safely yet.** SteamOS
|
||||
Manager's `BatteryChargeLimit1` (the interface Steam's own setting uses) has
|
||||
no backend in the Frame's device config, and the battery node is a
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Research and
|
||||
read-only headset checks are in [charge-limit.md](charge-limit.md). Not tested
|
||||
on a headset.
|
||||
`max1720x` fuel gauge with no `charge_control_end_threshold`. Read-only checks
|
||||
on the real Frame confirmed this on 0.5.6 beta; no charge setting was written.
|
||||
See [charge-limit.md](charge-limit.md).
|
||||
|
||||
## Unconfirmed claims made in these docs
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
from test_quest import apk_bytes
|
||||
|
||||
PKG = 'com.Armature.VR4'
|
||||
META = {'package': PKG, 'label': PKG, 'version': '', 'version_code': 210,
|
||||
@@ -66,6 +67,41 @@ class BackupTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'unique names'):
|
||||
backup.inspect([str(self.apk), str(self.obb[0]), str(duplicate)])
|
||||
|
||||
def test_supported_split_set_uses_manifest_base_and_existing_merge_path(self):
|
||||
base = self.root / 'not-named-base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'arm64.apk'
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
plan = backup.inspect([str(split), str(base)])
|
||||
self.assertEqual(plan['apk'], base)
|
||||
self.assertEqual(plan['splits'], [split])
|
||||
self.assertEqual(backup.public(plan)['splits'], ['arm64.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) as install:
|
||||
backup.install(plan)
|
||||
self.assertEqual(install.call_args.kwargs['splits'], [str(split)])
|
||||
|
||||
def test_split_mismatch_and_unsupported_resource_fail_before_frame_access(self):
|
||||
base = self.root / 'base.apk'
|
||||
base.write_bytes(apk_bytes(PKG, split_required=True, version_code=210))
|
||||
split = self.root / 'split.apk'
|
||||
with patch.object(backup.android, 'ssh') as ssh:
|
||||
split.write_bytes(apk_bytes(PKG, split='config.arm64_v8a', version_code=211,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'version cannot be verified'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.xxhdpi', version_code=210,
|
||||
files={'res/drawable/icon.png': b'png'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'screen-density or language resources'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
split.write_bytes(apk_bytes(PKG, split='config.x86_64', version_code=210,
|
||||
files={'lib/x86_64/libgame.so': b'game'}))
|
||||
with self.assertRaisesRegex(backup.android.FrameError, 'another device'):
|
||||
backup.inspect([str(base), str(split)])
|
||||
ssh.assert_not_called()
|
||||
|
||||
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'):
|
||||
@@ -204,6 +240,21 @@ class BundleTests(unittest.TestCase):
|
||||
self.assertTrue(all(not Path(p).exists() for p in folders))
|
||||
self.assertTrue(source.exists())
|
||||
|
||||
def test_bundle_with_supported_split_uses_same_preview_and_cleanup(self):
|
||||
source = self.bundle({
|
||||
'base.apk': apk_bytes(PKG, split_required=True, version_code=210),
|
||||
'config.arm64.apk': apk_bytes(PKG, split='config.arm64_v8a', version_code=210,
|
||||
files={'lib/arm64-v8a/libgame.so': b'game'}),
|
||||
}, suffix='.apks')
|
||||
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']['apk'], 'base.apk')
|
||||
self.assertEqual(read['plan']['splits'], ['config.arm64.apk'])
|
||||
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))
|
||||
|
||||
def test_browser_upload_bundle_is_staged_and_removed_with_extraction(self):
|
||||
source = self.bundle()
|
||||
token = server.android_backup({'action': 'begin'})['token']
|
||||
@@ -221,7 +272,7 @@ class BundleTests(unittest.TestCase):
|
||||
|
||||
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(), 'split.apk': self.apk.read_bytes()}, 'exactly one base'),
|
||||
({'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'),
|
||||
@@ -229,7 +280,7 @@ class BundleTests(unittest.TestCase):
|
||||
({'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'),
|
||||
({'manifest.json': b'{}'}, 'base APK'),
|
||||
]
|
||||
for entries, message in cases:
|
||||
with self.subTest(message=message), patch.object(backup.android, 'ssh') as ssh:
|
||||
@@ -277,7 +328,8 @@ class BundleTests(unittest.TestCase):
|
||||
('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'):
|
||||
message = 'exactly one base' if attrs.get('split') else 'needs split APKs'
|
||||
with self.assertRaisesRegex(backup.android.FrameError, message):
|
||||
backup.inspect([str(self.apk)])
|
||||
|
||||
def test_bundle_install_uses_existing_queue_and_cleans_after_failure(self):
|
||||
|
||||
+58
-14
@@ -131,10 +131,30 @@ def unpack_bundle(path, root):
|
||||
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.')
|
||||
raise android.FrameError('Android bundles must contain a base APK and optional supported splits or OBB files.')
|
||||
return paths
|
||||
|
||||
|
||||
def _apk_identity(path):
|
||||
"""Use the manifest, not the filename, to identify a base or split APK."""
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
elements = frame_apk.manifest_elements(
|
||||
frame_apk._read(archive, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
|
||||
attrs = next((attrs for tag, attrs in elements if tag == 'manifest'), {})
|
||||
package = (attrs.get('package') or (None, None, None))[2]
|
||||
split = (attrs.get('split') or (None, None, None))[2]
|
||||
code = (attrs.get('versionCode') or (None, None, None))[1]
|
||||
major = (attrs.get('versionCodeMajor') or (None, 0, None))[1]
|
||||
if not package or not android.PKG_RE.fullmatch(package):
|
||||
raise android.FrameError(f'{path.name}: no valid package in APK manifest')
|
||||
return package, split, (major, code) if code is not None else None
|
||||
except (zipfile.BadZipFile, frame_apk.ApkError, OSError, UnicodeError,
|
||||
ValueError, RuntimeError, EOFError, zlib.error, struct.error,
|
||||
IndexError) as error:
|
||||
raise android.FrameError(f'Could not read APK manifest in {path.name}: {error}') from error
|
||||
|
||||
|
||||
def _check_standalone(path):
|
||||
try:
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
@@ -168,19 +188,41 @@ def inspect(paths, discover=False):
|
||||
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
|
||||
apk, splits = None, []
|
||||
if apks:
|
||||
identities = [(p, *_apk_identity(p)) for p in apks]
|
||||
bases = [p for p, _, split, _ in identities if not split]
|
||||
if len(bases) != 1:
|
||||
raise android.FrameError('Choose one game at a time: exactly one base APK is required.')
|
||||
apk = bases[0]
|
||||
package, version = next((pkg, code) for p, pkg, _, code in identities if p == apk)
|
||||
split_names = set()
|
||||
for path, pkg, split, code in identities:
|
||||
if path == apk:
|
||||
continue
|
||||
if pkg != package or split in split_names:
|
||||
raise android.FrameError('Split APKs must have the same package and distinct split names.')
|
||||
if version is None or code is None or code != version:
|
||||
raise android.FrameError('Split APK version cannot be verified against the base APK.')
|
||||
split_names.add(split)
|
||||
splits.append(path)
|
||||
if apk:
|
||||
_check_standalone(apk)
|
||||
if not splits:
|
||||
_check_standalone(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))
|
||||
if not splits:
|
||||
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
|
||||
if splits:
|
||||
added = android.split_entries(str(apk), [str(p) for p in splits])
|
||||
if any(name.startswith('lib/') and not name.startswith('lib/arm64-v8a/') for name in added):
|
||||
raise android.FrameError('This split set includes native libraries for another device. '
|
||||
'Choose only the arm64-v8a split for the Frame.')
|
||||
checked = quest.inspect(info['package'], str(apk), [str(p) for p in splits])
|
||||
pkg = checked['package']
|
||||
if discover and len(files) == 1:
|
||||
files.extend(p for p in sorted(apk.parent.iterdir())
|
||||
@@ -213,13 +255,14 @@ def inspect(paths, discover=False):
|
||||
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,
|
||||
return {'apk': apk, 'splits': splits, '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,
|
||||
'splits': [p.name for p in plan['splits']],
|
||||
'obb': [{'name': p.name, 'size': p.stat().st_size} for p in plan['obb']],
|
||||
'size': sum(size for size, _ in plan['signatures'].values())}
|
||||
|
||||
@@ -243,7 +286,8 @@ def install(plan, display='auto', progress=None):
|
||||
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')
|
||||
flatscreen=None if display == 'auto' else display == 'flat',
|
||||
splits=[str(p) for p in plan['splits']])
|
||||
else:
|
||||
meta = android._meta_or_fail(pkg)
|
||||
if not plan['obb']:
|
||||
|
||||
+4
-3
@@ -1373,7 +1373,7 @@
|
||||
<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 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.
|
||||
<p class="sub">Have a backup? Choose the game's base APK, supported native-library or asset splits, and all its .obb files together, or one XAPK/APKS/APKM bundle. Resource, code and feature splits 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,.xapk,.apks,.apkm" multiple hidden>
|
||||
@@ -1916,7 +1916,7 @@
|
||||
</main>
|
||||
</div>
|
||||
<div class="dropzone" id="dropzone" hidden><div><b>Drop to send to the Frame</b>
|
||||
<span class="sub">Android backups: drop an APK with its OBB files, or one XAPK/APKS/APKM bundle (standalone APK only).
|
||||
<span class="sub">Android backups: drop a base APK with supported splits and OBB files, or one XAPK/APKS/APKM bundle.
|
||||
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>
|
||||
|
||||
@@ -3980,7 +3980,8 @@ async function importAndroidFiles(files, dev, probe = false) {
|
||||
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>`
|
||||
$("androidImportFiles").innerHTML = (plan.apk ? `<div class="sub">Game app</div><div>${esc(plan.apk)}</div>` +
|
||||
(plan.splits || []).map(f => `<div>${esc(f)} <span class="sub">split APK</span></div>`).join("")
|
||||
: `<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("");
|
||||
|
||||
Reference in new issue
Block a user