mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-14 04:05:52 +02:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9697812984 | ||
|
|
141b95ddde | ||
|
|
4ec2ff40c3 | ||
|
|
39e819872a | ||
|
|
ee7951e8d0 | ||
|
|
77c6c286cd | ||
|
|
ca53b26701 | ||
|
|
baec984449 | ||
|
|
18e5053b64 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 | ||
|
|
d456aa1f3d | ||
|
|
6c7b2ca22f | ||
|
|
8a06848b5f | ||
|
|
9cca67529b | ||
|
|
b656f80922 | ||
|
|
7cc418662d | ||
|
|
b2bbff4305 | ||
|
|
2cfcdaaf0e | ||
|
|
2960581541 | ||
|
|
8b1c290730 | ||
|
|
b64082c974 | ||
|
|
7c1d698400 | ||
|
|
b6406a90a4 | ||
|
|
9b26c907ce | ||
|
|
5f3798b79b | ||
|
|
6e0b140efc | ||
|
|
226ec40720 | ||
|
|
f1aaed6279 |
No files matched your search
@@ -213,6 +213,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
|
||||
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
|
||||
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
|
||||
| [Import from my Quest](docs/quest-import.md) | Copy games you own off your Quest by USB; what runs, and why licence-checked games don't |
|
||||
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
|
||||
| [Install links for websites](docs/web-install.md) | `frame-control://install` links and manifests, the rules, a button to paste |
|
||||
| [VR comfort and HUD](docs/vr-utilities.md) · [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
|
||||
@@ -39,6 +39,12 @@ section below), instead of going into Lepton Development:
|
||||
run at once alongside Lepton Development, each in its own container
|
||||
(`lepton-steamlaunch-<id>`, ADB on 5556, 5557, …).
|
||||
|
||||
Split APKs (a base plus `split_*.apk` files) install as one app:
|
||||
`python3 ui/frame_android.py install base.apk split1.apk …`. Lepton runs one
|
||||
APK, so splits that add native libraries or assets are merged into the base
|
||||
and re-signed; density and language splits are refused. See
|
||||
[quest-import.md](quest-import.md#split-apks). Not yet tried on a real Frame.
|
||||
|
||||
Verified with AntennaPod and Tabletop Tools: installed in about 7 s, launched
|
||||
from the shortcut, stopped, and relaunched with their data intact. ADB and
|
||||
Lepton Development aren't involved.
|
||||
|
||||
+16
-7
@@ -33,6 +33,12 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
every computer, which lets us count headsets rather than installs; it can't
|
||||
be turned back into the machine-id or a serial number. `headsets_saved` is how
|
||||
many headsets this copy of Frame Control has set up.
|
||||
|
||||
## Usage events
|
||||
|
||||
@@ -41,7 +47,7 @@ computer, exactly as they were sent.
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
|
||||
@@ -51,12 +57,15 @@ error becomes one category from this fixed list, plus Android's own
|
||||
`INSTALL_FAILED_…` code (`installer_code`) when there is one:
|
||||
`android_installer`, `apk_needs_newer_android`, `apk_wrong_abi`,
|
||||
`layer_missing`, `apk_repack_failed`, `tool_missing`, `apk_unreadable`,
|
||||
`cant_run_on_frame`, `steam_shortcut`, `frame_not_set_up`, `frame_auth`,
|
||||
`frame_unreachable`, `frame_disk_full`, `download_failed`, `flatpak`,
|
||||
`cancelled`, `lepton` or `other`. A VR APK that installed without Frame
|
||||
Control's OpenXR compatibility layer, because this copy of the app is missing
|
||||
it, carries `xr_layer_missing: true`; otherwise that field is left out. It
|
||||
names what was installed only when that's already public:
|
||||
`cant_run_on_frame`, `steam_shortcut`, `steam_not_running`,
|
||||
`steam_not_responding`, `steam_js_error`, `artwork_failed`, `copy_timeout`,
|
||||
`frame_not_set_up`, `frame_auth`, `frame_unreachable`, `frame_disk_full`,
|
||||
`copy_failed`, `download_failed`, `flatpak`, `cancelled`, `lepton`,
|
||||
`internal_error` (a mistake in Frame Control's own code) or `other`. A VR
|
||||
APK that installed without Frame Control's OpenXR compatibility layer,
|
||||
because this copy of the app is missing it, carries `xr_layer_missing: true`;
|
||||
otherwise that field is left out. It names what was installed only when
|
||||
that's already public:
|
||||
|
||||
- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
|
||||
catalogue app's package name
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# Import from my Quest
|
||||
|
||||
Move games you own from your own Meta Quest onto the Steam Frame. Plug the
|
||||
Quest into your computer by USB; Frame Control copies the game off it with
|
||||
adb, checks it can run in Lepton, and installs it on the Frame like any other
|
||||
APK, with its own Lepton instance and Steam library entry
|
||||
([apks.md](apks.md), [vr-apks.md](vr-apks.md)).
|
||||
|
||||
There is no licence-check bypass, and there won't be. A game that checks its
|
||||
Meta store licence is refused, with the reason. See
|
||||
[What won't run](#what-wont-run-and-why) below.
|
||||
|
||||
## Before you start: developer mode on the Quest
|
||||
|
||||
adb only reaches a Quest with developer mode on:
|
||||
|
||||
1. Make a free Meta developer organisation at
|
||||
[developers.meta.com](https://developers.meta.com/horizon/) on a **verified**
|
||||
Meta account (Meta asks for a phone number or payment card to verify).
|
||||
2. In the Meta Horizon phone app, open the headset's settings and switch
|
||||
**Developer Mode** on. Restart the Quest.
|
||||
3. Plug the Quest into the computer that runs Frame Control with a USB cable
|
||||
that carries data (some charging cables don't).
|
||||
4. Put the headset on. Accept **Allow USB debugging**, and tick **Always allow
|
||||
from this computer**.
|
||||
|
||||
Frame Control uses the adb it already uses for the Frame (an adb you
|
||||
installed, else the one it bundles). On Linux, adb also needs permission to
|
||||
open the Quest: add a udev rule for Meta's USB vendor id `2833`, for example
|
||||
`SUBSYSTEM=="usb", ATTR{idVendor}=="2833", MODE="0660", GROUP="plugdev"`, and
|
||||
be in that group. Then unplug and replug.
|
||||
|
||||
The Frame's own adb connections (Frame Control's SSH tunnels to Lepton,
|
||||
`127.0.0.1:<port>`) are never touched. Only a device that reports itself as
|
||||
Oculus or Meta, or a Quest model, counts as a Quest.
|
||||
|
||||
## Using it
|
||||
|
||||
In the app: **Android → On your Frame → Import from my Quest**. Choose **Look
|
||||
for my Quest**, then **Import** next to a game. Tick **Also copy each game's
|
||||
files in Android/data** to bring the game's external files too (some games
|
||||
keep saves there). Progress shows in the panel; the result and any reason it
|
||||
stopped appear in the message log.
|
||||
|
||||
A game with game data (an OBB folder, or Android/data when ticked) takes two
|
||||
imports, because Android can only take that data while the game's Lepton
|
||||
instance is running, and there is no way to start the instance without the
|
||||
game. Frame Control never starts the game itself:
|
||||
|
||||
1. **Import** installs the game and says it has data to add.
|
||||
2. Start the game from the Steam library and leave it on its first screen.
|
||||
3. **Import** again. Frame Control sees the game open with the same version,
|
||||
copies the data in, and closes the game. Start it again to play.
|
||||
|
||||
A game that quits straight away without its data can't take it this way.
|
||||
|
||||
From a terminal:
|
||||
|
||||
```sh
|
||||
python3 ui/frame_quest.py devices # what adb sees, and which is the Quest
|
||||
python3 ui/frame_quest.py list # apps on the Quest: split or not, OBB and data sizes
|
||||
python3 ui/frame_quest.py import com.example.game # copy one to the Frame
|
||||
python3 ui/frame_quest.py import com.example.game --data --serial 1WMHH000XXXXXX
|
||||
```
|
||||
|
||||
The API is `GET /api/quest` (the Quest, or guidance when there isn't one, and
|
||||
its apps) and `POST /api/quest` with
|
||||
`{"action": "import", "package": "...", "serial": "...", "data": false}`,
|
||||
which returns a background job (`/api/job`) with a stage and percentage.
|
||||
|
||||
## What an import does
|
||||
|
||||
1. Finds the Quest (`adb devices -l`, then `ro.product.manufacturer` and
|
||||
`ro.product.model`). If none is ready it says why: nothing connected, not
|
||||
authorised yet (the **Allow USB debugging** prompt), no USB permission
|
||||
(Linux), offline, another Android device, or several Quests.
|
||||
2. Lists third-party apps (`pm list packages -3`), each with `pm path` (one
|
||||
line is a single APK, several a split APK) and the size of
|
||||
`/sdcard/Android/obb/<package>` and `/sdcard/Android/data/<package>`.
|
||||
Labels aren't shown before import: reading them means copying the APK.
|
||||
3. Checks there is room on the computer (the APKs, a merged and a patched copy
|
||||
of them counted uncompressed, the OBB folder and the Android/data folder
|
||||
once, since that is streamed to the Frame), then copies the APK and its splits
|
||||
into a per-user cache folder (`Frame Control/quest-import` under the
|
||||
system's cache folder).
|
||||
4. **Checks the APK before anything else is copied**, refusing:
|
||||
- Meta's Platform SDK licence check (`libovrplatformloader.so`, in the base
|
||||
or any split);
|
||||
- 32-bit-only builds (native code but no `arm64-v8a`);
|
||||
- apps that need an Android newer than Lepton's (API 30);
|
||||
- split APKs Frame Control can't merge (below).
|
||||
VrApi-only builds (`libvrapi.so`, no OpenXR loader) are flagged, as for any
|
||||
APK: they install, but won't run.
|
||||
5. If the game isn't open on the Frame: installs it through the normal APK
|
||||
install (`frame_android.install`: VR detection, the launcher fix, the
|
||||
OpenXR compatibility layer, artwork), and says whether it has game data
|
||||
to add. An older Quest copy never replaces a newer version on the Frame
|
||||
(compared by version code, which installs now record; for installs made
|
||||
before that, it is read from the installed APK on the Frame) unless you
|
||||
tick **Allow replacing a newer version** (`--allow-downgrade`,
|
||||
`"allowDowngrade": true`). If the installed version can't be read at all,
|
||||
that permission is needed too.
|
||||
6. If the game is open on the Frame (the second import): refuses unless
|
||||
Frame Control's install record is for this package, in this package's own
|
||||
instance, at the Quest's exact version code (an open game is never
|
||||
replaced), otherwise copies the OBB folder (and `Android/data` if ticked)
|
||||
from the Quest, waits for Android to have finished booting, copies standard
|
||||
`main|patch.<version>.<package>.obb` files with the existing OBB helper
|
||||
(checked by SHA-256 inside Android, [sidequest.md](sidequest.md#obb-files))
|
||||
once Android's own `dumpsys package` in that instance shows the same
|
||||
package at the same version code,
|
||||
streams the `Android/data` files in as a tar written straight into the SSH
|
||||
transfer, then closes the game so its next start reads them, and checks
|
||||
its instance has stopped; if it hasn't, or the check itself fails (a
|
||||
failed `podman ps` never reads as "stopped"), the result says so. The game
|
||||
is closed whatever happens during the copy, including a local read error
|
||||
or Ctrl-C, and a failed close is reported without hiding the original
|
||||
error. Everything
|
||||
from the second check of the install record (after the pulls, which can
|
||||
be long) to closing the game holds the same lock as installs and removals.
|
||||
Other files in the OBB folder are listed as not copied.
|
||||
7. Deletes the cache folder, whether the import worked or not. A folder left
|
||||
by a crash is removed by a later import after six hours.
|
||||
|
||||
Only one import runs at a time. If copying game data fails, the game is
|
||||
closed and the message says so; start it and import again.
|
||||
|
||||
### Split APKs
|
||||
|
||||
Lepton runs one APK per instance, so the shared install path now merges a
|
||||
split APK into one (`frame_android.install(base, splits=[...])`, or
|
||||
`python3 ui/frame_android.py install base.apk split1.apk ...`). Merging copies
|
||||
each split's native libraries and assets into the base, drops the base
|
||||
manifest's `isSplitRequired` flag, and re-signs with Frame Control's key, as
|
||||
the launcher fix already does. Only splits that hold nothing but
|
||||
`lib/<abi>/*.so`, `assets/` and their own signature, manifest and an empty
|
||||
resource table are merged. Refused, with the reason, before anything reaches
|
||||
the Frame: splits with resources (screen density or language), code
|
||||
(`classes*.dex`), any other file, a manifest that declares components or
|
||||
libraries (`<service>`, `<uses-library>`...), feature splits, and splits of
|
||||
another package. The merged files are held in memory while the APK is
|
||||
repacked and signed (as for every patched APK), so a merge adds at most
|
||||
512 MB per file and 1 GB in all. Each file is read from its raw bytes and
|
||||
checked against both of its ZIP headers: the local header must name the same
|
||||
file with the same method, sizes and checksum as the central directory (or a
|
||||
data descriptor must carry them; ZIP64 sizes are understood), the data must
|
||||
end before the next entry, a stored file must be exactly its declared size, a
|
||||
compressed one must end exactly at the end of its data with nothing after it,
|
||||
and its checksum must match. So a forged header can't truncate a file or fill
|
||||
memory. A resource table counts as empty only
|
||||
if it defines nothing at all (styles and other complex entries included). A merged, re-signed APK can't update a
|
||||
copy signed by the publisher.
|
||||
|
||||
## What won't run, and why
|
||||
|
||||
- **Games that check their Meta store licence.** Store games call Meta's
|
||||
Platform SDK (`libovrplatformloader.so`) to confirm you bought them. That
|
||||
needs Meta's services, which only exist on Meta headsets, so on the Frame
|
||||
the game quits. Frame Control refuses these and **won't remove or work
|
||||
around the check**, and doesn't bundle, call or link to any tool that does.
|
||||
Ask the developer for a build without it, or get the game from Steam or
|
||||
itch.io. Most Meta Store games have it; most SideQuest, itch.io and
|
||||
open-source games don't.
|
||||
- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only.
|
||||
- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi.
|
||||
- **Anything else that needs Meta**: avatars, Quest-only device checks,
|
||||
passthrough and other `XR_FB_*` features SteamVR lacks
|
||||
([vr-apks.md](vr-apks.md#what-steamvrs-android-runtime-supports-verified)).
|
||||
- **Private app data** (`/data/data/<package>`) can't be read over adb without
|
||||
root, so only saves in `Android/data` come across.
|
||||
|
||||
What usually works: OpenXR games without Meta's licence check, for example
|
||||
open-source and itch.io builds you sideloaded onto the Quest.
|
||||
|
||||
Only import games you own, for your own use.
|
||||
|
||||
## Evidence and limits
|
||||
|
||||
**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`):
|
||||
device detection (Quest, the Frame's tunnel, the Frame over USB, other
|
||||
Android devices, unauthorised, no permissions, offline, none, several),
|
||||
package listing and split detection, the refusals, the order of copies
|
||||
(APK checked before the OBB is copied), the two-step install then data copy
|
||||
(the game is never started by Frame Control, never replaced while open, and
|
||||
closed after its data is copied), split merging and signing, the split
|
||||
refusals and size limits (including a forged zip bomb), the space estimate,
|
||||
and cache clean-up after success and failure.
|
||||
|
||||
**Not verified:** a real Quest (none was available), Quest system apps in
|
||||
`pm list packages -3`, real split APKs from the Meta Store, OBB and
|
||||
`Android/data` writes into a running Lepton instance on a real Frame
|
||||
(the OBB helper itself is also unverified there), whether games stay open
|
||||
without their OBB long enough for the second import, and the Linux udev rule.
|
||||
Large games are copied into memory while being merged or patched, as every
|
||||
patched APK already is.
|
||||
+2
-1
@@ -214,7 +214,8 @@ source-search endpoint remain unverified (the public install seam is tested).
|
||||
|
||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||
(`libovrplatformloader.so`) to check the Quest store licence need Meta's
|
||||
services. Frame Control won't work around that.
|
||||
services. Frame Control won't work around that. [Import from my
|
||||
Quest](quest-import.md) refuses these before copying anything to the Frame.
|
||||
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
|
||||
not a patch.
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Small APKs built in memory (binary manifest plus resource table), for test_frame_apk
|
||||
and the end-to-end tests. Deliberately doesn't import sandbox: tests/e2e runs in a
|
||||
throwaway container and keeps its real home, which sandbox would move."""
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
+27
-4
@@ -10,6 +10,7 @@ import atexit
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -22,7 +23,22 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path[:0] = [str(ROOT / 'ui'), str(ROOT / 'tests'), str(ROOT / 'tests' / 'smoke')]
|
||||
|
||||
ENABLED = os.environ.get('FRAME_E2E') == '1'
|
||||
# Only inside the compose `host` container (FRAME_E2E_CONTAINER from compose.yaml, plus
|
||||
# the container runtime's marker file): these tests drive ui/server.py with the real
|
||||
# home, ~/.ssh and cache, which is only safe in a throwaway container.
|
||||
REQUESTED = os.environ.get('FRAME_E2E') == '1'
|
||||
IN_CONTAINER = os.environ.get('FRAME_E2E_CONTAINER') == '1' and (
|
||||
os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv'))
|
||||
ENABLED = REQUESTED and IN_CONTAINER
|
||||
if ENABLED:
|
||||
# What tests/sandbox.py does, minus moving HOME: the container's real ~/.ssh is what
|
||||
# points `ssh frame` at the fake Frame, and OpenSSH reads ~/.ssh from the password
|
||||
# database, not $HOME. (These tests used to get this through test_frame_apk.)
|
||||
_data = tempfile.mkdtemp(prefix='frame-control-e2e-')
|
||||
atexit.register(shutil.rmtree, _data, ignore_errors=True)
|
||||
os.environ['FRAME_CONTROL_DATA_DIR'] = _data
|
||||
os.environ['FRAME_CONTROL_TELEMETRY'] = '0'
|
||||
os.environ['FRAME_COMPAT_DB_URL'] = 'http://127.0.0.1:9'
|
||||
CTL = os.environ.get('FAKEFRAME_CTL', 'http://fakeframe:9999').rstrip('/')
|
||||
FAKE_HOST = os.environ.get('FAKEFRAME_HOST', 'fakeframe')
|
||||
HOME = '/home/steamos'
|
||||
@@ -30,9 +46,14 @@ SERVER_LOG = os.path.join(tempfile.gettempdir(), 'fakeframe-e2e-server.log')
|
||||
|
||||
|
||||
def require():
|
||||
"""Call at module level: skips the module unless the fake Frame is up."""
|
||||
if not ENABLED:
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
"""Skips (the module, at module level; the class, in setUpClass) unless this is
|
||||
scripts/e2e.sh's container with the fake Frame up."""
|
||||
if ENABLED:
|
||||
return
|
||||
if REQUESTED:
|
||||
raise unittest.SkipTest('FRAME_E2E=1 outside the e2e container: these tests use the real home, ~/.ssh '
|
||||
'and app cache, so they only run in scripts/e2e.sh\'s throwaway container')
|
||||
raise unittest.SkipTest('needs the fake Frame: run scripts/e2e.sh (sets FRAME_E2E=1)')
|
||||
|
||||
|
||||
# ---- the fake Frame's control port --------------------------------------------
|
||||
@@ -97,6 +118,7 @@ class Server:
|
||||
|
||||
@classmethod
|
||||
def start(cls):
|
||||
require() # never a server on this computer's real data
|
||||
if cls.proc and cls.proc.poll() is None:
|
||||
return
|
||||
with socket.socket() as s:
|
||||
@@ -193,6 +215,7 @@ class FrameTestCase(unittest.TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
require()
|
||||
Server.start()
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@@ -6,6 +6,8 @@ import sys
|
||||
import harness
|
||||
from harness import api, ok, finished, ssh
|
||||
|
||||
harness.require()
|
||||
|
||||
sys.path.insert(0, str(harness.ROOT / 'ui'))
|
||||
import frame_mcp
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
|
||||
import harness
|
||||
from harness import HOME, exists, ok, state, upload, wait_for
|
||||
from test_frame_apk import apk, manifest, resources
|
||||
from apk_fixtures import apk, manifest, resources
|
||||
|
||||
harness.require()
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A stand-in for Android's adb, for tests/test_quest.py: just enough of `devices -l`,
|
||||
`shell`, and `pull` for frame_quest.py, run as a real subprocess.
|
||||
|
||||
$FAKEADB_STATE is a JSON file:
|
||||
devices [{"serial", "state", "extra" (the rest of the `devices -l` line), "props" {getprop name: value}}]
|
||||
root a folder standing in for each device's file system (remote /x/y is root/x/y)
|
||||
packages {package: [APK paths, as `pm path` prints them]}
|
||||
fail_pull a remote path whose pull fails
|
||||
Every call is appended to $FAKEADB_LOG as a JSON line. Shell commands are the simple
|
||||
`; `-separated ones frame_quest sends, each answered here: no real shell runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
with open(os.environ['FAKEADB_LOG'], 'a') as f:
|
||||
f.write(json.dumps(args) + '\n')
|
||||
with open(os.environ['FAKEADB_STATE']) as f:
|
||||
state = json.load(f)
|
||||
root = state.get('root', '')
|
||||
serial = None
|
||||
if args[:1] == ['-s']:
|
||||
serial, args = args[1], args[2:]
|
||||
|
||||
|
||||
def fail(message, code=1):
|
||||
sys.stderr.write(message + '\n')
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def local(remote):
|
||||
return os.path.join(root, remote.lstrip('/'))
|
||||
|
||||
|
||||
def size_kb(path):
|
||||
total = 0
|
||||
for dirpath, _, files in os.walk(path):
|
||||
total += sum(os.path.getsize(os.path.join(dirpath, n)) for n in files)
|
||||
return max(4, total // 1024)
|
||||
|
||||
|
||||
if args == ['devices', '-l']:
|
||||
print('List of devices attached')
|
||||
for d in state.get('devices', []):
|
||||
print(f"{d['serial']:<22} {d['state']} {d.get('extra', '')}".rstrip())
|
||||
sys.exit(0)
|
||||
|
||||
device = next((d for d in state.get('devices', []) if d['serial'] == serial), None)
|
||||
if serial is None or device is None:
|
||||
fail(f"adb: device '{serial}' not found")
|
||||
if device['state'] == 'unauthorized':
|
||||
fail('adb: device unauthorized.\nThis adb server\'s $ADB_VENDOR_KEYS is not set')
|
||||
if device['state'] != 'device':
|
||||
fail(f"adb: device {device['state']}")
|
||||
|
||||
if args[:1] == ['shell']:
|
||||
out = []
|
||||
for command in ' '.join(args[1:]).split('; '):
|
||||
command = command.replace(' 2>/dev/null', '').strip()
|
||||
words = command.split()
|
||||
if not words or command == 'true':
|
||||
continue
|
||||
if words[0] == 'getprop':
|
||||
out.append(device.get('props', {}).get(words[1], ''))
|
||||
elif words[0] == 'echo':
|
||||
out.append(' '.join(words[1:]))
|
||||
elif command == 'pm list packages -3':
|
||||
out += ['package:' + p for p in state.get('packages', {})]
|
||||
elif words[:2] == ['pm', 'path']:
|
||||
out += ['package:' + p for p in state.get('packages', {}).get(words[2], [])]
|
||||
elif words[:2] == ['du', '-sk']:
|
||||
if os.path.isdir(local(words[2])):
|
||||
out.append(f'{size_kb(local(words[2]))}\t{words[2]}')
|
||||
elif words[:3] == ['stat', '-c', '%s']:
|
||||
out += [str(os.path.getsize(local(p.strip("'")))) for p in words[3:]]
|
||||
else:
|
||||
fail(f'/system/bin/sh: {words[0]}: inaccessible or not found', 127)
|
||||
print('\n'.join(out))
|
||||
sys.exit(0)
|
||||
|
||||
if args[:1] == ['pull'] and len(args) == 3:
|
||||
remote, dest = args[1], args[2]
|
||||
if remote == state.get('fail_pull'):
|
||||
fail(f"adb: error: failed to copy '{remote}' to '{dest}': remote Connection reset")
|
||||
src = local(remote)
|
||||
if not os.path.exists(src):
|
||||
fail(f"adb: error: failed to stat remote object '{remote}': No such file or directory")
|
||||
if os.path.isdir(dest):
|
||||
dest = os.path.join(dest, os.path.basename(remote.rstrip('/')))
|
||||
if os.path.isdir(src):
|
||||
shutil.copytree(src, dest)
|
||||
else:
|
||||
shutil.copyfile(src, dest)
|
||||
print(f'{remote}: 1 file pulled.')
|
||||
sys.exit(0)
|
||||
|
||||
fail('adb: unknown command ' + ' '.join(args))
|
||||
@@ -43,6 +43,7 @@ services:
|
||||
FAKEFRAME_CTL: http://fakeframe:9999
|
||||
FAKEFRAME_HOST: fakeframe
|
||||
FRAME_E2E: "1"
|
||||
FRAME_E2E_CONTAINER: "1" # tests/e2e/harness.py refuses to run anywhere else
|
||||
healthcheck:
|
||||
test: ["CMD", "test", "-f", "/home/tester/.ready"]
|
||||
interval: 1s
|
||||
|
||||
+31
-1
@@ -1,12 +1,20 @@
|
||||
"""Imported first by every test module: nothing a test does reaches this person's
|
||||
app data, their telemetry, or the shared compatibility database.
|
||||
app data, their ~/.ssh, their telemetry, or the shared compatibility database.
|
||||
|
||||
Must run before any ui module is imported, since those read these at import time.
|
||||
The variables are set in os.environ, so servers and scripts the tests start
|
||||
inherit them too (they pass {**os.environ, ...}).
|
||||
"""
|
||||
import atexit
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# The environment and real ~/.ssh from before the changes below: test_sandbox
|
||||
# reruns tests from that environment and checks ~/.ssh is left alone.
|
||||
ORIGINAL_ENV = dict(os.environ)
|
||||
REAL_SSH_DIR = Path.home() / ".ssh"
|
||||
|
||||
_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
|
||||
atexit.register(shutil.rmtree, _dir, ignore_errors=True)
|
||||
@@ -14,3 +22,25 @@ os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
|
||||
os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
|
||||
# A maintainer's machine holds the database key; send anything that slips through nowhere.
|
||||
os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
|
||||
|
||||
# A throwaway home, so Path.home() / "~" land here: ~/.ssh (config, host-key pins,
|
||||
# its lock file) and the other per-user folders some modules use. A server started
|
||||
# without FRAME_CONTROL_SSH_DIR used to import the real ~/.ssh/config and seed a pin
|
||||
# into the real ~/.ssh/frame-control-hosts on every run.
|
||||
#
|
||||
# tests/e2e must not import this (directly or through a test_* module): they run in
|
||||
# a throwaway container whose real ~/.ssh points `ssh frame` at the fake Frame, and
|
||||
# OpenSSH finds ~/.ssh from the password database, not $HOME. Shared helpers they
|
||||
# need live in sandbox-free modules such as apk_fixtures.py.
|
||||
HOME = os.path.join(_dir, "home")
|
||||
SSH_DIR = os.path.join(HOME, ".ssh")
|
||||
os.makedirs(SSH_DIR, mode=0o700)
|
||||
os.environ["HOME"] = HOME
|
||||
os.environ["USERPROFILE"] = HOME # Path.home() on Windows
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"] = SSH_DIR # frame_devices.ssh_dir(), frame_connect.SSH_DIR
|
||||
# The per-user folders that don't follow HOME when these are set: frame_host.cache_dir()
|
||||
# (servers warm indexes there and sweep its staging folders) and data_dir()'s fallbacks.
|
||||
for _name, _sub in (("LOCALAPPDATA", "AppData/Local"), ("APPDATA", "AppData/Roaming"),
|
||||
("XDG_CACHE_HOME", ".cache"), ("XDG_DATA_HOME", ".local/share"),
|
||||
("XDG_STATE_HOME", ".local/state"), ("XDG_CONFIG_HOME", ".config")):
|
||||
os.environ[_name] = os.path.join(HOME, *_sub.split("/"))
|
||||
@@ -1,4 +1,5 @@
|
||||
"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
@@ -255,3 +256,47 @@ class SensorTests(unittest.TestCase):
|
||||
self.assertIsNone(status.activity_level())
|
||||
with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
|
||||
self.assertEqual(status.activity_level(), 3)
|
||||
|
||||
|
||||
class DesktopBundleTests(unittest.TestCase):
|
||||
"""server.comfort runs the copy cached on the headset; the sources go only when it's missing."""
|
||||
|
||||
@unittest.skipUnless(os.name == "posix", "checks POSIX permissions")
|
||||
def test_sources_are_sent_once_per_version(self):
|
||||
import server
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
here, home = Path(tmp) / 'ui', Path(tmp) / 'frame'
|
||||
here.mkdir()
|
||||
home.mkdir()
|
||||
for name in server.COMFORT_FILES:
|
||||
(here / name).write_text('# ' + name + '\n')
|
||||
(here / 'frame_comfort.py').write_text('import json, sys\nprint(json.dumps({"active": False, "got": sys.argv[1]}))\n')
|
||||
sent = []
|
||||
|
||||
def ssh(remote, *, stdin, timeout): # the headset: the script run under its own home
|
||||
sent.append(stdin)
|
||||
return subprocess.run([sys.executable, '-'], input=stdin, capture_output=True, text=True, check=True,
|
||||
env={**os.environ, 'HOME': str(home)}).stdout
|
||||
|
||||
with patch.object(server, 'HERE', here), patch.object(server, 'ssh', side_effect=ssh):
|
||||
first = server.comfort({'action': 'status'})
|
||||
self.assertEqual(json.loads(first['got']), {'action': 'status'})
|
||||
self.assertEqual(len(sent), 2) # the check, then the sources
|
||||
self.assertNotIn('# frame_steam.py', sent[0])
|
||||
self.assertIn('# frame_steam.py', sent[1])
|
||||
self.assertEqual(server.comfort({'action': 'status'}), first)
|
||||
self.assertEqual(len(sent), 3)
|
||||
self.assertNotIn('# frame_steam.py', sent[2])
|
||||
cached, = (home / '.cache/frame-control/comfort').iterdir()
|
||||
self.assertEqual(sorted(p.name for p in cached.iterdir()), sorted(server.COMFORT_FILES))
|
||||
self.assertEqual(cached.stat().st_mode & 0o777, 0o700)
|
||||
self.assertTrue(all(p.stat().st_mode & 0o777 == 0o600 for p in cached.iterdir()))
|
||||
# A copy cut short is completed; a changed helper is a new version.
|
||||
(cached / 'frame_status.py').unlink()
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 5)
|
||||
self.assertTrue((cached / 'frame_status.py').exists())
|
||||
(here / 'frame_steam.py').write_text('# frame_steam.py, changed\n')
|
||||
server.comfort({'action': 'status'})
|
||||
self.assertEqual(len(sent), 7)
|
||||
self.assertEqual(len(list((home / '.cache/frame-control/comfort').iterdir())), 2)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -58,3 +59,32 @@ const log = ()=>{}, toast = ()=>{};
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_poll_waits_while_the_frame_is_unreachable(self):
|
||||
page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
|
||||
start = page.index('async function pollComfort()')
|
||||
code = page[start:page.index('$("comfortForm").onsubmit', start)]
|
||||
js = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const el = {textContent: 'Checking session…'};
|
||||
const $ = () => el;
|
||||
let online = false, comfortBusy = false, calls = 0, timers = 0;
|
||||
const api = async () => { calls++; return {}; };
|
||||
const renderComfort = () => {};
|
||||
const setTimeout = () => { timers++; };
|
||||
''' + code + r'''
|
||||
(async () => {
|
||||
await pollComfort();
|
||||
assert.equal(calls, 0); // no ssh attempt while the status check can't reach the Frame
|
||||
assert.match(el.textContent, /when the Frame is connected/);
|
||||
assert.equal(timers, 1); // still scheduled, to resume when it's back
|
||||
online = true;
|
||||
await pollComfort();
|
||||
assert.equal(calls, 1);
|
||||
online = null; // a status check that failed for another reason: keep polling
|
||||
await pollComfort();
|
||||
assert.equal(calls, 2);
|
||||
})().catch(e => { console.error(e); process.exitCode = 1; });
|
||||
'''
|
||||
result = subprocess.run(['node', '-e', js], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline authenticated repository fixtures; no tests contact a server."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Offline artwork, Steam API and launcher supervision regressions."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
@@ -422,6 +423,19 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
|
||||
self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
|
||||
|
||||
def test_failed_update_removes_partial_apk_and_keeps_the_app(self):
|
||||
with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
|
||||
patch.object(android, 'read_meta', return_value=dict(self.existing)), \
|
||||
patch.object(android, '_copy', side_effect=android.FrameError('copying x.apk to the Frame timed out')), \
|
||||
patch.object(android, 'ssh', return_value='') as ssh, \
|
||||
patch.object(android, 'shortcut_tool') as api:
|
||||
with self.assertRaisesRegex(android.FrameError, 'timed out'):
|
||||
android._install('x.apk', self.info, 'org.test.vr', False, None, None)
|
||||
commands = [c.args[0] for c in ssh.call_args_list]
|
||||
self.assertIn('rm -f Applications/Android/org.test.vr/app.apk.part', commands)
|
||||
self.assertFalse(any(c.startswith('rm -rf') for c in commands))
|
||||
api.assert_not_called()
|
||||
|
||||
def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
|
||||
with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
|
||||
patch.object(android, 'stop'), \
|
||||
@@ -485,6 +499,49 @@ class InstallTests(unittest.TestCase):
|
||||
self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
|
||||
|
||||
|
||||
class CopyAndSshTests(unittest.TestCase):
|
||||
"""The commands behind an install fail as FrameErrors with readable words, and a large APK
|
||||
gets the time it needs."""
|
||||
|
||||
def fake(self, stderr, code=1, stdout=b''):
|
||||
"""frame_host.run_ssh running a stand-in that writes these bytes, with the caller's options."""
|
||||
script = (f'import sys; sys.stdout.buffer.write({stdout!r}); sys.stderr.buffer.write({stderr!r}); '
|
||||
f'sys.exit({code})')
|
||||
return lambda argv, **kwargs: subprocess.run([sys.executable, '-c', script], **kwargs)
|
||||
|
||||
def test_large_apk_copy_timeout_grows_with_its_size(self):
|
||||
seen = {}
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)), \
|
||||
patch.object(android.os.path, 'getsize', return_value=2 * 1024 ** 3):
|
||||
android._copy(f.name, 'Applications/Android/x/app.apk.part')
|
||||
# 2 GB at the slowest rate allowed: 4096 s, not the old fixed 600 (300 MB at 4 Mbit/s).
|
||||
self.assertGreaterEqual(seen['timeout'], 2 * 1024 ** 3 / (512 * 1024))
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=lambda argv, **kw: seen.update(kw)):
|
||||
android._copy(f.name, 'x/launch.sh', timeout=120)
|
||||
self.assertEqual(seen['timeout'], 120) # a small file keeps the least time given
|
||||
|
||||
def test_copy_failure_in_another_encoding_is_a_frame_error(self):
|
||||
# A message from a Windows code page, or a file name, isn't UTF-8.
|
||||
with tempfile.NamedTemporaryFile(suffix='.apk') as f, \
|
||||
patch.object(android.shutil, 'which', return_value=None), \
|
||||
patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'scp: Verbindung \x81ber\xff\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Acopying .* to the Frame failed: scp: Verbindung'):
|
||||
android._copy(f.name, 'x/app.apk.part')
|
||||
|
||||
def test_ssh_failure_messages_are_text(self):
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'mv: \xff cannot move\n')):
|
||||
with self.assertRaisesRegex(android.FrameError, r'\Amv: .* cannot move\Z'):
|
||||
android.ssh('mv a b')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'cat: write error: No space left on device\n')):
|
||||
with self.assertRaises(android.FrameError) as caught:
|
||||
android.ssh('cat > x', input=b'\x89PNG') # bytes in, bytes out
|
||||
self.assertEqual(str(caught.exception), 'cat: write error: No space left on device')
|
||||
with patch.object(android.frame_host, 'run_ssh', side_effect=self.fake(b'', code=0, stdout=b'ok \xff\n')):
|
||||
self.assertEqual(android.ssh('echo ok').strip(), 'ok �')
|
||||
|
||||
|
||||
class SteamAPITests(unittest.TestCase):
|
||||
def test_artwork_api_enums_and_safe_serialization(self):
|
||||
with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
|
||||
|
||||
+15
-76
@@ -11,82 +11,7 @@ import zipfile
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
|
||||
import frame_apk # noqa: E402
|
||||
|
||||
|
||||
def pool(strings, utf8=False):
|
||||
"""A ResStringPool chunk."""
|
||||
data, offsets = b'', []
|
||||
for s in strings:
|
||||
offsets.append(len(data))
|
||||
if utf8:
|
||||
b = s.encode()
|
||||
data += bytes([len(s), len(b)]) + b + b'\0'
|
||||
else:
|
||||
data += struct.pack('<H', len(s)) + s.encode('utf-16-le') + b'\0\0'
|
||||
data += b'\0' * (-len(data) % 4)
|
||||
start = 28 + 4 * len(strings)
|
||||
body = struct.pack(f'<{len(strings)}I', *offsets) + data
|
||||
return struct.pack('<HHIIIIII', 1, 28, 28 + len(body), len(strings), 0, 0x100 if utf8 else 0, start, 0) + body
|
||||
|
||||
|
||||
def manifest(package, label_ref, version_ref, min_sdk, package_raw=True, foreign_label=False):
|
||||
"""<manifest package versionName><uses-sdk minSdkVersion/><application label icon/></manifest>.
|
||||
|
||||
package_raw=False drops the package's raw string (as some repackers do);
|
||||
foreign_label adds a non-android `label` attribute after android:label.
|
||||
"""
|
||||
strings = ['label', 'icon', 'versionName', 'minSdkVersion', 'package', 'manifest', 'uses-sdk',
|
||||
'application', package, 'junk', 'label', 'versionCode'] # the second 'label' has no android id
|
||||
resmap = struct.pack('<4I', 0x01010001, 0x01010002, 0x0101021c, 0x0101020c)
|
||||
resmap = struct.pack('<HHI', 0x0180, 8, 8 + len(resmap)) + resmap
|
||||
|
||||
def element(name, attrs):
|
||||
body = struct.pack('<IIHHHHHH', 0xffffffff, name, 20, 20, len(attrs), 0, 0, 0)
|
||||
for aname, raw, dtype, value in attrs:
|
||||
body += struct.pack('<IIIHBBI', 0xffffffff, aname, raw, 8, 0, dtype, value)
|
||||
return struct.pack('<HHIII', 0x0102, 16, 16 + len(body), 1, 0xffffffff) + body
|
||||
|
||||
none = 0xffffffff
|
||||
chunks = (pool(strings) + resmap
|
||||
+ element(5, [(4, 8 if package_raw else none, frame_apk.T_STRING, 8),
|
||||
(2, none, frame_apk.T_REF, version_ref),
|
||||
(11, none, frame_apk.T_INT_DEC, 210)])
|
||||
+ element(6, [(3, none, frame_apk.T_INT_DEC, min_sdk)])
|
||||
+ element(7, [(0, none, frame_apk.T_REF, label_ref), (1, none, frame_apk.T_REF, 0x7f020000)]
|
||||
+ ([(10, 9, frame_apk.T_STRING, 9)] if foreign_label else [])))
|
||||
return struct.pack('<HHI', 3, 8, 8 + len(chunks)) + chunks
|
||||
|
||||
|
||||
def resources(values):
|
||||
"""resources.arsc with package 0x7f; values: {(type id, entry, language, density): global string index}."""
|
||||
strings = ['French label', 'App label', '2.1', 'res/icon_lo.png', 'res/icon_hi.png', 'res/icon.xml']
|
||||
pkg_body = b''
|
||||
for (tid, lang, density), entries in values.items():
|
||||
cfg = struct.pack('<I4x2s4xH', 64, lang.encode().ljust(2, b'\0'), density).ljust(64, b'\0')
|
||||
count = max(entries) + 1
|
||||
offsets, data = [], b''
|
||||
for i in range(count):
|
||||
if i in entries:
|
||||
offsets.append(len(data))
|
||||
data += struct.pack('<HHI', 8, 0, 0) + struct.pack('<HBBI', 8, 0, frame_apk.T_STRING, entries[i])
|
||||
else:
|
||||
offsets.append(0xffffffff)
|
||||
header = 20 + 64
|
||||
estart = header + 4 * count
|
||||
body = struct.pack(f'<{count}I', *offsets) + data
|
||||
pkg_body += struct.pack('<HHIBBHII', 0x0201, header, header + len(body), tid, 0, 0, count, estart) + cfg + body
|
||||
pkg_header = struct.pack('<HHII', 0x0200, 288, 288 + len(pkg_body), 0x7f).ljust(288, b'\0')
|
||||
pkg = pkg_header + pkg_body
|
||||
table = pool(strings, utf8=True) + pkg
|
||||
return struct.pack('<HHII', 2, 12, 12 + len(table), 1) + table
|
||||
|
||||
|
||||
def apk(files):
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w') as z:
|
||||
for name, data in files.items():
|
||||
z.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
from apk_fixtures import apk, manifest, pool, resources # noqa: E402,F401
|
||||
|
||||
|
||||
class ApkInfo(unittest.TestCase):
|
||||
@@ -168,6 +93,20 @@ class ApkInfo(unittest.TestCase):
|
||||
frame_apk.MAX_MANIFEST = limit
|
||||
self.assertLess(peak, 8 * 1024**2)
|
||||
|
||||
def test_damaged_download_is_an_apk_error(self):
|
||||
# Corrupt deflate data used to escape as zlib.error, which the upload path didn't catch
|
||||
# (a 500, and no install report) and frame_android.install reported as 'other'.
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as z:
|
||||
z.writestr('AndroidManifest.xml', manifest('com.example.bad', 0x7f010000, 0x7f010001, 21) * 20)
|
||||
data = bytearray(buf.getvalue())
|
||||
at = data.index(b'PK\x03\x04')
|
||||
start = at + 30 + sum(struct.unpack_from('<HH', data, at + 26)) # past the name and extra field
|
||||
for i in range(start, start + 40):
|
||||
data[i] ^= 0x5a
|
||||
with self.assertRaisesRegex(frame_apk.ApkError, 'could not read the APK manifest'):
|
||||
self.read(bytes(data))
|
||||
|
||||
def test_refuses_compression_android_cant_read(self):
|
||||
for method in (zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA):
|
||||
buf = io.BytesIO()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Local-only VR manifest, raw ZIP and independent signature checks."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -15,7 +16,7 @@ import frame_apk
|
||||
import frame_apk_vr as vr
|
||||
import frame_apk_sign as signing
|
||||
import frame_android
|
||||
from test_frame_apk import pool
|
||||
from apk_fixtures import pool
|
||||
|
||||
|
||||
DEFAULT = 'android.intent.category.DEFAULT'
|
||||
|
||||
@@ -6,6 +6,7 @@ identity line, then starts TLS as the *client*.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Every public sideload entry point reaches the mandatory five-slot writer."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
|
||||
+17
-1
@@ -46,6 +46,17 @@ def explain(msg):
|
||||
return None
|
||||
|
||||
|
||||
class FailureDetail(unittest.TestCase):
|
||||
def test_an_address_that_failed_says_why_in_a_few_words(self):
|
||||
# #87: the banner put each address's detail after the headline, and with the whole
|
||||
# message as the detail it said everything twice ("...may be answering here..").
|
||||
self.assertEqual(fl.failure_detail({"message": "A long sentence.", "short": "SSH identity changed"}),
|
||||
"SSH identity changed")
|
||||
self.assertEqual(fl.failure_detail({"message": "The connection to the Frame dropped. Try again."}),
|
||||
"The connection to the Frame dropped. Try again.")
|
||||
self.assertEqual(fl.failure_detail(None), "SSH failed")
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def test_answers_refusals_and_unknown_names(self):
|
||||
with socket.socket() as srv:
|
||||
@@ -200,7 +211,8 @@ class Connecting(unittest.TestCase):
|
||||
rows = {p["host"]: p for p in s["probes"]}
|
||||
self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
|
||||
self.assertEqual(rows["::1"]["state"], "sshfailed")
|
||||
self.assertIn("different SSH identity", rows["::1"]["detail"])
|
||||
# Its row says why in a few words: the banner's headline already has the whole sentence (#87).
|
||||
self.assertEqual(rows["::1"]["detail"], "SSH identity changed")
|
||||
# Every ssh command was pointed at the winner, with the host key pinned per device.
|
||||
alias, opts = self.routes[-1]
|
||||
self.assertEqual(alias, "frame-t")
|
||||
@@ -260,6 +272,8 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
|
||||
self.assertIn("SSH key", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["auth"], "denied") # the page offers Set Up Connection
|
||||
self.assertEqual(s["error"]["short"], "This computer's key refused") # the header pill's words (#87)
|
||||
self.assertEqual(s["probes"][0]["detail"], "This computer's key refused")
|
||||
|
||||
def test_pinned_identity_is_checked_strictly(self):
|
||||
d = self.device("localhost")
|
||||
@@ -349,6 +363,7 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "identity"))
|
||||
self.assertEqual(s["error"]["hostkey"]["state"], "missing")
|
||||
self.assertIn("missing or can't be read", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["short"], "Saved SSH identity missing")
|
||||
view = next(x for x in fl.devices_view(self.link)["devices"] if x["id"] == d["id"])
|
||||
self.assertTrue(view["identity_lost"])
|
||||
scan = fl.devices_action(self.link, {"action": "identity-scan", "id": d["id"]}, None)["scan"]
|
||||
@@ -634,6 +649,7 @@ class Connecting(unittest.TestCase):
|
||||
self.assertEqual((shown["state"], shown["type"], shown["host"]), ("changed", "ssh-ed25519", "localhost"))
|
||||
self.assertTrue(shown["fingerprint"].startswith("SHA256:"))
|
||||
self.assertIn("Devices tab", s["error"]["message"])
|
||||
self.assertEqual(s["error"]["short"], "SSH identity changed")
|
||||
self.assertEqual(fd.known_hosts(d["id"]).read_text(), saved) # never accepted on its own
|
||||
r = self.command(d)
|
||||
self.assertEqual(r.returncode, 255)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""The connection pill, the offline banner and the Devices tab's selection: the page's own
|
||||
functions from ui/index.html, run in Node against a minimal DOM (#87)."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import pathlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
PAGE = (ROOT / "ui/index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def between(start, end):
|
||||
return PAGE[PAGE.index(start):PAGE.index(end)]
|
||||
|
||||
|
||||
SETUP = r'''
|
||||
const assert = require('node:assert/strict');
|
||||
const elements = new Map();
|
||||
const $ = id => {
|
||||
if (!elements.has(id)) elements.set(id, {id, textContent: '', innerHTML: '', title: '', hidden: true, value: ''});
|
||||
return elements.get(id);
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
||||
const serverNow = () => 1000;
|
||||
const activeStage = s => (s.stages || []).find(x => x.state === 'active');
|
||||
const ago = () => 'a minute ago';
|
||||
const openTrust = () => {};
|
||||
const window = {};
|
||||
let online = false;
|
||||
const link = {live: true, s: null};
|
||||
const dv = {list: [], sel: null, data: null, found: 'x', editing: 'y'};
|
||||
let rendered = 0;
|
||||
const renderDevices = () => { rendered++; };
|
||||
let refuse = false;
|
||||
const act = async (label, fn) => refuse ? undefined : fn();
|
||||
const api = async () => ({ok: true});
|
||||
const STAGES = [{id: 'find', label: 'Finding the headset', state: 'done'},
|
||||
{id: 'identity', label: "Checking the headset's identity", state: 'failed'}];
|
||||
const changed = {phase: 'failed', retry_at: 1012, device: {id: 'd1', name: 'Office Frame'}, network: {name: 'Home'},
|
||||
stages: STAGES, probes: [{host: 'frame.local', detail: 'SSH identity changed'}],
|
||||
error: {stage: 'identity', short: 'SSH identity changed', hostkey: {state: 'changed'},
|
||||
message: 'This address answered with a different SSH identity from the one saved for Office Frame. '
|
||||
+ 'If not, a different device may be answering here.'}};
|
||||
'''
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node runs the page's own functions")
|
||||
class LinkUI(unittest.TestCase):
|
||||
def run_page(self, checks):
|
||||
code = (between("function netLabel(s) {", "function renderConnDlg() {")
|
||||
+ between("async function useDevice(id) {", "async function devAction(")
|
||||
+ between("function deviceStatus(d) {", "function renderDevices() {"))
|
||||
script = SETUP + code + "\n(async () => {\n" + checks + "\n})().catch(e => { console.error(e); process.exitCode = 1; });"
|
||||
r = subprocess.run(["node", "-e", script], capture_output=True, text=True)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
|
||||
def test_the_pill_leads_with_the_reason(self):
|
||||
# The header pill cut off "Offline: checking the headset's identity failed · retry in…":
|
||||
# the reason comes first, in a few words, and only the countdown gets cut.
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<b>Office Frame<\/b><span>SSH identity changed · retry in 12 s<\/span>/);
|
||||
assert.equal($('conn').title, 'Office Frame is offline: SSH identity changed · retry in 12 s. Click for details.');
|
||||
// Without a short reason: the stage that failed.
|
||||
link.s = {...changed, retry_at: null, error: {stage: 'identity', message: 'Something.'}};
|
||||
renderPill();
|
||||
assert.match($('conn').innerHTML, /<span>Checking the headset's identity failed<\/span>/);
|
||||
''')
|
||||
|
||||
def test_the_banner_never_doubles_a_full_stop(self):
|
||||
# "…may be answering here.." and "…(Developer Mode must be on).." (#87).
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
renderBanner();
|
||||
assert.equal($('offDetail').textContent, 'Office Frame on Home. Tried frame.local: SSH identity changed. Trying again in 12 s.');
|
||||
link.s = {...changed, probes: [
|
||||
{host: 'frame.local', detail: "The Frame didn't accept this computer's SSH key. Set Up Connection pairs this computer with it again (Developer Mode must be on)."},
|
||||
{host: '100.64.0.2', detail: 'No answer'}]};
|
||||
renderBanner();
|
||||
const text = $('offDetail').textContent;
|
||||
assert.ok(!/\.\./.test(text), text);
|
||||
assert.ok(text.includes("frame.local: the Frame didn't accept"), text);
|
||||
assert.ok(text.includes('must be on); 100.64.0.2: no answer. Trying'), text);
|
||||
assert.equal(asClause('SSH identity changed.'), 'SSH identity changed'); // abbreviations keep their case
|
||||
assert.equal(asClause('Answered in 3 ms'), 'answered in 3 ms');
|
||||
''')
|
||||
|
||||
def test_the_devices_list_keeps_the_whole_message_within_reach(self):
|
||||
self.run_page(r'''
|
||||
link.s = changed;
|
||||
const [dot, text, full] = deviceStatus({active: true, addresses: []});
|
||||
assert.equal(dot, 'off');
|
||||
assert.equal(text, 'SSH identity changed');
|
||||
assert.equal(full, changed.error.message); // the row's tooltip
|
||||
''')
|
||||
|
||||
def test_using_a_headset_selects_it(self):
|
||||
# After "Use this headset" on Office Frame, the last headset's details stayed on show.
|
||||
self.run_page(r'''
|
||||
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd1', name: 'Office Frame', active: false}];
|
||||
dv.sel = 'd0';
|
||||
await useDevice('d1');
|
||||
assert.equal(dv.sel, 'd1');
|
||||
assert.equal(dv.found, null);
|
||||
assert.equal(dv.editing, null);
|
||||
assert.equal(rendered, 1);
|
||||
// Refused (an install is running): the selection stays, and the header shows the one in use.
|
||||
dv.list = [{id: 'd0', name: 'Living Room Frame', active: true}, {id: 'd2', name: 'Spare', active: false}];
|
||||
dv.sel = 'd0'; refuse = true;
|
||||
await useDevice('d2');
|
||||
assert.equal(dv.sel, 'd0');
|
||||
assert.equal($('devSel').value, 'd0');
|
||||
''')
|
||||
|
||||
|
||||
class HeaderLayout(unittest.TestCase):
|
||||
def test_the_devices_tab_explanation_wraps(self):
|
||||
# It was cut to one line with no way to read the rest.
|
||||
self.assertIn(".dev-problem .s { white-space: normal; }", PAGE)
|
||||
self.assertEqual(len(re.findall(r'<div class="item dev-problem" role="alert">', PAGE)), 2)
|
||||
|
||||
def test_the_header_buttons_dont_give_way(self):
|
||||
# Refresh overhung the right edge at 1280 px: the pill (and the headset picker) shrink, not the buttons.
|
||||
header = between("<header>", "</header>")
|
||||
end = header[header.index('<div class="hdr-end">'):]
|
||||
for el in ('id="conn"', 'id="devSel"', 'id="battChip"', 'id="reportBtn"', 'id="refreshAll"'):
|
||||
self.assertIn(el, end)
|
||||
self.assertIn(".hdr-end > :not(.pill, .devsel) { flex: none; }", PAGE)
|
||||
self.assertRegex(PAGE, r"\.hdr-end \{[^}]*min-width: 0;[^}]*margin-left: auto;")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -7,6 +7,7 @@ input reaching the source, and closing.
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import base64
|
||||
import http.client
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fake-Frame panel responses and the headset page's real HTTP guards."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import http.client
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
+1129
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,122 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "ui"))
|
||||
import frame_connect # noqa: E402
|
||||
import frame_devices # noqa: E402
|
||||
import frame_host # noqa: E402
|
||||
|
||||
|
||||
def inside(path, folder):
|
||||
path, folder = os.path.realpath(path), os.path.realpath(folder)
|
||||
return path == folder or path.startswith(folder + os.sep)
|
||||
|
||||
|
||||
def snapshot(folder):
|
||||
"""{relative path: (size, mtime)} for everything under FOLDER."""
|
||||
out = {}
|
||||
for base, dirs, files in os.walk(folder):
|
||||
for name in dirs + files:
|
||||
p = os.path.join(base, name)
|
||||
try:
|
||||
st = os.lstat(p)
|
||||
except OSError:
|
||||
continue
|
||||
out[os.path.relpath(p, folder)] = (st.st_size, st.st_mtime_ns)
|
||||
return out
|
||||
|
||||
|
||||
class Sandbox(unittest.TestCase):
|
||||
"""Tests once wrote a host-key pin into the real ~/.ssh/frame-control-hosts on every
|
||||
run (a server started without FRAME_CONTROL_SSH_DIR imported the real ~/.ssh/config)."""
|
||||
|
||||
def test_home_and_ssh_dir_are_throwaway(self):
|
||||
tmp = tempfile.gettempdir()
|
||||
self.assertTrue(inside(Path.home(), tmp), Path.home())
|
||||
self.assertTrue(inside(os.path.expanduser("~"), tmp))
|
||||
for d in (frame_devices.ssh_dir(), frame_devices.pin_dir(), frame_connect.SSH_DIR,
|
||||
os.environ["FRAME_CONTROL_SSH_DIR"], frame_host.cache_dir(), frame_host.data_dir()):
|
||||
self.assertTrue(inside(d, tmp), d)
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
|
||||
def test_cache_folders_on_every_platform_are_throwaway(self):
|
||||
"""cache_dir() reads LOCALAPPDATA on Windows and XDG_CACHE_HOME on Linux, which a
|
||||
moved HOME alone doesn't cover: servers sweep staging folders there."""
|
||||
tmp = tempfile.gettempdir()
|
||||
for platform in ("MAC", "WINDOWS", None):
|
||||
with mock.patch.object(frame_host, "MAC", platform == "MAC"), \
|
||||
mock.patch.object(frame_host, "WINDOWS", platform == "WINDOWS"):
|
||||
self.assertTrue(inside(frame_host.cache_dir("apk-sources"), tmp), platform)
|
||||
for name in ("LOCALAPPDATA", "APPDATA", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"):
|
||||
self.assertTrue(inside(os.environ[name], tmp), name)
|
||||
|
||||
def test_end_to_end_tests_stay_out_of_it(self):
|
||||
"""tests/e2e keeps the container's real home (OpenSSH reads ~/.ssh from the password
|
||||
database, not $HOME), so nothing there may import sandbox, even through a test_* module."""
|
||||
here = Path(__file__).resolve().parent
|
||||
pattern = re.compile(r"^\s*(import sandbox|from sandbox |import test_|from test_)", re.M)
|
||||
for f in [*(here / "e2e").glob("*.py"), *(here / "smoke").glob("*.py"), here / "apk_fixtures.py"]:
|
||||
self.assertIsNone(pattern.search(f.read_text(encoding="utf-8")), f)
|
||||
|
||||
def test_end_to_end_tests_start_no_server_outside_their_container(self):
|
||||
"""Plain discovery over tests/e2e, with or without FRAME_E2E=1, must skip everything
|
||||
and never start ui/server.py: outside scripts/e2e.sh's container it would run on
|
||||
this computer's real app data, ~/.ssh and cache."""
|
||||
e2e = str(Path(__file__).resolve().parent / "e2e")
|
||||
code = ("import io, sys, unittest; sys.path.insert(0, sys.argv[1]); import harness; calls = []\n"
|
||||
"harness.Server.start = classmethod(lambda cls: calls.append(cls))\n"
|
||||
"suite = unittest.defaultTestLoader.discover(sys.argv[1], top_level_dir=sys.argv[1])\n"
|
||||
"r = unittest.TextTestRunner(stream=io.StringIO()).run(suite)\n"
|
||||
"print(len(calls), r.testsRun, len(r.skipped), len(r.errors) + len(r.failures))")
|
||||
for extra in ({}, {"FRAME_E2E": "1"}):
|
||||
env = {k: v for k, v in os.environ.items() if not k.startswith("FRAME_E2E")}
|
||||
env.update(extra, PYTHONDONTWRITEBYTECODE="1")
|
||||
out = subprocess.run([sys.executable, "-c", code, e2e], env=env, capture_output=True, text=True,
|
||||
timeout=120)
|
||||
self.assertEqual(out.returncode, 0, out.stderr[-2000:])
|
||||
started, ran, skipped, broken = map(int, out.stdout.split())
|
||||
self.assertEqual((started, broken), (0, 0), (extra, out.stdout))
|
||||
self.assertEqual(ran, skipped, (extra, out.stdout))
|
||||
self.assertGreater(skipped, 0, extra)
|
||||
|
||||
def test_child_processes_inherit_it(self):
|
||||
out = subprocess.run(
|
||||
[sys.executable, "-c", "import sys; sys.path.insert(0, sys.argv[1]); import frame_devices, frame_connect;"
|
||||
" from pathlib import Path; print(Path.home()); print(frame_devices.ssh_dir()); print(frame_connect.SSH_DIR)",
|
||||
str(ROOT / "ui")], env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||
capture_output=True, text=True, check=True).stdout.split("\n")
|
||||
for d in filter(None, out):
|
||||
self.assertFalse(inside(d, sandbox.REAL_SSH_DIR), d)
|
||||
self.assertTrue(inside(d, tempfile.gettempdir()), d)
|
||||
|
||||
def test_real_ssh_folder_untouched_by_tests_that_start_servers(self):
|
||||
"""Reruns, from the environment the suite was started in, the tests that used to
|
||||
leak (servers started without FRAME_CONTROL_SSH_DIR) plus the device and connect
|
||||
tests, and checks nothing under the real ~/.ssh changed."""
|
||||
real = sandbox.REAL_SSH_DIR
|
||||
if inside(real, tempfile.gettempdir()):
|
||||
self.skipTest("this run's HOME is already a temporary folder")
|
||||
before = snapshot(real)
|
||||
env = {k: v for k, v in sandbox.ORIGINAL_ENV.items() if k != "FRAME_CONTROL_SSH_DIR"}
|
||||
env["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
r = subprocess.run([sys.executable, "-m", "unittest", "-q", "test_server.OneServer", "test_server.LocalMode",
|
||||
"test_devices", "test_connect"],
|
||||
cwd=str(Path(__file__).resolve().parent), env=env, capture_output=True, text=True,
|
||||
timeout=300)
|
||||
self.assertEqual(r.returncode, 0, r.stderr[-3000:])
|
||||
after = snapshot(real)
|
||||
changed = sorted(k for k in set(before) | set(after) if before.get(k) != after.get(k)
|
||||
and not k.startswith("agent")) # ssh-agent sockets come and go on their own
|
||||
self.assertEqual(changed, [], f"tests changed the real {real}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,3 +1,4 @@
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
+134
-6
@@ -138,7 +138,7 @@ class Gates(Base):
|
||||
tm.diagnostic("POST /api/android install", RuntimeError("boom"))
|
||||
tm.diagnostic("POST /api/comfort status", RuntimeError("ssh exited 255")) # the command's own exit code?
|
||||
self.assertEqual([e["properties"]["error_category"] for e in self.queued()],
|
||||
["frame_unreachable", "other", "other", "other"])
|
||||
["frame_unreachable", "internal_error", "other", "other"])
|
||||
|
||||
def test_page_events_are_checked(self):
|
||||
self.assertTrue(tm.page_event({"event": "tab_viewed", "properties": {"tab": "android", "extra": "x"}})["queued"])
|
||||
@@ -164,6 +164,38 @@ class Lifecycle(Base):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
self.assertEqual(len(self.queued()), 1)
|
||||
|
||||
def test_frame_once_per_headset_with_saved_count(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "0123456789abcdef", 2)
|
||||
tm.frame_seen("20260922.1", "3.8", "fedcba9876543210", 2)
|
||||
props = [e["properties"] for e in self.queued()]
|
||||
self.assertEqual([(p["headset_id"], p["headsets_saved"]) for p in props],
|
||||
[("0123456789abcdef", 2), ("fedcba9876543210", 2)])
|
||||
|
||||
def test_frame_many_headsets_are_not_sent_twice(self):
|
||||
ids = ["%016x" % n for n in range(25)]
|
||||
for _ in range(2):
|
||||
for h in ids:
|
||||
tm.frame_seen("20260922.1", "3.8", h, 25)
|
||||
self.assertEqual(len(self.queued()), 25)
|
||||
|
||||
def test_frame_headset_id_must_be_the_hash(self):
|
||||
tm.frame_seen("20260922.1", "3.8", "/etc/machine-id contents", None)
|
||||
p = self.queued()[0]["properties"]
|
||||
self.assertNotIn("headset_id", p)
|
||||
self.assertNotIn("headsets_saved", p)
|
||||
|
||||
|
||||
class HeadsetId(unittest.TestCase):
|
||||
def test_hash_of_machine_id_and_none_without_one(self):
|
||||
import hashlib
|
||||
import frame_status
|
||||
with mock.patch.object(frame_status, "read", return_value="abc123"):
|
||||
self.assertEqual(frame_status.headset_id(),
|
||||
hashlib.sha256(b"frame-control-headset:abc123").hexdigest()[:16])
|
||||
with mock.patch.object(frame_status, "read", return_value=None):
|
||||
self.assertIsNone(frame_status.headset_id())
|
||||
|
||||
|
||||
class Sending(Base):
|
||||
def serve(self, status=200):
|
||||
@@ -263,6 +295,72 @@ class Scrub(unittest.TestCase):
|
||||
self.assertEqual(tm.categorize(FileNotFoundError(2, "No such file or directory", "ssh"))[0], "tool_missing")
|
||||
self.assertEqual(tm.categorize("[WinError 2] The system cannot find the file specified")[0], "tool_missing")
|
||||
|
||||
def test_apk_install_failures_that_were_other(self):
|
||||
"""What frame_android.install can raise, worded as it is, that 0.4.x sent as 'other'
|
||||
(or as frame_unreachable, when it was the Steam client or a slow copy)."""
|
||||
tail = 'Traceback (most recent call last):\n File "<stdin>", line 19, in target_ws\n'
|
||||
cases = {
|
||||
"steam_not_running": [
|
||||
"SharedJSContext not found: is the Steam client running?",
|
||||
tail + "urllib.error.URLError: <urlopen error [Errno 111] Connection refused>",
|
||||
tail + "ConnectionRefusedError: [Errno 111] Connection refused",
|
||||
],
|
||||
"steam_not_responding": [
|
||||
"websocket handshake failed",
|
||||
tail + "TimeoutError: timed out",
|
||||
tail + "socket.timeout: timed out",
|
||||
tail + ' File "<stdin>", line 45, in _read\nEOFError',
|
||||
],
|
||||
"steam_js_error": ['JS error: {"exceptionId": 1, "text": "Uncaught", "exception": '
|
||||
'{"description": "Error: Steam artwork API unavailable; installation is incomplete"}}'],
|
||||
"artwork_failed": [
|
||||
"Steam artwork renderer did not produce every slot",
|
||||
tail + "ValueError: incomplete artwork render",
|
||||
tail + "ValueError: hero render is 0 bytes of png",
|
||||
"could not prepare artwork: unknown artwork slot",
|
||||
tail + "ValueError: all five Steam artwork slots are required",
|
||||
],
|
||||
"copy_timeout": ["copying Big Game (timed out).apk to the Frame timed out",
|
||||
"copying launch.sh to the Frame timed out"],
|
||||
"copy_failed": [
|
||||
"copying game.apk to the Frame failed: rsync: connection unexpectedly closed (0 bytes received "
|
||||
"so far) [sender]\nrsync error: error in rsync protocol data stream (code 12) at io.c(231)",
|
||||
"copying game.apk to the Frame failed: ",
|
||||
],
|
||||
"apk_unreadable": [
|
||||
"x.apk: not a readable APK: [Errno 2] No such file or directory: 'x.apk'",
|
||||
"x.apk: could not read the APK manifest: Error -3 while decompressing data: invalid block type",
|
||||
"x.apk: the APK manifest has no package name",
|
||||
"x.apk: lib/arm64-v8a/libx.so in the APK uses a compression Android does not",
|
||||
"x.apk: AndroidManifest.xml in the APK is 40 MB, more than a real one (16 MB)",
|
||||
],
|
||||
}
|
||||
for category, messages in cases.items():
|
||||
for message in messages:
|
||||
self.assertEqual(tm.categorize(message)[0], category, message)
|
||||
# Still where they were: ssh's own failures, and a full disk during a copy.
|
||||
self.assertEqual(tm.categorize("timed out talking to frame")[0], "frame_unreachable")
|
||||
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: ssh: connect to host 10.0.0.2 "
|
||||
"port 22: Connection refused")[0], "frame_unreachable")
|
||||
self.assertEqual(tm.categorize("copying a.apk to the Frame failed: rsync: write failed on "
|
||||
"\"app.apk.part\": No space left on device (28)")[0], "frame_disk_full")
|
||||
self.assertEqual(tm.categorize("download failed: timed out")[0], "download_failed")
|
||||
|
||||
def test_python_errors_are_internal_whatever_they_say(self):
|
||||
import json as _json
|
||||
for error in (KeyError("paths"), TypeError("'NoneType' object is not subscriptable"),
|
||||
AttributeError("x"), _json.JSONDecodeError("Expecting value", "", 0),
|
||||
UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"),
|
||||
KeyError("timed out")):
|
||||
self.assertEqual(tm.categorize(error)[0], "internal_error", repr(error))
|
||||
self.assertEqual(tm.categorize("KeyError: 'paths'")[0], "other") # only the exception itself
|
||||
self.assertEqual(tm.categorize(OSError(28, "No space left on device"))[0], "frame_disk_full")
|
||||
|
||||
def test_privacy_doc_lists_every_category(self):
|
||||
doc = (ROOT / "docs" / "privacy.md").read_text()
|
||||
for name in {n for n, _ in tm.CATEGORIES} | {"internal_error", "other"}:
|
||||
self.assertIn(f"`{name}`", doc)
|
||||
|
||||
|
||||
class Compat(Base):
|
||||
def test_reports_are_shared_only_after_opting_in_without_file_names(self):
|
||||
@@ -376,14 +474,17 @@ class LinkFailureProvenance(Base):
|
||||
c.close()
|
||||
return result
|
||||
|
||||
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
|
||||
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
|
||||
# Failure; after the status poll's first connection failure, theirs are held back too,
|
||||
# also past the 10-minute window.
|
||||
def serve(self):
|
||||
self.httpd = self.server.ThreadingHTTPServer(("127.0.0.1", 0), self.server.Handler)
|
||||
threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
|
||||
self.addCleanup(self.httpd.server_close)
|
||||
self.addCleanup(self.httpd.shutdown)
|
||||
|
||||
def test_routes_that_rewrap_a_link_failure_keep_its_mark(self):
|
||||
# Review round 4: /api/android and /api/titles re-raise frame_android's FrameError as a
|
||||
# Failure; after the first connection failure, theirs are held back too, also past the
|
||||
# 10-minute window.
|
||||
self.serve()
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n")
|
||||
meta = {"label": "Test App", "game_id": 5, "shortcut": None}
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down), \
|
||||
@@ -399,7 +500,34 @@ class LinkFailureProvenance(Base):
|
||||
self.assertEqual(self.request("/api/android", {"action": "launch", "package": "org.test"})[0], 502)
|
||||
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
|
||||
if e["event"] == "$exception"]
|
||||
self.assertEqual(sent, [("POST /api/comfort status", "frame_unreachable"), ("POST /api/android launch", "other")])
|
||||
# The comfort poll's own failure isn't one (see the next test).
|
||||
self.assertEqual(sent, [("POST /api/android launch", "frame_unreachable"), ("POST /api/android launch", "other")])
|
||||
|
||||
def test_the_comfort_poll_meeting_an_unreachable_frame_is_not_sent(self):
|
||||
# 0.4.2: most reports were this poll (every 5 s) finding the headset asleep, away or
|
||||
# refusing ssh. Like GET /api/status, it isn't reported; a session started or cancelled
|
||||
# by hand still is, once, and a real failure of the poll is too.
|
||||
self.serve()
|
||||
for stderr in ("ssh: connect to host 10.0.0.5 port 22: Connection timed out\r\n",
|
||||
"banner exchange: Connection to UNKNOWN port -1: Connection refused\r\n",
|
||||
"ssh: Could not resolve hostname frame: nodename nor servname provided\r\n",
|
||||
"@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @\r\nHost key verification failed.\r\n",
|
||||
"No ED25519 host key is known for frame-control-f7b4e9ac and you have requested strict "
|
||||
"checking.\r\nHost key verification failed.\r\n",
|
||||
"steamos@192.168.1.9: Permission denied (publickey,password).\r\n"):
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", stderr)
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down):
|
||||
self.assertGreaterEqual(self.request("/api/comfort", {"action": "status"})[0], 500, stderr)
|
||||
self.assertEqual(self.queued(), [])
|
||||
down = subprocess.CompletedProcess(["ssh"], 255, "", "ssh: connect to host 10.0.0.5 port 22: Host is down\r\n")
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=down):
|
||||
self.request("/api/comfort", {"action": "cancel"})
|
||||
broken = subprocess.CompletedProcess(["ssh"], 1, "", "Traceback (most recent call last):\nOSError: boom\n")
|
||||
with mock.patch.object(frame_host, "run_ssh", return_value=broken):
|
||||
self.assertEqual(self.request("/api/comfort", {"action": "status"})[0], 502)
|
||||
sent = [(e["properties"]["where"], e["properties"]["error_category"]) for e in self.queued()
|
||||
if e["event"] == "$exception"]
|
||||
self.assertEqual(sent, [("POST /api/comfort cancel", "frame_unreachable"), ("POST /api/comfort status", "other")])
|
||||
|
||||
def test_download_failures_after_a_poll_failure_are_still_sent(self):
|
||||
# Review round 3: these file names, in the real checksum message through the web-link worker,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Run: python3 -m unittest discover -s tests
|
||||
"""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Own VR telemetry, optional catalogue and entitlement guards; no headset needed."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
import ctypes
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
+381
-22
@@ -7,7 +7,7 @@ in the Steam library and gets its own SteamVR panel. Nothing goes through
|
||||
Lepton Development, which wipes its apps on exit. See docs/apks.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_android.py
|
||||
install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
install APK [SPLIT.apk ...] [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
|
||||
install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
|
||||
refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
|
||||
"""
|
||||
@@ -18,7 +18,7 @@ import frame_artwork
|
||||
import frame_host
|
||||
import tempfile
|
||||
import zipfile
|
||||
from frame_apk_vr import add_launcher_category
|
||||
from frame_apk_vr import add_launcher_category, drop_split_required
|
||||
from frame_apk_sign import repack
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -61,13 +61,20 @@ def ssh(cmd, input=None, timeout=120):
|
||||
try:
|
||||
# No inherited stdin (see server.ssh): Windows' ssh.exe would wait on it.
|
||||
feed = {'input': input} if input is not None else {'stdin': subprocess.DEVNULL}
|
||||
# The Frame writes UTF-8, whatever this computer's code page (Windows would decode it as
|
||||
# cp1252). errors='replace': stray bytes (a file name) mustn't turn a failed command, or
|
||||
# one that worked, into a UnicodeDecodeError.
|
||||
text = ({'text': True, 'encoding': 'utf-8', 'errors': 'replace'}
|
||||
if isinstance(input, str) or input is None else {})
|
||||
p = frame_host.run_ssh(['ssh', *SSH_OPTS, FRAME, cmd], capture_output=True, **feed,
|
||||
timeout=timeout, text=isinstance(input, str) or input is None)
|
||||
timeout=timeout, **text)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise frame_host.link_failure(FrameError(f'timed out talking to {FRAME}'))
|
||||
if p.returncode != 0:
|
||||
error = FrameError((p.stderr or p.stdout or f'ssh exited {p.returncode}').strip()[-600:])
|
||||
stderr = p.stderr if isinstance(p.stderr, str) else (p.stderr or b'').decode(errors='replace')
|
||||
# Bytes input gives bytes output: decode it, so the message isn't "b'...'".
|
||||
stderr, stdout = (v if isinstance(v, str) else (v or b'').decode(errors='replace')
|
||||
for v in (p.stderr, p.stdout))
|
||||
error = FrameError((stderr or stdout or f'ssh exited {p.returncode}').strip()[-600:])
|
||||
raise frame_host.link_failure(error) if frame_host.ssh_link_failed(p.returncode, stderr) else error
|
||||
return p.stdout
|
||||
|
||||
@@ -135,12 +142,22 @@ def check_installable(info):
|
||||
"(or arm64, or universal) and install that instead")
|
||||
|
||||
|
||||
_install_lock = threading.Lock() # installs are rare; one at a time avoids every race
|
||||
# Installs are rare; one at a time avoids every race. Reentrant so a caller (a Quest import's
|
||||
# final checks and data copy) can hold it around install_obb and install, which take it too.
|
||||
_install_lock = threading.RLock()
|
||||
# The slowest transfer a copy is allowed before it times out, in bytes a second (about 4 Mbit/s,
|
||||
# a slow or busy Wi-Fi link). A fixed 10 minutes failed any APK over about 300 MB at that rate.
|
||||
COPY_MIN_RATE = 512 * 1024
|
||||
|
||||
|
||||
def _copy(src, dest, executable=False, timeout=600):
|
||||
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp."""
|
||||
"""Copy a local file to the Frame: rsync where installed (not on Windows), else scp.
|
||||
timeout is the least time allowed; a large file gets longer (COPY_MIN_RATE)."""
|
||||
name = os.path.basename(src)
|
||||
try:
|
||||
timeout = max(timeout, os.path.getsize(src) / COPY_MIN_RATE)
|
||||
except OSError:
|
||||
pass # rsync or scp says what's wrong with the file
|
||||
rsync = None if frame_host.WINDOWS else shutil.which('rsync') # see server.push_file
|
||||
if rsync:
|
||||
cmd = ['rsync', '-a', *(['--chmod=u+x'] if executable else []),
|
||||
@@ -148,7 +165,8 @@ def _copy(src, dest, executable=False, timeout=600):
|
||||
else:
|
||||
cmd = ['scp', *SSH_OPTS, src, f'{FRAME}:{dest}']
|
||||
try:
|
||||
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=timeout)
|
||||
frame_host.run_ssh(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
|
||||
errors='replace', timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise FrameError(f'copying {name} to the Frame timed out')
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -174,7 +192,303 @@ def _write_meta(d, meta):
|
||||
install_hooks = []
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
|
||||
# Split APK members that belong to the split itself, not the app: never merged.
|
||||
SPLIT_OWN = re.compile(r'^(AndroidManifest\.xml|stamp-cert-sha256|META-INF/.*)$')
|
||||
# What a mergeable split may add: native libraries and assets, nothing else.
|
||||
SPLIT_ADDS = re.compile(r'^(lib/[^/]+/[^/]+\.so|assets/.*[^/])$')
|
||||
# Manifest elements a config or install-time asset split carries. Anything else (an
|
||||
# activity, service, uses-library...) belongs to a feature split, which needs its manifest.
|
||||
SPLIT_TAGS = {'manifest', 'application', 'uses-sdk', 'module', 'delivery', 'install-time', 'fusing'}
|
||||
# Every merged byte is held in memory while the APK is repacked and signed (as for every
|
||||
# patched APK), so splits are bounded by what's actually inflated, not only the headers.
|
||||
MAX_SPLIT_MEMBER = 512 * 1024**2
|
||||
MAX_SPLIT_TOTAL = 1024**3
|
||||
|
||||
|
||||
def _split_manifest(path, name):
|
||||
"""(package, split name) from a split APK's manifest, refusing splits that declare
|
||||
anything the merged app would lose with that manifest."""
|
||||
with zipfile.ZipFile(path) as z:
|
||||
elements = frame_apk.manifest_elements(frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST))
|
||||
attrs = next((a for tag, a in elements if tag == 'manifest'), {})
|
||||
split = (attrs.get('split') or (None, None, None))[2]
|
||||
feature = attrs.get('isFeatureSplit')
|
||||
if feature and feature[1]:
|
||||
raise FrameError(f'{name} ({split or "split"}) is a feature split with its own code and manifest; '
|
||||
'Frame Control can only merge splits that add native libraries or assets')
|
||||
extra = sorted({tag for tag, _ in elements if tag not in SPLIT_TAGS})
|
||||
if extra:
|
||||
raise FrameError(f'{name} ({split or "split"}) declares <{">, <".join(extra)}> in its manifest; '
|
||||
'Frame Control can only merge splits that add native libraries or assets')
|
||||
return (attrs.get('package') or (None, None, None))[2], split
|
||||
|
||||
|
||||
def split_entries(base, splits):
|
||||
"""What merging splits into base adds: {entry name: (split path, entry name, size)}.
|
||||
|
||||
Lepton runs one APK per instance, so a split app is merged into one APK. Only splits
|
||||
that add native libraries (the arm64 ABI split) or install-time assets can be: a split
|
||||
with code, components or resources (screen density, language) needs its own manifest
|
||||
or resource table, which Frame Control doesn't merge, so those are refused."""
|
||||
try:
|
||||
with zipfile.ZipFile(base) as z:
|
||||
taken = set(z.namelist())
|
||||
package = apk_info(base)['package']
|
||||
plan, total = {}, 0
|
||||
for path in splits:
|
||||
name = os.path.basename(path)
|
||||
split_pkg, split_name = _split_manifest(path, name)
|
||||
label = f'{name} ({split_name or "split"})'
|
||||
if split_pkg != package:
|
||||
raise FrameError(f'{name} is not part of {package} (it says {split_pkg or "nothing"})')
|
||||
with zipfile.ZipFile(path) as z:
|
||||
for member in z.infolist():
|
||||
entry = member.filename
|
||||
if entry.endswith('/') or SPLIT_OWN.match(entry):
|
||||
continue
|
||||
if entry == 'resources.arsc':
|
||||
# A config split for native code may carry an empty table; any resource needs merging.
|
||||
if not resources_empty(frame_apk._read(z, entry, frame_apk.MAX_ARSC)):
|
||||
raise FrameError(f'{label} holds screen-density or language resources; Frame Control '
|
||||
'can only merge splits that add native libraries or assets')
|
||||
continue
|
||||
if entry.startswith('res/'):
|
||||
raise FrameError(f'{label} holds screen-density or language resources ({entry}); Frame '
|
||||
'Control can only merge splits that add native libraries or assets')
|
||||
if re.match(r'^classes\d*\.dex$', entry):
|
||||
raise FrameError(f'{label} contains code ({entry}); Frame Control can only merge splits '
|
||||
'that add native libraries or assets')
|
||||
if not SPLIT_ADDS.match(entry):
|
||||
raise FrameError(f'{label} contains {entry}; Frame Control can only merge splits that '
|
||||
'add native libraries or assets')
|
||||
if entry in taken:
|
||||
raise FrameError(f'{name} and another part of the app both contain {entry}')
|
||||
if member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
||||
raise FrameError(f'{entry} in {name} uses a compression Android does not')
|
||||
if member.file_size > MAX_SPLIT_MEMBER:
|
||||
raise FrameError(f'{entry} in {name} is {member.file_size / 1024**2:.0f} MB, more than '
|
||||
f'Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
|
||||
total += member.file_size
|
||||
if total > MAX_SPLIT_TOTAL:
|
||||
raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB, more than '
|
||||
'Frame Control merges')
|
||||
taken.add(entry)
|
||||
plan[entry] = (path, entry, member.file_size)
|
||||
return plan
|
||||
except (OSError, zipfile.BadZipFile, struct.error, IndexError, frame_apk.ApkError) as e:
|
||||
raise FrameError(f'could not read the split APKs: {e}') from e
|
||||
|
||||
|
||||
def resources_empty(data):
|
||||
"""True only for a resource table that provably defines nothing: every type chunk's
|
||||
entries absent. Bags (styles, plurals) count, and anything not understood counts too."""
|
||||
def covered(start, end):
|
||||
chunks, at = [], start
|
||||
for chunk in frame_apk._chunks(data, start, end):
|
||||
if chunk[2] != at:
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
at = chunk[2] + chunk[3]
|
||||
return chunks if at == end else None
|
||||
|
||||
try:
|
||||
if len(data) < 12 or struct.unpack_from('<H', data, 0)[0] != 0x0002:
|
||||
return False
|
||||
top = covered(struct.unpack_from('<H', data, 2)[0], len(data))
|
||||
if top is None:
|
||||
return False
|
||||
for ctype, _, off, size in top:
|
||||
if ctype == 0x0001: # the global string pool
|
||||
continue
|
||||
if ctype != 0x0200:
|
||||
return False
|
||||
inner = covered(off + struct.unpack_from('<H', data, off + 2)[0], off + size)
|
||||
if inner is None:
|
||||
return False
|
||||
for kind, hs, t, _ in inner:
|
||||
if kind in (0x0001, 0x0202): # type and key names, type specs
|
||||
continue
|
||||
if kind != 0x0201:
|
||||
return False
|
||||
flags, count = struct.unpack_from('<xBxxI', data, t + 8)
|
||||
if flags & 0x01: # sparse: only present entries are listed
|
||||
if count:
|
||||
return False
|
||||
elif flags & 0x02:
|
||||
if any(o != 0xffff for o in struct.unpack_from(f'<{count}H', data, t + hs)):
|
||||
return False
|
||||
elif any(o != 0xffffffff for o in struct.unpack_from(f'<{count}I', data, t + hs)):
|
||||
return False
|
||||
return True
|
||||
except (struct.error, IndexError):
|
||||
return False
|
||||
|
||||
|
||||
def _zip64_sizes(extra, usize, csize):
|
||||
"""(uncompressed, compressed) with 0xffffffff fields taken from a ZIP64 extra field."""
|
||||
at = 0
|
||||
while at + 4 <= len(extra):
|
||||
kind, length = struct.unpack_from('<HH', extra, at)
|
||||
body = extra[at + 4:at + 4 + length]
|
||||
if kind == 0x0001:
|
||||
values, pos = [], 0
|
||||
for field in (usize, csize):
|
||||
if field == 0xffffffff:
|
||||
if pos + 8 > len(body):
|
||||
raise FrameError('truncated ZIP64 extra field')
|
||||
values.append(struct.unpack_from('<Q', body, pos)[0])
|
||||
pos += 8
|
||||
else:
|
||||
values.append(field)
|
||||
return tuple(values)
|
||||
at += 4 + length
|
||||
if 0xffffffff in (usize, csize):
|
||||
raise FrameError('ZIP64 sizes without a ZIP64 extra field')
|
||||
return usize, csize
|
||||
|
||||
|
||||
def _has_zip64_extra(extra):
|
||||
at = 0
|
||||
while at + 4 <= len(extra):
|
||||
kind, length = struct.unpack_from('<HH', extra, at)
|
||||
if kind == 0x0001:
|
||||
return True
|
||||
at += 4 + length
|
||||
return False
|
||||
|
||||
|
||||
def read_member(path, member):
|
||||
"""A split member's bytes, read from its raw data and checked against both headers.
|
||||
|
||||
The local header must name the same file, with the same method and (unless a data
|
||||
descriptor carries them, which is then checked) the same sizes and checksum as the
|
||||
central directory; the data must end before the next member; a stored member must be
|
||||
exactly its size and a deflated stream must end exactly at the end of its data, with
|
||||
nothing after it. Output is bounded by the declared size: never trusts zipfile, which
|
||||
stops early at a smaller declared size."""
|
||||
with zipfile.ZipFile(path) as z:
|
||||
info = z.getinfo(member)
|
||||
starts = sorted({i.header_offset for i in z.infolist()} | {z.start_dir})
|
||||
name, size = f'{member} in {os.path.basename(path)}', info.file_size
|
||||
if info.flag_bits & 1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
|
||||
raise FrameError(f'{name} is encrypted or uses a compression Android does not')
|
||||
if size > MAX_SPLIT_MEMBER or info.compress_size > MAX_SPLIT_MEMBER:
|
||||
raise FrameError(f'{name} is larger than Frame Control merges ({MAX_SPLIT_MEMBER // 1024**2} MB)')
|
||||
following = next((o for o in starts if o > info.header_offset), None)
|
||||
with open(path, 'rb') as raw:
|
||||
raw.seek(info.header_offset)
|
||||
header = raw.read(30)
|
||||
if len(header) != 30 or header[:4] != b'PK\3\4':
|
||||
raise FrameError(f'{name}: invalid local ZIP header')
|
||||
flags, method, crc, csize, usize, nl, el = struct.unpack_from('<2xHH4xIIIHH', header, 4)
|
||||
local_name, extra = raw.read(nl), raw.read(el)
|
||||
if local_name != info.orig_filename.encode('utf-8' if flags & 0x800 else 'cp437', 'surrogateescape'):
|
||||
raise FrameError(f'{name}: the local header names {local_name.decode("utf-8", "replace")!r}')
|
||||
if method != info.compress_type or (flags & 0x08) != (info.flag_bits & 0x08):
|
||||
raise FrameError(f'{name} does not match its header (local and central entries differ)')
|
||||
start = info.header_offset + 30 + nl + el
|
||||
compressed = raw.read(info.compress_size)
|
||||
if len(compressed) != info.compress_size:
|
||||
raise FrameError(f'{name} is truncated')
|
||||
if flags & 0x08:
|
||||
# Sizes and checksum follow the data: (signature,) crc, then sizes of 4 bytes each,
|
||||
# or 8 for ZIP64. Streaming writers use the ZIP64 form even for small files when
|
||||
# sizes weren't known up front, so the width isn't implied by the final sizes: take
|
||||
# the layout that matches the central directory and fits before the next entry,
|
||||
# preferring one that ends exactly there, then the width the local header implies.
|
||||
zip64 = 0xffffffff in (csize, usize) or _has_zip64_extra(extra)
|
||||
tail = raw.read(24)
|
||||
if len(tail) < 12:
|
||||
raise FrameError(f'{name}: truncated data descriptor')
|
||||
end = start + info.compress_size
|
||||
fits = []
|
||||
for signed in (True, False):
|
||||
if signed and tail[:4] != b'PK\7\x08':
|
||||
continue
|
||||
for width in (20, 12):
|
||||
body = tail[4:4 + width] if signed else tail[:width]
|
||||
if len(body) < width:
|
||||
continue
|
||||
values = struct.unpack_from('<IQQ' if width == 20 else '<III', body)
|
||||
length = width + (4 if signed else 0)
|
||||
if values == (info.CRC, info.compress_size, size) and \
|
||||
(following is None or end + length <= following):
|
||||
exact = following is not None and end + length == following
|
||||
fits.append((not exact, (width == 20) != zip64, length))
|
||||
if not fits:
|
||||
raise FrameError(f'{name} does not match its header (local and central entries differ, '
|
||||
'or its data descriptor is truncated)')
|
||||
crc, csize, usize = info.CRC, info.compress_size, size
|
||||
descriptor = min(fits)[2]
|
||||
else:
|
||||
usize, csize = _zip64_sizes(extra, usize, csize)
|
||||
descriptor = 0
|
||||
if (crc, csize, usize) != (info.CRC, info.compress_size, size):
|
||||
raise FrameError(f'{name} does not match its header (local and central entries differ)')
|
||||
if following is not None and start + info.compress_size + descriptor > following:
|
||||
raise FrameError(f'{name} overlaps the next ZIP entry')
|
||||
if info.compress_type == zipfile.ZIP_STORED:
|
||||
if info.compress_size != size:
|
||||
raise FrameError(f'{name} does not match its header (stored {info.compress_size} bytes, says {size})')
|
||||
data = compressed
|
||||
else:
|
||||
inflate = zlib.decompressobj(-15)
|
||||
try:
|
||||
data = inflate.decompress(compressed, size + 1)
|
||||
except zlib.error as e:
|
||||
raise FrameError(f'{name} is corrupt: {e}') from e
|
||||
if len(data) != size or not inflate.eof or inflate.unconsumed_tail or inflate.unused_data:
|
||||
raise FrameError(f'{name} does not match its header (its compressed data is not exactly '
|
||||
f'{size} bytes with nothing after it)')
|
||||
if zlib.crc32(data) != info.CRC:
|
||||
raise FrameError(f'{name} does not match its checksum')
|
||||
return data
|
||||
|
||||
|
||||
def merge_splits(base, splits, dst):
|
||||
"""One installable APK from a base APK and its splits (see split_entries), re-signed
|
||||
with Frame Control's key, without the base's isSplitRequired flag."""
|
||||
plan = split_entries(base, splits)
|
||||
add, total = {}, 0
|
||||
try:
|
||||
for entry, (path, member, size) in plan.items():
|
||||
data = read_member(path, member)
|
||||
total += len(data)
|
||||
if total > MAX_SPLIT_TOTAL:
|
||||
raise FrameError(f'the split APKs add more than {MAX_SPLIT_TOTAL // 1024**3} GB')
|
||||
add[entry] = data
|
||||
with zipfile.ZipFile(base) as z:
|
||||
original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
|
||||
manifest = drop_split_required(original)
|
||||
repack(base, dst, replace={'AndroidManifest.xml': manifest} if manifest != original else None, add=add)
|
||||
except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
|
||||
raise FrameError(f'could not merge the split APKs: {e}') from e
|
||||
return {'merged': sorted(os.path.basename(p) for p in splits), 'entries': len(plan)}
|
||||
|
||||
|
||||
def split_size(splits):
|
||||
"""Bytes a merge adds, from the split headers (merge_splits enforces the real sizes)."""
|
||||
total = 0
|
||||
for path in splits:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
total += sum(m.file_size for m in z.infolist() if SPLIT_ADDS.match(m.filename))
|
||||
return total
|
||||
|
||||
|
||||
def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None,
|
||||
splits=None):
|
||||
"""splits: the rest of a split APK (base first, as apk_path); merged into one APK first."""
|
||||
if splits:
|
||||
start = time.time()
|
||||
with tempfile.TemporaryDirectory(prefix=f'{TMP_PREFIX}{os.getpid()}-') as tmp:
|
||||
merged = os.path.join(tmp, 'merged.apk')
|
||||
try:
|
||||
merge_splits(apk_path, list(splits), merged)
|
||||
except Exception as e: # reported like any other failed install
|
||||
_after_install(None, None, e, start)
|
||||
raise
|
||||
meta = install(merged, flatscreen, name, source or os.path.basename(apk_path), icon_png, xr_compat, artwork)
|
||||
return meta
|
||||
start, info = time.time(), None
|
||||
try:
|
||||
info = apk_info(apk_path)
|
||||
@@ -257,6 +571,7 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
'source': source or os.path.basename(apk_path)})
|
||||
presentation['warnings'] = art_warnings + presentation.get('warnings', [])
|
||||
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
|
||||
'version_code': info.get('version_code'),
|
||||
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
|
||||
'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
|
||||
'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
|
||||
@@ -279,6 +594,13 @@ def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
|
||||
ssh(f'rm -rf {d}', timeout=30)
|
||||
except FrameError:
|
||||
pass
|
||||
elif not ok:
|
||||
# An update that failed: the app still works, but a partial copy of the new APK
|
||||
# (scp leaves one; it can be gigabytes) mustn't stay beside it.
|
||||
try:
|
||||
ssh(f'rm -f {d}/app.apk.part', timeout=30)
|
||||
except FrameError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -336,6 +658,34 @@ except (OSError, ValueError, TypeError, AttributeError):
|
||||
"""
|
||||
|
||||
|
||||
def _apk_parser_script():
|
||||
"""Frame-side Python preamble that loads this copy's stdlib APK parser (frame_apk)."""
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
source = f.read()
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
return script
|
||||
|
||||
|
||||
def installed_version_code(pkg):
|
||||
"""The version code of pkg's installed app.apk, read on the Frame (only its manifest
|
||||
crosses the link), or None if there is no readable APK. Connection failures raise."""
|
||||
if not PKG_RE.match(pkg or ''):
|
||||
raise FrameError(f'bad package name {pkg!r}')
|
||||
script = _apk_parser_script() + f"""try:
|
||||
code = sys.modules['frame_apk'].apk_info({(APPS_DIR + '/' + pkg + '/app.apk')!r}).get('version_code')
|
||||
except Exception:
|
||||
code = None
|
||||
print(json.dumps(code if isinstance(code, int) else None))
|
||||
"""
|
||||
try:
|
||||
code = json.loads(ssh('python3 -', input=script, timeout=120).strip().splitlines()[-1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return code if isinstance(code, int) and not isinstance(code, bool) else None
|
||||
|
||||
|
||||
def art_missing(m):
|
||||
"""True when a Frame Control install has no complete Steam artwork on record."""
|
||||
return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
|
||||
@@ -357,13 +707,7 @@ def refresh_art(pkg=None, artwork=None, fill_only=False):
|
||||
m = _meta_or_fail(pkg)
|
||||
d = f'{APPS_DIR}/{pkg}'
|
||||
# Parse the APK on the Frame, transferring only its icon/label, not the APK.
|
||||
modules = {}
|
||||
for module in ('frame_apk', 'frame_apk_vr'):
|
||||
with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
|
||||
modules[module] = f.read()
|
||||
script = 'import sys, types, json, base64\n'
|
||||
for module, source in modules.items():
|
||||
script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
|
||||
script = _apk_parser_script()
|
||||
script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
|
||||
script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
|
||||
script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
|
||||
@@ -424,10 +768,22 @@ def _clean_meta(m):
|
||||
return m
|
||||
|
||||
|
||||
def running_instances():
|
||||
"""Lepton container name -> adb port, for the instances that are running now."""
|
||||
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
|
||||
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
|
||||
def running_instances(strict=False):
|
||||
"""Lepton container name -> adb port, for the instances that are running now.
|
||||
|
||||
strict: a failed `podman ps` raises FrameError instead of reading as "nothing running",
|
||||
and containers without an adb port label count too (port None); for callers that must
|
||||
not mistake an error for a stopped instance."""
|
||||
if not strict:
|
||||
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}" 2>/dev/null || true')
|
||||
return dict(line.split()[:2] for line in out.splitlines() if len(line.split()) >= 2)
|
||||
out = ssh('podman ps --format "{{.Names}} {{.Labels.adb_port}}"')
|
||||
found = {}
|
||||
for line in out.splitlines():
|
||||
cols = line.split()
|
||||
if cols:
|
||||
found[cols[0]] = cols[1] if len(cols) > 1 and cols[1] != '<no' else None
|
||||
return found
|
||||
|
||||
|
||||
def list_apps():
|
||||
@@ -576,8 +932,11 @@ def main():
|
||||
r = frame_apk_versions.alternatives(
|
||||
info['package'] if info else args[0], info.get('version_code') if info else None)
|
||||
elif cmd == 'install':
|
||||
r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None)
|
||||
files = [a for a in args if not a.startswith('--')]
|
||||
if not files:
|
||||
raise FrameError('install requires APK [SPLIT.apk ...]')
|
||||
r = install(files[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
|
||||
xr_compat=False if '--no-xr-compat' in args else None, splits=files[1:])
|
||||
elif cmd == 'refresh-art':
|
||||
if len(args) != 1:
|
||||
raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
|
||||
|
||||
+4
-1
@@ -7,6 +7,7 @@ the resource table in resources.arsc.
|
||||
"""
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
|
||||
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
|
||||
@@ -221,7 +222,9 @@ def apk_info(path):
|
||||
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
|
||||
from frame_apk_vr import detection
|
||||
vr_info = detection(manifest_data, names)
|
||||
except (struct.error, IndexError, zipfile.BadZipFile) as e:
|
||||
# A damaged download: corrupt deflate data (zlib.error), a member cut short (EOFError),
|
||||
# undecodable text (UnicodeDecodeError) or an encrypted entry (RuntimeError).
|
||||
except (struct.error, IndexError, zipfile.BadZipFile, zlib.error, EOFError, ValueError, RuntimeError) as e:
|
||||
raise ApkError(f'could not read the APK manifest: {e}')
|
||||
tags = {}
|
||||
for tag, attrs in elements:
|
||||
|
||||
@@ -126,3 +126,46 @@ def detection(data, names):
|
||||
issues.append('Uses OpenXR (good).')
|
||||
info['vr_issues'] = issues
|
||||
return info
|
||||
|
||||
|
||||
IS_SPLIT_REQUIRED = 0x01010591 # android:isSplitRequired
|
||||
|
||||
|
||||
def drop_split_required(axml_bytes):
|
||||
"""The manifest without <application android:isSplitRequired>, for a base APK whose
|
||||
splits were merged into it: Android refuses a lone base that still says it needs them."""
|
||||
chunks = list(frame_apk._chunks(axml_bytes, 8, len(axml_bytes)))
|
||||
pool = next((c for c in chunks if c[0] == 1), None)
|
||||
if pool is None:
|
||||
raise frame_apk.ApkError('AndroidManifest.xml has no string pool')
|
||||
strings = frame_apk._string_pool(axml_bytes, pool[2])
|
||||
resmap = ()
|
||||
for kind, header_size, offset, chunk_size in chunks:
|
||||
if kind == 0x180:
|
||||
resmap = struct.unpack_from('<%dI' % ((chunk_size - header_size) // 4), axml_bytes, offset + header_size)
|
||||
result, changed = bytearray(axml_bytes[:8]), False
|
||||
for kind, hs, off, size in chunks:
|
||||
chunk = bytearray(axml_bytes[off:off + size])
|
||||
if kind == 0x0102 and not changed:
|
||||
name, astart, asize, count = struct.unpack_from('<4xIHHH', chunk, hs)
|
||||
if name < len(strings) and strings[name] == 'application':
|
||||
for i in range(count):
|
||||
a = hs + astart + i * asize
|
||||
attr = struct.unpack_from('<I', chunk, a + 4)[0]
|
||||
known = resmap[attr] if attr < len(resmap) else None
|
||||
if known == IS_SPLIT_REQUIRED or (known is None and attr < len(strings)
|
||||
and strings[attr] == 'isSplitRequired'):
|
||||
del chunk[a:a + asize]
|
||||
struct.pack_into('<I', chunk, 4, len(chunk))
|
||||
struct.pack_into('<H', chunk, hs + 12, count - 1)
|
||||
# id/class/style attribute indexes are 1-based positions in the list.
|
||||
for at in (hs + 14, hs + 16, hs + 18):
|
||||
index = struct.unpack_from('<H', chunk, at)[0]
|
||||
struct.pack_into('<H', chunk, at, 0 if index == i + 1 else index - 1 if index > i + 1 else index)
|
||||
changed = True
|
||||
break
|
||||
result += chunk
|
||||
if not changed:
|
||||
return axml_bytes
|
||||
struct.pack_into('<I', result, 4, len(result))
|
||||
return bytes(result)
|
||||
+2
-1
@@ -31,7 +31,8 @@ import frame_host # noqa: E402
|
||||
FRAME_USER = os.environ.get("FRAME_USER", "steamos")
|
||||
USER_FROM_ENV = "FRAME_USER" in os.environ
|
||||
FRAME_ALIAS = os.environ.get("FRAME_ALIAS", "frame")
|
||||
SSH_DIR = Path.home() / ".ssh"
|
||||
# $FRAME_CONTROL_SSH_DIR in tests, as frame_devices.ssh_dir(), so they never touch the real one.
|
||||
SSH_DIR = Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
|
||||
KEY = SSH_DIR / "id_ed25519_frame"
|
||||
# The devkit service only accepts ssh-rsa keys (write_key in Valve's
|
||||
# steamos-devkit-service), so pairing uses a second key next to the ed25519 one.
|
||||
|
||||
+15
-4
@@ -238,6 +238,13 @@ def scrub_failure(text, hosts=()):
|
||||
return frame_telemetry.scrub(hide_operands(hide_hosts(str(text or ""), hosts)), 600)
|
||||
|
||||
|
||||
def failure_detail(error):
|
||||
"""What an address that answered but failed SSH shows: the short reason when there is one
|
||||
(the whole message is the banner's headline already), else the message."""
|
||||
error = error or {}
|
||||
return error.get("short") or error.get("message") or "SSH failed"
|
||||
|
||||
|
||||
def failure_category(message, raw=""):
|
||||
"""The telemetry error category (frame_unreachable, frame_auth, ...) for a failure: a fixed
|
||||
word, never its text."""
|
||||
@@ -810,7 +817,9 @@ class Link:
|
||||
return "Connecting"
|
||||
|
||||
def fail(self, sid, message, raw="", **more):
|
||||
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth)."""
|
||||
"""A stage failed. `more`: what the page can offer to fix it (hostkey, auth), and `short`:
|
||||
the reason in a few words, for the places a whole sentence doesn't fit (the header pill,
|
||||
the address that failed)."""
|
||||
self.stage(sid, "failed", message)
|
||||
with self.cond:
|
||||
self.state["error"] = dict({"stage": sid, "message": message, "raw": raw}, **more)
|
||||
@@ -896,7 +905,7 @@ class Link:
|
||||
self.learn(device, a["host"], net, None)
|
||||
return True
|
||||
with self.cond:
|
||||
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
|
||||
why_not = failure_detail(self.state["error"])
|
||||
self.probe_update(i, state="sshfailed", detail=why_not)
|
||||
if outcome != "next":
|
||||
return False
|
||||
@@ -946,7 +955,7 @@ class Link:
|
||||
self.learn(device, a["host"], net, results[pick]["rtt_ms"])
|
||||
return True
|
||||
with self.cond:
|
||||
why_not = (self.state["error"] or {}).get("message") or "SSH failed"
|
||||
why_not = failure_detail(self.state["error"])
|
||||
self.probe_update(pick, state="sshfailed", detail=why_not)
|
||||
if outcome != "next":
|
||||
return False
|
||||
@@ -1269,6 +1278,7 @@ class Link:
|
||||
"If SteamOS was reinstalled or reset, or this is a new headset, check its new key and trust "
|
||||
"it on the Devices tab. If not, a different device may be answering here.")
|
||||
self.fail("identity", message, text,
|
||||
short="Saved SSH identity missing" if lost else "SSH identity changed",
|
||||
hostkey={"state": "missing" if lost else "changed", "type": shown[0] if shown else None,
|
||||
"fingerprint": shown[1] if shown else None, "host": host})
|
||||
return "next"
|
||||
@@ -1279,7 +1289,8 @@ class Link:
|
||||
if re.search(r"Permission denied", text) and not forward:
|
||||
# Not something a retry fixes: the headset needs this computer's key (Set Up Connection).
|
||||
self.fail("login", self.explain(text) or "The headset didn't accept this computer's key. Run Set Up "
|
||||
"Connection: it pairs this computer with the headset again.", text, auth="denied")
|
||||
"Connection: it pairs this computer with the headset again.", text, auth="denied",
|
||||
short="This computer's key refused")
|
||||
return "stop"
|
||||
if step == "connected":
|
||||
self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
"""Import from my Quest: copy games you own off your own Meta Quest onto the Frame.
|
||||
|
||||
The Quest is plugged into this computer by USB with developer mode on; its own
|
||||
adb copies the APK (and split APKs) into a per-user cache. The APK is checked
|
||||
before anything goes to the Frame, then installed through frame_android.install
|
||||
like any other APK. Game data (the OBB folder and, if asked, the external
|
||||
app-data folder) is copied by a second import while the person has the game
|
||||
open, since only a running Lepton instance can take it; Frame Control never
|
||||
starts the game itself. The cache is always removed. Games that check their Meta store licence
|
||||
(libovrplatformloader.so) are refused: that check needs Meta's services, and
|
||||
Frame Control does not remove it or work around it. See docs/quest-import.md.
|
||||
|
||||
Python stdlib only. CLI: python3 ui/frame_quest.py
|
||||
devices | list [--serial SERIAL] | import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade]
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
import frame_android as android
|
||||
import frame_host
|
||||
|
||||
OBB = '/sdcard/Android/obb'
|
||||
DATA = '/sdcard/Android/data'
|
||||
OBB_NAME = r'(main|patch)\.[0-9]+\.{}\.obb'
|
||||
# Serials of adb connections that aren't a USB headset: Frame Control's own SSH tunnels
|
||||
# to the Frame's Lepton instances (127.0.0.1:<port>, server.AdbTunnel) and emulators.
|
||||
TUNNEL_RE = re.compile(r'^(127\.0\.0\.1|localhost|\[::1\]):\d+$')
|
||||
QUEST_MAKERS = {'oculus', 'meta'}
|
||||
META_APPS = ('com.oculus.', 'com.meta.', 'com.facebook.')
|
||||
CACHE_TTL = 6 * 3600 # an import left behind by a crash is swept after this
|
||||
ADB_TIMEOUT = 30
|
||||
PULL_TIMEOUT = 3 * 3600
|
||||
READY_TIMEOUT = 150
|
||||
BATCH = 15 # packages per adb shell command while listing
|
||||
|
||||
ENTITLEMENT = ("{label} checks your Meta Quest store licence with Meta's Platform SDK "
|
||||
"(libovrplatformloader.so). That check needs Meta's services, which only exist on Meta "
|
||||
"headsets, so the game would quit on the Frame. Frame Control won't remove or work around "
|
||||
"the check. Ask the developer for a build without it, or look for the game on Steam or itch.io.")
|
||||
NO_DEVICE = ("No Quest found on this computer's USB. Connect the Quest with a cable that carries data, "
|
||||
"put the headset on and allow USB debugging. Developer mode must be on first: it needs a Meta "
|
||||
"developer organisation on a verified Meta account (developers.meta.com), then Developer Mode "
|
||||
"switched on for the headset in the Meta Horizon phone app.")
|
||||
UNAUTHORISED = ("The Quest hasn't allowed this computer yet. Put the headset on and accept the \"Allow USB "
|
||||
"debugging\" prompt (tick Always allow from this computer), then try again. No prompt? Unplug "
|
||||
"and replug the cable, or switch developer mode off and on.")
|
||||
NO_PERMISSIONS = ("This computer sees the Quest but isn't allowed to open it over USB. On Linux, add a udev "
|
||||
"rule for Meta's USB vendor id 2833 (see docs/quest-import.md), then replug it.")
|
||||
OFFLINE = "adb reports the Quest offline. Unplug and replug the cable, or restart the headset."
|
||||
GONE = "The Quest was disconnected. Plug it back in, put it on, and try again."
|
||||
|
||||
|
||||
class QuestError(android.FrameError):
|
||||
pass
|
||||
|
||||
|
||||
def _adb_cmd():
|
||||
try:
|
||||
return [frame_host.adb()]
|
||||
except frame_host.HostError as e:
|
||||
raise QuestError(str(e)) from e
|
||||
|
||||
|
||||
def _friendly(out):
|
||||
low = out.lower()
|
||||
if 'unauthorized' in low:
|
||||
return UNAUTHORISED
|
||||
if 'no permissions' in low or 'insufficient permissions' in low:
|
||||
return NO_PERMISSIONS
|
||||
if 'device offline' in low:
|
||||
return OFFLINE
|
||||
if 'no devices/emulators found' in low or re.search(r"device '[^']*' not found", low):
|
||||
return GONE
|
||||
return None
|
||||
|
||||
|
||||
def adb(*args, serial=None, timeout=ADB_TIMEOUT):
|
||||
"""Run this computer's adb (the one Frame Control already finds) and return stdout."""
|
||||
cmd = _adb_cmd() + (['-s', serial] if serial else []) + list(args)
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, stdin=subprocess.DEVNULL, text=True, errors='replace',
|
||||
timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise QuestError(f'adb {args[0]} took more than {timeout} s; check the Quest is still connected')
|
||||
except OSError as e:
|
||||
raise QuestError(f'could not run adb: {e}')
|
||||
if r.returncode != 0:
|
||||
out = (r.stderr or '').strip() or (r.stdout or '').strip()
|
||||
raise QuestError(_friendly(out) or out[-600:] or f'adb exited {r.returncode}')
|
||||
return r.stdout
|
||||
|
||||
|
||||
# ---- Finding the Quest ------------------------------------------------------
|
||||
|
||||
def _parse_devices(out):
|
||||
found = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith('List of devices') or line.startswith('*'):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
serial, rest = parts[0], (parts[1] if len(parts) > 1 else '')
|
||||
state = 'no permissions' if rest.startswith('no permissions') else (rest.split() or ['unknown'])[0]
|
||||
fields = dict(t.split(':', 1) for t in rest.split() if re.match(r'^[a-z_]+:', t))
|
||||
found.append({'serial': serial, 'state': state, 'usb': 'usb' in fields,
|
||||
'model': fields.get('model', '').replace('_', ' '), 'manufacturer': ''})
|
||||
return found
|
||||
|
||||
|
||||
def devices():
|
||||
"""Every adb device on this computer, each with a kind: quest, frame, android (another
|
||||
Android device), frame-tunnel (Frame Control's own SSH-tunnelled connection to a Lepton
|
||||
instance, never touched here), emulator, or unknown (not authorised, offline...)."""
|
||||
found = _parse_devices(adb('devices', '-l', timeout=15))
|
||||
for d in found:
|
||||
if TUNNEL_RE.match(d['serial']):
|
||||
d['kind'] = 'frame-tunnel'
|
||||
elif d['serial'].startswith('emulator-'):
|
||||
d['kind'] = 'emulator'
|
||||
elif d['state'] != 'device':
|
||||
d['kind'] = 'unknown'
|
||||
else:
|
||||
try:
|
||||
props = adb('shell', 'getprop ro.product.manufacturer; getprop ro.product.model',
|
||||
serial=d['serial'], timeout=15).splitlines() + ['', '']
|
||||
except QuestError:
|
||||
d['kind'] = 'unknown'
|
||||
continue
|
||||
d['manufacturer'], d['model'] = props[0].strip(), props[1].strip() or d['model']
|
||||
maker = d['manufacturer'].lower()
|
||||
d['kind'] = ('quest' if maker in QUEST_MAKERS or 'quest' in d['model'].lower()
|
||||
else 'frame' if maker == 'valve' else 'android')
|
||||
return found
|
||||
|
||||
|
||||
def find_quest(serial=None):
|
||||
"""The one connected, authorised Quest, or a QuestError saying what to do."""
|
||||
found = devices()
|
||||
if serial:
|
||||
d = next((d for d in found if d['serial'] == serial), None)
|
||||
if not d:
|
||||
raise QuestError(f'No adb device {serial} on this computer. ' + NO_DEVICE)
|
||||
if d['kind'] == 'quest':
|
||||
return d
|
||||
state = {'unauthorized': UNAUTHORISED, 'no permissions': NO_PERMISSIONS, 'offline': OFFLINE}.get(d['state'])
|
||||
what = ' '.join(x for x in (d['manufacturer'] or d['kind'], d['model']) if x)
|
||||
raise QuestError(state or f"{serial} isn't a Meta Quest ({what}).")
|
||||
quests = [d for d in found if d['kind'] == 'quest']
|
||||
if len(quests) == 1:
|
||||
return quests[0]
|
||||
if quests:
|
||||
raise QuestError('Several Quests are connected: ' + ', '.join(f"{d['model']} ({d['serial']})" for d in quests)
|
||||
+ '. Choose one, or unplug the others.')
|
||||
states = {d['state'] for d in found if d['kind'] == 'unknown'}
|
||||
for state, message in (('unauthorized', UNAUTHORISED), ('no permissions', NO_PERMISSIONS), ('offline', OFFLINE)):
|
||||
if state in states:
|
||||
raise QuestError(message)
|
||||
others = [d for d in found if d['kind'] in ('android', 'frame')]
|
||||
if others:
|
||||
raise QuestError(NO_DEVICE + ' (adb sees ' + ', '.join(
|
||||
f"{d['manufacturer']} {d['model']}".strip() for d in others) + ", which isn't a Quest.)")
|
||||
raise QuestError(NO_DEVICE)
|
||||
|
||||
|
||||
# ---- What's on it -------------------------------------------------------------
|
||||
|
||||
def _package_script(packages):
|
||||
parts = []
|
||||
for p in packages:
|
||||
parts += [f'echo @@{p}', f'pm path {p}', f'du -sk {OBB}/{p} 2>/dev/null', f'du -sk {DATA}/{p} 2>/dev/null']
|
||||
return '; '.join(parts + ['true'])
|
||||
|
||||
|
||||
def _parse_packages(out, packages):
|
||||
apps, current = {}, None
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('@@') and line[2:] in packages:
|
||||
current = apps[line[2:]] = {'package': line[2:], 'apks': [], 'obb': None, 'data': None}
|
||||
elif current is None or not line:
|
||||
continue
|
||||
elif line.startswith('package:'):
|
||||
current['apks'].append(line[len('package:'):])
|
||||
else:
|
||||
size, _, where = line.partition('\t') if '\t' in line else line.partition(' ')
|
||||
if size.isdigit():
|
||||
key = 'obb' if where.strip().startswith(OBB + '/') else 'data' if where.strip().startswith(DATA + '/') else None
|
||||
if key:
|
||||
current[key] = int(size) * 1024
|
||||
result = []
|
||||
for p in packages:
|
||||
app = apps.get(p)
|
||||
if not app or not app['apks']:
|
||||
continue # removed while listing
|
||||
app['split'] = len(app['apks']) > 1
|
||||
app['meta'] = p.startswith(META_APPS)
|
||||
result.append(app)
|
||||
return result
|
||||
|
||||
|
||||
def packages(serial):
|
||||
"""Third-party apps on the Quest: APK paths (several means a split APK), and the size
|
||||
in bytes of the OBB and external data folders (None when there is none)."""
|
||||
out = adb('shell', 'pm list packages -3', serial=serial)
|
||||
names = sorted({l.split(':', 1)[1].strip() for l in out.splitlines() if l.startswith('package:')})
|
||||
names = [n for n in names if android.PKG_RE.match(n)]
|
||||
apps = []
|
||||
for i in range(0, len(names), BATCH):
|
||||
batch = names[i:i + BATCH]
|
||||
apps += _parse_packages(adb('shell', _package_script(batch), serial=serial, timeout=120), batch)
|
||||
return apps
|
||||
|
||||
|
||||
def package(serial, pkg):
|
||||
apps = _parse_packages(adb('shell', _package_script([pkg]), serial=serial, timeout=60), [pkg])
|
||||
if not apps:
|
||||
raise QuestError(f"{pkg} isn't installed on the Quest")
|
||||
return apps[0]
|
||||
|
||||
|
||||
def status(serial=None, with_apps=True):
|
||||
"""For the page: the Quest (or why there isn't one), what else adb sees, and its apps."""
|
||||
try:
|
||||
found = devices()
|
||||
except QuestError as e:
|
||||
return {'quest': None, 'devices': [], 'guidance': str(e), 'apps': []}
|
||||
try:
|
||||
quest = find_quest(serial)
|
||||
except QuestError as e:
|
||||
return {'quest': None, 'devices': found, 'guidance': str(e), 'apps': []}
|
||||
return {'quest': quest, 'devices': found, 'guidance': None,
|
||||
'apps': packages(quest['serial']) if with_apps else []}
|
||||
|
||||
|
||||
# ---- Checking what was copied ---------------------------------------------------
|
||||
|
||||
def inspect(package_name, base, splits=()):
|
||||
"""Refuse what can't or mustn't run on the Frame, before anything is copied to it."""
|
||||
names = set()
|
||||
for path in [base, *splits]:
|
||||
try:
|
||||
with zipfile.ZipFile(path) as z:
|
||||
names |= set(z.namelist())
|
||||
except (zipfile.BadZipFile, OSError) as e:
|
||||
raise QuestError(f'{os.path.basename(path)} copied from the Quest is not a readable APK: {e}')
|
||||
info = android.apk_info(base)
|
||||
label = info['label']
|
||||
if info['package'] != package_name:
|
||||
raise QuestError(f"The APK copied for {package_name} says it is {info['package']}; not importing it")
|
||||
if any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names):
|
||||
raise QuestError(ENTITLEMENT.format(label=label))
|
||||
abis = sorted({n.split('/')[1] for n in names if n.startswith('lib/') and n.count('/') >= 2})
|
||||
if abis and 'arm64-v8a' not in abis:
|
||||
raise QuestError(f"{label} is a 32-bit-only build ({', '.join(abis)}). The Frame runs 64-bit ARM "
|
||||
"(arm64-v8a) apps only; older Quest games are often 32-bit. Ask the developer "
|
||||
"whether a 64-bit build exists.")
|
||||
android.check_installable(dict(info, abis=abis)) # Android version
|
||||
if splits:
|
||||
android.split_entries(base, splits) # a split APK Frame Control can't merge fails now
|
||||
warnings = []
|
||||
openxr = 'lib/arm64-v8a/libopenxr_loader.so' in names
|
||||
if 'lib/arm64-v8a/libvrapi.so' in names and not openxr:
|
||||
warnings.append("Uses Meta's legacy VrApi and no OpenXR. The Frame doesn't have VrApi, so it "
|
||||
"will install but won't run.")
|
||||
return {'package': info['package'], 'label': label, 'version': info['version'],
|
||||
'version_code': info.get('version_code'), 'abis': abis,
|
||||
'vr': bool(info.get('vr') or openxr), 'warnings': warnings}
|
||||
|
||||
|
||||
# ---- Importing ------------------------------------------------------------------
|
||||
|
||||
_import_lock = threading.Lock()
|
||||
_sleep = time.sleep
|
||||
|
||||
|
||||
def cache_root():
|
||||
return Path(frame_host.cache_dir('quest-import'))
|
||||
|
||||
|
||||
def sweep_cache(now=None):
|
||||
"""Remove imports a crash left behind (older than CACHE_TTL)."""
|
||||
root, now = cache_root(), now or time.time()
|
||||
if root.is_dir():
|
||||
for d in root.iterdir():
|
||||
try:
|
||||
if now - d.stat().st_mtime > CACHE_TTL:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _safe(name):
|
||||
return re.sub(r'[^A-Za-z0-9._-]', '_', name)[:120] or 'part.apk'
|
||||
|
||||
|
||||
def _remote_sizes(serial, paths):
|
||||
out = adb('shell', 'stat -c %s ' + ' '.join(shlex.quote(p) for p in paths), serial=serial)
|
||||
return sum(int(x) for x in out.split() if x.isdigit())
|
||||
|
||||
|
||||
def _pull(serial, remote, local):
|
||||
adb('pull', remote, str(local), serial=serial, timeout=PULL_TIMEOUT)
|
||||
|
||||
|
||||
def _wait_ready(meta):
|
||||
"""Until the app's Lepton instance has finished booting Android."""
|
||||
container = f"lepton-steamlaunch-{int(meta['instance'])}"
|
||||
deadline = time.monotonic() + READY_TIMEOUT
|
||||
while time.monotonic() < deadline:
|
||||
out = android.ssh(f"podman exec {container} /system/bin/sh -c 'getprop sys.boot_completed' "
|
||||
"2>/dev/null || true", timeout=30)
|
||||
if out.strip() == '1':
|
||||
return
|
||||
_sleep(3)
|
||||
raise QuestError(f"{meta['label']} didn't start on the Frame within {READY_TIMEOUT // 60} minutes, so its "
|
||||
"game data couldn't be added. Check it opens from the Steam library, then import again.")
|
||||
|
||||
|
||||
def push_data(meta, folder):
|
||||
"""Stream a pulled /sdcard/Android/data/<package> folder into the running instance as a
|
||||
tar, written straight into the SSH transfer: nothing is staged on this computer."""
|
||||
import frame_android_data
|
||||
pkg = meta['package']
|
||||
container = f"lepton-steamlaunch-{int(meta['instance'])}"
|
||||
script = f'set -eu; mkdir -p {DATA}/{pkg}; cd {DATA}/{pkg}; tar -x -o -f -'
|
||||
read_end, write_end = os.pipe()
|
||||
state = {'count': 0, 'error': None}
|
||||
|
||||
def write():
|
||||
try:
|
||||
with os.fdopen(write_end, 'wb') as out, \
|
||||
tarfile.open(fileobj=out, mode='w|', format=tarfile.GNU_FORMAT) as tar:
|
||||
for path in sorted(Path(folder).rglob('*')):
|
||||
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
||||
continue # regular files and folders only
|
||||
info = tar.gettarinfo(str(path), arcname=path.relative_to(folder).as_posix())
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ''
|
||||
if path.is_file():
|
||||
with path.open('rb') as f:
|
||||
tar.addfile(info, f)
|
||||
state['count'] += 1
|
||||
else:
|
||||
tar.addfile(info)
|
||||
except (OSError, tarfile.TarError, ValueError) as e: # ValueError: the reader closed first
|
||||
state['error'] = e
|
||||
|
||||
writer = threading.Thread(target=write, daemon=True)
|
||||
writer.start()
|
||||
try:
|
||||
with os.fdopen(read_end, 'rb') as src:
|
||||
frame_android_data._stream(shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script]),
|
||||
src=src)
|
||||
finally:
|
||||
writer.join(60)
|
||||
if state['error']:
|
||||
raise QuestError(f'could not read the app files copied from the Quest: {state["error"]}')
|
||||
return state['count']
|
||||
|
||||
|
||||
def space_needed(apk_bytes, merged_bytes, obb_bytes, data_bytes):
|
||||
"""Bytes an import needs free on this computer: the APKs as copied, a merged APK and the
|
||||
install's patched copy of it (each up to merged_bytes), the OBB folder and the
|
||||
Android/data folder (streamed to the Frame, never staged twice)."""
|
||||
return apk_bytes + 2 * merged_bytes + obb_bytes + data_bytes
|
||||
|
||||
|
||||
def _check_space(work, need, pkg):
|
||||
free = shutil.disk_usage(str(work)).free
|
||||
if need > free:
|
||||
raise QuestError(f'Importing {pkg} needs about {need / 1024**3:.1f} GB free on this computer '
|
||||
f'({free / 1024**3:.1f} GB available in {work.parent})')
|
||||
|
||||
|
||||
def import_game(package_name, serial=None, data=False, progress=None, allow_downgrade=False):
|
||||
"""Copy package_name from the Quest to the Frame. progress(stage, percent).
|
||||
|
||||
Frame Control never starts the game itself. Its game data (OBB, and Android/data if
|
||||
asked) can only be written into a running Lepton instance, and there is no way to
|
||||
boot one without its game, so that's a second import: the person starts the game,
|
||||
imports again, and Frame Control copies the data in and closes the game.
|
||||
|
||||
allow_downgrade: replace a newer version on the Frame with the Quest's older one."""
|
||||
if not android.PKG_RE.match(package_name or ''):
|
||||
raise QuestError('invalid package name')
|
||||
report = progress or (lambda stage, percent=None: None)
|
||||
if not _import_lock.acquire(blocking=False):
|
||||
raise QuestError('Another Quest import is running; wait for it to finish')
|
||||
try:
|
||||
report('Looking for your Quest', 1)
|
||||
quest = find_quest(serial)
|
||||
app = package(quest['serial'], package_name)
|
||||
sweep_cache()
|
||||
work = cache_root() / f'{package_name}-{secrets.token_hex(4)}'
|
||||
work.mkdir(parents=True)
|
||||
try:
|
||||
return _import(quest['serial'], app, data, work, report, allow_downgrade)
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
finally:
|
||||
_import_lock.release()
|
||||
|
||||
|
||||
def _running(pkg):
|
||||
# strict: a failed `podman ps` raises, never reads as "not running".
|
||||
return f'lepton-steamlaunch-{android.instance_id(pkg)}' in android.running_instances(strict=True)
|
||||
|
||||
|
||||
def _installed_code(pkg, meta):
|
||||
"""The installed version code: from the install record, else (installs made before
|
||||
records kept it) read from the installed app.apk on the Frame; None if unknown."""
|
||||
code = (meta or {}).get('version_code')
|
||||
if isinstance(code, int) and not isinstance(code, bool):
|
||||
return code
|
||||
return android.installed_version_code(pkg) if meta else None
|
||||
|
||||
|
||||
def _version(name, code):
|
||||
return ' '.join(x for x in (f'version {name}' if name else '', f'code {code}' if code is not None else 'unknown code') if x)
|
||||
|
||||
|
||||
def _check_downgrade(existing, checked, allow):
|
||||
"""Refuse to replace a newer install on the Frame with the Quest's older copy."""
|
||||
if not existing or allow:
|
||||
return
|
||||
have, new = _installed_code(existing['package'], existing), checked['version_code']
|
||||
if have is None or new is None:
|
||||
raise QuestError(f"Frame Control can't tell which version of {checked['label']} is on the Frame, so it "
|
||||
"won't replace it in case that one is newer. Allow replacing a newer version (or "
|
||||
"--allow-downgrade) to install your Quest's copy anyway.")
|
||||
if int(new) < int(have):
|
||||
raise QuestError(f"The Frame has a newer {checked['label']} ({_version(existing.get('version'), have)}) than "
|
||||
f"your Quest ({_version(checked['version'], new)}). Frame Control won't replace it with "
|
||||
"an older version unless you allow it (Allow replacing a newer version, or "
|
||||
"--allow-downgrade).")
|
||||
|
||||
|
||||
def _check_target(pkg, meta, checked):
|
||||
"""The install record of the game open on the Frame, if it is this package, in its own
|
||||
instance, at the Quest's exact version code; else a QuestError."""
|
||||
label = checked['label']
|
||||
if (not meta or meta.get('package') != pkg or android._int(meta.get('instance')) != android.instance_id(pkg)):
|
||||
raise QuestError(f"{label} is open on the Frame, but Frame Control has no matching install record for "
|
||||
'it. Close it, then import again to install it.')
|
||||
have, new = _installed_code(pkg, meta), checked['version_code']
|
||||
if have is None or new is None or int(have) != int(new):
|
||||
raise QuestError(f"{label} is open on the Frame with a different version (Frame: "
|
||||
f"{_version(meta.get('version'), have)}; Quest: {_version(checked['version'], new)}). "
|
||||
"Close it, then import again to install the Quest's version.")
|
||||
return meta
|
||||
|
||||
|
||||
def _check_android_package(meta, code):
|
||||
"""The package Android itself has installed in the instance is this one, at this version."""
|
||||
pkg = meta['package']
|
||||
container = f"lepton-steamlaunch-{int(meta['instance'])}"
|
||||
out = android.ssh(f"podman exec {container} /system/bin/sh -c 'dumpsys package {pkg}' 2>/dev/null || true",
|
||||
timeout=60)
|
||||
# Only this package's own block (dumpsys can list other packages too).
|
||||
block = re.search(r'^\s*Package \[' + re.escape(pkg) + r'\] \(.*?(?=^\s*Package \[|\Z)', out, re.M | re.S)
|
||||
found = re.search(r'\bversionCode=(\d+)', block.group(0)) if block else None
|
||||
if not found:
|
||||
raise QuestError(f'Android in {container} does not have {pkg} installed')
|
||||
if int(found.group(1)) != int(code):
|
||||
raise QuestError(f'Android in {container} has {pkg} version code {found.group(1)}, not the '
|
||||
f"Quest's {code}")
|
||||
|
||||
|
||||
def _close(pkg):
|
||||
"""Stop the game and confirm its instance is gone: (closed, why not)."""
|
||||
try:
|
||||
android.stop(pkg)
|
||||
for _ in range(6):
|
||||
if not _running(pkg):
|
||||
return True, None
|
||||
_sleep(2)
|
||||
return False, 'it is still running'
|
||||
except Exception as e: # never replaces the error that led here; unconfirmed counts as not closed
|
||||
return False, str(e) or type(e).__name__
|
||||
|
||||
|
||||
def _import(serial, app, data, work, report, allow_downgrade=False):
|
||||
pkg = app['package']
|
||||
obb_bytes = app['obb'] or 0
|
||||
data_bytes = (app['data'] or 0) if data else 0
|
||||
apk_bytes = _remote_sizes(serial, app['apks'])
|
||||
_check_space(work, space_needed(apk_bytes, apk_bytes, obb_bytes, data_bytes), pkg)
|
||||
# 1. The APK (and its splits), checked before anything else is copied.
|
||||
local = []
|
||||
for i, remote in enumerate(app['apks']):
|
||||
report(f'Copying the app from your Quest ({i + 1} of {len(app["apks"])})', 5 + 20 * i // len(app['apks']))
|
||||
name = 'base.apk' if os.path.basename(remote) == 'base.apk' else f'{i:02d}-{_safe(os.path.basename(remote))}'
|
||||
_pull(serial, remote, work / name)
|
||||
if not (work / name).is_file():
|
||||
raise QuestError(f'adb did not copy {remote} from the Quest')
|
||||
local.append(work / name)
|
||||
base = next((p for p in local if p.name == 'base.apk'), local[0])
|
||||
splits = [p for p in local if p != base]
|
||||
report('Checking it can run on the Frame', 27)
|
||||
checked = inspect(pkg, str(base), [str(p) for p in splits])
|
||||
warnings = list(checked['warnings'])
|
||||
label = checked['label']
|
||||
# A merge stores what splits compressed: count it uncompressed, now the files are here.
|
||||
merged = base.stat().st_size + android.split_size([str(p) for p in splits])
|
||||
_check_space(work, space_needed(0, merged, obb_bytes, data_bytes), pkg)
|
||||
wants_data = app['obb'] is not None or bool(data and app['data'] is not None)
|
||||
if not _running(pkg):
|
||||
# 2a. Install. The game is never started here: see import_game. Checked and installed
|
||||
# under the shared install lock, so nothing installs or removes it in between.
|
||||
report('Installing on the Frame', 60)
|
||||
with android._install_lock:
|
||||
if _running(pkg):
|
||||
raise QuestError(f'{label} was started on the Frame during the import. Close it, then import again.')
|
||||
_check_downgrade(android.read_meta(pkg), checked, allow_downgrade)
|
||||
meta = android.install(str(base), splits=[str(p) for p in splits], source='Meta Quest (USB)')
|
||||
report('Done', 100)
|
||||
message = f"Imported {meta['label']} from your Quest. It's in the Steam library."
|
||||
if wants_data:
|
||||
what = ' and '.join(x for x in ('its OBB game data' if app['obb'] is not None else '',
|
||||
'its Android/data files' if data and app['data'] is not None else '') if x)
|
||||
message += (f" To add {what}, start {meta['label']} from the Steam library, leave it on its first "
|
||||
"screen, then import it again here: Frame Control copies the data in and closes the game.")
|
||||
note = android.layer_note(meta)
|
||||
return {'message': ' '.join([message, *warnings, *([note] if note else [])]), 'app': meta, 'obb': [],
|
||||
'files': 0, 'warnings': warnings, 'split': bool(splits), 'needs_game_data': wants_data,
|
||||
'closed': None}
|
||||
# 2b. The game is open on the Frame: add its data to exactly this install, never replacing it.
|
||||
_check_target(pkg, android.read_meta(pkg), checked) # early, before copying the data off the Quest
|
||||
if not wants_data:
|
||||
raise QuestError(f"{label} is already installed and open on the Frame, and has no game data to add. "
|
||||
'Close it first to install it again.')
|
||||
obb_files = []
|
||||
if app['obb'] is not None:
|
||||
report('Copying game data (OBB) from your Quest', 30)
|
||||
(work / 'obb').mkdir()
|
||||
_pull(serial, f'{OBB}/{pkg}', work / 'obb')
|
||||
folder = work / 'obb' / pkg
|
||||
files = sorted(p for p in folder.iterdir() if p.is_file()) if folder.is_dir() else []
|
||||
obb_files = [p for p in files if re.fullmatch(OBB_NAME.format(re.escape(pkg)), p.name)]
|
||||
skipped = [p.name for p in files if p not in obb_files]
|
||||
if skipped:
|
||||
warnings.append('Not copied, not standard OBB file names: ' + ', '.join(skipped))
|
||||
data_folder = None
|
||||
if data and app['data'] is not None:
|
||||
report('Copying app files from your Quest', 50)
|
||||
(work / 'data').mkdir()
|
||||
_pull(serial, f'{DATA}/{pkg}', work / 'data')
|
||||
data_folder = work / 'data' / pkg if (work / 'data' / pkg).is_dir() else None
|
||||
copied_obb, copied_files, failure = [], 0, None
|
||||
# The pulls can take a long time: check the target again, and hold the shared install
|
||||
# lock (install, remove and install_obb take it too) until the game is closed.
|
||||
with android._install_lock:
|
||||
if not _running(pkg):
|
||||
raise QuestError(f'{label} was closed on the Frame during the import. Start it, then import again.')
|
||||
meta = _check_target(pkg, android.read_meta(pkg), checked)
|
||||
closed, why = None, None
|
||||
try:
|
||||
report('Waiting for Android on the Frame', 70)
|
||||
_wait_ready(meta)
|
||||
_check_android_package(meta, checked['version_code'])
|
||||
if obb_files:
|
||||
report('Copying game data (OBB) to the Frame', 80)
|
||||
copied_obb = android.install_obb(pkg, [str(p) for p in obb_files])['obb']
|
||||
if data_folder:
|
||||
report('Copying app files to the Frame', 92)
|
||||
copied_files = push_data(meta, data_folder)
|
||||
except Exception as e: # any failure, local (OSError) or on the Frame, is reported below
|
||||
failure = e
|
||||
finally:
|
||||
# Whatever happened, even Ctrl-C part-way through a write, the game is closed so it
|
||||
# never runs on half-copied data. A failed close never hides the original error.
|
||||
interrupted = sys.exc_info()[0] is not None
|
||||
closed, why = _close(pkg)
|
||||
if interrupted and not closed:
|
||||
print(f"Frame Control couldn't close {meta['label']} on the Frame ({why}); close it from Steam "
|
||||
'before playing.', file=sys.stderr)
|
||||
if failure:
|
||||
message = f"Copying {meta['label']}'s game data failed: {failure}."
|
||||
message += (' The game was closed; start it and import again to retry.' if closed else
|
||||
f" Frame Control also couldn't close the game ({why}): close it from Steam, then start it "
|
||||
'and import again.')
|
||||
raise QuestError(message) from failure
|
||||
report('Done', 100)
|
||||
message = f"Added {meta['label']}'s game data from your Quest"
|
||||
message += ('; the game is closed, start it again from the Steam library.' if closed else
|
||||
f", but Frame Control couldn't close the game ({why}). Close it from Steam before playing, so it "
|
||||
'starts with the new data.')
|
||||
if copied_obb:
|
||||
message += f' Added {len(copied_obb)} OBB file{"s" if len(copied_obb) != 1 else ""}.'
|
||||
if copied_files:
|
||||
message += f' Copied {copied_files} app file{"s" if copied_files != 1 else ""}.'
|
||||
return {'message': ' '.join([message, *warnings]), 'app': meta, 'obb': copied_obb, 'files': copied_files,
|
||||
'warnings': warnings, 'split': bool(splits), 'needs_game_data': False, 'closed': closed}
|
||||
|
||||
|
||||
def main():
|
||||
cmd, *args = sys.argv[1:] or ['help']
|
||||
serial = args[args.index('--serial') + 1] if '--serial' in args and args.index('--serial') + 1 < len(args) else None
|
||||
try:
|
||||
if cmd == 'devices':
|
||||
r = devices()
|
||||
elif cmd == 'list':
|
||||
r = packages(find_quest(serial)['serial'])
|
||||
elif cmd == 'import':
|
||||
names = [a for a in args if not a.startswith('--') and a != serial]
|
||||
if len(names) != 1:
|
||||
raise QuestError('usage: import PACKAGE [--serial SERIAL] [--data] [--allow-downgrade]')
|
||||
r = import_game(names[0], serial, data='--data' in args, allow_downgrade='--allow-downgrade' in args,
|
||||
progress=lambda stage, percent=None: print(f'{percent:>3}% {stage}' if percent is not None
|
||||
else stage, file=sys.stderr))
|
||||
else:
|
||||
sys.exit(__doc__)
|
||||
except (android.FrameError, OSError) as e:
|
||||
sys.exit(f'error: {e}')
|
||||
print(json.dumps(r, indent=1))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+11
-1
@@ -6,6 +6,7 @@ error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
|
||||
"""
|
||||
import ctypes as C
|
||||
import glob
|
||||
import hashlib
|
||||
import math
|
||||
import json
|
||||
import os
|
||||
@@ -121,7 +122,16 @@ def os_release():
|
||||
k, _, v = line.partition("=")
|
||||
out[k] = v.strip('"')
|
||||
return {"version": out.get("VERSION_ID"), "build": out.get("BUILD_ID"),
|
||||
"variant": out.get("VARIANT_ID")}
|
||||
"variant": out.get("VARIANT_ID"), "headset": headset_id()}
|
||||
|
||||
|
||||
def headset_id():
|
||||
"""A one-way hash of the headset's /etc/machine-id: the same for this headset on every
|
||||
computer, and the machine-id itself never leaves the headset. None without one."""
|
||||
mid = read("/etc/machine-id")
|
||||
if not mid:
|
||||
return None
|
||||
return hashlib.sha256(("frame-control-headset:" + mid).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def volume():
|
||||
|
||||
+40
-7
@@ -50,6 +50,7 @@ SETTINGS = STATE / 'settings.json'
|
||||
OUTBOX = STATE / 'outbox.jsonl'
|
||||
SENT = STATE / 'sent.jsonl'
|
||||
SENT_KEEP = 200
|
||||
FRAMES_SEEN_KEEP = 100 # build/headset pairs already reported, enough for several headsets' updates
|
||||
OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
|
||||
FLUSH_EVERY = 60
|
||||
REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
|
||||
@@ -266,9 +267,23 @@ CATEGORIES = [
|
||||
('layer_missing', re.compile(r'OpenXR compatibility layer')),
|
||||
('apk_repack_failed', re.compile(r'could not prepare the APK for the Frame')),
|
||||
('tool_missing', re.compile(r"\[WinError 2\]|No such file or directory: '(?:ssh|scp|rsync|adb)")),
|
||||
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name')),
|
||||
('apk_unreadable', re.compile(r'(?i)not a zip|bad apk|AndroidManifest|ApkError|unexpected package name|'
|
||||
r'not a readable APK|not an APK|APK manifest|in the APK (?:uses a compression|is )')),
|
||||
('cant_run_on_frame', re.compile(r"can't run on the Frame")),
|
||||
('steam_shortcut', re.compile(r'(?i)steam did not return a shortcut|shortcut list|no Steam shortcut')),
|
||||
# The Frame-side scripts that talk to the Steam client's UI (frame/android/steam_shortcuts.py), which
|
||||
# fail with their own words or a Python traceback, its last lines kept. Before frame_unreachable:
|
||||
# "Connection refused" and "timed out" here are Steam's debug port on the Frame, not ssh.
|
||||
('steam_not_running', re.compile(r'SharedJSContext not found|'
|
||||
r'URLError: <urlopen error \[Errno 111\] Connection refused>|'
|
||||
r'ConnectionRefusedError: \[Errno 111\]')),
|
||||
('steam_not_responding', re.compile(r'websocket handshake failed|(?:socket\.timeout|TimeoutError): timed out|'
|
||||
r'(?:\A|\n)EOFError\Z')),
|
||||
('steam_js_error', re.compile(r'JS error: ')),
|
||||
('artwork_failed', re.compile(r'incomplete artwork render|artwork renderer did not produce|'
|
||||
r'render is \d+ bytes of|could not prepare artwork|all five Steam artwork slots')),
|
||||
# Copying the APK (or its launcher) to the Frame: rsync or scp ran out of time.
|
||||
('copy_timeout', re.compile(r'to the Frame timed out\Z')),
|
||||
('frame_not_set_up', re.compile(r'(?i)Could not resolve hostname|no "?frame"? (?:SSH )?alias')),
|
||||
('frame_auth', re.compile(r'(?i)Permission denied|Host key verification failed|'
|
||||
r'REMOTE HOST IDENTIFICATION HAS CHANGED|host key is known for')),
|
||||
@@ -280,6 +295,8 @@ CATEGORIES = [
|
||||
r'Network is unreachable|Host is down|asleep|kex_exchange|banner exchange|'
|
||||
r'ssh: connect to host |mux_client_|client_loop: ')),
|
||||
('frame_disk_full', re.compile(r'(?i)No space left|disk full|ENOSPC')),
|
||||
# rsync or scp failed for another reason ("connection unexpectedly closed", or no words at all).
|
||||
('copy_failed', re.compile(r'\Acopying .+ to the Frame failed: ')),
|
||||
('download_failed', re.compile(r'(?i)HTTP (?:Error )?\d{3}|URLError|download|certificate verify failed')),
|
||||
('flatpak', re.compile(r'(?i)flatpak|flathub')),
|
||||
('cancelled', re.compile(r'(?i)cancel')),
|
||||
@@ -287,8 +304,16 @@ CATEGORIES = [
|
||||
]
|
||||
|
||||
|
||||
# Python's own errors for a mistake in Frame Control's code, whatever their message says.
|
||||
BUG_ERRORS = (KeyError, IndexError, TypeError, AttributeError, NameError, AssertionError,
|
||||
ZeroDivisionError, RecursionError, UnicodeError, json.JSONDecodeError)
|
||||
|
||||
|
||||
def categorize(message):
|
||||
"""(category, detail): a fixed category name, plus an Android installer code when there is one."""
|
||||
"""(category, detail): a fixed category name, plus an Android installer code when there is one.
|
||||
message may be the exception itself: one of BUG_ERRORS is internal_error."""
|
||||
if isinstance(message, BUG_ERRORS):
|
||||
return 'internal_error', None
|
||||
text = str(message or '')
|
||||
for name, pattern in CATEGORIES:
|
||||
m = pattern.search(text)
|
||||
@@ -366,16 +391,24 @@ def app_started():
|
||||
_save(s)
|
||||
|
||||
|
||||
def frame_seen(build, version):
|
||||
"""The Frame's SteamOS build, once per build (public build numbers)."""
|
||||
key = f'{build}/{version}'
|
||||
def frame_seen(build, version, headset=None, saved=None):
|
||||
"""The Frame's SteamOS build (public build numbers), once per build for each headset.
|
||||
headset: the anonymous hash from frame_status.headset_id; saved: how many headsets
|
||||
this copy of the app has set up."""
|
||||
headset = headset if isinstance(headset, str) and re.fullmatch(r'[0-9a-f]{16}', headset) else None
|
||||
key = f'{build}/{version}' + (f'/{headset}' if headset else '')
|
||||
with _lock:
|
||||
s = settings()
|
||||
if not build or key in s['frames_seen']:
|
||||
return
|
||||
s['frames_seen'] = (s['frames_seen'] + [key])[-20:]
|
||||
s['frames_seen'] = (s['frames_seen'] + [key])[-FRAMES_SEEN_KEEP:]
|
||||
_save(s)
|
||||
capture('frame_connected', {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]})
|
||||
p = {'steamos_build': str(build)[:40], 'steamos_version': str(version or '')[:40]}
|
||||
if headset:
|
||||
p['headset_id'] = headset
|
||||
if isinstance(saved, int) and saved >= 0:
|
||||
p['headsets_saved'] = saved
|
||||
capture('frame_connected', p)
|
||||
|
||||
|
||||
def install_finished(kind, ok, seconds=None, error=None, diagnose=True, **props):
|
||||
|
||||
+111
-22
@@ -51,6 +51,10 @@
|
||||
nav a:hover { color: var(--bright); }
|
||||
nav a.on { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
.spacer { flex: 1; }
|
||||
/* The connection pill and the buttons after it, pushed to the right: closer together than the tabs, and the
|
||||
pill (not the buttons) gives way when the window is narrow, so nothing is pushed past the right edge. */
|
||||
.hdr-end { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; margin-left: auto; }
|
||||
.hdr-end > :not(.pill, .devsel) { flex: none; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-radius: 3px;
|
||||
background: var(--btn); color: var(--text); font-size: 13px; white-space: nowrap; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
@@ -60,6 +64,7 @@
|
||||
main { max-width: 1480px; margin: 0 auto; padding: 22px calc(28px + env(safe-area-inset-right)) 30px calc(28px + env(safe-area-inset-left)); display: flex; flex-direction: column; gap: 26px; }
|
||||
.grid-top { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(330px, 1fr); gap: 22px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 22px; }
|
||||
@media (max-width: 1366px) { header { gap: 20px; } nav a { padding: 0 11px; } }
|
||||
@media (max-width: 1150px) { .grid-top, .grid-3 { grid-template-columns: 1fr; } header { gap: 16px; padding: env(safe-area-inset-top) calc(18px + env(safe-area-inset-right)) 0 calc(18px + env(safe-area-inset-left)); } nav a { padding: 0 10px; } }
|
||||
@media (max-width: 820px) { .brand b { display: none; } }
|
||||
nav svg { display: none; }
|
||||
@@ -74,7 +79,7 @@
|
||||
.page { display: flex; flex-direction: column; gap: 26px; }
|
||||
.page:not(.on) { display: none; }
|
||||
nav kbd { font: inherit; font-size: 10.5px; color: var(--dim); margin-left: 7px; letter-spacing: 0; }
|
||||
@media (max-width: 1150px) { nav kbd { display: none; } }
|
||||
@media (max-width: 1366px) { nav kbd { display: none; } } /* the tabs' titles still name the key */
|
||||
|
||||
/* ---- offline banner ---- */
|
||||
.banner { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 4px;
|
||||
@@ -87,14 +92,14 @@
|
||||
.wait::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--dim); flex: none; }
|
||||
|
||||
/* ---- connection pill, its details dialog, and the Devices tab (frame_link.py) ---- */
|
||||
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 190px; flex: 0 1 auto; background: var(--btn); }
|
||||
.pill { height: 40px; padding: 0 12px; gap: 9px; max-width: 420px; min-width: 150px; flex: 0 1 auto; background: var(--btn); }
|
||||
.pill .dot { flex: none; }
|
||||
.pill .dot.wait { background: var(--warn); box-shadow: 0 0 6px rgba(217,162,58,.7); animation: pulse 1s ease-in-out infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.pill .pt { display: flex; flex-direction: column; align-items: flex-start; min-width: 0; line-height: 1.2; text-align: left; }
|
||||
.pill .pt b { font-weight: 500; font-size: 13px; color: var(--bright); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pill .pt span { font-size: 11.5px; color: var(--muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; }
|
||||
.devsel { background: var(--btn); color: var(--text); border: 0; border-radius: 3px; height: 40px; padding: 0 8px; font: inherit; font-size: 13px; max-width: 160px; min-width: 90px; }
|
||||
.dlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
|
||||
padding: 22px; width: min(640px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
|
||||
.dlg::backdrop { background: rgba(0,0,0,.55); }
|
||||
@@ -132,7 +137,7 @@
|
||||
.dev-panel select, .dlg select { background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent; border-radius: 3px;
|
||||
padding: 8px 8px; font: inherit; font-size: 13px; }
|
||||
.addr .t { overflow-wrap: anywhere; }
|
||||
.addr .s { white-space: normal; }
|
||||
.addr .s, .dev-problem .s { white-space: normal; }
|
||||
.addr-edit { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto auto; gap: 8px; align-items: center; width: 100%; }
|
||||
.addr-add { display: grid; grid-template-columns: 2fr 1fr 1.4fr auto; gap: 8px; margin-top: 12px; }
|
||||
@media (max-width: 700px) { .dev-form, .addr-edit, .addr-add { grid-template-columns: 1fr; } }
|
||||
@@ -655,7 +660,7 @@
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
|
||||
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
<div class="hdr-end">
|
||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||
<select class="devsel desk-only" id="devSel" aria-label="Headset" title="Switch headset" hidden></select>
|
||||
<span class="chip" id="battChip" title="Battery">—</span>
|
||||
@@ -665,6 +670,7 @@
|
||||
<button id="refreshAll" title="Refresh (R)" aria-label="Refresh">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
@@ -677,7 +683,8 @@
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, and whether installs on the Frame worked. Never file names, paths,
|
||||
your operating system, which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
<button class="small" id="noticeSettings">Privacy settings</button>
|
||||
@@ -951,6 +958,20 @@
|
||||
library under its own name and opens in its own panel. Test launches it and records
|
||||
whether it stays up; Report records how it worked for you.</div>
|
||||
</div>
|
||||
<section class="panel" id="quest">
|
||||
<div class="shelf-head"><h2>Import from my Quest</h2><span class="count" id="questCount"></span><span class="spacer"></span>
|
||||
<button class="small" id="questScan" title="Look for a Meta Quest plugged into this computer by USB">Look for my Quest</button></div>
|
||||
<div class="sub" id="questState" role="status" aria-live="polite">Plug your Quest into this computer by USB, put it on, then Look for my Quest.</div>
|
||||
<label class="sub" style="display:block;margin-top:8px"><input type="checkbox" id="questData"> Also copy each game's files in Android/data (some games keep saves there)</label>
|
||||
<label class="sub" style="display:block;margin-top:4px"><input type="checkbox" id="questOlder"> Allow replacing a newer version on the Frame with the Quest's older one</label>
|
||||
<progress id="questProgress" max="100" hidden aria-label="Import progress" style="width:100%;margin-top:8px"></progress>
|
||||
<div class="list" id="questApps" style="margin-top:8px"></div>
|
||||
<div class="hint">Copies games you own from your own Quest (developer mode on) to the Frame. A game with
|
||||
OBB data takes two imports: the first installs it; start it from the Steam library, then import again to
|
||||
copy its data in. Frame Control never starts the game itself. Games that
|
||||
check their Meta store licence need Meta's services, which only Meta headsets have, so they are refused:
|
||||
Frame Control won't remove that check. <a href="https://github.com/saphid/frame-control/blob/main/docs/quest-import.md" target="_blank" rel="noopener noreferrer">How it works</a></div>
|
||||
</section>
|
||||
<section class="panel disp" id="display">
|
||||
<div class="shelf-head"><h2>Android display</h2><span class="count">Lepton</span><span class="spacer"></span>
|
||||
<button class="small" id="dispReload" title="Read the current values again">Refresh</button></div>
|
||||
@@ -1142,7 +1163,8 @@
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only.</span></label>
|
||||
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
|
||||
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
<span class="sub">Your APK reports and tests (package, version, result, your notes) go to the shared
|
||||
compatibility database, so the verdicts get better for everyone.</span></label>
|
||||
@@ -1427,6 +1449,7 @@ function applyHostWording(h) {
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
$("quest").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
|
||||
if (!h.mobile) $("shotsFolder").textContent = h.fileManager === "your file manager" ? "Open folder" : `Show in ${h.fileManager}`;
|
||||
$("shotsSaveNew").textContent = h.mobile ? `Save all to ${h.computer}` : `Save new to ${h.computer}`;
|
||||
$("clipMac").textContent = `Send ${h.computer} clipboard`;
|
||||
@@ -1587,7 +1610,7 @@ $("vrUtilities").onclick = async e => {
|
||||
await loadUtilities();
|
||||
};
|
||||
|
||||
// The Frame owns the clock. Poll independently of status and the selected tab.
|
||||
// The Frame owns the clock. Poll independently of the selected tab, from the first status check on.
|
||||
let comfortBusy = false, comfortSeen = new Set(), comfortSession = null, comfortHydrated = false;
|
||||
async function localNotification(message, request = false) {
|
||||
try {
|
||||
@@ -1646,7 +1669,12 @@ function renderComfort(s) {
|
||||
if ($("comfortEvents").textContent !== history) $("comfortEvents").textContent = history;
|
||||
}
|
||||
async function pollComfort() {
|
||||
if (!comfortBusy) {
|
||||
// Not while the Frame can't be reached: each poll would be another ssh attempt waiting to
|
||||
// time out, and the banner already says why. Status checks bring it back (setOnline).
|
||||
if (online === false) {
|
||||
const message = "Session status appears when the Frame is connected.";
|
||||
if ($("comfortStatus").textContent !== message) $("comfortStatus").textContent = message;
|
||||
} else if (!comfortBusy) {
|
||||
comfortBusy = true;
|
||||
try { renderComfort(await api("/api/comfort", {action: "status"})); }
|
||||
catch (e) {
|
||||
@@ -1680,7 +1708,6 @@ $("castBtn").onclick = () => {
|
||||
// Fullscreen is a direct user gesture; iPhone falls back to its inline viewer.
|
||||
$("viewer").requestFullscreen?.().catch(() => {});
|
||||
};
|
||||
pollComfort();
|
||||
|
||||
// ---- battery ----
|
||||
function battery(b, power) {
|
||||
@@ -3317,6 +3344,51 @@ async function loadAndroid() {
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Import from my Quest (frame_quest.py): adb on this computer, never on the Frame ----
|
||||
const quest = { serial: null };
|
||||
function questSize(bytes) {
|
||||
if (bytes == null) return "";
|
||||
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||||
if (bytes >= 1048576) return `${Math.round(bytes / 1048576)} MB`;
|
||||
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
||||
}
|
||||
async function loadQuest() {
|
||||
$("questScan").disabled = true; $("questState").textContent = "Looking for your Quest…"; $("questApps").innerHTML = "";
|
||||
let d;
|
||||
try { d = await api("/api/quest"); }
|
||||
catch (e) { $("questState").textContent = e.message; return; }
|
||||
finally { $("questScan").disabled = false; }
|
||||
quest.serial = d.quest ? d.quest.serial : null;
|
||||
$("questCount").textContent = d.quest ? `${d.apps.length} app${d.apps.length === 1 ? "" : "s"}` : "";
|
||||
if (!d.quest) { $("questState").textContent = d.guidance || "No Quest found."; return; }
|
||||
$("questState").textContent = `${d.quest.model || "Quest"} connected (${d.quest.serial}). Games that check their Meta licence are refused when you import them.`;
|
||||
const installed = new Set(androidApps.map(a => a.package));
|
||||
$("questApps").innerHTML = d.apps.length ? d.apps.map(a => {
|
||||
const bits = [a.split ? `split APK (${a.apks.length} files)` : "single APK"];
|
||||
if (a.obb != null) bits.push(`OBB ${questSize(a.obb)}`);
|
||||
if (a.data != null) bits.push(`Android/data ${questSize(a.data)}`);
|
||||
if (a.meta) bits.push("Meta app: needs Meta's services");
|
||||
return `<div class="item"><div class="grow"><div class="t">${esc(a.package)}</div><div class="s">${esc(bits.join(" · "))}</div></div>
|
||||
${installed.has(a.package) ? `<span class="tag">On the Frame</span>` : ""}
|
||||
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${installed.has(a.package) ? "Import again" : "Import"}</button></div>`;
|
||||
}).join("") : `<div class="sub">No games or apps you installed are on this Quest.</div>`;
|
||||
}
|
||||
$("questScan").onclick = loadQuest;
|
||||
$("questApps").addEventListener("click", e => {
|
||||
const b = e.target.closest("[data-quest-import]");
|
||||
if (!b) return;
|
||||
const pkg = b.dataset.questImport, key = "quest:" + pkg;
|
||||
if (installing.has(key)) return;
|
||||
b.disabled = true;
|
||||
const bar = $("questProgress");
|
||||
bar.hidden = false; bar.value = 0;
|
||||
runJob(`Import ${pkg} from Quest`, key,
|
||||
() => api("/api/quest", { action: "import", package: pkg, serial: quest.serial, data: $("questData").checked, allowDowngrade: $("questOlder").checked }),
|
||||
j => { if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; })
|
||||
.then(r => { bar.hidden = true; if (r && r.app) { $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
|
||||
else { b.disabled = false; $("questState").textContent = `${pkg} wasn't imported; the reason is in the message log below.`; } });
|
||||
});
|
||||
|
||||
async function loadCatalog() {
|
||||
try { cat.apps = (await api("/api/android/catalog")).apps; }
|
||||
catch (e) { $("catGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
|
||||
@@ -4495,7 +4567,7 @@ if (window.frameApp && window.frameApp.onInstallLink) {
|
||||
}
|
||||
|
||||
setView("headset");
|
||||
refresh().then(() => loadShots()); // after status, so app names resolve
|
||||
refresh().then(() => { loadShots(); pollComfort(); }); // after status, so app names resolve and comfort knows if the Frame answers
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden && online === false) refresh(); });
|
||||
</script>
|
||||
<script>
|
||||
@@ -4524,6 +4596,20 @@ function netLabel(s) {
|
||||
}
|
||||
function viaText(v) { return v ? v.host + (v.ip && v.ip !== v.host ? ` (${v.ip})` : "") : ""; }
|
||||
|
||||
// Why the last attempt failed, in a few words: the server's short reason, else the stage that failed.
|
||||
function failureReason(s) {
|
||||
const err = s.error || {};
|
||||
if (err.short) return err.short;
|
||||
const st = (s.stages || []).find(x => x.id === err.stage);
|
||||
return st ? `${st.label} failed` : "Not connected";
|
||||
}
|
||||
// A sentence or a label as a clause inside another sentence: no closing full stop (the sentence
|
||||
// adds its own) and a lower-case first letter, unless the first word is an abbreviation ("SSH").
|
||||
function asClause(text) {
|
||||
const t = String(text || "").trim().replace(/[.\s]+$/, "");
|
||||
return /^[A-Z][a-z]/.test(t) ? t[0].toLowerCase() + t.slice(1) : t;
|
||||
}
|
||||
|
||||
// The pill in the header: always there, and says what's happening right now.
|
||||
function renderPill() {
|
||||
const s = link.s;
|
||||
@@ -4535,9 +4621,8 @@ function renderPill() {
|
||||
line = `${netLabel(s)} → ${viaText(s.via)}` + (s.via && s.via.rtt_ms != null ? ` · ${s.via.rtt_ms} ms` : "");
|
||||
} else if (s.phase === "failed") {
|
||||
dot = "off";
|
||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
const st = (s.stages || []).find(x => x.id === (s.error || {}).stage);
|
||||
line = `Offline: ${st ? st.label.toLowerCase() : "not connected"} failed` + (left != null ? ` · retry in ${left} s` : "");
|
||||
// The reason first, in a few words, so a narrow pill cuts the countdown rather than the reason.
|
||||
line = failureReason(s) + (s.retry_at ? ` · retry in ${Math.max(0, Math.ceil(s.retry_at - serverNow()))} s` : "");
|
||||
} else {
|
||||
const st = activeStage(s);
|
||||
const probing = (s.probes || []).find(p => p.state === "trying" || p.state === "resolving");
|
||||
@@ -4545,7 +4630,7 @@ function renderPill() {
|
||||
: "Connecting…";
|
||||
}
|
||||
$("conn").innerHTML = `<span class="dot ${dot}"></span><span class="pt"><b>${esc(dev)}</b><span>${esc(line)}</span></span>`;
|
||||
$("conn").title = `${dev}: ${line}. Click for details.`;
|
||||
$("conn").title = `${dev}${s.phase === "failed" ? " is offline" : ""}: ${line}. Click for details.`;
|
||||
return true;
|
||||
}
|
||||
window.connPill = renderPill;
|
||||
@@ -4562,13 +4647,13 @@ function renderBanner() {
|
||||
if (s.phase === "failed" && s.error) {
|
||||
$("offline").hidden = false;
|
||||
$("offMsg").textContent = s.error.message;
|
||||
const tried = (s.probes || []).map(p => `${p.host}: ${p.detail.toLowerCase()}`).join("; ");
|
||||
const tried = (s.probes || []).map(p => `${p.host}: ${asClause(p.detail)}`).join("; ");
|
||||
const left = s.retry_at ? Math.max(0, Math.ceil(s.retry_at - serverNow())) : null;
|
||||
$("offDetail").textContent = `${s.device ? s.device.name : "Frame"} on ${netLabel(s)}. ${tried ? "Tried " + tried + ". " : ""}` +
|
||||
(left != null ? `Trying again in ${left} s.` : "");
|
||||
} else if (s.phase === "connecting" && online === false) {
|
||||
const st = activeStage(s);
|
||||
$("offDetail").textContent = `Trying again: ${st ? st.label.toLowerCase() + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
|
||||
$("offDetail").textContent = `Trying again: ${st ? asClause(st.label) + (st.detail ? " (" + st.detail + ")" : "") : "connecting"}…`;
|
||||
}
|
||||
}
|
||||
window.connBanner = renderBanner;
|
||||
@@ -4746,7 +4831,10 @@ async function useDevice(id) {
|
||||
if (!res) { // refused (an install is running): show the headset still in use
|
||||
const current = dv.list.find(x => x.active);
|
||||
if (current) $("devSel").value = current.id;
|
||||
return;
|
||||
}
|
||||
// The Devices tab shows the headset now in use, not the one it was showing before.
|
||||
if (dv.sel !== id) { dv.sel = id; dv.found = null; dv.editing = null; renderDevices(); }
|
||||
}
|
||||
async function devAction(label, body, btn) {
|
||||
const res = await act(label, () => api("/api/devices", body), btn);
|
||||
@@ -4758,7 +4846,8 @@ function deviceStatus(d) {
|
||||
const s = link.s;
|
||||
if (d.active && s) {
|
||||
return s.phase === "connected" ? ["on", `Connected via ${viaText(s.via)}`]
|
||||
: s.phase === "failed" ? ["off", s.error ? s.error.message : "Offline"] : ["", "Connecting…"];
|
||||
: s.phase === "failed" ? ["off", s.error ? s.error.short || s.error.message : "Offline", s.error && s.error.message]
|
||||
: ["", "Connecting…"];
|
||||
}
|
||||
const last = Math.max(0, ...d.addresses.map(a => a.last_ok || 0));
|
||||
return ["", d.transient ? "Not set up in Frame Control" : last ? `Last connected ${ago(last)}` : "Not connected yet"];
|
||||
@@ -4768,10 +4857,10 @@ function renderDevices() {
|
||||
if (!dv.data) return;
|
||||
$("devCount").textContent = dv.list.length;
|
||||
$("devList").innerHTML = dv.list.map(d => {
|
||||
const [dot, text] = deviceStatus(d);
|
||||
const [dot, text, full] = deviceStatus(d);
|
||||
return `<div class="item dev-item${d.id === dv.sel ? " sel" : ""}" data-dev="${esc(d.id)}">
|
||||
<span class="dot ${dot}"></span><div class="grow"><div class="t">${esc(d.name)} ${d.active ? '<span class="tag">In use</span>' : ""}</div>
|
||||
<div class="s">ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
||||
<div class="s"${full ? ` title="${esc(full)}"` : ""}>ssh ${esc(d.alias)} · ${esc(text)}</div></div>
|
||||
${d.active ? "" : `<button class="small" data-use="${esc(d.id)}">Use this headset</button>`}</div>`;
|
||||
}).join("") || `<div class="sub">No headsets yet. Add one to get started.</div>`;
|
||||
$("devList").querySelectorAll("[data-dev]").forEach(el => el.onclick = e => {
|
||||
@@ -4822,10 +4911,10 @@ function renderDetail() {
|
||||
<button class="small" data-mv="1" title="Try later"${i < d.addresses.length - 1 ? "" : " disabled"}>↓</button>
|
||||
<button class="small" data-ed>Edit</button><button class="small danger" data-rm>Remove</button></div>`).join("");
|
||||
const err = d.active && link.s && link.s.phase === "failed" ? link.s.error : null;
|
||||
const problem = err && err.hostkey ? `<div class="item" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
|
||||
const problem = err && err.hostkey ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">${err.hostkey.state === "missing"
|
||||
? "Its saved SSH identity is missing" : "Its SSH identity has changed"}</div>
|
||||
<div class="s">${esc(err.message)}</div></div><button class="small action" id="dvTrustNow">Check the new key…</button></div>`
|
||||
: err && err.auth === "denied" ? `<div class="item" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
|
||||
: err && err.auth === "denied" ? `<div class="item dev-problem" role="alert"><div class="grow"><div class="t">The headset didn't accept this computer's key</div>
|
||||
<div class="s">Set Up Connection pairs this computer with it again. On the headset, Developer Mode must be on.</div></div>
|
||||
<button class="small action" id="dvPair">Set Up Connection…</button></div>` : "";
|
||||
el.innerHTML = `<div class="shelf-head"><h2>${esc(d.name)}</h2>${d.active ? '<span class="count">In use</span>' : ""}<span class="spacer"></span>
|
||||
|
||||
+89
-21
@@ -57,6 +57,7 @@ import frame_link # noqa: E402
|
||||
import frame_macview # noqa: E402
|
||||
import frame_media # noqa: E402
|
||||
import frame_panels # noqa: E402
|
||||
import frame_quest # noqa: E402
|
||||
import frame_report # noqa: E402
|
||||
import frame_store # noqa: E402
|
||||
import frame_telemetry # noqa: E402
|
||||
@@ -403,11 +404,38 @@ def status(_body):
|
||||
s = json.loads(ssh("python3 -", stdin=(HERE / "frame_status.py").read_text(), timeout=20))
|
||||
osr = s.get("os") if isinstance(s, dict) else None
|
||||
if isinstance(osr, dict):
|
||||
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"))
|
||||
# The headset's hash is for usage statistics only; the page and MCP clients don't get it.
|
||||
frame_telemetry.frame_seen(osr.get("build"), osr.get("version"), osr.pop("headset", None),
|
||||
len(LINK.reg.devices()) if LINK else None)
|
||||
frame_report.frame.update(build=osr.get("build"), version=osr.get("version"))
|
||||
return s
|
||||
|
||||
|
||||
COMFORT_FILES = ("frame_comfort.py", "frame_status.py", "frame_steam.py")
|
||||
COMFORT_MISSING = {"comfort": "missing"} # what COMFORT_RUN prints when the cached copy isn't complete
|
||||
# Runs frame_comfort.py from ~/.cache/frame-control/comfort/<version>, first writing any of
|
||||
# `files` that aren't there (none: just check they all are). Private, and each file written
|
||||
# whole or not at all, so a copy cut short is finished by the next call.
|
||||
COMFORT_RUN = """import json, os, pathlib, subprocess, sys
|
||||
os.umask(0o077)
|
||||
files = %r
|
||||
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
|
||||
if files:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name, source in files.items():
|
||||
path = root / name
|
||||
if not path.exists():
|
||||
tmp = root / (name + '.' + str(os.getpid()))
|
||||
tmp.write_text(source)
|
||||
tmp.replace(path)
|
||||
if not all((root / name).is_file() for name in %r):
|
||||
print('{"comfort": "missing"}')
|
||||
sys.exit()
|
||||
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
|
||||
print(r.stdout, end='')
|
||||
"""
|
||||
|
||||
|
||||
def comfort(body):
|
||||
try:
|
||||
frame_comfort.validate(body)
|
||||
@@ -415,25 +443,14 @@ def comfort(body):
|
||||
raise Failure(str(e), 400)
|
||||
# Content-addressed, user-only helper bundle. Desktop and phone use the same
|
||||
# on-headset state/lock; no listener, service registration or third-party app.
|
||||
import hashlib
|
||||
files = {name: (HERE / name).read_text() for name in
|
||||
("frame_comfort.py", "frame_status.py", "frame_steam.py")}
|
||||
# The page polls this every few seconds, so run the copy already on the headset and
|
||||
# send the sources only when that version isn't there yet.
|
||||
files = {name: (HERE / name).read_text() for name in COMFORT_FILES}
|
||||
version = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()[:16]
|
||||
script = """import json, os, pathlib, subprocess, sys
|
||||
os.umask(0o077)
|
||||
files = %r
|
||||
root = pathlib.Path.home() / '.cache/frame-control/comfort' / %r
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for name, source in files.items():
|
||||
path = root / name
|
||||
if not path.exists():
|
||||
tmp = root / (name + '.' + str(os.getpid()))
|
||||
tmp.write_text(source)
|
||||
tmp.replace(path)
|
||||
r = subprocess.run([sys.executable, str(root / 'frame_comfort.py'), %r], capture_output=True, text=True)
|
||||
print(r.stdout, end='')
|
||||
""" % (files, version, json.dumps(body))
|
||||
out = json.loads(ssh("python3 -", stdin=script, timeout=65))
|
||||
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % ({}, version, COMFORT_FILES, json.dumps(body)), timeout=65))
|
||||
if out == COMFORT_MISSING:
|
||||
out = json.loads(ssh("python3 -", stdin=COMFORT_RUN % (files, version, COMFORT_FILES, json.dumps(body)),
|
||||
timeout=65))
|
||||
if out.get("error") and "active" not in out:
|
||||
raise Failure(out["error"], 409)
|
||||
return out
|
||||
@@ -1309,6 +1326,48 @@ def android(body):
|
||||
raise Failure("unknown action", 400)
|
||||
|
||||
|
||||
# ---- Import from my Quest (frame_quest.py, docs/quest-import.md) --------------
|
||||
|
||||
QUEST_ON_FRAME = "Import from my Quest runs on your computer, with the Quest plugged into it by USB."
|
||||
QUEST_SERIAL = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,128}$")
|
||||
|
||||
|
||||
def quest_serial(value):
|
||||
if value in (None, ""):
|
||||
return None
|
||||
if not isinstance(value, str) or not QUEST_SERIAL.match(value):
|
||||
raise Failure("invalid Quest serial", 400)
|
||||
return value
|
||||
|
||||
|
||||
def quest_status(query):
|
||||
"""The Quest on this computer's USB (or what to do), and the apps on it."""
|
||||
if LOCAL:
|
||||
return {"quest": None, "devices": [], "guidance": QUEST_ON_FRAME, "apps": []}
|
||||
try:
|
||||
return frame_quest.status(quest_serial((parse_qs(query).get("serial") or [None])[0]))
|
||||
except frame_android.FrameError as e:
|
||||
raise Failure(str(e)) from e
|
||||
|
||||
|
||||
def quest(body):
|
||||
"""{"action": "import", "package", "serial"?, "data"?}: a background job with progress."""
|
||||
if LOCAL:
|
||||
raise Failure(QUEST_ON_FRAME, 400)
|
||||
if body.get("action") != "import":
|
||||
raise Failure("unknown action", 400)
|
||||
pkg = str(body.get("package", ""))
|
||||
if not frame_android.PKG_RE.match(pkg):
|
||||
raise Failure("invalid Android package id", 400)
|
||||
serial, data, older = quest_serial(body.get("serial")), body.get("data", False), body.get("allowDowngrade", False)
|
||||
if type(data) is not bool or type(older) is not bool:
|
||||
raise Failure("data and allowDowngrade must be true or false", 400)
|
||||
ensure_master()
|
||||
return start_job(f"Import {pkg} from Quest",
|
||||
lambda report: frame_quest.import_game(pkg, serial, data=data, progress=report,
|
||||
allow_downgrade=older), progress=True)
|
||||
|
||||
|
||||
# Errors that are the APK's own fault, so they belong in the compatibility
|
||||
# database as install_failed. Connection trouble and the like don't.
|
||||
APK_FAULTS = {"android_installer", "apk_needs_newer_android", "apk_wrong_abi"}
|
||||
@@ -2221,7 +2280,7 @@ POST = {
|
||||
"/api/agent/approval": agent_approval, "/api/assistant/chat": assistant_chat,
|
||||
"/api/input": remote_input, "/api/touch": remote_touch,
|
||||
"/api/settings/artwork": frame_steamgriddb.save_settings,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/sources": source_manage, "/api/sources/install": source_install, "/api/android/display": android_display, "/api/android": android, "/api/quest": quest, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
|
||||
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots,
|
||||
"/api/webinstall/check": webinstall_check, "/api/webinstall/start": webinstall_start,
|
||||
"/api/webinstall/cancel": webinstall_cancel,
|
||||
@@ -2280,6 +2339,10 @@ def connection_state():
|
||||
|
||||
# ---- HTTP ------------------------------------------------------------------
|
||||
|
||||
# POST reads the page repeats on a timer, as (path, action).
|
||||
POLLS = {("/api/comfort", "status")}
|
||||
|
||||
|
||||
def action_of(body):
|
||||
"""The action a request asked for, for diagnostics: a short word, never user data."""
|
||||
a = body.get("action") if isinstance(body, dict) else None
|
||||
@@ -2436,6 +2499,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
apps = frame_android.list_apps()
|
||||
backfill_art(apps=apps)
|
||||
self.send_json({"apps": apps})
|
||||
elif path == "/api/quest":
|
||||
self.send_json(quest_status(url.query))
|
||||
elif path == "/api/titles":
|
||||
ensure_master()
|
||||
titles_list = frame_titles.list_titles()
|
||||
@@ -2533,7 +2598,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except ClientGone:
|
||||
raise
|
||||
except Failure as e:
|
||||
if e.status >= 500:
|
||||
# A poll that couldn't reach the headset is the headset asleep or away, which the page
|
||||
# already shows; like GET /api/status, not an error to report.
|
||||
polled = (path, action_of(body)) in POLLS and frame_telemetry.link_failed(e)
|
||||
if e.status >= 500 and not polled:
|
||||
frame_telemetry.diagnostic(f"POST {path} {action_of(body)}", e)
|
||||
self.send_error_json(str(e), e.status, e.apk)
|
||||
except (ValueError, TypeError) as e:
|
||||
|
||||
Reference in new issue
Block a user