mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-14 15:01:27 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9697812984 | ||
|
|
77c6c286cd | ||
|
|
ca53b26701 | ||
|
|
baec984449 | ||
|
|
18e5053b64 | ||
|
|
7faf80fc2b | ||
|
|
6ea20a2806 |
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 |
|
||||
|
||||
+2
-3
@@ -115,11 +115,10 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.6",
|
||||
"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.6",
|
||||
"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.
|
||||
|
||||
+3
-10
@@ -33,13 +33,6 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `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
|
||||
@@ -51,9 +44,9 @@ computer, exactly as they were sent.
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `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 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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
@@ -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))
|
||||
+1129
File diff suppressed because it is too large.
Load diff
@@ -159,42 +159,6 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
@@ -266,39 +230,6 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
|
||||
+353
-17
@@ -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__)))
|
||||
@@ -142,7 +142,9 @@ 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
|
||||
@@ -190,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)
|
||||
@@ -273,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', []),
|
||||
@@ -359,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)
|
||||
@@ -380,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"
|
||||
@@ -447,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():
|
||||
@@ -599,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')
|
||||
|
||||
@@ -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)
|
||||
@@ -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
-41
@@ -18,9 +18,7 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
|
||||
same project whatever is chosen here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Opens, installs and updates
|
||||
carry a country instead, worked out on this computer from its time zone setting
|
||||
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
@@ -45,7 +43,6 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
import frame_timezones
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
@@ -68,7 +65,6 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
@@ -109,7 +105,7 @@ def blocked():
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
@@ -151,12 +147,12 @@ def update_settings(changes):
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if changes.get('noticeShown'):
|
||||
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True # only the notice itself moves notice_version on
|
||||
s['notice_shown'] = True
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
@@ -165,16 +161,10 @@ def update_settings(changes):
|
||||
return state()
|
||||
|
||||
|
||||
def _notice_current(s):
|
||||
"""Whether the notice this person saw lists everything that's sent now. If not, the page
|
||||
shows it again, and what it added waits until it has been seen."""
|
||||
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
@@ -382,36 +372,20 @@ def page_event(body):
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def country():
|
||||
"""The two-letter country of the computer's time zone setting, or None. Only names in the
|
||||
tz database's table count, so nothing else in the setting can be sent. The app passes the
|
||||
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
|
||||
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
|
||||
if not tz:
|
||||
try:
|
||||
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
return frame_timezones.COUNTRY.get(tz)
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day, with the country
|
||||
(left out when sending if the notice this person saw came before it: flush)."""
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
cc = country()
|
||||
where = {'country': cc} if cc else {}
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed', where)
|
||||
capture('app_installed')
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version'], **where})
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened', where)
|
||||
capture('app_opened')
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_save(s)
|
||||
@@ -606,14 +580,10 @@ def flush(timeout=20):
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
with _lock:
|
||||
s = settings()
|
||||
_drop_unwanted(s)
|
||||
_drop_unwanted(settings())
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
if not _notice_current(s): # what a newer notice added stays here until it's been seen
|
||||
for e in batch:
|
||||
e['properties'].pop('country', None)
|
||||
try:
|
||||
post(batch, timeout)
|
||||
except SendError:
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {
|
||||
'Africa/Abidjan': 'CI',
|
||||
'Africa/Accra': 'GH',
|
||||
'Africa/Addis_Ababa': 'ET',
|
||||
'Africa/Algiers': 'DZ',
|
||||
'Africa/Asmara': 'ER',
|
||||
'Africa/Asmera': 'ER',
|
||||
'Africa/Bamako': 'ML',
|
||||
'Africa/Bangui': 'CF',
|
||||
'Africa/Banjul': 'GM',
|
||||
'Africa/Bissau': 'GW',
|
||||
'Africa/Blantyre': 'MW',
|
||||
'Africa/Brazzaville': 'CG',
|
||||
'Africa/Bujumbura': 'BI',
|
||||
'Africa/Cairo': 'EG',
|
||||
'Africa/Casablanca': 'MA',
|
||||
'Africa/Ceuta': 'ES',
|
||||
'Africa/Conakry': 'GN',
|
||||
'Africa/Dakar': 'SN',
|
||||
'Africa/Dar_es_Salaam': 'TZ',
|
||||
'Africa/Djibouti': 'DJ',
|
||||
'Africa/Douala': 'CM',
|
||||
'Africa/El_Aaiun': 'EH',
|
||||
'Africa/Freetown': 'SL',
|
||||
'Africa/Gaborone': 'BW',
|
||||
'Africa/Harare': 'ZW',
|
||||
'Africa/Johannesburg': 'ZA',
|
||||
'Africa/Juba': 'SS',
|
||||
'Africa/Kampala': 'UG',
|
||||
'Africa/Khartoum': 'SD',
|
||||
'Africa/Kigali': 'RW',
|
||||
'Africa/Kinshasa': 'CD',
|
||||
'Africa/Lagos': 'NG',
|
||||
'Africa/Libreville': 'GA',
|
||||
'Africa/Lome': 'TG',
|
||||
'Africa/Luanda': 'AO',
|
||||
'Africa/Lubumbashi': 'CD',
|
||||
'Africa/Lusaka': 'ZM',
|
||||
'Africa/Malabo': 'GQ',
|
||||
'Africa/Maputo': 'MZ',
|
||||
'Africa/Maseru': 'LS',
|
||||
'Africa/Mbabane': 'SZ',
|
||||
'Africa/Mogadishu': 'SO',
|
||||
'Africa/Monrovia': 'LR',
|
||||
'Africa/Nairobi': 'KE',
|
||||
'Africa/Ndjamena': 'TD',
|
||||
'Africa/Niamey': 'NE',
|
||||
'Africa/Nouakchott': 'MR',
|
||||
'Africa/Ouagadougou': 'BF',
|
||||
'Africa/Porto-Novo': 'BJ',
|
||||
'Africa/Sao_Tome': 'ST',
|
||||
'Africa/Timbuktu': 'ML',
|
||||
'Africa/Tripoli': 'LY',
|
||||
'Africa/Tunis': 'TN',
|
||||
'Africa/Windhoek': 'NA',
|
||||
'America/Adak': 'US',
|
||||
'America/Anchorage': 'US',
|
||||
'America/Anguilla': 'AI',
|
||||
'America/Antigua': 'AG',
|
||||
'America/Araguaina': 'BR',
|
||||
'America/Argentina/Buenos_Aires': 'AR',
|
||||
'America/Argentina/Catamarca': 'AR',
|
||||
'America/Argentina/ComodRivadavia': 'AR',
|
||||
'America/Argentina/Cordoba': 'AR',
|
||||
'America/Argentina/Jujuy': 'AR',
|
||||
'America/Argentina/La_Rioja': 'AR',
|
||||
'America/Argentina/Mendoza': 'AR',
|
||||
'America/Argentina/Rio_Gallegos': 'AR',
|
||||
'America/Argentina/Salta': 'AR',
|
||||
'America/Argentina/San_Juan': 'AR',
|
||||
'America/Argentina/San_Luis': 'AR',
|
||||
'America/Argentina/Tucuman': 'AR',
|
||||
'America/Argentina/Ushuaia': 'AR',
|
||||
'America/Aruba': 'AW',
|
||||
'America/Asuncion': 'PY',
|
||||
'America/Atikokan': 'CA',
|
||||
'America/Atka': 'US',
|
||||
'America/Bahia': 'BR',
|
||||
'America/Bahia_Banderas': 'MX',
|
||||
'America/Barbados': 'BB',
|
||||
'America/Belem': 'BR',
|
||||
'America/Belize': 'BZ',
|
||||
'America/Blanc-Sablon': 'CA',
|
||||
'America/Boa_Vista': 'BR',
|
||||
'America/Bogota': 'CO',
|
||||
'America/Boise': 'US',
|
||||
'America/Buenos_Aires': 'AR',
|
||||
'America/Cambridge_Bay': 'CA',
|
||||
'America/Campo_Grande': 'BR',
|
||||
'America/Cancun': 'MX',
|
||||
'America/Caracas': 'VE',
|
||||
'America/Catamarca': 'AR',
|
||||
'America/Cayenne': 'GF',
|
||||
'America/Cayman': 'KY',
|
||||
'America/Chicago': 'US',
|
||||
'America/Chihuahua': 'MX',
|
||||
'America/Ciudad_Juarez': 'MX',
|
||||
'America/Coral_Harbour': 'CA',
|
||||
'America/Cordoba': 'AR',
|
||||
'America/Costa_Rica': 'CR',
|
||||
'America/Coyhaique': 'CL',
|
||||
'America/Creston': 'CA',
|
||||
'America/Cuiaba': 'BR',
|
||||
'America/Curacao': 'CW',
|
||||
'America/Danmarkshavn': 'GL',
|
||||
'America/Dawson': 'CA',
|
||||
'America/Dawson_Creek': 'CA',
|
||||
'America/Denver': 'US',
|
||||
'America/Detroit': 'US',
|
||||
'America/Dominica': 'DM',
|
||||
'America/Edmonton': 'CA',
|
||||
'America/Eirunepe': 'BR',
|
||||
'America/El_Salvador': 'SV',
|
||||
'America/Ensenada': 'MX',
|
||||
'America/Fort_Nelson': 'CA',
|
||||
'America/Fort_Wayne': 'US',
|
||||
'America/Fortaleza': 'BR',
|
||||
'America/Glace_Bay': 'CA',
|
||||
'America/Godthab': 'GL',
|
||||
'America/Goose_Bay': 'CA',
|
||||
'America/Grand_Turk': 'TC',
|
||||
'America/Grenada': 'GD',
|
||||
'America/Guadeloupe': 'GP',
|
||||
'America/Guatemala': 'GT',
|
||||
'America/Guayaquil': 'EC',
|
||||
'America/Guyana': 'GY',
|
||||
'America/Halifax': 'CA',
|
||||
'America/Havana': 'CU',
|
||||
'America/Hermosillo': 'MX',
|
||||
'America/Indiana/Indianapolis': 'US',
|
||||
'America/Indiana/Knox': 'US',
|
||||
'America/Indiana/Marengo': 'US',
|
||||
'America/Indiana/Petersburg': 'US',
|
||||
'America/Indiana/Tell_City': 'US',
|
||||
'America/Indiana/Vevay': 'US',
|
||||
'America/Indiana/Vincennes': 'US',
|
||||
'America/Indiana/Winamac': 'US',
|
||||
'America/Indianapolis': 'US',
|
||||
'America/Inuvik': 'CA',
|
||||
'America/Iqaluit': 'CA',
|
||||
'America/Jamaica': 'JM',
|
||||
'America/Jujuy': 'AR',
|
||||
'America/Juneau': 'US',
|
||||
'America/Kentucky/Louisville': 'US',
|
||||
'America/Kentucky/Monticello': 'US',
|
||||
'America/Knox_IN': 'US',
|
||||
'America/Kralendijk': 'BQ',
|
||||
'America/La_Paz': 'BO',
|
||||
'America/Lima': 'PE',
|
||||
'America/Los_Angeles': 'US',
|
||||
'America/Louisville': 'US',
|
||||
'America/Lower_Princes': 'SX',
|
||||
'America/Maceio': 'BR',
|
||||
'America/Managua': 'NI',
|
||||
'America/Manaus': 'BR',
|
||||
'America/Marigot': 'MF',
|
||||
'America/Martinique': 'MQ',
|
||||
'America/Matamoros': 'MX',
|
||||
'America/Mazatlan': 'MX',
|
||||
'America/Mendoza': 'AR',
|
||||
'America/Menominee': 'US',
|
||||
'America/Merida': 'MX',
|
||||
'America/Metlakatla': 'US',
|
||||
'America/Mexico_City': 'MX',
|
||||
'America/Miquelon': 'PM',
|
||||
'America/Moncton': 'CA',
|
||||
'America/Monterrey': 'MX',
|
||||
'America/Montevideo': 'UY',
|
||||
'America/Montreal': 'CA',
|
||||
'America/Montserrat': 'MS',
|
||||
'America/Nassau': 'BS',
|
||||
'America/New_York': 'US',
|
||||
'America/Nipigon': 'CA',
|
||||
'America/Nome': 'US',
|
||||
'America/Noronha': 'BR',
|
||||
'America/North_Dakota/Beulah': 'US',
|
||||
'America/North_Dakota/Center': 'US',
|
||||
'America/North_Dakota/New_Salem': 'US',
|
||||
'America/Nuuk': 'GL',
|
||||
'America/Ojinaga': 'MX',
|
||||
'America/Panama': 'PA',
|
||||
'America/Pangnirtung': 'CA',
|
||||
'America/Paramaribo': 'SR',
|
||||
'America/Phoenix': 'US',
|
||||
'America/Port-au-Prince': 'HT',
|
||||
'America/Port_of_Spain': 'TT',
|
||||
'America/Porto_Acre': 'BR',
|
||||
'America/Porto_Velho': 'BR',
|
||||
'America/Puerto_Rico': 'PR',
|
||||
'America/Punta_Arenas': 'CL',
|
||||
'America/Rainy_River': 'CA',
|
||||
'America/Rankin_Inlet': 'CA',
|
||||
'America/Recife': 'BR',
|
||||
'America/Regina': 'CA',
|
||||
'America/Resolute': 'CA',
|
||||
'America/Rio_Branco': 'BR',
|
||||
'America/Rosario': 'AR',
|
||||
'America/Santa_Isabel': 'MX',
|
||||
'America/Santarem': 'BR',
|
||||
'America/Santiago': 'CL',
|
||||
'America/Santo_Domingo': 'DO',
|
||||
'America/Sao_Paulo': 'BR',
|
||||
'America/Scoresbysund': 'GL',
|
||||
'America/Shiprock': 'US',
|
||||
'America/Sitka': 'US',
|
||||
'America/St_Barthelemy': 'BL',
|
||||
'America/St_Johns': 'CA',
|
||||
'America/St_Kitts': 'KN',
|
||||
'America/St_Lucia': 'LC',
|
||||
'America/St_Thomas': 'VI',
|
||||
'America/St_Vincent': 'VC',
|
||||
'America/Swift_Current': 'CA',
|
||||
'America/Tegucigalpa': 'HN',
|
||||
'America/Thule': 'GL',
|
||||
'America/Thunder_Bay': 'CA',
|
||||
'America/Tijuana': 'MX',
|
||||
'America/Toronto': 'CA',
|
||||
'America/Tortola': 'VG',
|
||||
'America/Vancouver': 'CA',
|
||||
'America/Virgin': 'VI',
|
||||
'America/Whitehorse': 'CA',
|
||||
'America/Winnipeg': 'CA',
|
||||
'America/Yakutat': 'US',
|
||||
'America/Yellowknife': 'CA',
|
||||
'Antarctica/Casey': 'AQ',
|
||||
'Antarctica/Davis': 'AQ',
|
||||
'Antarctica/DumontDUrville': 'AQ',
|
||||
'Antarctica/Macquarie': 'AU',
|
||||
'Antarctica/Mawson': 'AQ',
|
||||
'Antarctica/McMurdo': 'AQ',
|
||||
'Antarctica/Palmer': 'AQ',
|
||||
'Antarctica/Rothera': 'AQ',
|
||||
'Antarctica/South_Pole': 'AQ',
|
||||
'Antarctica/Syowa': 'AQ',
|
||||
'Antarctica/Troll': 'AQ',
|
||||
'Antarctica/Vostok': 'AQ',
|
||||
'Arctic/Longyearbyen': 'SJ',
|
||||
'Asia/Aden': 'YE',
|
||||
'Asia/Almaty': 'KZ',
|
||||
'Asia/Amman': 'JO',
|
||||
'Asia/Anadyr': 'RU',
|
||||
'Asia/Aqtau': 'KZ',
|
||||
'Asia/Aqtobe': 'KZ',
|
||||
'Asia/Ashgabat': 'TM',
|
||||
'Asia/Ashkhabad': 'TM',
|
||||
'Asia/Atyrau': 'KZ',
|
||||
'Asia/Baghdad': 'IQ',
|
||||
'Asia/Bahrain': 'BH',
|
||||
'Asia/Baku': 'AZ',
|
||||
'Asia/Bangkok': 'TH',
|
||||
'Asia/Barnaul': 'RU',
|
||||
'Asia/Beirut': 'LB',
|
||||
'Asia/Bishkek': 'KG',
|
||||
'Asia/Brunei': 'BN',
|
||||
'Asia/Calcutta': 'IN',
|
||||
'Asia/Chita': 'RU',
|
||||
'Asia/Choibalsan': 'MN',
|
||||
'Asia/Chongqing': 'CN',
|
||||
'Asia/Chungking': 'CN',
|
||||
'Asia/Colombo': 'LK',
|
||||
'Asia/Dacca': 'BD',
|
||||
'Asia/Damascus': 'SY',
|
||||
'Asia/Dhaka': 'BD',
|
||||
'Asia/Dili': 'TL',
|
||||
'Asia/Dubai': 'AE',
|
||||
'Asia/Dushanbe': 'TJ',
|
||||
'Asia/Famagusta': 'CY',
|
||||
'Asia/Gaza': 'PS',
|
||||
'Asia/Harbin': 'CN',
|
||||
'Asia/Hebron': 'PS',
|
||||
'Asia/Ho_Chi_Minh': 'VN',
|
||||
'Asia/Hong_Kong': 'HK',
|
||||
'Asia/Hovd': 'MN',
|
||||
'Asia/Irkutsk': 'RU',
|
||||
'Asia/Istanbul': 'TR',
|
||||
'Asia/Jakarta': 'ID',
|
||||
'Asia/Jayapura': 'ID',
|
||||
'Asia/Jerusalem': 'IL',
|
||||
'Asia/Kabul': 'AF',
|
||||
'Asia/Kamchatka': 'RU',
|
||||
'Asia/Karachi': 'PK',
|
||||
'Asia/Kashgar': 'CN',
|
||||
'Asia/Kathmandu': 'NP',
|
||||
'Asia/Katmandu': 'NP',
|
||||
'Asia/Khandyga': 'RU',
|
||||
'Asia/Kolkata': 'IN',
|
||||
'Asia/Krasnoyarsk': 'RU',
|
||||
'Asia/Kuala_Lumpur': 'MY',
|
||||
'Asia/Kuching': 'MY',
|
||||
'Asia/Kuwait': 'KW',
|
||||
'Asia/Macao': 'MO',
|
||||
'Asia/Macau': 'MO',
|
||||
'Asia/Magadan': 'RU',
|
||||
'Asia/Makassar': 'ID',
|
||||
'Asia/Manila': 'PH',
|
||||
'Asia/Muscat': 'OM',
|
||||
'Asia/Nicosia': 'CY',
|
||||
'Asia/Novokuznetsk': 'RU',
|
||||
'Asia/Novosibirsk': 'RU',
|
||||
'Asia/Omsk': 'RU',
|
||||
'Asia/Oral': 'KZ',
|
||||
'Asia/Phnom_Penh': 'KH',
|
||||
'Asia/Pontianak': 'ID',
|
||||
'Asia/Pyongyang': 'KP',
|
||||
'Asia/Qatar': 'QA',
|
||||
'Asia/Qostanay': 'KZ',
|
||||
'Asia/Qyzylorda': 'KZ',
|
||||
'Asia/Rangoon': 'MM',
|
||||
'Asia/Riyadh': 'SA',
|
||||
'Asia/Saigon': 'VN',
|
||||
'Asia/Sakhalin': 'RU',
|
||||
'Asia/Samarkand': 'UZ',
|
||||
'Asia/Seoul': 'KR',
|
||||
'Asia/Shanghai': 'CN',
|
||||
'Asia/Singapore': 'SG',
|
||||
'Asia/Srednekolymsk': 'RU',
|
||||
'Asia/Taipei': 'TW',
|
||||
'Asia/Tashkent': 'UZ',
|
||||
'Asia/Tbilisi': 'GE',
|
||||
'Asia/Tehran': 'IR',
|
||||
'Asia/Tel_Aviv': 'IL',
|
||||
'Asia/Thimbu': 'BT',
|
||||
'Asia/Thimphu': 'BT',
|
||||
'Asia/Tokyo': 'JP',
|
||||
'Asia/Tomsk': 'RU',
|
||||
'Asia/Ujung_Pandang': 'ID',
|
||||
'Asia/Ulaanbaatar': 'MN',
|
||||
'Asia/Ulan_Bator': 'MN',
|
||||
'Asia/Urumqi': 'CN',
|
||||
'Asia/Ust-Nera': 'RU',
|
||||
'Asia/Vientiane': 'LA',
|
||||
'Asia/Vladivostok': 'RU',
|
||||
'Asia/Yakutsk': 'RU',
|
||||
'Asia/Yangon': 'MM',
|
||||
'Asia/Yekaterinburg': 'RU',
|
||||
'Asia/Yerevan': 'AM',
|
||||
'Atlantic/Azores': 'PT',
|
||||
'Atlantic/Bermuda': 'BM',
|
||||
'Atlantic/Canary': 'ES',
|
||||
'Atlantic/Cape_Verde': 'CV',
|
||||
'Atlantic/Faeroe': 'FO',
|
||||
'Atlantic/Faroe': 'FO',
|
||||
'Atlantic/Jan_Mayen': 'SJ',
|
||||
'Atlantic/Madeira': 'PT',
|
||||
'Atlantic/Reykjavik': 'IS',
|
||||
'Atlantic/South_Georgia': 'GS',
|
||||
'Atlantic/St_Helena': 'SH',
|
||||
'Atlantic/Stanley': 'FK',
|
||||
'Australia/ACT': 'AU',
|
||||
'Australia/Adelaide': 'AU',
|
||||
'Australia/Brisbane': 'AU',
|
||||
'Australia/Broken_Hill': 'AU',
|
||||
'Australia/Canberra': 'AU',
|
||||
'Australia/Currie': 'AU',
|
||||
'Australia/Darwin': 'AU',
|
||||
'Australia/Eucla': 'AU',
|
||||
'Australia/Hobart': 'AU',
|
||||
'Australia/LHI': 'AU',
|
||||
'Australia/Lindeman': 'AU',
|
||||
'Australia/Lord_Howe': 'AU',
|
||||
'Australia/Melbourne': 'AU',
|
||||
'Australia/NSW': 'AU',
|
||||
'Australia/North': 'AU',
|
||||
'Australia/Perth': 'AU',
|
||||
'Australia/Queensland': 'AU',
|
||||
'Australia/South': 'AU',
|
||||
'Australia/Sydney': 'AU',
|
||||
'Australia/Tasmania': 'AU',
|
||||
'Australia/Victoria': 'AU',
|
||||
'Australia/West': 'AU',
|
||||
'Australia/Yancowinna': 'AU',
|
||||
'Brazil/Acre': 'BR',
|
||||
'Brazil/DeNoronha': 'BR',
|
||||
'Brazil/East': 'BR',
|
||||
'Brazil/West': 'BR',
|
||||
'Canada/Atlantic': 'CA',
|
||||
'Canada/Central': 'CA',
|
||||
'Canada/Eastern': 'CA',
|
||||
'Canada/Mountain': 'CA',
|
||||
'Canada/Newfoundland': 'CA',
|
||||
'Canada/Pacific': 'CA',
|
||||
'Canada/Saskatchewan': 'CA',
|
||||
'Canada/Yukon': 'CA',
|
||||
'Chile/Continental': 'CL',
|
||||
'Chile/EasterIsland': 'CL',
|
||||
'Cuba': 'CU',
|
||||
'Egypt': 'EG',
|
||||
'Eire': 'IE',
|
||||
'Europe/Amsterdam': 'NL',
|
||||
'Europe/Andorra': 'AD',
|
||||
'Europe/Astrakhan': 'RU',
|
||||
'Europe/Athens': 'GR',
|
||||
'Europe/Belfast': 'GB',
|
||||
'Europe/Belgrade': 'RS',
|
||||
'Europe/Berlin': 'DE',
|
||||
'Europe/Bratislava': 'SK',
|
||||
'Europe/Brussels': 'BE',
|
||||
'Europe/Bucharest': 'RO',
|
||||
'Europe/Budapest': 'HU',
|
||||
'Europe/Busingen': 'DE',
|
||||
'Europe/Chisinau': 'MD',
|
||||
'Europe/Copenhagen': 'DK',
|
||||
'Europe/Dublin': 'IE',
|
||||
'Europe/Gibraltar': 'GI',
|
||||
'Europe/Guernsey': 'GG',
|
||||
'Europe/Helsinki': 'FI',
|
||||
'Europe/Isle_of_Man': 'IM',
|
||||
'Europe/Istanbul': 'TR',
|
||||
'Europe/Jersey': 'JE',
|
||||
'Europe/Kaliningrad': 'RU',
|
||||
'Europe/Kiev': 'UA',
|
||||
'Europe/Kirov': 'RU',
|
||||
'Europe/Kyiv': 'UA',
|
||||
'Europe/Lisbon': 'PT',
|
||||
'Europe/Ljubljana': 'SI',
|
||||
'Europe/London': 'GB',
|
||||
'Europe/Luxembourg': 'LU',
|
||||
'Europe/Madrid': 'ES',
|
||||
'Europe/Malta': 'MT',
|
||||
'Europe/Mariehamn': 'AX',
|
||||
'Europe/Minsk': 'BY',
|
||||
'Europe/Monaco': 'MC',
|
||||
'Europe/Moscow': 'RU',
|
||||
'Europe/Nicosia': 'CY',
|
||||
'Europe/Oslo': 'NO',
|
||||
'Europe/Paris': 'FR',
|
||||
'Europe/Podgorica': 'ME',
|
||||
'Europe/Prague': 'CZ',
|
||||
'Europe/Riga': 'LV',
|
||||
'Europe/Rome': 'IT',
|
||||
'Europe/Samara': 'RU',
|
||||
'Europe/San_Marino': 'SM',
|
||||
'Europe/Sarajevo': 'BA',
|
||||
'Europe/Saratov': 'RU',
|
||||
'Europe/Simferopol': 'UA',
|
||||
'Europe/Skopje': 'MK',
|
||||
'Europe/Sofia': 'BG',
|
||||
'Europe/Stockholm': 'SE',
|
||||
'Europe/Tallinn': 'EE',
|
||||
'Europe/Tirane': 'AL',
|
||||
'Europe/Tiraspol': 'MD',
|
||||
'Europe/Ulyanovsk': 'RU',
|
||||
'Europe/Uzhgorod': 'UA',
|
||||
'Europe/Vaduz': 'LI',
|
||||
'Europe/Vatican': 'VA',
|
||||
'Europe/Vienna': 'AT',
|
||||
'Europe/Vilnius': 'LT',
|
||||
'Europe/Volgograd': 'RU',
|
||||
'Europe/Warsaw': 'PL',
|
||||
'Europe/Zagreb': 'HR',
|
||||
'Europe/Zaporozhye': 'UA',
|
||||
'Europe/Zurich': 'CH',
|
||||
'GB': 'GB',
|
||||
'GB-Eire': 'GB',
|
||||
'Hongkong': 'HK',
|
||||
'Iceland': 'IS',
|
||||
'Indian/Antananarivo': 'MG',
|
||||
'Indian/Chagos': 'IO',
|
||||
'Indian/Christmas': 'CX',
|
||||
'Indian/Cocos': 'CC',
|
||||
'Indian/Comoro': 'KM',
|
||||
'Indian/Kerguelen': 'TF',
|
||||
'Indian/Mahe': 'SC',
|
||||
'Indian/Maldives': 'MV',
|
||||
'Indian/Mauritius': 'MU',
|
||||
'Indian/Mayotte': 'YT',
|
||||
'Indian/Reunion': 'RE',
|
||||
'Iran': 'IR',
|
||||
'Israel': 'IL',
|
||||
'Jamaica': 'JM',
|
||||
'Japan': 'JP',
|
||||
'Kwajalein': 'MH',
|
||||
'Libya': 'LY',
|
||||
'Mexico/BajaNorte': 'MX',
|
||||
'Mexico/BajaSur': 'MX',
|
||||
'Mexico/General': 'MX',
|
||||
'NZ': 'NZ',
|
||||
'NZ-CHAT': 'NZ',
|
||||
'Navajo': 'US',
|
||||
'PRC': 'CN',
|
||||
'Pacific/Apia': 'WS',
|
||||
'Pacific/Auckland': 'NZ',
|
||||
'Pacific/Bougainville': 'PG',
|
||||
'Pacific/Chatham': 'NZ',
|
||||
'Pacific/Chuuk': 'FM',
|
||||
'Pacific/Easter': 'CL',
|
||||
'Pacific/Efate': 'VU',
|
||||
'Pacific/Enderbury': 'KI',
|
||||
'Pacific/Fakaofo': 'TK',
|
||||
'Pacific/Fiji': 'FJ',
|
||||
'Pacific/Funafuti': 'TV',
|
||||
'Pacific/Galapagos': 'EC',
|
||||
'Pacific/Gambier': 'PF',
|
||||
'Pacific/Guadalcanal': 'SB',
|
||||
'Pacific/Guam': 'GU',
|
||||
'Pacific/Honolulu': 'US',
|
||||
'Pacific/Johnston': 'UM',
|
||||
'Pacific/Kanton': 'KI',
|
||||
'Pacific/Kiritimati': 'KI',
|
||||
'Pacific/Kosrae': 'FM',
|
||||
'Pacific/Kwajalein': 'MH',
|
||||
'Pacific/Majuro': 'MH',
|
||||
'Pacific/Marquesas': 'PF',
|
||||
'Pacific/Midway': 'UM',
|
||||
'Pacific/Nauru': 'NR',
|
||||
'Pacific/Niue': 'NU',
|
||||
'Pacific/Norfolk': 'NF',
|
||||
'Pacific/Noumea': 'NC',
|
||||
'Pacific/Pago_Pago': 'AS',
|
||||
'Pacific/Palau': 'PW',
|
||||
'Pacific/Pitcairn': 'PN',
|
||||
'Pacific/Pohnpei': 'FM',
|
||||
'Pacific/Ponape': 'FM',
|
||||
'Pacific/Port_Moresby': 'PG',
|
||||
'Pacific/Rarotonga': 'CK',
|
||||
'Pacific/Saipan': 'MP',
|
||||
'Pacific/Samoa': 'AS',
|
||||
'Pacific/Tahiti': 'PF',
|
||||
'Pacific/Tarawa': 'KI',
|
||||
'Pacific/Tongatapu': 'TO',
|
||||
'Pacific/Truk': 'FM',
|
||||
'Pacific/Wake': 'UM',
|
||||
'Pacific/Wallis': 'WF',
|
||||
'Pacific/Yap': 'FM',
|
||||
'Poland': 'PL',
|
||||
'Portugal': 'PT',
|
||||
'ROC': 'TW',
|
||||
'ROK': 'KR',
|
||||
'Singapore': 'SG',
|
||||
'Turkey': 'TR',
|
||||
'US/Alaska': 'US',
|
||||
'US/Aleutian': 'US',
|
||||
'US/Arizona': 'US',
|
||||
'US/Central': 'US',
|
||||
'US/East-Indiana': 'US',
|
||||
'US/Eastern': 'US',
|
||||
'US/Hawaii': 'US',
|
||||
'US/Indiana-Starke': 'US',
|
||||
'US/Michigan': 'US',
|
||||
'US/Mountain': 'US',
|
||||
'US/Pacific': 'US',
|
||||
'US/Samoa': 'AS',
|
||||
'W-SU': 'RU',
|
||||
}
|
||||
+62
-2
@@ -683,7 +683,7 @@
|
||||
</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, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
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>
|
||||
@@ -958,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>
|
||||
@@ -1148,7 +1162,7 @@
|
||||
<section class="panel" id="privacy">
|
||||
<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, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
|
||||
<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. 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>
|
||||
@@ -1435,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`;
|
||||
@@ -3329,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; }
|
||||
|
||||
+46
-1
@@ -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
|
||||
@@ -1325,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"}
|
||||
@@ -2237,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,
|
||||
@@ -2456,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()
|
||||
|
||||
Reference in new issue
Block a user