Compare commits

...
4 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
Alex SouthwellandSAPHID 4b68971383 Add read-only power and heat sampler for issue #52 (#135)
* Add read-only power and heat sampler for issue 52

* Make power sampler IIO fixture portable on Windows

* test: normalize Windows fixture paths for power sampler

---------

Co-authored-by: SAPHID <saphid@users.noreply.github.com>
2026-10-24 04:43:47 +00:00
16 changed files with 1074 additions and 12 deletions

No files matched your search

+10
View File
@@ -2,6 +2,16 @@
## 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;
active smoke-test locks stop measurement. No power settings are changed.
- Show saved Android compatibility reports on store search cards and app details.
Reports refresh in the background, so a stalled database or credential lookup
cannot delay search. Warnings allow installation; build compatibility blockers
+3
View File
@@ -44,6 +44,9 @@ Live video of what the lenses show, or of the app panel in use, flat and still h
**🔋 Battery and status**<br>
Charge, charging watts and time left, storage, memory, temperature, Wi-Fi, and what's running.
For repeatable read-only battery, power-rail, temperature and fan measurements,
use the [power and heat sampler](docs/power-and-heat.md).
</td>
</tr>
<tr>
+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.
+149
View File
@@ -0,0 +1,149 @@
{
"date": "2026-10-24",
"osVersion": "0.5.6",
"osBuild": "20261015.6141207",
"commands": [
"scripts/frame-power-sample.sh --count 3 --interval 1",
"scripts/frame-power-sample.sh --count 1 --csv"
],
"jsonSampleCount": 3,
"elapsedSeconds": [
0.078011,
1.088542,
2.107291
],
"stopped": null,
"unavailableGroups": [
[
"proximity"
],
[
"proximity"
],
[
"proximity"
]
],
"selectedSummary": {
"battery.percent": {
"unit": "%",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 99,
"max": 99,
"mean": 99.0,
"first": 99,
"last": 99
},
"battery.status": {
"unit": "state",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"states": [
"Not charging"
]
},
"battery.tempC": {
"unit": "C",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": 26.400000000000002,
"max": 26.400000000000002,
"mean": 26.4,
"first": 26.400000000000002,
"last": 26.400000000000002
},
"battery.watts": {
"unit": "W",
"sources": [
"/sys/class/power_supply/max1720x_bat_7-36"
],
"available": 3,
"unavailable": 0,
"min": -0.12,
"max": -0.02,
"mean": -0.07,
"first": -0.02,
"last": -0.07
},
"cpu.percent": {
"unit": "%",
"sources": [
"/proc/stat"
],
"available": 2,
"unavailable": 1,
"min": 8.7,
"max": 11.0,
"mean": 9.85,
"first": 11.0,
"last": 8.7
},
"fan.pwmRaw": {
"unit": "raw",
"sources": [
"/sys/class/hwmon/hwmon50/pwm1"
],
"available": 3,
"unavailable": 0,
"min": 41,
"max": 41,
"mean": 41.0,
"first": 41,
"last": 41
},
"fan.slg4ax46073v.fan1_input": {
"unit": "RPM",
"sources": [
"/sys/class/hwmon/hwmon50/fan1_input"
],
"available": 3,
"unavailable": 0,
"min": 8624,
"max": 8638,
"mean": 8628.666667,
"first": 8638,
"last": 8624
},
"proximity.raw": {
"unit": "raw",
"sources": [
"/sys/bus/iio/devices/iio:device2/in_proximity_raw"
],
"available": 0,
"unavailable": 3,
"states": []
},
"rail.max34417_9_10.vph.power1": {
"unit": "W",
"sources": [
"/sys/class/hwmon/hwmon53/power1_input"
],
"available": 3,
"unavailable": 0,
"min": 4.656299,
"max": 4.804066,
"mean": 4.705555,
"first": 4.656299,
"last": 4.804066
}
},
"csvRows": 92,
"csvSummaryMetrics": 91,
"csvHasExplicitUnavailable": true,
"limits": [
"Read-only short transport/parser check; no controlled baseline or worn/load/battery experiment.",
"Proximity unavailable; numeric thermistors are not proof of sensor accuracy.",
"No settings, services, device files or application state changed; no remote installation.",
"No smoke lock at preflight or sampler checks; advisory checks cannot exclude a concurrent lock appearing during a read."
]
}
+122
View File
@@ -0,0 +1,122 @@
# Read-only power and heat measurements
Issue [#52](https://github.com/saphid/frame-control/issues/52) starts with
measurement. `scripts/frame-power-sample.sh` is a bounded, on-demand sampler
over the existing `ssh frame` alias. It needs Python 3 on both computers and
an already paired, awake, reachable Frame. It uses strict SSH host-key checking
and batch mode; it never pairs, prompts for a password or changes SSH security.
```sh
# Twelve samples, five seconds apart (about 55 seconds), JSON report:
scripts/frame-power-sample.sh > power.json
# Five-minute observation: samples at 0, 5, ... 300 seconds:
scripts/frame-power-sample.sh --count 61 --interval 5 > baseline.json
# Long-form CSV samples; JSON summary goes separately to stderr:
scripts/frame-power-sample.sh --count 12 --interval 5 --csv > power.csv 2> summary.json
```
Count is limited to 1–720; interval to finite values from 1–60 seconds. These
are target start-to-start intervals, not guarantees: read latency can delay a
sample. Wall-clock `time` and monotonic `elapsedSeconds` record actual timing.
The first sample has no CPU utilization: two valid `/proc/stat` observations
are required. CPU utilization then covers the interval between observations,
averaged across the machine, rather than the utilization of one process.
## What it reads
- Battery percentage, driver charging state, signed battery watts and battery
temperature, using the same parser as Frame Control's battery details.
Positive battery watts mean charging on the Frame fuel gauge; negative means
discharge. `Not charging` alone does not prove a charger is connected.
- CPU utilization from `/proc/stat`; GPU clock from the known Frame devfreq path.
- Named thermal zones in °C, with zone names retained. A thermal zone is not
automatically a CPU sensor; there is no inferred “CPU temperature” or safe
temperature threshold.
- hwmon power channels, labels and driver names in watts. `vph` is the measured
system rail, not power at the wall. CPU `apc*` and GPU `gfx` rails are separate
readings; do not sum all channels as if they were independent power consumers.
- Fan RPM and the Frame fan driver's raw `pwm1`. No generic percentage conversion
is assumed. The historical Frame driver reported a percent-like value of 41.
- Named IIO board temperatures, PM8550B input/charge current in amps, and raw
VCNL4000 proximity if the account can read it. Raw proximity has no inferred
worn/unworn threshold.
Each metric includes its unit, source path and availability state. Missing,
unreadable or invalid values are JSON `null`, not zero. A missing sensor family
is also listed in each sample's `unavailableGroups`; CSV adds unavailable group
rows. Numeric zero remains a valid reading. The OS build is recorded per sample
when `/etc/os-release` is readable; hostnames, addresses, machine IDs, process
lists, logs, screenshots and application inventories are not collected.
The JSON report contains raw samples and a summary per metric: available and
unavailable counts, numeric minimum/maximum/arithmetic mean/first/last, or the
observed charging states. Means exclude unavailable readings. They are sample
means, not time-weighted energy or battery-runtime estimates. Different sample
availability and intervals must be considered when comparing sessions.
Numeric validity does not establish sensor accuracy. Historical readings
already showed an apparently absent/broken right-display thermistor near
−16 °C. This firmware also reports one charger-lite die sensor at −60 °C.
Those raw numbers remain visible for diagnosis; neither should be used to
infer cooling performance or safety. This tool does not diagnose heat, explain
a spike, predict battery health, or promise power savings.
## Safety and shared-device use
Only file reads under `/proc`, `/sys` and `/etc/os-release` run on the Frame.
The Python sources are sent on SSH stdin and not installed on the headset.
There is no sudo, OpenVR initialization, service call, settings write, process
control, journal query, wake, suspend or reboot. No daemon, telemetry upload or
automatic measurement is added. Redirects above write only to your computer.
The observer itself uses a little CPU and SSH traffic, so label baseline
conditions and avoid interpreting very small changes as savings.
The sampler checks `/tmp/frame-test.lock` on the computer before SSH, and on
the Frame before any probe and before each sample. If a smoke session is active,
it exits 75. A lock appearing mid-run stops the next sample, retains earlier
samples, sets `stopped`, and exits 75. It never removes or acquires another
session's lock. This is an advisory check, not an atomic lease: a smoke session
can start during a file read. Coordinate sessions and defer measurement when
timing overlaps. It does not block someone else from starting a smoke test.
SSH failures/timeouts return a nonzero exit and an explanation, without
presenting a zero or an old report as a successful measurement. Fully missing
sensors can still produce a successful report with explicit unavailable states.
On a connection failure, partial samples buffered on the headset can be lost.
## Evidence and remaining work
Read-only checks on **2026-10-24**, SteamOS **0.5.6**, build
**20261015.6141207**, confirmed battery, CPU ticks, thermal zones, rail power,
fan, GPU clock, board temperatures and charger current through the final
sampler. Proximity was unavailable. Both JSON and CSV paths were exercised;
see [the recorded results](evidence/power-sampler-2026-10-24.json).
No smoke lock was present at the preflight or sampler checks. No settings,
services, application state or device files were changed. This short run is
a parser/transport check, not a controlled baseline or a worn-headset test.
The original planning document is in commit `fe132e3` (2026-09-29). It recorded
an off-head, charging idle baseline of about 4.71 W, CPU zones around 40–45 °C,
and about 8,350 fan RPM on build 20260925.6191901. Those conditions and firmware
differ from the current check; comparisons do not establish a regression.
This change completes only the sampler portion of “Plan: leave it on, but
cheaply.” Controlled worn/home-space, streaming/game load and unplugged
baselines, and the cause of the 2026-09-28 95 °C spike, remain uninvestigated.
Quiet mode, compositor changes, CPU/fan/charging changes and remote-wake tests
are separate work. No controls are offered here. Follow the shared-device
procedure in [testing.md](testing.md#headset-smoke-test) for future experiments.
Offline validation:
```sh
python3 -m unittest discover -s tests -p test_power_sample.py -v
bash -n scripts/frame-power-sample.sh
python3 -m py_compile scripts/frame_power_sample.py
```
Fixtures cover units, signed and zero values, missing/malformed/permission-denied
readings, named/discovered channels, unknown charging state, CPU warmup,
summary counts, CSV quoting, strict SSH transport and smoke-lock interruption.
+6
View File
@@ -12,6 +12,12 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
## Unit tests
The read-only power/heat sampler has fixture and transport tests in
`tests/test_power_sample.py`. Run
`python3 -m unittest discover -s tests -p test_power_sample.py -v`.
See [power-and-heat.md](power-and-heat.md) for sampling, smoke-lock behavior and
real-device verification limits.
```sh
python3 -m unittest discover -s tests
```
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Read-only, bounded power/heat sampler. See docs/power-and-heat.md.
set -euo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
exec python3 "$root/scripts/frame_power_sample.py" "$@"
+211
View File
@@ -0,0 +1,211 @@
"""Bounded read-only Frame measurements; no device files are written.
The host sends this and frame_status.py over one trusted SSH connection. Only
battery() and CPU tick helpers are reused: no OpenVR, services or analytics.
"""
import argparse
import csv
import glob
import io
import json
import math
from pathlib import Path
import subprocess
import sys
import time
LOCK = Path('/tmp/frame-test.lock')
UNAVAILABLE = 'unavailable: not reported, unreadable or invalid'
def read(path):
try:
return Path(path).read_text().strip()
except (OSError, UnicodeError):
return None
def number(path, scale=1, minimum=None, maximum=None):
try:
value = int(read(path)) * scale
if not math.isfinite(value):
return None
if minimum is not None and value < minimum or maximum is not None and value > maximum:
return None
return round(value, 6)
except (TypeError, ValueError, OverflowError):
return None
def snapshot(status, before):
if LOCK.exists():
raise RuntimeError('Smoke session active (/tmp/frame-test.lock); no sensors read.')
ticks = status.cpu_ticks()
readings = {}
def add(key, value, unit, source):
readings[key] = {'value': value, 'unit': unit, 'source': source,
'state': 'available' if value is not None else UNAVAILABLE}
battery = status.battery() or {}
for field, unit in (('percent', '%'), ('status', 'state'), ('watts', 'W'),
('tempC', 'C')):
value = battery.get(field)
if field == 'status' and value not in ('Charging', 'Discharging', 'Not charging', 'Full'):
value = None
add('battery.' + field, value, unit, battery.get('source'))
add('cpu.percent', status.cpu_percent(before, ticks), '%', '/proc/stat')
if before is None:
readings['cpu.percent']['state'] = 'unavailable: needs two valid CPU tick samples'
add('gpu.clock', number('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6, 0),
'MHz', '/sys/class/devfreq/3d00000.gpu/cur_freq')
# Discover by driver name/label, never assume a hwmon index. Keep source
# paths and channel numbers to distinguish duplicate labels.
for node in sorted(glob.glob('/sys/class/hwmon/hwmon*')):
name = read(node + '/name') or Path(node).name
for path in sorted(glob.glob(node + '/power*_input')):
channel = Path(path).name[:-6]
label = read(node + '/' + channel + '_label') or channel
add(f'rail.{name}.{label}.{channel}', number(path, 1e-6, 0), 'W', path)
for path in sorted(glob.glob(node + '/fan*_input')):
add(f'fan.{name}.{Path(path).stem}', number(path, minimum=0), 'RPM', path)
# pwm1 on Frame's driver is percent; leave raw to avoid assuming the
# generic hwmon 0..255 scale on other drivers or later firmware.
if name == 'slg4ax46073v':
add('fan.pwmRaw', number(node + '/pwm1', minimum=0), 'raw', node + '/pwm1')
for node in sorted(glob.glob('/sys/class/thermal/thermal_zone*')):
name = read(node + '/type') or Path(node).name
add(f'thermal.{name}.{Path(node).name}', number(node + '/temp', 0.001, -273.15, 200),
'C', node + '/temp')
for node in sorted(glob.glob('/sys/bus/iio/devices/iio:device*')):
for path in sorted(glob.glob(node + '/in_temp_*_input')):
add(f'board.{Path(node).name}.{Path(path).stem}', number(path, 0.001, -273.15, 200),
'C', path)
for field in ('iin', 'ichg'):
path = node + '/in_current_pm8550b_' + field + '_fb_input'
# Known names, but no fixed IIO index; absent channels are covered
# by group availability below.
if Path(path).exists():
add(f'charger.{Path(node).name}.{field}', number(path, 1e-6), 'A', path)
if read(node + '/name') == 'vcnl4000':
add('proximity.raw', number(node + '/in_proximity_raw', minimum=0),
'raw', node + '/in_proximity_raw')
unavailable = [group for group in ('rail', 'fan', 'thermal', 'board', 'charger', 'proximity')
if not any(k.startswith(group + '.') and v['value'] is not None
for k, v in readings.items())]
os_info = dict(line.split('=', 1) for line in (read('/etc/os-release') or '').splitlines()
if '=' in line)
return {'time': time.time(), 'osBuild': os_info.get('BUILD_ID', '').strip('"') or None,
'readings': readings, 'unavailableGroups': unavailable}, ticks
def summarize(samples):
result = {}
keys = sorted({key for sample in samples for key in sample['readings']})
for key in keys:
rows = [s['readings'][key] for s in samples if key in s['readings']]
values = [r['value'] for r in rows if r['value'] is not None]
item = {'unit': rows[0]['unit'], 'sources': sorted({r['source'] for r in rows if r['source']}),
'available': len(values), 'unavailable': len(samples) - len(values)}
if values and all(isinstance(v, (int, float)) for v in values):
item.update(min=min(values), max=max(values), mean=round(sum(values) / len(values), 6),
first=values[0], last=values[-1])
else:
item['states'] = sorted(set(values))
result[key] = item
return result
def collect(status, count, interval):
samples, before = [], None
started = time.monotonic()
stopped = None
for index in range(count):
if index:
time.sleep(max(0, started + index * interval - time.monotonic()))
try:
sample, before = snapshot(status, before)
except RuntimeError as exc:
stopped = str(exc)
break
sample['elapsedSeconds'] = round(time.monotonic() - started, 6)
samples.append(sample)
return {'schemaVersion': 1, 'requestedSamples': count, 'intervalSeconds': interval,
'stopped': stopped, 'samples': samples, 'summary': summarize(samples)}
def csv_samples(report):
output = io.StringIO(newline='')
writer = csv.writer(output)
writer.writerow(('time', 'elapsedSeconds', 'metric', 'value', 'unit', 'source', 'state'))
for sample in report['samples']:
for key, row in sample['readings'].items():
# csv quoting does not prevent spreadsheet formulas in labels.
def cell(value):
return "'" + value if isinstance(value, str) and value.startswith(('=', '+', '-', '@')) else value
writer.writerow([cell(v) for v in (sample['time'], sample['elapsedSeconds'], key,
row['value'], row['unit'], row['source'], row['state'])])
for group in sample['unavailableGroups']:
writer.writerow((sample['time'], sample['elapsedSeconds'], group, '', '', '', UNAVAILABLE))
return output.getvalue()
def parser():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument('--count', type=int, default=12, help='samples, 1..720 (default 12)')
ap.add_argument('--interval', type=float, default=5, help='seconds, 1..60 (default 5)')
ap.add_argument('--csv', action='store_true', help='CSV samples on stdout; JSON summary on stderr')
ap.add_argument('--probe', action='store_true', help=argparse.SUPPRESS)
return ap
def remote_payload():
root = Path(__file__).resolve().parents[1]
source = (root / 'ui/frame_status.py').read_text()
# Executing as a module prevents frame_status.main() from probing unrelated
# status. Nothing is installed, cached or logged on the headset.
return ('import types, sys\n'
'status = types.ModuleType("frame_status")\n'
f'exec(compile({source!r}, "frame_status.py", "exec"), status.__dict__)\n'
'sys.modules["frame_status"] = status\n' + Path(__file__).read_text())
def main(argv=None):
ap = parser()
args = ap.parse_args(argv)
if not 1 <= args.count <= 720 or not math.isfinite(args.interval) or not 1 <= args.interval <= 60:
ap.error('count must be 1..720 and interval must be finite, 1..60 seconds')
# Also honor a coordinator lock on the computer before opening SSH.
if LOCK.exists():
print('Smoke session active (/tmp/frame-test.lock); no SSH or sensors accessed.', file=sys.stderr)
return 75
if args.probe:
import frame_status
report = collect(frame_status, args.count, args.interval)
else:
command = ['ssh', '-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5',
'-o', 'ServerAliveCountMax=2', 'frame',
'python3', '-', '--probe', '--count', str(args.count), '--interval', str(args.interval)]
try:
run = subprocess.run(command, input=remote_payload(), text=True, capture_output=True,
timeout=args.count * args.interval + 30)
if run.returncode:
print('Read-only sample failed: ' + (run.stderr.strip() or f'SSH exit {run.returncode}'),
file=sys.stderr)
return run.returncode
report = json.loads(run.stdout)
except (OSError, subprocess.TimeoutExpired, ValueError) as exc:
print(f'Read-only sample unavailable: {exc}', file=sys.stderr)
return 1
if args.csv:
sys.stdout.write(csv_samples(report))
print(json.dumps({k: v for k, v in report.items() if k != 'samples'}, allow_nan=False), file=sys.stderr)
else:
print(json.dumps(report, allow_nan=False))
return 75 if report['stopped'] else 0
if __name__ == '__main__':
sys.exit(main())
+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()
+209
View File
@@ -0,0 +1,209 @@
"""Power sampler fixtures and SSH transport contract; no real device contact."""
import sandbox # noqa: F401
import contextlib
import csv
import importlib.util
import io
import json
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location('power_sample', ROOT / 'scripts/frame_power_sample.py')
power = importlib.util.module_from_spec(spec)
spec.loader.exec_module(power)
sys.path.insert(0, str(ROOT / 'ui'))
import frame_status as status
class Sampler(unittest.TestCase):
def patch(self, obj, name, **kwargs):
p = mock.patch.object(obj, name, **kwargs)
result = p.start()
self.addCleanup(p.stop)
return result
def fixture(self, files):
# Windows cannot create a directory named iio:device8. Keep the
# production sysfs spelling in returned paths while using a portable
# name inside this temporary fixture tree.
def local_path(path):
return self.root / str(path).replace('\\', '/').lstrip('/').replace(':', '__COLON__')
for path, value in files.items():
node = local_path(path)
node.parent.mkdir(parents=True, exist_ok=True)
node.write_text(str(value))
actual_glob = power.glob.glob
def paths(pattern):
return ['/' + Path(p).relative_to(self.root).as_posix().replace('__COLON__', ':')
for p in actual_glob(str(local_path(pattern)))]
actual_read = power.read
self.patch(power.glob, 'glob', side_effect=paths)
self.patch(power, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
self.patch(status, 'read', side_effect=lambda p: actual_read(str(local_path(str(p)))))
return local_path
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
p = mock.patch.object(power, 'LOCK', self.root / 'lock')
p.start()
self.addCleanup(p.stop)
def test_real_file_units_zero_signed_battery_and_named_sensors(self):
h = '/sys/class/hwmon/hwmon88'
b = '/sys/class/power_supply/max1720x_bat_7-36'
self.fixture({h + '/name': 'max34417_9_10', h + '/power1_label': 'vph',
h + '/power1_input': '4710000',
'/sys/class/hwmon/hwmon3/name': 'slg4ax46073v',
'/sys/class/hwmon/hwmon3/fan1_input': '0',
'/sys/class/hwmon/hwmon3/pwm1': '41',
'/sys/class/thermal/thermal_zone9/type': 'cpu3_top_thermal',
'/sys/class/thermal/thermal_zone9/temp': '45321',
b + '/type': 'Battery', b + '/capacity': '0', b + '/status': 'Discharging',
b + '/voltage_now': '3800000', b + '/current_now': '-1000000',
'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
sample, ticks = power.snapshot(status, (80, 80))
r = sample['readings']
self.assertEqual(r['rail.max34417_9_10.vph.power1']['value'], 4.71)
self.assertEqual(r['battery.percent']['value'], 0)
self.assertEqual(r['battery.watts']['value'], -3.8)
self.assertEqual(r['battery.status']['value'], 'Discharging')
self.assertEqual(r['fan.slg4ax46073v.fan1_input']['value'], 0)
self.assertEqual(r['fan.pwmRaw']['unit'], 'raw')
self.assertEqual(r['thermal.cpu3_top_thermal.thermal_zone9']['value'], 45.321)
self.assertEqual(r['cpu.percent']['value'], 50)
self.assertEqual(ticks, (100, 90))
self.assertEqual(sample['unavailableGroups'], ['board', 'charger', 'proximity'])
def test_missing_and_invalid_values_remain_unavailable(self):
self.fixture({'/sys/class/thermal/thermal_zone0/temp': '999999999',
'/sys/class/hwmon/hwmon2/name': 'fan',
'/sys/class/hwmon/hwmon2/fan1_input': '-1'})
sample, ticks = power.snapshot(status, None)
self.assertIsNone(ticks)
self.assertTrue(all(r['value'] is None for r in sample['readings'].values()))
self.assertEqual(len(sample['unavailableGroups']), 6)
self.assertIn('two valid CPU', sample['readings']['cpu.percent']['state'])
def test_permission_error_is_unavailable(self):
with mock.patch.object(Path, 'read_text', side_effect=PermissionError):
self.assertIsNone(power.number('/unreadable'))
for value in ('NaN', 'Infinity', '1.5', '', '9' * 5000):
with mock.patch.object(power, 'read', return_value=value):
self.assertIsNone(power.number('/invalid'))
def test_iio_units_and_permission_denied_proximity(self):
node = '/sys/bus/iio/devices/iio:device8'
local_path = self.fixture({node + '/name': 'vcnl4000',
node + '/in_temp_pcba_at_wifi_input': '37500',
node + '/in_current_pm8550b_iin_fb_input': '241671',
'/etc/os-release': 'BUILD_ID=20261015.6141207\n'})
# exists() is used only for optional known charger channels and lock.
exists = Path.exists
with mock.patch.object(Path, 'exists', lambda p: exists(p) if p == power.LOCK
else exists(local_path(str(p)))):
sample, _ = power.snapshot(status, None)
self.assertEqual(sample['osBuild'], '20261015.6141207')
self.assertEqual(sample['readings']['board.iio:device8.in_temp_pcba_at_wifi_input']['value'], 37.5)
self.assertEqual(sample['readings']['charger.iio:device8.iin']['value'], 0.241671)
self.assertIsNone(sample['readings']['proximity.raw']['value'])
self.assertIn('proximity', sample['unavailableGroups'])
def test_unknown_charging_state_is_not_claimed(self):
self.fixture({})
with mock.patch.object(status, 'battery', return_value={'status': 'Unknown', 'percent': 50}):
r = power.snapshot(status, None)[0]['readings']
self.assertIsNone(r['battery.status']['value'])
self.assertEqual(r['battery.percent']['value'], 50)
def test_smoke_lock_prevents_any_sensor_reads(self):
power.LOCK.mkdir()
with mock.patch.object(status, 'cpu_ticks') as ticks:
with self.assertRaisesRegex(RuntimeError, 'Smoke session'):
power.snapshot(status, None)
ticks.assert_not_called()
def test_smoke_lock_appearing_between_samples_stops_and_preserves_partial_report(self):
self.fixture({'/proc/stat': 'cpu 10 0 0 90 0 0 0 0'})
with mock.patch.object(power.time, 'sleep', side_effect=lambda _: power.LOCK.mkdir()):
report = power.collect(status, 3, 1)
self.assertEqual(len(report['samples']), 1)
self.assertIn('Smoke session', report['stopped'])
self.assertEqual(report['requestedSamples'], 3)
def test_summaries_ignore_missing_values_and_keep_state_changes(self):
def row(value, unit='W'):
return {'value': value, 'unit': unit, 'source': '/sensor', 'state': 'available'}
samples = [{'readings': {'rail': row(0), 'state': row('Charging', 'state')}},
{'readings': {'rail': row(None), 'state': row('Full', 'state')}},
{'readings': {'rail': row(6)}}]
summary = power.summarize(samples)
self.assertEqual(summary['rail'], dict(unit='W', sources=['/sensor'], available=2,
unavailable=1, min=0, max=6, mean=3, first=0, last=6))
self.assertEqual(summary['state']['states'], ['Charging', 'Full'])
self.assertEqual(summary['state']['unavailable'], 1)
def test_csv_quoting_and_unavailable_groups(self):
report = {'samples': [{'time': 1, 'elapsedSeconds': 0,
'readings': {'fan,test': {'value': -3.8, 'unit': 'W',
'source': '=formula', 'state': 'available'}},
'unavailableGroups': ['thermal']}]}
rows = list(csv.reader(io.StringIO(power.csv_samples(report))))
self.assertEqual(rows[1][2:6], ['fan,test', '-3.8', 'W', "'=formula"])
self.assertEqual(rows[2][-1], power.UNAVAILABLE)
def test_bounded_arguments_rejected_before_ssh(self):
for args in (['--count', '0'], ['--count', '721'], ['--interval', 'nan'],
['--interval', 'inf'], ['--interval', '0.5'], ['--interval', '61']):
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit):
power.main(args)
run.assert_not_called()
def test_local_lock_prevents_ssh(self):
power.LOCK.mkdir()
with mock.patch.object(power.subprocess, 'run') as run, contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(power.main([]), 75)
run.assert_not_called()
def test_ssh_payload_runs_as_module_and_outputs_report(self):
report = dict(samples=[], summary={}, stopped=None)
def ssh(command, **kwargs):
self.assertIn('BatchMode=yes', command)
self.assertIn('StrictHostKeyChecking=yes', command)
self.assertIn('frame', command)
self.assertEqual(command[-4:], ['--count', '2', '--interval', '1.0'])
# Execute the exact transmitted source, with main suppressed. Its
# frame_status module must be importable without running status().
namespace = {'__name__': 'payload_test'}
with mock.patch.dict(sys.modules):
exec(compile(kwargs['input'], '<ssh-stdin>', 'exec'), namespace)
remote = sys.modules['frame_status']
self.assertTrue(callable(remote.battery))
with mock.patch.object(remote, 'status', side_effect=AssertionError('full status forbidden')), \
mock.patch.object(remote, 'battery', return_value=None), \
mock.patch.object(remote, 'cpu_ticks', return_value=(100, 90)), \
mock.patch.object(namespace['glob'], 'glob', return_value=[]), \
mock.patch.object(namespace['Path'], 'exists', return_value=False):
report.update(namespace['collect'](remote, 1, 1))
return subprocess.CompletedProcess(command, 0, json.dumps(report), '')
output = io.StringIO()
with mock.patch.object(power.subprocess, 'run', side_effect=ssh), contextlib.redirect_stdout(output):
self.assertEqual(power.main(['--count', '2', '--interval', '1']), 0)
self.assertEqual(len(json.loads(output.getvalue())['samples']), 1)
def test_ssh_failures_are_not_measurements(self):
for result in (subprocess.CompletedProcess([], 255, '', 'offline'),
subprocess.CompletedProcess([], 0, 'bad json', '')):
output = io.StringIO()
with mock.patch.object(power.subprocess, 'run', return_value=result), \
contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
self.assertNotEqual(power.main([]), 0)
self.assertEqual(output.getvalue(), '')
+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-'