Compare commits

...
Author SHA1 Message Date
saphidandClaude Opus 5.5 92a68285b5 Meta library: show sizes first, warn about left-out game data, count apps
From a live test with a real account (71 owned entries):
- the size was cut off behind the package name; sizes now come first;
- a required data file Frame Control can't place as an OBB (The Climb's
  1.8 GB main OBB) was never mentioned; the row now says it won't be
  installed;
- the count said games, but Meta's list includes apps and environments.
Problems and warnings get their own coloured line; the verdict colours
now win over the row's grey text (they were grey before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:07:36 +11:00
saphidandClaude Opus 5.5 5878c45ddb Meta library: count PERMANENT entitlements as owned
A real account's entitlements all came back with active_state PERMANENT
(92 records, 71 of them Quest apps), and the library only accepted ACTIVE,
so it showed no games. Both now count as owned; inactive and expired
entitlements are still left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:47:23 +11:00
saphidandClaude Opus 5.5 d773babaa3 Meta sign-in: allow Instagram, and say where a stopped redirect went
A live sign-in with a real Meta account goes auth.oculus.com -> auth.meta.com
-> www.facebook.com -> www.instagram.com -> auth.meta.com. The window only
allowed oculus.com, meta.com and facebook.com, so it stopped the Instagram
step and sat blank. instagram.com is now allowed (same HTTPS-only checks;
the cookie is still read only from oculus.com). A page change the window
stops is remembered by host only, so closing the stuck window says why; it
is forgotten once the window loads a page again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:39:59 +11:00
saphidandClaude Opus 5.5 c060df4b50 My Meta library: one Disconnect at a time, guarded from the confirm until the delete returns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:17:39 +11:00
saphidandClaude Opus 5.5 c7125bc3de My Meta library: Sign in with Meta closes two sign-out races
- cancel_signin marks an in-flight attempt and, independently, deletes the
  account if that attempt saved it, so a cancel landing after the save but
  before the connect's cleanup still removes the token.
- Sign-out runs one at a time (a second call joins it) and refuses new
  sign-ins while it runs; it goes round until no window, connect or cancel is
  left. The page keeps Sign in with Meta off from pressing Disconnect until
  the server has deleted the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:10:59 +11:00
saphidandClaude Opus 5.5 9c33711e6f My Meta library: Sign in with Meta cancels only after its connect settles
When the sign-in window is closed, times out or is signed out of while a
connect is under way, the page is answered at once, but cancel-signin is
posted only after that connect settles (saved, refused or failed), so the
server always sees the cancel after the connect and deletes the token if
that connect saved it. Nothing sent means no cancel. Sign-out, Disconnect's
first step, waits for any such cancel (also from a window closed earlier)
before the page posts its disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:00:36 +11:00
saphidandClaude Opus 5.5 9d92396d49 My Meta library: Sign in with Meta cancels by in-flight attempt and saved owner
- frame_meta keeps only in-flight sign-in attempts (removed when their connect
  finishes) instead of a bounded list of cancelled ids, so other cancels can't
  push one out, and records which attempt saved the account. Cancelling an
  attempt being checked stops its save; cancelling the attempt that just saved
  deletes that token; a later sign-in's or a pasted token's account is never
  touched; unknown attempts change nothing.
- will-redirect applies the per-frame rule: frames may be redirected to any
  https: page, the window only to Meta's sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:54:38 +11:00
saphidandClaude Opus 5.5 be2eda8304 My Meta library: Sign in with Meta lets frames load any HTTPS page
Meta's login can embed captcha and CDN frames from other sites (Arkose Labs,
hCaptcha, reCAPTCHA, fbcdn), so frames inside the sign-in window may now go to
any https: address (or about:blank/about:srcdoc); other schemes (http:, data:,
blob:, javascript:, file:...) are still stopped. The window itself stays on
the Meta/Oculus/Facebook allow-list. Frames can't reach the cookie read or IPC.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:44:32 +11:00
saphidandClaude Opus 5.5 f5bed08931 My Meta library: Sign in with Meta drops abandoned attempts, holds frames to the list
Review fixes for the sign-in window:
- Each sign-in has a random attempt id. Closing, timing out or signing out
  stops cookie intake first, then tells the server (new cancel-signin action)
  not to save a connect from that attempt, even one still waiting on Meta
  (frame_meta.connect/cancel_signin; 409 from the server). Disconnect signs
  out of the window before deleting the token.
- will-frame-navigate holds every frame to the allow-list; a frame going
  elsewhere is stopped, never opened outside.
- The window is destroyed, not closed, and will-prevent-unload is overridden,
  so beforeunload can't keep it alive; sign-out clears the session only after
  the window is gone.
- Permission checks refused as well as requests; no developer tools.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:39:25 +11:00
saphid e144325375 Merge branch 'feat/quest-import' into feat/meta-library 2026-10-14 09:26:42 +11:00
saphidandClaude Opus 5.5 291b8ec295 My Meta library: Sign in with Meta in a Frame Control window
The desktop app can now open Meta's own login page (https://secure.oculus.com,
as BSManager's "Log in with Meta") in a window with its own session
(persist:meta-signin: sandboxed, context isolation, no preload or Node,
permissions refused). Navigation stays on https oculus.com, meta.com and
facebook.com; other links open in the system browser and other redirects are
stopped. The oc_ac_at cookie is read only from that session and only for
oculus.com, sent once per value through the server's existing connect path
(check_token, who am I, 0600 file), and the window closes. The page only gets
the server's answer, never the token. Disconnect also signs out of Meta in
Frame Control by clearing that session. Pasting a token stays as the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:26:18 +11:00
saphidandClaude Opus 5.5 9697812984 Merge origin/main into feat/quest-import
One conflict, in ui/frame_android.py where main added COPY_MIN_RATE (size-scaled
copy timeouts) beside the install lock this branch made reentrant: both kept,
the lock stays an RLock. ui/index.html and ui/server.py merged on their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:22:56 +11:00
saphidandClaude Opus 5.5 0fc6fbe596 My Meta library: find overlapping token matches so a partial match can't hide the real start
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:39:57 +11:00
saphidandClaude Opus 5.5 f2261bac1c My Meta library: find every redaction span in the original text and merge overlaps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:34:03 +11:00
saphidandClaude Opus 5.5 7c45d0ff24 My Meta library: redact token-shaped values before the known token's encoded forms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:23:08 +11:00
saphidandClaude Opus 5.5 19233fbf7d My Meta library: close the last gaps in "every accepted token is redacted"
- check_token refuses values with no lower-case letter (real tokens are mixed
  case), so the kept upper-case-constant shape can never hold an accepted
  token, even with a _SUFFIX appended.
- The token-shape pattern has no word boundary: a token is found after any
  character (_OC..., /OC..., libOC...), and its run takes every following
  token character, so an accepted token is always inside one match. Still
  one run per match, linear (timing tests unchanged).
- Whole-token redaction runs before access_token= and Authorization
  redaction, so OCaccess_token=123456 goes whole, prefix included.

The property test now also surrounds each accepted random token with random
printable-ASCII neighbours (and _FLAG, access_token=) and checks the output is
a start of the left side, one <redacted>, and an end of the right side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:16:39 +11:00
saphidandClaude Opus 5.5 3ef978e232 My Meta library: one token character set for accepting and redacting
check_token accepted any printable character, more than the generic redaction
looked for, so an accepted value like OCAbcdef.GhijkLmnopQrstUvwx wasn't
redacted and could show as a library name. TOKEN_CHARS (letters, digits and
the base64 / URL-safe base64 characters + / = - _) and TOKEN_MIN (20) now
define both: check_token refuses anything else ("That doesn't look like a
Meta token"), including upper-case underscore constants the redaction keeps,
and the generic pattern is built from the same constants. The public tools
only describe tokens of letters and digits; widen TOKEN_CHARS in one place
if a real token needs more. Property test: 3000 random OC/FRL values over the
allowed set; every one check_token accepts is redacted in full in five
contexts and never shown as a library name. Dotted regression too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:04:47 +11:00
saphidandClaude Opus 5.5 8afa1113c9 My Meta library: redact digit-free token shapes too
check_token accepts tokens without digits (OCAbcdefGhijkLmnopQrstUvwx), but the
generic redaction kept OC/FRL runs that had none, so such a value in a log line
(dlopen failed: "<token>.so") was shown as the library name. The generic pattern
now redacts every run of the shape check_token accepts (OC or FRL prefix, 20+
characters of token characters), digits or not, still as one linear match.
Upper-case underscore constants (OCULUS_PLATFORM_SDK_VERSION) are kept, as are
short words, package names and runs too short to be a token. Tests for
digit-free redaction, the 20-character boundary, the excerpt, and identifiers
that must stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:54:13 +11:00
saphidandClaude Opus 5.5 7fe7e504da My Meta library: never surface a failed log read; linear-time log and token scans
- A Frame error during a test (an ssh failure with empty stderr raises its
  stdout, which can be the game's log) becomes the fixed "couldn't read the
  game's log from the Frame" message, with no original text and no chained
  cause. run_test wraps every step; the /api/meta test job also maps any
  other exception to a fixed text, and the CLI and Install and test go
  through run_test. Close failures no longer echo their error text.
- Log scans are linear: signatures look at the first 4 KB of each line and
  every repetition followed by more required text is bounded; excerpts
  split the redacted line once and fullmatch only words of 64 characters or
  less; the generic OC/FRL token pattern matches each run once (no
  lookahead) and keeps it unless it has a digit. Timing tests: 100 KB
  adversarial lines through classify, redact and every excerpt each finish
  well under 200 ms (the old library and token patterns took 19 s and 9 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:38:46 +11:00
saphidandClaude Opus 5.5 f678b4c8a2 My Meta library: redact twice-encoded tokens; never show a cut-off token as a name
- _clean matches each token character literally, percent-encoded (%XX) or
  percent-encoded twice (%25XX), either letter case, hex in any case, still
  with 8+ character starts. This restores the double-encoding the port from
  feat/itch-purchases dropped; regression cases for double-encoded, recased
  and prefix forms.
- A shown log excerpt (library, extension, exception or signal name) is
  taken from the line after the whole line is redacted, never cut from
  something longer, and dropped unless it looks like a real identifier
  (64 characters at most, no 32+ alphanumeric run, no 16+ run mixing upper,
  lower and digits). Tested with the token, and the tail of a longer one,
  as a library name in a dlopen failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:21:38 +11:00
saphidandClaude Opus 5.5 33e4d2c092 My Meta library: fix review findings (token on redirects, addresses, redaction, budget, tests)
- Redirects off Meta's hosts are refused, before any lookup or connection,
  when the address carries the token in any form anywhere (host, path, query,
  fragment; literal, recased, percent-encoded, 8+ character starts) or an
  access_token parameter, instead of stripping only access_token.
- Connections resolve once, require every address to be public (an
  allow-list that refuses ff02::, fec0::, NAT64, 6to4, Teredo and the IPv4
  special blocks), connect to exactly a checked address with hostname-
  verified TLS within one shared deadline, and use no environment proxies.
- Redaction uses the itch branch's _clean (each character literal or
  percent-encoded in either case, 8+ character starts).
  These three mirror _web.public_address / resolve_public /
  _public_connection and _itch_account._clean / _carries_key on
  feat/itch-purchases, so the two can be unified when both merge.
- One byte budget per install (16 GB) counts every byte as it arrives,
  including files of unknown size; overflow stops and removes the file.
- Test causes are fixed texts; reports never carry log text. A short
  excerpt (extension, library, exception or signal name) is shown only,
  redacted before it is shortened.
- A running game must be confirmed closed before a test; a failed close
  after a hang is reported ("the game may still be running").
- A licence failure needs explicit failure evidence; a game open without a
  VR-session line is "inconclusive" and not reported to the compat DB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:59:19 +11:00
saphidandClaude Opus 5.5 621330d397 My Meta library (Experimental): install and test Quest games you own
Opt-in, off until you connect: paste your own Meta access token, which is
checked with a read-only "who am I" query and kept owner-only in
data_dir/meta-account.json. Lists the Quest apps the account owns, downloads
an owned app's APK and OBB from Meta's download server into the install
cache (size limits, declared-size and optional SHA-256 checks, cleaned up
after every install), and installs it through Import from my Quest's checks,
install and two-step data copy. Install and test runs the game, classifies
the outcome from its log (runs, runs with issues, quits, crashes, hangs) with
a plain-English cause, and records it through the existing compatibility
report path with source meta-store and the version code, shared only per the
Privacy settings.

The token goes only to graph.oculus.com and securecdn.oculus.com over
HTTPS, is dropped on redirects elsewhere, and is redacted (any case,
percent-encoded, before truncation) from every message. A disconnect wins
over a connect in flight and stops downloads. No licence-check bypass.
Protocol facts come from the public QAVS, OculusGraphQLApiLib, DownQuest and
BSManager sources (docs/meta-library.md); the code is our own.

UI: Android -> My Meta library. CLI: ui/frame_meta.py. API: /api/meta.
Tested offline with a fake Meta and fake Frame (tests/test_meta.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:32:28 +11:00
saphidandClaude Opus 5.5 15e14c0d47 Quest import: warn about Meta's licence check instead of refusing
A game linking libovrplatformloader.so now installs with a warning ("uses
Meta's licence check; it may quit, hang, or run without online features
on the Frame. Frame Control won't remove the check."): linking the SDK
doesn't mean the check is enforced (VRC.Quest.Security.1 has only been
Recommended since 2021). 32-bit-only and API > 30 are still refused. The
VrApi warning says "may not run". The data step (copy into the open game,
then close it) becomes add_game_data, for reuse by My Meta library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:32:28 +11:00
saphidandClaude Opus 5.5 77c6c286cd Split merge: accept 64-bit data descriptors on small streamed ZIP entries
Streaming writers (Python's unseekable ZipFile, force_zip64) write ZIP64
data descriptors even for small files, so the descriptor width can't be
taken from the final sizes. The reader now accepts the signed or unsigned,
32- or 64-bit layout that matches the central directory and fits before
the next entry, preferring one that ends exactly there, then the width the
local header's ZIP64 metadata implies. Format tests cover streamed ZIP64
stored and deflated entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:54:55 +11:00
saphidandClaude Opus 5.5 ca53b26701 Quest import: fix round-3 review findings (legacy versions, strict ZIP, closing)
- Installs made before records kept a version code no longer get replaced by
  an older Quest copy: the installed code is read from app.apk on the Frame
  (stdlib parser, only the manifest crosses the link); if it can't be read,
  replacing needs allowDowngrade. The data-copy guard uses the same lookup.
- Split members are checked against both ZIP headers: same local name and
  method, local sizes and checksum (or a validated data descriptor, with
  ZIP64 extra fields understood) equal to the central directory, no overlap
  with the next entry, and no trailing bytes after a deflate stream.
- The game is closed in a finally block, so a local OSError or Ctrl-C part-way
  through a copy still closes it; the original exception is kept and a failed
  close is reported separately.
- running_instances(strict=True) fails closed (a failed `podman ps` raises),
  used by the Quest import; other callers keep the lenient listing.
- The dumpsys version check reads only the package's own block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:43:03 +11:00
saphidandClaude Opus 5.5 baec984449 Quest import: fix round-2 review findings (versions, locking, strict split reads)
- An older Quest copy no longer replaces a newer install on the Frame: installs
  now record versionCode, and a downgrade is refused unless allowed
  (--allow-downgrade, "allowDowngrade", or the panel's checkbox).
- The game-data copy only targets this package's own install record and
  instance at the Quest's exact version code, and checks Android's own
  dumpsys in that instance before copying.
- After the (possibly long) pulls the target is checked again, and the final
  checks, the transfer and closing the game hold the shared install lock,
  now reentrant so install_obb and install can be called under it.
- Closing the game is confirmed; a failed or incomplete close is reported
  instead of claiming the game was closed.
- Split members are read from their raw bytes: stored members must be exactly
  their declared size, deflated streams must end exactly there, then the
  checksum; forged sizes with prefix checksums are refused.
- A split's resource table counts as empty only if no type chunk defines any
  entry (bags included) and every chunk is understood.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:24:30 +11:00
saphidandClaude Opus 5.5 18e5053b64 Quest import: fix review findings (bounded split merge, no game launch, streamed data)
- Split merging only accepts splits holding lib/<abi>/*.so, assets/, their own
  signature/manifest and an empty resource table. Code (classes*.dex), other
  files, resource splits, feature splits and split manifests declaring
  components or libraries are refused before anything reaches the Frame.
- Merged bytes are bounded (512 MB per file, 1 GB in all) from the headers and
  again by bounded reads with a checksum, so a zip bomb or a forged header
  can't fill memory.
- Import never starts the game. The first import installs it; game data (OBB,
  Android/data) is copied by a second import while the person has the game
  open, never replacing an open game's APK, then the game is closed.
- Android/data is streamed as a tar straight into the SSH transfer, not
  staged; the space check counts split payloads uncompressed and data once.
- The panel shows small sizes in KB rather than "1 MB".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:02:42 +11:00
saphidandClaude Opus 5.5 7faf80fc2b Import from my Quest: copy games you own off a USB-connected Quest
New ui/frame_quest.py (CLI: devices | list | import), GET/POST /api/quest and
an Import from my Quest panel on the Android page. It finds a Quest on this
computer's adb (never Frame Control's own tunnelled serials), lists third-party
apps with split, OBB and Android/data details, pulls the APKs into a per-user
cache and checks them before copying anything else: Meta's Platform SDK
licence check (libovrplatformloader.so) and 32-bit-only builds are refused,
VrApi-only builds flagged. It then installs through the normal APK path, adds
OBBs with the existing helper and, if asked, Android/data files, and always
removes the cache. No licence-check bypass is bundled, called or linked.

Tests use a fake adb (tests/fakeadb.py). Docs: docs/quest-import.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:17:11 +11:00
saphidandClaude Opus 5.5 6ea20a2806 Android installs: accept split APKs by merging them into one APK
Lepton runs one APK per instance, so frame_android.install(base, splits=[...])
(and `frame_android.py install base.apk split.apk ...`) merges splits that add
native libraries or assets into the base, drops the manifest's isSplitRequired
flag, and re-signs with Frame Control's key. Density and language splits,
splits of another package and clashing entries are refused before anything
reaches the Frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:17:11 +11:00
23 changed files with 6926 additions and 28 deletions

No files matched your search

+2
View File
@@ -213,6 +213,8 @@ 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 may not |
| [My Meta library](docs/meta-library.md) | Experimental: install and test Quest games your Meta account owns, with your own token |
| [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 |
+43 -1
View File
@@ -1,7 +1,7 @@
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
// a free loopback port and shows it in a native window. The server does all the
// work over the `frame` SSH alias; this file only hosts it.
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, shell } = require("electron");
const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, nativeImage, session, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -11,6 +11,7 @@ const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
const updater = require("./updater");
const { createMetaSignIn } = require("./meta-signin");
const run = promisify(execFile);
@@ -347,6 +348,47 @@ ipcMain.handle("comfort:notify", (e, message) => {
});
});
// ---- Sign in with Meta (app/meta-signin.js, docs/meta-library.md) ----
// The token goes from the sign-in window's own session straight to the server's
// connect path; the page asks for it and only hears the server's answer.
function postApi(apiPath, body) {
return new Promise((resolve, reject) => {
if (!url) return reject(new Error("Frame Control's server isn't running."));
const data = JSON.stringify(body);
const req = http.request(new URL(apiPath, url), {
method: "POST", timeout: 60000,
headers: { "Content-Type": "application/json", "X-Frame-UI": process.env.FRAME_UI_KEY || "1",
"Content-Length": Buffer.byteLength(data) },
}, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (c) => { text += c; });
res.on("end", () => {
let parsed = {};
try { parsed = JSON.parse(text); } catch {}
resolve({ status: res.statusCode, data: parsed });
});
});
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
req.end(data);
});
}
let metaSignIn = null;
const meta = () => (metaSignIn ||= createMetaSignIn({
BrowserWindow, session, shell, parent: () => win,
// The page only offers sign-in after its "I understand" box is ticked (checked again below).
connect: (token, attempt) => postApi("/api/meta", { action: "connect", token, acknowledged: true, attempt }),
cancel: (attempt) => postApi("/api/meta", { action: "cancel-signin", attempt }),
}));
ipcMain.handle("meta:signin", (e, acknowledged) => {
if (!fromUi(e)) return null;
if (acknowledged !== true) return { ok: false, error: "My Meta library is experimental: tick that you understand the warning first." };
return meta().signIn();
});
ipcMain.handle("meta:signout", (e) => fromUi(e) ? meta().signOut() : false);
// frame-control://install links from websites (docs/web-install.md). They can
// arrive before the window or server exists (macOS open-url on a cold launch),
// so they wait here until the page asks for them. The page checks the link with
+252
View File
@@ -0,0 +1,252 @@
// "Sign in with Meta" for My Meta library (docs/meta-library.md), the way BSManager's
// "Log in with Meta" works: Meta's own login page in a Frame Control window, then the
// access token cookie (oc_ac_at) that page sets is handed to the local server's
// existing connect path (format check, "who am I" with Meta, 0600 file).
//
// The window has its own persistent session (PARTITION), so the cookie is read only
// from there: never from Frame Control's main window, never from any browser. It has
// no preload and no Node, and it only navigates within Meta's login sites; anything
// else opens in the system browser. Nothing here logs the token, and the page only
// ever gets back the server's answer (connected, display name), never the token.
// Electron is passed in, so app/test/meta-signin.test.js can run it with fakes.
const crypto = require("crypto");
const PARTITION = "persist:meta-signin";
// BSManager's loginUrl (src/main/services/bs-version-download/bs-oculus-downloader.service.ts).
const LOGIN_URL = "https://secure.oculus.com";
const COOKIE = "oc_ac_at";
// Sites the login can go through: the Oculus store web, Meta accounts, Facebook login,
// and Instagram (Meta's login passes through www.instagram.com: seen live, 2026-10-14).
const ALLOWED = ["oculus.com", "meta.com", "facebook.com", "instagram.com"];
const TIMEOUT = 10 * 60 * 1000; // as BSManager
const underDomain = (host, domains) => {
host = String(host || "").replace(/^\./, "").toLowerCase();
return domains.some((d) => host === d || host.endsWith("." + d));
};
// Plain HTTPS on the default port, to one of the login sites.
function allowedUrl(raw) {
let u;
try { u = new URL(String(raw)); } catch { return false; }
if (u.protocol !== "https:" || u.username || u.password || (u.port && u.port !== "443")) return false;
return underDomain(u.hostname, ALLOWED);
}
// Frames inside the page: any HTTPS address (Meta's login can embed captcha and CDN
// frames from other sites), or an empty frame; never data:, blob:, javascript:, http:,
// file: or any other scheme. Frames can't reach the cookie read or IPC (main process only).
function allowedSubframeUrl(raw) {
if (/^about:(?:blank|srcdoc)$/.test(String(raw))) return true;
let u;
try { u = new URL(String(raw)); } catch { return false; }
return u.protocol === "https:" && !u.username && !u.password;
}
const onOculus = (raw) => { try { return underDomain(new URL(raw).hostname, ["oculus.com"]); } catch { return false; } };
// A cheap first look, so a signed-out or app value is never sent anywhere; the server's
// check_token (ui/frame_meta.py) has the final word.
function looksLikeUserToken(v) {
return typeof v === "string" && /^(?:OC|FRL)[A-Za-z0-9+/=_-]{18,4093}$/.test(v)
&& !/^OC\d{15}$/.test(v) && /[a-z]/.test(v);
}
const fingerprint = (v) => crypto.createHash("sha256").update(v).digest("hex");
// Anything token-shaped out of a message, in case a server error ever carried one.
function scrub(text, token) {
let s = String(text == null ? "" : text);
if (token) s = s.split(token).join("<redacted>");
return s.replace(/(?:OC|FRL)[A-Za-z0-9+/=_%|:-]{8,}/g, "<redacted>").slice(0, 600);
}
// deps: { BrowserWindow, session, shell, parent() -> BrowserWindow|null, timeout,
// connect(token, attempt) -> Promise<{status, data}> (the server's connect action),
// cancel(attempt) -> Promise (the server's cancel-signin: never save that attempt) }
function createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent = () => null, timeout = TIMEOUT }) {
let active = null; // { win, promise, finish }
const pending = new Set(); // cancels still to reach the server, from flows already finished
let signingOut = null; // the sign-out under way; no sign-in starts while it runs
const SIGNING_OUT = "Frame Control is signing out of Meta; try again in a moment.";
const openOutside = (target) => {
if (/^https?:\/\//i.test(String(target))) shell.openExternal(String(target));
};
function signIn() {
if (signingOut) return Promise.resolve({ ok: false, error: SIGNING_OUT });
if (active) { active.win.focus(); return active.promise; }
const ses = session.fromPartition(PARTITION);
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false));
ses.setPermissionCheckHandler(() => false);
// Electron adds its own name and the app's to the user agent; send a plain Chrome one.
if (ses.getUserAgent && ses.setUserAgent) {
ses.setUserAgent(ses.getUserAgent().replace(/\s(?:Electron|frame-control|Frame Control)\/\S+/g, ""));
}
const p = parent();
const win = new BrowserWindow({
width: 520, height: 760, title: "Sign in with Meta – Frame Control", autoHideMenuBar: true,
...(p ? { parent: p } : {}),
webPreferences: { partition: PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true,
nodeIntegrationInSubFrames: false, webviewTag: false, safeDialogs: true, devTools: false },
});
const wc = win.webContents;
let resolve;
const promise = new Promise((r) => { resolve = r; });
let done = false, busy = false, again = false, lastError = null, blocked = null;
const tried = new Set(); // fingerprints of values already sent, so each goes once
// The server refuses to save a connect from an attempt this window gave up on.
const attemptId = crypto.randomUUID();
const onCookie = (_e, cookie, _cause, removed) => {
if (!removed && cookie && cookie.name === COOKIE && underDomain(cookie.domain, ["oculus.com"])) attempt();
};
const timer = setTimeout(() => finish({ ok: false, error: "Sign-in timed out after 10 minutes. Try again." }), timeout);
if (timer.unref) timer.unref();
// Stops cookie intake first, then the window (destroy: a page's beforeunload can't
// keep it open), and answers the page. Unless it connected, and if it sent a token, it
// then has the server drop this attempt: only once the connect under way has settled,
// so the server always sees the cancel after the connect (and deletes the token if
// that connect saved it). The returned promise settles when that is done.
let finished = null;
let inflight = null; // the last connect sent
function finish(result) {
if (done) return finished || Promise.resolve(); // destroy() below re-enters through "closed"
done = true;
ses.cookies.removeListener("changed", onCookie);
clearTimeout(timer);
if (active && active.win === win) active = null;
if (!win.isDestroyed()) win.destroy();
resolve(result);
if (result.ok || !inflight) return (finished = Promise.resolve()); // nothing sent: nothing to cancel
finished = Promise.resolve(inflight).catch(() => {}).then(() => cancel(attemptId)).catch(() => {});
pending.add(finished);
finished.then(() => pending.delete(finished));
return finished;
}
async function attempt() {
if (done) return;
if (busy) { again = true; return; }
busy = true;
try {
do {
again = false;
// Only this window's own session, and only Meta's Oculus cookie.
const cookies = await ses.cookies.get({ name: COOKIE });
const c = cookies.find((k) => underDomain(k.domain, ["oculus.com"]) && looksLikeUserToken(k.value));
if (!c || done) continue;
const key = fingerprint(c.value);
if (tried.has(key)) continue;
tried.add(key);
let r;
try {
inflight = connect(c.value, attemptId);
r = await inflight;
} catch {
lastError = "Frame Control's local server didn't answer.";
continue;
}
if (done) return;
const d = (r && r.data) || {};
if (r && r.status === 200 && d.connected) {
finish({ ok: true, connected: true, user: d.user && d.user.name ? { name: scrub(d.user.name, c.value).slice(0, 80) } : null,
message: scrub(d.message || "Connected your Meta account (Experimental)", c.value) });
return;
}
lastError = scrub(d.error || `HTTP ${r && r.status}`, c.value);
} while (again && !done);
} catch {
// The session went away (the window closed); nothing to do.
} finally {
busy = false;
}
}
// Navigation stays on Meta's login sites. A link elsewhere opens in the system
// browser; a redirect elsewhere is just stopped (it may carry sign-in data).
// Every frame: the window itself stays on the list; frames inside it may load any
// HTTPS page (captchas), and one going anywhere else is just stopped.
wc.on("will-navigate", (e, target) => { if (!allowedUrl(target)) { e.preventDefault(); openOutside(target); } });
// A stopped page change in the window itself says where it went (the host only), so
// closing the stuck window explains why instead of "closed before you signed in".
const stopped = (to) => {
let host = "";
try { host = new URL(String(to)).hostname; } catch { /* not an address */ }
blocked = `Meta's sign-in went to ${scrub(host || "an address", null).slice(0, 80)}, which Frame Control doesn't allow.`;
};
wc.on("will-frame-navigate", (e) => {
if (e.isMainFrame ? allowedUrl(e.url) : allowedSubframeUrl(e.url)) return;
e.preventDefault();
if (e.isMainFrame) stopped(e.url);
});
// Redirects fire for frames too: the same rule per frame (details on the event in
// current Electron, positional arguments in older ones; unknown counts as the window).
wc.on("will-redirect", (e, target, _inPlace, isMainFrame) => {
const to = e && e.url !== undefined ? e.url : target;
const main = e && e.isMainFrame !== undefined ? e.isMainFrame : isMainFrame !== false;
if (main ? allowedUrl(to) : allowedSubframeUrl(to)) return;
e.preventDefault();
if (main) stopped(to);
});
// A page's beforeunload never keeps the window open.
wc.on("will-prevent-unload", (e) => e.preventDefault());
wc.setWindowOpenHandler(({ url: target }) => {
if (allowedUrl(target)) wc.loadURL(target).catch(() => {});
else openOutside(target);
return { action: "deny" };
});
// Signed in: the cookie changes, or the window lands back on Meta's store site.
ses.cookies.on("changed", onCookie);
wc.on("did-navigate", (_e, target) => { blocked = null; if (onOculus(target)) attempt(); }); // it recovered
wc.on("did-navigate-in-page", (_e, target) => { if (onOculus(target)) attempt(); });
win.on("closed", () => finish({ ok: false, cancelled: true,
error: lastError ? `The sign-in window closed before Frame Control could connect. ${lastError}`
: blocked ? `The sign-in window was closed before you signed in. ${blocked}`
: "The sign-in window was closed before you signed in." }));
active = { win, promise, finish };
wc.loadURL(LOGIN_URL).catch(() => {});
return promise;
}
// Signs out of Meta in Frame Control: stops any sign-in under way (and has the server
// refuse to save it), destroys the window, then clears everything
// the sign-in session stored (cookies, storage, cache), so the next sign-in starts fresh.
// One at a time (a second call joins the first), and no sign-in can start meanwhile.
// It goes round until no window is open and no connect or cancel is left, so nothing
// started before it can set a token after it (or after the Disconnect that follows).
function signOut() {
if (!signingOut) {
signingOut = (async () => {
for (;;) {
if (active) {
const { win, finish } = active;
await finish({ ok: false, cancelled: true, error: "Signed out of Meta in Frame Control." });
// Clear only once nothing can write to the session again.
if (!win.isDestroyed()) throw new Error("The Sign in with Meta window didn't close; try again.");
} else if (pending.size) {
// A connect under way settles and is cancelled before the caller (Disconnect)
// deletes the account, so it can't be saved afterwards.
await Promise.all([...pending]);
} else {
break;
}
}
const ses = session.fromPartition(PARTITION);
await ses.clearStorageData();
await ses.clearCache();
if (ses.clearAuthCache) await ses.clearAuthCache();
return true;
})().finally(() => { signingOut = null; });
}
return signingOut;
}
return { signIn, signOut, isOpen: () => !!active, isSigningOut: () => !!signingOut };
}
module.exports = { createMetaSignIn, allowedUrl, allowedSubframeUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE, ALLOWED };
+1
View File
@@ -39,6 +39,7 @@
"preload.js",
"install-link.js",
"updater.js",
"meta-signin.js",
"package.json",
"build/icon.png"
],
+8 -1
View File
@@ -6,7 +6,8 @@
// the headset can't be reached, and keeps the Frame menu's list of headsets up to date.
// It also receives frame-control://install links (docs/web-install.md): only
// what the link asked for, never an install; the page asks the user first.
// And it passes update state both ways: see app/updater.js.
// And it passes update state both ways: see app/updater.js. It opens the Sign in
// with Meta window and signs out of it (app/meta-signin.js).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
@@ -39,6 +40,12 @@ contextBridge.exposeInMainWorld("frameApp", {
ipcRenderer.removeAllListeners("report:open");
ipcRenderer.on("report:open", () => cb());
},
// Sign in with Meta (app/meta-signin.js): Meta's login page in its own window. The
// answer is the server's (connected, display name), never the token.
meta: {
signIn: (acknowledged) => ipcRenderer.invoke("meta:signin", acknowledged === true),
signOut: () => ipcRenderer.invoke("meta:signout"),
},
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
+578
View File
@@ -0,0 +1,578 @@
// Run: node --test app/test/
// "Sign in with Meta" (app/meta-signin.js) with fake Electron windows, sessions and cookies.
const nodeTest = require("node:test");
// A broken flow can leave a sign-in waiting forever; fail it instead of hanging.
const test = (name, fn) => nodeTest(name, { timeout: 5000 }, fn);
const assert = require("node:assert");
const { EventEmitter } = require("node:events");
const { createMetaSignIn, allowedUrl, looksLikeUserToken, scrub, PARTITION, LOGIN_URL, COOKIE } = require("../meta-signin");
const TOKEN = "OCAbcdefghijklmnopQRSTUVwxyz0123456789abcdEFGH";
const OTHER = "OCzyxwvutsrqponmlkJIHGFEDcba9876543210zyxwVUTS";
class FakeCookies extends EventEmitter {
constructor() { super(); this.store = []; this.reads = 0; }
async get(filter) {
this.reads++;
return this.store.filter((c) => !filter.name || c.name === filter.name);
}
set(cookie) { // as the page would: store it, then tell listeners
this.store = this.store.filter((c) => !(c.name === cookie.name && c.domain === cookie.domain)).concat(cookie);
this.emit("changed", {}, cookie, "explicit", false);
}
}
class FakeSession {
constructor(name, log) { this.name = name; this.log = log; this.cookies = new FakeCookies(); this.cleared = []; this.permission = null; }
setPermissionRequestHandler(fn) { this.permission = fn; }
setPermissionCheckHandler(fn) { this.permissionCheck = fn; }
getUserAgent() { return "Mozilla/5.0 Chrome/140.0 frame-control/0.4.4 Electron/44.4.5 Safari/537.36"; }
setUserAgent(ua) { this.ua = ua; }
async clearStorageData(opts) { this.log.push("clear"); this.cleared.push(["storage", opts]); }
async clearCache() { this.cleared.push(["cache"]); }
async clearAuthCache() { this.cleared.push(["auth"]); }
}
function fakes({ timeout } = {}) {
const log = []; // the order things happened in
const sessions = new Map();
const defaultSession = new FakeSession("default", log);
const session = {
defaultSession,
fromPartition(name) {
if (!sessions.has(name)) sessions.set(name, new FakeSession(name, log));
return sessions.get(name);
},
};
const windows = [];
class BrowserWindow extends EventEmitter {
constructor(opts) {
super();
this.opts = opts;
this.destroyed = false;
this.veto = false; // a page whose beforeunload keeps the window open on close()
this.focused = 0;
const wc = new EventEmitter();
wc.loaded = [];
wc.loadURL = async (u) => { wc.loaded.push(u); };
wc.setWindowOpenHandler = (fn) => { wc.openHandler = fn; };
// Deliberately a different session: the code must read the partition's, not guess.
wc.session = defaultSession;
this.webContents = wc;
windows.push(this);
}
isDestroyed() { return this.destroyed; }
focus() { this.focused++; }
close() { if (this.destroyed || this.veto) return; this.destroy(); }
destroy() { if (this.destroyed) return; this.destroyed = true; log.push("destroyed"); this.emit("closed"); }
}
const opened = [];
const shell = { openExternal: (u) => { opened.push(u); } };
const calls = [], attempts = [], cancels = [];
let answer = () => ({ status: 200, data: { connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" } });
const connect = async (token, attempt) => { calls.push(token); attempts.push(attempt); log.push("connect"); return answer(token); };
const cancel = async (attempt) => {
cancels.push(attempt);
log.push(`cancel (cookie listeners: ${session.fromPartition(PARTITION).cookies.listenerCount("changed")})`);
};
const flow = createMetaSignIn({ BrowserWindow, session, shell, connect, cancel, parent: () => null, ...(timeout ? { timeout } : {}) });
return { flow, session, sessions, windows, opened, calls, attempts, cancels, log, setAnswer: (fn) => { answer = fn; },
meta: () => session.fromPartition(PARTITION) };
}
const tick = () => new Promise((r) => setImmediate(r));
async function settle() { for (let i = 0; i < 10; i++) await tick(); }
function navigate(wc, event, target) {
let prevented = false;
wc.emit(event, { preventDefault: () => { prevented = true; } }, target);
return prevented;
}
test("only plain HTTPS addresses on Meta's login sites are allowed", () => {
for (const ok of ["https://secure.oculus.com", "https://secure.oculus.com/my/profile", "https://auth.meta.com/login/",
"https://www.meta.com/", "https://www.facebook.com/login.php", "https://oculus.com:443/",
"https://www.instagram.com/accounts/login/"]) {
assert.ok(allowedUrl(ok), ok);
}
for (const bad of ["http://secure.oculus.com/", "https://oculus.com.evil.example/", "https://evil-oculus.com/",
"https://notmeta.com/", "https://example.com/", "javascript:alert(1)", "file:///etc/passwd",
"https://secure.oculus.com:8443/", "https://user:pw@secure.oculus.com/", "https://evil.example/?u=https://www.meta.com",
"data:text/html,hi", "not a url"]) {
assert.ok(!allowedUrl(bad), bad);
}
});
test("the window is isolated: its own partition, no preload, no Node, sandboxed, on Meta's login page", async () => {
const f = fakes();
f.flow.signIn();
const [w] = f.windows;
const wp = w.opts.webPreferences;
assert.strictEqual(wp.partition, PARTITION);
assert.strictEqual(PARTITION, "persist:meta-signin");
assert.strictEqual(wp.contextIsolation, true);
assert.strictEqual(wp.nodeIntegration, false);
assert.strictEqual(wp.sandbox, true);
assert.strictEqual(wp.preload, undefined);
assert.strictEqual(wp.devTools, false);
assert.deepStrictEqual(w.webContents.loaded, [LOGIN_URL]);
assert.strictEqual(LOGIN_URL, "https://secure.oculus.com");
// Permission prompts (camera, notifications...) are refused.
let granted = null;
f.meta().permission(null, "media", (v) => { granted = v; });
assert.strictEqual(granted, false);
assert.strictEqual(f.meta().permissionCheck(null, "notifications", "https://secure.oculus.com"), false);
assert.ok(!/Electron|frame-control/.test(f.meta().ua));
w.close();
});
test("navigation stays on Meta's sites; other links open in the system browser", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(navigate(wc, "will-navigate", "https://auth.meta.com/login/"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://www.facebook.com/login.php"), false);
assert.strictEqual(navigate(wc, "will-navigate", "https://example.com/help"), true);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// A redirect elsewhere is stopped and not opened anywhere (it may carry sign-in data).
assert.strictEqual(navigate(wc, "will-redirect", "https://evil.example/cb?x=1"), true);
assert.strictEqual(navigate(wc, "will-redirect", "https://auth.meta.com/next"), false);
assert.deepStrictEqual(f.opened, ["https://example.com/help"]);
// New windows: never created. Meta's own load in this window; others go outside; non-web schemes nowhere.
assert.deepStrictEqual(wc.openHandler({ url: "https://www.meta.com/legal/terms/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "https://example.org/" }), { action: "deny" });
assert.deepStrictEqual(wc.openHandler({ url: "file:///etc/passwd" }), { action: "deny" });
await settle();
assert.deepStrictEqual(wc.loaded, [LOGIN_URL, "https://www.meta.com/legal/terms/"]);
assert.deepStrictEqual(f.opened, ["https://example.com/help", "https://example.org/"]);
f.windows[0].close();
});
test("the cookie is read only from the sign-in partition, only for Meta's Oculus domain", async () => {
const f = fakes();
// The same cookie elsewhere: the default session and another partition. Never read.
f.session.defaultSession.cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
f.session.fromPartition("persist:other").cookies.store.push({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const done = f.flow.signIn();
const w = f.windows[0];
// A cookie with the right name on another site, and a signed-out value: ignored.
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: OTHER });
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: "OC123456789012345" });
w.webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
await settle();
assert.deepStrictEqual(f.calls, []);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.strictEqual(r.ok, true);
assert.strictEqual(f.session.defaultSession.cookies.reads, 0);
assert.strictEqual(f.session.fromPartition("persist:other").cookies.reads, 0);
assert.ok(f.sessions.get(PARTITION).cookies.reads > 0);
});
test("landing on the logged-in page picks up a cookie set earlier", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.store.push({ name: COOKIE, domain: "secure.oculus.com", value: TOKEN }); // no change event
f.windows[0].webContents.emit("did-navigate", {}, "https://auth.meta.com/login/"); // not Meta's store site yet
await settle();
assert.deepStrictEqual(f.calls, []);
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile");
assert.strictEqual((await done).ok, true);
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("after a connect the window closes, and the page gets the answer, never the token", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.ok(f.windows[0].isDestroyed());
assert.ok(!f.flow.isOpen());
assert.deepStrictEqual(r, { ok: true, connected: true, user: { name: "Alex" }, message: "Connected your Meta account (Experimental)" });
assert.ok(!JSON.stringify(r).includes(TOKEN));
// The cookie listener is gone: a later change sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("a token the server refuses keeps the window open, is sent once, and the next one connects", async () => {
const f = fakes();
f.setAnswer((t) => t === TOKEN ? { status: 400, data: { error: "Meta didn't accept this token." } }
: { status: 200, data: { connected: true, user: null } });
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].webContents.emit("did-navigate", {}, "https://secure.oculus.com/my/profile"); // same value again
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
assert.ok(!f.windows[0].isDestroyed());
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
const r = await done;
assert.deepStrictEqual(f.calls, [TOKEN, OTHER]);
assert.strictEqual(r.ok, true);
assert.strictEqual(r.user, null);
});
test("the token is never logged, and an error that echoed it is scrubbed", async () => {
const seen = [];
const names = ["log", "info", "warn", "error", "debug", "trace"];
const saved = Object.fromEntries(names.map((n) => [n, console[n]]));
const writes = [process.stdout.write, process.stderr.write];
for (const n of names) console[n] = (...a) => { seen.push(a.map(String).join(" ")); };
process.stdout.write = (c, ...rest) => { seen.push(String(c)); return writes[0].call(process.stdout, c, ...rest); };
process.stderr.write = (c, ...rest) => { seen.push(String(c)); return writes[1].call(process.stderr, c, ...rest); };
let r;
try {
const f = fakes();
f.setAnswer((t) => ({ status: 500, data: { error: `RuntimeError: bad ${t} and ${t.slice(0, 12)}` } }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // the user gives up
r = await done;
} finally {
for (const n of names) console[n] = saved[n];
[process.stdout.write, process.stderr.write] = writes;
}
assert.strictEqual(r.ok, false);
assert.strictEqual(r.cancelled, true);
assert.ok(!r.error.includes(TOKEN) && !r.error.includes(TOKEN.slice(0, 12)), r.error);
assert.match(r.error, /<redacted>/);
assert.ok(!seen.some((s) => s.includes(TOKEN.slice(0, 12))), "token in output");
assert.strictEqual(scrub(`x FRL${"a".repeat(30)} y`), "x <redacted> y");
});
test("closing the window before signing in reports it, and a second click reuses the open window", async () => {
const f = fakes();
const a = f.flow.signIn();
const b = f.flow.signIn();
assert.strictEqual(a, b);
assert.strictEqual(f.windows.length, 1);
assert.strictEqual(f.windows[0].focused, 1);
f.windows[0].close();
const r = await a;
assert.deepStrictEqual(r, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
assert.deepStrictEqual(f.calls, []);
});
test("disconnect's sign-out closes the window and clears only the sign-in partition", async () => {
const f = fakes();
f.session.fromPartition("persist:other");
const pending = f.flow.signIn();
await f.flow.signOut();
const r = await pending;
assert.strictEqual(r.ok, false);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.meta().cleared, [["storage", undefined], ["cache"], ["auth"]]);
assert.deepStrictEqual(f.session.defaultSession.cleared, []);
assert.deepStrictEqual(f.session.fromPartition("persist:other").cleared, []);
// The next sign-in starts with a new window.
f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.windows[1].close();
});
test("the first look at a cookie value matches the server's token rules", () => {
assert.ok(looksLikeUserToken(TOKEN));
assert.ok(looksLikeUserToken("FRL" + "aB3".repeat(10)));
for (const bad of [undefined, "", "OC123456789012345", "OC|123|abc", "OCabc%2Fdef" + "x".repeat(20), "OCshort",
"XY" + "a".repeat(30), "OC" + "A".repeat(30)]) {
assert.ok(!looksLikeUserToken(bad), String(bad));
}
});
function frameNav(wc, url, isMainFrame) {
let prevented = false;
wc.emit("will-frame-navigate", { url, isMainFrame, preventDefault: () => { prevented = true; } });
return prevented;
}
test("the window stays on Meta's sites; frames inside it may load any HTTPS page, nothing else", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
// Frames: captcha and CDN frames from other sites load; other schemes are stopped.
for (const ok of ["https://client-api.arkoselabs.com/fc/gc/", "https://newassets.hcaptcha.com/captcha/v1/x",
"https://www.google.com/recaptcha/api2/anchor", "https://static.xx.fbcdn.net/x",
"https://www.facebook.com/plugin", "about:blank", "about:srcdoc"]) {
assert.strictEqual(frameNav(wc, ok, false), false, ok);
}
for (const bad of ["http://www.facebook.com/plugin", "data:text/html,<script>x</script>", "blob:https://evil.example/1",
"javascript:alert(1)", "file:///etc/passwd", "ftp://example.com/", "https://u:p@evil.example/",
"chrome://settings", "not a url"]) {
assert.strictEqual(frameNav(wc, bad, false), true, bad);
}
// The window itself: Meta's sites only.
assert.strictEqual(frameNav(wc, "about:blank", true), true);
assert.strictEqual(frameNav(wc, "https://evil.example/", true), true);
assert.strictEqual(frameNav(wc, "https://client-api.arkoselabs.com/", true), true);
assert.strictEqual(frameNav(wc, "https://auth.meta.com/login/", true), false);
assert.deepStrictEqual(f.opened, []); // stopped, never opened outside
f.windows[0].destroy();
});
function redirect(wc, url, isMainFrame, style) {
let prevented = false;
const e = { preventDefault: () => { prevented = true; } };
if (style === "details") wc.emit("will-redirect", Object.assign(e, { url, isMainFrame }));
else wc.emit("will-redirect", e, url, false, isMainFrame);
return prevented;
}
test("redirects follow the same rule per frame", async () => {
const f = fakes();
f.flow.signIn();
const wc = f.windows[0].webContents;
for (const style of ["details", "positional"]) {
// A captcha frame redirected within HTTPS loads; a frame redirected to data: or http: doesn't.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", false, style), false, style);
assert.strictEqual(redirect(wc, "https://newassets.hcaptcha.com/x", false, style), false, style);
assert.strictEqual(redirect(wc, "data:text/html,<b>x</b>", false, style), true, style);
assert.strictEqual(redirect(wc, "http://www.facebook.com/x", false, style), true, style);
assert.strictEqual(redirect(wc, "javascript:alert(1)", false, style), true, style);
// The window: Meta's sites only.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/v2/next", true, style), true, style);
assert.strictEqual(redirect(wc, "https://auth.meta.com/next", true, style), false, style);
}
// Without frame information it is treated as the window.
assert.strictEqual(redirect(wc, "https://client-api.arkoselabs.com/", undefined, "positional"), true);
assert.deepStrictEqual(f.opened, []);
f.windows[0].destroy();
});
test("a redirect the window stops is named when the window is closed", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://www.instagram.com/x?next=1", true, "details"), false); // Meta's login goes here
assert.strictEqual(redirect(wc, "https://login.evil.example/cb?code=SECRET", true, "details"), true);
f.windows[0].close();
const r = await p;
assert.strictEqual(r.ok, false);
assert.match(r.error, /went to login\.evil\.example, which Frame Control doesn't allow/);
assert.doesNotMatch(r.error, /SECRET|code=/);
});
test("a stopped redirect is forgotten once the window loads a page again", async () => {
const f = fakes();
const p = f.flow.signIn();
const wc = f.windows[0].webContents;
assert.strictEqual(redirect(wc, "https://login.evil.example/cb", true, "details"), true);
wc.emit("did-navigate", {}, "https://auth.meta.com/login/"); // the user carried on
f.windows[0].close();
assert.deepStrictEqual(await p, { ok: false, cancelled: true, error: "The sign-in window was closed before you signed in." });
});
test("a page's beforeunload can't keep the window open", async () => {
const f = fakes();
const done = f.flow.signIn();
const w = f.windows[0];
let prevented = false;
w.webContents.emit("will-prevent-unload", { preventDefault: () => { prevented = true; } });
assert.ok(prevented);
// Even if close() would be vetoed, finishing destroys the window.
w.veto = true;
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
assert.strictEqual((await done).ok, true);
assert.ok(w.isDestroyed());
});
test("closing during a connect: the cancel goes to the server only after that connect settles", async () => {
for (const outcome of ["saved", "refused", "failed"]) {
const f = fakes();
let release, fail;
f.setAnswer(() => new Promise((r, j) => { release = r; fail = j; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
f.windows[0].close(); // the user gives up while the server and Meta are answering
const r = await done; // the page hears at once
assert.strictEqual(r.ok, false);
await settle();
assert.deepStrictEqual(f.cancels, [], outcome); // not before the connect has settled
if (outcome === "saved") release({ status: 200, data: { connected: true } });
else if (outcome === "refused") release({ status: 400, data: { error: "no" } });
else fail(new Error("socket hang up"));
await settle();
// The same attempt id the connect carried, after it settled and after cookie intake stopped.
assert.deepStrictEqual(f.cancels, [f.attempts[0]], outcome);
assert.match(f.cancels[0], /^[A-Za-z0-9-]{8,64}$/);
assert.deepStrictEqual(f.log, ["connect", "destroyed", "cancel (cookie listeners: 0)"], outcome);
assert.deepStrictEqual(f.calls, [TOKEN]);
}
});
test("closing before any connect needs no cancel", async () => {
const f = fakes();
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".evil.example", value: TOKEN }); // nothing usable
await settle();
f.windows[0].close();
assert.strictEqual((await done).ok, false);
await f.flow.signOut();
await settle();
assert.deepStrictEqual(f.calls, []);
assert.deepStrictEqual(f.cancels, []);
});
test("a timeout during a connect cancels after it settles", async () => {
const f = fakes({ timeout: 50 });
let release;
f.setAnswer(() => new Promise((r) => { release = r; })); // Meta is slow
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
const r = await done;
assert.match(r.error, /timed out/);
assert.ok(f.windows[0].isDestroyed());
assert.deepStrictEqual(f.cancels, []);
release({ status: 400, data: { error: "no" } });
await settle();
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("disconnect during a connect waits for it, cancels it, then clears, before the page deletes the account", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.log.length = 0;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; }); // the page's Disconnect, step one
assert.strictEqual((await done).ok, false);
await settle();
assert.strictEqual(signedOut, false); // still waiting for the connect
assert.deepStrictEqual(f.cancels, []);
release({ status: 200, data: { connected: true } }); // it saved, just now
await out;
// Only now would the page post its disconnect: the cancel (which deletes what that
// connect saved) has reached the server, and the session is cleared.
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("a later disconnect still waits for a closed window's connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const done = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await done;
let signedOut = false;
const out = f.flow.signOut().then(() => { signedOut = true; });
await settle();
assert.strictEqual(signedOut, false);
release({ status: 400, data: { error: "no" } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
});
test("nothing sent means nothing to cancel; sign-out destroys the window, cancels, then clears", async () => {
const f = fakes();
// Nothing sent: closing cancels nothing.
const a = f.flow.signIn();
f.windows[0].close();
await a;
assert.deepStrictEqual(f.cancels, []);
// Something sent, then signed out: the attempt is cancelled before the session is cleared.
f.setAnswer(() => ({ status: 400, data: { error: "Meta didn't accept this token." } }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[1].veto = true; // and its page tries to stay open
f.log.length = 0;
await f.flow.signOut();
assert.strictEqual((await b).ok, false);
assert.ok(f.windows[1].isDestroyed());
assert.deepStrictEqual(f.log, ["destroyed", "cancel (cookie listeners: 0)", "clear"]);
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
// After sign-out a new cookie in the partition sends nothing.
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
});
test("each sign-in has its own attempt id", async () => {
const f = fakes();
f.setAnswer(() => ({ status: 400, data: { error: "no" } }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close();
await a;
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
f.windows[1].close();
await b;
await settle();
assert.strictEqual(f.attempts.length, 2);
assert.notStrictEqual(f.attempts[0], f.attempts[1]);
assert.deepStrictEqual(f.cancels, f.attempts);
});
test("no sign-in can start while a sign-out waits on an earlier connect", async () => {
const f = fakes();
let release;
f.setAnswer(() => new Promise((r) => { release = r; }));
const first = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
const out = f.flow.signOut(); // Disconnect pressed while that connect is under way
assert.strictEqual((await first).ok, false);
assert.ok(f.flow.isSigningOut());
// The page answered, so the user tries again at once: refused, no window, nothing sent.
const second = await f.flow.signIn();
assert.deepStrictEqual(second, { ok: false, error: "Frame Control is signing out of Meta; try again in a moment." });
assert.strictEqual(f.windows.length, 1);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
assert.deepStrictEqual(f.calls, [TOKEN]);
// A second sign-out joins the first.
assert.strictEqual(f.flow.signOut(), out);
release({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual(f.cancels, [f.attempts[0]]);
assert.strictEqual(f.meta().cleared.filter(([k]) => k === "storage").length, 1);
assert.ok(!f.flow.isSigningOut());
// Once it is done, signing in works again.
f.setAnswer(() => ({ status: 200, data: { connected: true } }));
const third = f.flow.signIn();
assert.strictEqual(f.windows.length, 2);
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
assert.strictEqual((await third).ok, true);
});
test("sign-out goes round until no window and no connect or cancel is left", async () => {
const f = fakes();
let releaseA;
f.setAnswer(() => new Promise((r) => { releaseA = r; }));
const a = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: TOKEN });
await settle();
f.windows[0].close(); // window A closed; its cancel waits on its connect
await a;
// Window B is open, with its own connect under way, when sign-out starts.
let releaseB;
f.setAnswer(() => new Promise((r) => { releaseB = r; }));
const b = f.flow.signIn();
f.meta().cookies.set({ name: COOKIE, domain: ".oculus.com", value: OTHER });
await settle();
let finished = false;
const out = f.flow.signOut().then(() => { finished = true; });
await b;
releaseB({ status: 400, data: { error: "no" } });
await settle();
assert.strictEqual(finished, false); // A's connect is still under way
releaseA({ status: 200, data: { connected: true } });
await out;
assert.deepStrictEqual([...f.cancels].sort(), [...f.attempts].sort());
assert.strictEqual(f.attempts.length, 2);
assert.strictEqual(f.log[f.log.length - 1], "clear");
});
+6
View File
@@ -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.
+367
View File
@@ -0,0 +1,367 @@
# My Meta library (Experimental)
Install Quest games your Meta account owns straight from Meta, test whether
they run on the Steam Frame, and add the result to the shared compatibility
reports. It is off until you connect, and it lives in **Android → My Meta
library**.
**Read this first.** My Meta library uses Meta's undocumented internal API
with your own Meta access token, as the open-source tools QAVS, DownQuest and
BSManager do. It may stop working whenever Meta changes things, and using it
may be against Meta's terms. Your account is your responsibility. Frame
Control is unofficial and not made or endorsed by Meta or Valve.
What it will and won't do:
- It lists and downloads only games your account owns (its active
entitlements). It never downloads anything else.
- It installs games as Meta ships them. There is **no licence-check bypass**:
no patching, no stub libraries, no OVRPort or FramePort. A game that
enforces Meta's licence check may quit or hang on the Frame, because that
check needs Meta's services, which the Frame doesn't have.
- Game files go only to a cache folder on your computer and then to your
Frame. They are deleted from the cache after each install, and never
uploaded or shared. Reports carry results only (below), never files or your
token.
## Your token
Frame Control never reads your browser's cookies. There are two ways to give
it your token.
**Sign in with Meta** (desktop app only; a plain browser doesn't show it).
Tick **I understand…**, then choose **Sign in with Meta…**. Meta's own login
page (`https://secure.oculus.com`, the address BSManager's "Log in with Meta"
opens) appears in a Frame Control window. Sign in there as usual. When Meta's
site sets its access token cookie (`oc_ac_at`), Frame Control takes it from
that window, connects with it exactly as if you had pasted it (below), and
closes the window. The window:
- has its own storage (the Electron session `persist:meta-signin`), separate
from Frame Control's main window and from every browser on your computer;
the cookie is read only from there, and only for `oculus.com`;
- runs Meta's page sandboxed, with no access to Frame Control (no preload, no
Node, context isolation on, no developer tools) and every permission request
or check (camera, notifications…) refused;
- only goes to `https://` pages on `oculus.com`, `meta.com`, `facebook.com` and
`instagram.com` (and their subdomains; Meta's login passes through Instagram).
A link anywhere else opens in your system browser; a redirect anywhere else
is stopped, and closing the window then says where it went. Pop-ups on those sites open in the same
window. Frames inside the page may load any `https://` page (Meta's login
can embed captcha and CDN frames from other sites, such as Arkose Labs,
hCaptcha, reCAPTCHA or fbcdn) or an empty frame (`about:blank`,
`about:srcdoc`); a frame going to any other scheme (`http:`, `data:`,
`blob:`, `javascript:`, `file:`…) is just stopped. Redirects follow the same
rule for the frame they happen in. Frames can't reach the
token: the cookie is read by Frame Control's main process, and the window
has no IPC;
- sends a cookie value on only if it looks like a user token (`OC` or `FRL`,
mixed case, no `%`, `|` or `:`), and each value once. If the server refuses
it, the window stays open; close it to see why;
- gives up after 10 minutes, as BSManager does;
- when it is closed, times out or is signed out of, stops reading cookies
first, then is destroyed (a page's "leave this page?" can't keep it open),
and, if it had sent a token, tells the server to drop that sign-in: each
sign-in has a random attempt id. The cancel is sent only once the connect
under way has finished (saved, refused or failed), so the server always
gets it after the connect; with nothing sent, there is nothing to cancel.
The server lists an attempt only while its
connect is being checked: cancelled then, it isn't saved; cancelled just
after its token was saved (even before the connect has finished tidying
up), that token is deleted (as a disconnect). A cancel
never removes an account a later sign-in or a pasted token saved, and one
for an unknown attempt changes nothing.
The page never sees the token: the desktop app hands it straight to the local
server's connect path and tells the page only the answer (connected, and your
Meta display name). It isn't logged anywhere.
The window keeps you signed in to Meta (in `persist:meta-signin` only) until
you **Disconnect**, which also signs out of Meta in Frame Control: it clears
that window's cookies, storage and cache, so the next sign-in starts fresh.
**Sign out of Meta in Frame Control** next to the sign-in button does just
that part, for a sign-in that didn't connect.
**Paste a token.** You copy your own token and paste it in. The two ways
BSManager's guide describes
([How to obtain your Oculus Token](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token)):
1. **From the Meta website.** Sign in at `https://secure.oculus.com`. Open the
browser's developer tools, then **Application** (Chrome, Edge) or
**Storage** (Firefox) → **Cookies** → `https://secure.oculus.com`. Copy the
value of `oc_ac_at`. It starts with `OC`.
2. **From the Meta Quest PC app** (Windows). Press **Ctrl+Shift+I**, open
**Network**, press **Ctrl+R**, type `graph` in the filter, open the first
request, and copy the access token from its **Payload**. It starts with
`FRL`.
Treat it like a password: anyone with it can act as your Meta account. Signing
out of the Meta website or app usually ends it; then connect again with a new
one.
Frame Control refuses values that can't be a user token before sending them
anywhere: anything not starting with `OC` or `FRL`, URL-encoded copies (with
`%`), app tokens (with `|` or `:`), cut-off values (under 20 characters), and
anything with characters other than letters, digits and `+ / = - _` (the
base64 and URL-safe base64 sets), or with no lower-case letter (real tokens are
mixed case). The public tools only describe tokens made
of letters and digits; if a real token needs another character, it has to be
added in one place (`TOKEN_CHARS` in `ui/frame_meta.py`), which also makes
the redaction look for it.
## Using it
1. Tick **I understand…**, then either choose **Sign in with Meta…** (desktop
app) or paste your token and choose **Connect**. Frame Control asks Meta
"who am I" with it (a read-only query); only a token Meta accepts is kept.
2. Your Quest games are listed with their version, size, game data, and the
community verdict from the compatibility database when there is one.
3. **Install** downloads the game and installs it on the Frame, as [Import
from my Quest](quest-import.md) does. A game with OBB game data then needs
a second **Install** while you have it open on the Frame: Frame Control
downloads the data, copies it in and closes the game.
4. **Install and test** does the same, then starts the game itself (twice if
it has game data to add: once to add it, once for the test), watches it for
about 30 seconds, and records what happened. **Test again** repeats only
the test.
5. **Disconnect** deletes the token from your computer and, in the desktop
app, signs out of Meta in Frame Control (clears the sign-in window's
storage). Games already on the Frame stay.
From a terminal (the token is read from standard input, never an argument):
```sh
python3 ui/frame_meta.py connect --token-stdin # paste the token, press Enter
python3 ui/frame_meta.py status
python3 ui/frame_meta.py list [--refresh] # owned Quest games, with community verdicts
python3 ui/frame_meta.py download APP_ID # into the local install cache (a later download clears it after 6 hours)
python3 ui/frame_meta.py install APP_ID [--test] [--allow-downgrade]
python3 ui/frame_meta.py test com.example.game [--wait 30] [--no-report]
python3 ui/frame_meta.py disconnect
```
The local API: `GET /api/meta` (connected or not, never the token),
`GET /api/meta?library=1[&refresh=1]` (the games), and `POST /api/meta` with
`{"action": "connect", "token": "...", "acknowledged": true}`,
`{"action": "disconnect"}`, `{"action": "cancel-signin", "attempt": "..."}`, `{"action": "install", "app": "APP_ID", "test": false,
"allowDowngrade": false}` or `{"action": "test", "package": "..."}`. Install and
test return a background job (`/api/job`). Sign in with Meta uses the same
`connect` action: the desktop app's main process (`app/meta-signin.js`) posts
it with the cookie's value and an `"attempt"` id (8 to 64 letters, digits or
`-`) once the page's acknowledgement box is ticked. A connect whose attempt
was cancelled while it was checked answers 409 and saves nothing;
`cancel-signin` for an attempt whose token is the saved account deletes it.
## What an install does
1. Checks the game is in your library (by its Meta app id), and that there's
room on this computer for the APK, a patched copy of it and the game data.
2. Downloads the APK from Meta's download server, within the size Meta
declared (and at most 2 GB), checking its SHA-256 if Meta gives one, and
that it is an APK of the package and version Meta said.
3. Runs Import from my Quest's checks. Refused: 32-bit-only builds (no
`arm64-v8a`), and apps that need an Android newer than Lepton's (API 30).
Installed with a warning: games that use Meta's licence check
(`libovrplatformloader.so`), and VrApi-only games ("may not run").
4. Installs it with `frame_android.install` (VR detection, the launcher fix,
the OpenXR compatibility layer, artwork, a Steam library entry). An older
copy never silently replaces a newer one on the Frame unless you tick
**Allow replacing a newer version**.
5. Game data: the app's OBB (saved as Meta names it when that is a standard
`main|patch.<version>.<package>.obb` name, otherwise as
`main.<version code>.<package>.obb`) and any *required* asset files with
standard OBB names, at most 8 GB each. One install downloads at most 16 GB
in all, APK included, counted as the bytes arrive (so files whose size Meta
doesn't give count too); a file that goes past it is stopped and removed.
The data is copied into the
running game by the same two-step helper Import from my Quest uses. Other
asset files (DLC packs, `.pak` files a game fetches through Meta's
Platform SDK) aren't downloaded; the result lists them.
6. Deletes everything it downloaded, whether the install worked or not.
Downloads left by a crash are removed after six hours.
## The test, and the report
The test starts the game, waits for its Lepton instance, then looks every 10
seconds for 30 seconds: is the game's process alive, and what does its Android
log (main, system and crash buffers) say. It recognises:
| In the log | Cause given |
|---|---|
| "entitlement check failed", "not entitled", `ovr_Entitlement…` or `IsUserEntitledToApplication` reporting a failure (a call that passes doesn't count) | Meta's licence check failed |
| Platform SDK initialisation errors | Meta's Platform SDK couldn't start |
| `Failed to launch SystemActivities`, "device not supported" | The game checks it is on a Meta Quest |
| `vrapi_… was called before…`, VrApi load errors | Legacy VrApi |
| `XR_ERROR_EXTENSION_NOT_PRESENT` (with the extension's name) | An OpenXR feature the Frame lacks |
| `XR_ERROR_API_VERSION_UNSUPPORTED`, runtime/initialisation failures | OpenXR version or session problems |
| `VK_ERROR_DEVICE_LOST`, `zink`, `libgallium_dri.so`, `GL_INVALID_…`, shader failures | Graphics driver errors |
| `ClipboardManager` | Lepton's missing clipboard service |
| `NoSuchMethodError`, `UnsatisfiedLinkError`, `dlopen failed` | Android or library features Lepton lacks |
| `FATAL EXCEPTION`, `Fatal signal`, `SIGSEGV` | A crash |
| `ANR in …`, "Input dispatching timed out" | Not responding |
| `XR_SESSION_STATE_SYNCHRONIZED/VISIBLE/FOCUSED`, `xrEndFrame` | The VR scene started |
The outcome is one of **runs** or **runs with issues** (its VR scene
started), **quits** (closed by itself), **crashes**, **hangs** (Android
reported it not responding; Frame Control then closes it, and says so if it
couldn't), **didn't start** (its instance never came up), or **no clear
result**: it stayed open but its log never showed its VR scene starting. That
can be a game still loading, or one stuck after a failed check; Frame Control
can't tell, so it doesn't record it. A game already open is closed first, so
the test starts clean; if it can't be closed, nothing is tested. If the Frame
fails while the test reads the game or its log, the test says only that it
couldn't read the log: a failed command's output can be the game's log, so it
is never shown. Signatures look at the first 4 KB of each log line and are
bounded, so a huge or hostile log can't make the test slow. They
are written for Frame Control from the research in [vr-apks.md](vr-apks.md);
they are not copied from other tools.
The result goes through the same path as **Test** and **Report**
(`frame_catalog.add_report`): `via` probe, source `meta-store`, the result
(`runs`, `crashes` or `instance_failed`, the values the database accepts), and
notes with the outcome, the cause, the names of the matched signals and the
version code, all fixed texts. Nothing else: no token, account name, file or
log text. The page may show a short name from the log next to the cause (an
OpenXR extension, a library, an exception class or a signal), taken from the
log line after the token is removed from it, and left out unless it looks like
a real name (64 characters at most, no long mixed letter-and-digit runs);
reports never carry it. It is always kept on this
computer; it reaches the shared database only if **Share compatibility
results** is on in Privacy settings, as for every report. The panel offers to
turn that on.
## Your token and privacy
- Kept in `meta-account.json` in Frame Control's per-user data folder
(`~/Library/Application Support/Frame Control` on macOS,
`%APPDATA%\Frame Control` on Windows, `~/.local/share/frame-control` on
Linux), written owner-only (0600) through a temporary file. Never on the
Frame.
- Sent only to `graph.oculus.com` (in the POST body) and
`securecdn.oculus.com` (in the download address, which is how that server
takes it), over HTTPS. A download that redirects elsewhere (Meta's CDN) is
followed only to a plain HTTPS address on port 443 that doesn't carry the
token in any form, anywhere (host, path, query or fragment; literal,
recased, percent-encoded, or 8+ characters of it), nor an `access_token`
parameter. That is checked before any lookup or connection, and a redirect
that fails it is refused. API answers that redirect are refused.
- Every connection goes to an address checked to be public (an allow-list:
no private, local, shared, reserved, multicast, NAT64, 6to4 or Teredo
addresses), and to exactly that address, with TLS checked for the host
name, so a second DNS answer can't swap in another. System proxy settings
aren't used. This is the same design as the itch.io purchases branch.
- Never in logs, error messages, API answers, the page or reports: every
message is cleaned first, matching the token (or any start of it of 8+
characters) in any letter case, with each character written literally or
percent-encoded once or twice (%XX, %25XX) in either case, and any other
token-shaped value, before it is shortened.
- Disconnect deletes the file and the library Frame Control holds in memory,
and stops a download under way. A connect still being checked when you
disconnect is not saved. In the desktop app, before the token is deleted, it
also stops any sign-in under way, destroys the sign-in window, waits for a
connect still under way to finish and cancels its attempt (deleting its
token if it was just saved), and only then clears the `persist:meta-signin` session
(cookies, storage, cache); no other session is touched. Sign in with Meta
stays off from the moment you press Disconnect until the token is deleted,
and the app refuses a sign-in while it is signing out.
- With Sign in with Meta, a second copy of your Meta sign-in lives in that
session's storage, in Electron's per-user data folder (`Partitions/meta-signin`),
until you disconnect or sign out of Meta in Frame Control.
## Where the protocol comes from
Frame Control's code is its own (MIT). These public sources describe the
protocol; Frame Control takes only facts from them (addresses, query ids,
field names), not code. QAVS and BSManager are GPL-3.0; OculusGraphQLApiLib,
DownQuest and Oculus-downgrader state no licence.
| Fact | Source |
|---|---|
| GraphQL at `https://graph.oculus.com/graphql`, POST, form fields `access_token`, `doc_id`, `variables`; `OAuthException` / code 190 for a bad token | [OculusGraphQLApiLib `GraphQLClient.cs`](https://github.com/ComputerElite/OculusGraphQLApiLib/blob/866081e2/OculusGraphQLApiLib/GraphQL/GraphQLClient.cs), [DownQuest `DownQuest.js`](https://github.com/ptrpaws/DownQuest/blob/50f5d558/DownQuest.js) |
| "Who am I": `doc_id` 4149322231793299, `data.viewer.user {id, alias}` | OculusGraphQLApiLib `GraphQLClient.cs` and `Results/User.cs` |
| Owned apps: `doc_id` 4850747515044496, `data.viewer.user.active_entitlements.nodes[].item` | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/Entitlement.cs`, `Results/Application.cs`; used by [QAVS `GameDownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/GameDownloadManager.cs) |
| One binary: `doc_id` 4734929166632773 with `binaryID`; fields `package_name`, `version`, `version_code`, `size`, `obb_binary`, `asset_files` (`file_name`, `size`, `is_required`) | OculusGraphQLApiLib `GraphQLClient.cs`, `Results/OculusBinary.cs`, `Results/AssetFile.cs` |
| Downloads: `https://securecdn.oculus.com/binaries/download/?id=<id>&access_token=<token>`, the same for the APK, `obb_binary` and asset files; QAVS takes the OBB and required asset files | OculusGraphQLApiLib `Game/GameDownloader.cs`, [QAVS `DownloadManager.cs`](https://github.com/ComputerElite/QuestAppVersionSwitcher/blob/36bebd5a/QuestAppVersionSwitcher/DownloadManager.cs), DownQuest `DownQuest.js` |
| Token checks (`OC`/`FRL`, no `%`, `|`, `:`, not `OC` + 15 digits) | OculusGraphQLApiLib `GraphQL/TokenTools.cs`, [BSManager `oculus.helpers.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/shared/helpers/oculus.helpers.ts) |
| How users get a token (`oc_ac_at` cookie, or the PC app's `FRL` token) | [BSManager wiki](https://github.com/Zagrios/bs-manager/wiki/How-to-obtain-your-Oculus-Token) |
| Sign in with Meta: open `https://secure.oculus.com` in an app window, and once it reaches `https://secure.oculus.com/my/profile` read the `oc_ac_at` cookie from that window's session (checked on `did-navigate`, `did-navigate-in-page`, `did-stop-loading`); 10-minute timeout | [BSManager `bs-oculus-downloader.service.ts`](https://github.com/Zagrios/bs-manager/blob/00380c79/src/main/services/bs-version-download/bs-oculus-downloader.service.ts) (`getUserTokenFromMetaAuth`) |
**Assumed, not seen in those sources:** that the entitlement and binary
answers carry the fields above in the shapes the libraries' models describe
(the stored queries are opaque); that the list isn't paginated (none of the
tools page it); that `ANDROID_6DOF` marks Quest apps; where downloads redirect
to (the tools let their HTTP client follow redirects; Frame Control follows
up to five, to any public HTTPS host, and refuses one carrying the token); and
a `sha256`
field (none was seen, so in practice only sizes are checked). The log
signatures for the test are inferred from the compatibility research, not
from a real Frame.
## Evidence and limits
**Verified locally** with a fake Meta and a fake Frame (`tests/test_meta.py`):
token checks; storage owner-only through a temporary file; redaction (the
stored token recased and percent-encoded in either case, starts of it, other
token-shaped values, and redaction before cutting); HTTPS-only URLs; the
public-address allow-list, resolving once and connecting only to a checked
address within one deadline; redirects off Meta's hosts refused when they
carry the token anywhere, before any lookup; the token never sent to the
CDN; GraphQL redirects refused; disconnect beating a connect in flight and
stopping a download; library parsing (Quest apps only, nothing expired or
inactive, details from the binary query); downloads with an OBB; size, length
and checksum limits and the whole-install budget with nothing left behind;
refusing apps not in the
library; the handoff to Import from my Quest's checks, install and data step;
the licence-check warning instead of a refusal; test outcomes from log
fixtures (a passing licence check isn't a failure; no VR scene is no clear
result, not recorded); closing before a test and saying when a close failed;
reports carrying only fixed texts, no token, log text or file names. The
real TLS connection code is the same as the itch.io branch's and is exercised
here only with a fake connection.
**Sign in with Meta, verified locally** with fake Electron windows, sessions
and cookies (`app/test/meta-signin.test.js`): the window's options (its own
partition, sandbox, context isolation, no Node, no preload) and Meta's login
address, no developer tools; permission requests and checks refused; the
navigation allow-list (links elsewhere open in the system browser, redirects
elsewhere are stopped, pop-ups never become windows); frames allowed any
HTTPS page but no other scheme, the window itself only Meta's sites; a
page's beforeunload not keeping the window open; the cookie read only from
`persist:meta-signin` and only for `oculus.com`, even when the default
session and another partition hold one; signed-out values ignored; a value
sent once, a refused one keeping the window open; the window closing after a
connect, with the page getting the answer and never the token; nothing
logged, and a server error that echoed the token scrubbed; closing the window
or signing out ending the wait; closing, timing out or signing out during a
connect cancelling that attempt (with its own id) after cookie intake stops
and only after that connect settles (saved, refused or failed), and no cancel
when nothing was sent; sign-out (Disconnect's first step) waiting for a
connect under way, even from a window closed earlier, before it finishes;
sign-out destroying the window before clearing only that partition; no
sign-in starting while a sign-out runs (it is refused: "try again in a
moment"), a second sign-out joining the first, and a sign-out waiting for
every window and connect started before it. The
frame redirects held to the frame rule, the window's to Meta's sites. The
server side (`tests/test_meta.py`): an attempt cancelled while Meta is
answering not saved, even after 300 other cancels; a cancel just after the
save deleting that token; a cancel never removing a later sign-in's or a
pasted token's account; unknown attempts changing nothing; no attempt left
listed after a connect, refused or not; attempt ids checked.
**Differences from BSManager:** BSManager uses its app's default session (and
clears only `.oculus.com` cookies afterwards); Frame Control uses a session of
its own and clears all of it on disconnect. BSManager waits for
`/my/profile`; Frame Control also reacts to the cookie changing, and to any
`oculus.com` page, and lets the server decide whether the value is a
working token.
**Not verified:** Sign in with Meta against Meta's real login page (no real
Meta account was used, and nothing was sent to Meta): whether Meta's login
still goes only through `oculus.com`, `meta.com` and `facebook.com`, whether
it uses pop-ups (they open in the same window here, which would break a
pop-up that reports back to its opener), whether `oc_ac_at` is still set on
`oculus.com` after login, and whether Meta accepts the window (Frame Control
removes Electron's name from the user agent as a precaution). A real Meta token and Meta's real answers (none was
available; nothing was sent to Meta), real downloads and their redirects,
real Quest games installed or tested on a real Frame, and whether the log
signatures match what real games print in Lepton.
+10 -2
View File
@@ -83,13 +83,21 @@ SteamOS and Lepton builds. Before sending:
- the notes, app name and version are scrubbed like error messages (see
below)
- the APK's source is kept only if it's `F-Droid` or the public host name of
a download link (`https://example.com/…`). File names, user names,
- the APK's source is kept only if it's `F-Droid`, `meta-store` (a test
from [My Meta library](meta-library.md)) or the public host name of a
download link (`https://example.com/…`). File names, user names,
passwords, ports, paths, IP addresses and local host names are dropped
When you turn this on, reports you made earlier on this computer are shared
too.
My Meta library (Experimental) keeps your Meta access token in
`meta-account.json` in this data folder, owner-only, and sends it only to
Meta's API and download hosts. It is never in analytics, reports, error
details or logs; its test reports carry the package, version, version code,
title, result and the reason, nothing from your account. See
[meta-library.md](meta-library.md#your-token-and-privacy).
The maintainer's `python3 ui/frame_compat_db.py sync` copies these events
into the compatibility database, marked `via=community…`. It takes at most
30 per reporter per day.
+208
View File
@@ -0,0 +1,208 @@
# 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 uses Meta's
licence check installs with a warning: on the Frame that check can't reach
Meta, so the game may quit, hang, or run without online features. See
[What won't run](#what-wont-run-and-why) below. To install games you own
straight from Meta instead, without a Quest, see [My Meta library
(Experimental)](meta-library.md).
## 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:
- 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).
Installed with a warning:
- games that use Meta's licence check (Meta's Platform SDK,
`libovrplatformloader.so`, in the base or any split): "uses Meta's
licence check; it may quit, hang, or run without online features on the
Frame. Frame Control won't remove the check." Linking the SDK doesn't
mean a game enforces the check: Meta's store requirement for it
(VRC.Quest.Security.1) has only been *Recommended* since 2021, and what
a game does when the check fails is up to the game;
- VrApi-only builds (`libvrapi.so`, no OpenXR loader), flagged as for any
APK: the Frame has no VrApi, so they may not 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 enforce their Meta store licence.** Many store games link
Meta's Platform SDK (`libovrplatformloader.so`), for the licence check and
for user ids, achievements, multiplayer, cloud saves or DLC. On the Frame the
SDK can't reach Meta's services, which only exist on Meta headsets. A game
that enforces the check then quits or hangs; one that doesn't may run
without its online features. Frame Control can't tell which from the APK, so
it installs these with a warning and **won't remove or work around the
check**, and doesn't bundle, call or link to any tool that does. If yours
quits, ask the developer for a build without the check, or get the game from
Steam or itch.io. Most SideQuest, itch.io and open-source games don't link
the SDK.
- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only. Refused.
- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi. They
install with a warning, but may not run.
- **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 and the licence-check warning, 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.
+9 -2
View File
@@ -214,9 +214,16 @@ 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: no patching, no stub
libraries, no OVRPort or FramePort. Linking the SDK doesn't mean an app
enforces the check (Meta's VRC.Quest.Security.1 has only been
*Recommended* since 2021, and a failed check does nothing by itself; the app
decides), so [Import from my Quest](quest-import.md) and [My Meta
library](meta-library.md) install these with a warning: they may quit,
hang, or run without online features.
- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
not a patch.
not a patch. They install with a warning that they may not run: some
OpenXR games ship `libvrapi.so` without depending on it.
## Frame Control does this for you
+100
View File
@@ -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))
+40
View File
@@ -0,0 +1,40 @@
// The Meta library's rows, run in node against the real functions from ui/index.html.
// Found in a live test with a real account (2026-10-14): sizes were cut off behind the
// package name, a required data file Frame Control can't place was not mentioned, and
// apps and environments were counted as "games".
import { readFileSync } from "fs";
const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
const lines = src.split("\n");
const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
function grab(name) {
const start = lines.findIndex(l => new RegExp(`^(?:(?:async )?function ${name}\\(|const ${name} = )`).test(l));
if (start < 0) fail(`no top-level ${name} in index.html`);
let depth = 0;
for (let i = start; i < lines.length; i++) {
for (const c of lines[i]) depth += c === "{" ? 1 : c === "}" ? -1 : 0;
if (depth <= 0) return lines.slice(start, i + 1).join("\n") + "\n";
}
fail(`unbalanced ${name}`);
}
const els = { metaCount: { textContent: "" }, metaApps: { innerHTML: "" } };
const metaLib = { connected: true, results: new Map(), apps: [
{ id: "1", title: "The Climb", package: "com.crytek.climb", version: "666.0", version_code: 666, size: 30639455,
data_files: 0, skipped_files: ["main.com.crytek.climb.obb"] },
{ id: "2", title: "Beat Saber", package: "com.beatgames.beatsaber", problem: "Meta's API said HTTP 500", skipped_files: [] },
] };
const render = new Function("$", "metaLib", "androidApps", "installing",
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
render(id => els[id], metaLib, [], new Set());
const html = els.metaApps.innerHTML;
if (els.metaCount.textContent !== "2 apps") fail("count: " + els.metaCount.textContent);
const climb = html.split('class="item"')[1];
if (!/<div class="s" title="[^"]*">29 MB · v666\.0 \(666\) · com\.crytek\.climb</.test(climb)) fail("size not first: " + climb);
if (!/Not installed: 1 required data file \(main\.com\.crytek\.climb\.obb\)/.test(climb)) fail("skipped data not shown: " + climb);
const saber = html.split('class="item"')[2];
if (!/<div class="s v-no"[^>]*>Meta&#39;s API said HTTP 500</.test(saber)) fail("problem not on its own line: " + saber);
if (/Not installed/.test(saber)) fail("warning without skipped files");
metaLib.apps = metaLib.apps.slice(0, 1);
render(id => els[id], metaLib, [], new Set());
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
console.log("ok");
+1266
View File
File diff suppressed because it is too large. Load diff
+1144
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -463,6 +463,11 @@ class PageScripts(unittest.TestCase):
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "store_detail.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
def test_meta_library_rows_render(self):
"""tests/page/meta_rows.mjs runs the real Meta library row rendering from index.html."""
r = subprocess.run(["node", str(ROOT / "tests" / "page" / "meta_rows.mjs")], capture_output=True, text=True)
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
@unittest.skipIf(os.name == "nt", "runs on the Frame (Linux); local-bin/ssh is a POSIX shell script")
class LocalMode(unittest.TestCase):
+353 -17
View File
@@ -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')
+44 -1
View File
@@ -118,7 +118,7 @@ def detection(data, names):
info, _ = inspect(data)
issues = []
if 'lib/arm64-v8a/libvrapi.so' in names:
issues.append("Uses Meta's legacy VrApi, which the Frame doesn't have; it won't run.")
issues.append("Uses Meta's legacy VrApi, which the Frame doesn't have; it may not run.")
if any(n.endswith('/libovrplatformloader.so') for n in names):
issues.append("Uses Meta's platform SDK; if it checks your Quest store licence it will quit.")
if 'lib/arm64-v8a/libopenxr_loader.so' in names:
@@ -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)
+1442
View File
File diff suppressed because it is too large. Load diff
+644
View File
@@ -0,0 +1,644 @@
"""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 link Meta's Platform SDK
(libovrplatformloader.so) install with a warning: linking it doesn't mean the game
enforces a licence check, but if it does the game may quit or hang on the Frame, and
Frame Control does not remove the check 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
# Linking the Platform SDK doesn't mean a game enforces the licence check (Meta's
# VRC.Quest.Security.1 has only been Recommended since 2021), so it's a warning, not a refusal.
ENTITLEMENT = ("Uses Meta's licence check (Platform SDK, libovrplatformloader.so); it may quit, hang, or run "
"without online features on the Frame. Frame Control won't remove the check.")
VRAPI = ("Uses Meta's legacy VrApi and no OpenXR loader. The Frame doesn't have VrApi, so it may not run.")
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 run on the Frame (32-bit only, Android newer than Lepton's, splits
that can't be merged), before anything is copied to it; warn about what may not run."""
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")
platform_sdk = any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names)
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 = [ENTITLEMENT] if platform_sdk else []
openxr = 'lib/arm64-v8a/libopenxr_loader.so' in names
if 'lib/arm64-v8a/libvrapi.so' in names and not openxr:
warnings.append(VRAPI)
return {'package': info['package'], 'label': label, 'version': info['version'],
'version_code': info.get('version_code'), 'abis': abis,
'vr': bool(info.get('vr') or openxr), 'platform_sdk': platform_sdk, '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
result = add_game_data(pkg, checked, [str(p) for p in obb_files], data_folder, report, warnings)
result['split'] = bool(splits)
return result
def add_game_data(pkg, checked, obb_files, data_folder, report, warnings=(), source='your Quest'):
"""Copy game data into the game open on the Frame, then close it: the second step of a
two-step install (Import from my Quest, and My Meta library). checked is inspect()'s
result for the APK the data belongs to; obb_files are local OBB paths, data_folder a
local Android/data/<package> folder or None. Raises QuestError, after closing the game,
if anything fails."""
warnings = list(warnings)
label = checked['label']
copied_obb, copied_files, failure = [], 0, None
# Fetching the data 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 {source}"
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': False, '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()
+1 -1
View File
@@ -486,7 +486,7 @@ def compat_report(report):
if k in p:
p[k] = scrub(p[k], n)
src = str(p.pop('source', '') or '')
if src == 'F-Droid':
if src in ('F-Droid', 'meta-store'): # fixed source tags, never a person's file name
p['source'] = src
elif src.startswith(('http://', 'https://')) and _scrub_url(src) != '<url>':
p['source'] = _scrub_url(src)
+253 -2
View File
@@ -398,8 +398,9 @@
.cat-card .s { color: var(--muted); font-size: 12px; }
.cat-card .why { color: var(--muted); font-size: 11.5px; line-height: 1.4; }
.cat-card .row { margin-top: auto; }
.v-works, .v-likely { color: var(--green-hi); } .v-maybe { color: var(--warn); }
.v-unlikely, .v-no { color: var(--bad); }
.v-works, .v-likely, .item .s.v-works, .item .s.v-likely { color: var(--green-hi); }
.v-maybe, .item .s.v-maybe { color: var(--warn); }
.v-unlikely, .v-no, .item .s.v-unlikely, .item .s.v-no { color: var(--bad); } /* over .item .s's grey */
.vbadge { font-size: 10.5px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; margin-left: auto; }
@media (max-width: 1100px) { .and-grid { grid-template-columns: 1fr; } }
/* ---- Steam games (owned + store) ---- */
@@ -958,6 +959,55 @@
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
use Meta's licence check install with a warning: on the Frame that check can't reach Meta, so they may quit,
hang, or run without online features. 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" id="metaLib">
<div class="shelf-head"><h2>My Meta library</h2><span class="tag">Experimental</span><span class="count" id="metaCount"></span><span class="spacer"></span>
<button class="small" id="metaRefresh" hidden title="Read your Meta library again">Refresh</button>
<button class="small" id="metaDisconnect" hidden title="Forget your Meta token on this computer (and sign out of Meta in Frame Control)">Disconnect</button></div>
<div class="sub" id="metaState" role="status" aria-live="polite">Off. Nothing is sent to Meta until you connect.</div>
<p class="hint" style="color:var(--warn)">Experimental. This uses Meta's undocumented internal API with your own Meta
access token. It may stop working whenever Meta changes things, and using it may be against Meta's terms; your
account is your responsibility. It only lists and downloads games your account owns, installs them as they are,
and never removes a licence check.</p>
<div id="metaConnectBox">
<label class="sub" style="display:block;margin-top:6px"><input type="checkbox" id="metaAck"> I understand, and want to
use my own Meta token with Frame Control</label>
<div id="metaSignInRow" hidden style="display:flex;gap:8px;align-items:center;margin-top:6px">
<button class="small" id="metaSignIn" disabled title="Opens Meta's own login page in a Frame Control window">Sign in with Meta…</button>
<span class="hint" style="margin:0">or paste your token:</span><span class="spacer"></span>
<button class="small" id="metaSignOut" title="Clear the Meta sign-in that Frame Control's sign-in window keeps">Sign out of Meta in Frame Control</button></div>
<div style="display:flex;gap:8px;margin-top:6px">
<input type="password" id="metaToken" autocomplete="off" spellcheck="false" style="flex:1"
placeholder="Your Meta access token (starts with OC or FRL)" aria-label="Meta access token" disabled>
<button class="small" id="metaConnect" disabled>Connect</button></div>
<div class="hint"><a href="https://github.com/saphid/frame-control/blob/main/docs/meta-library.md#your-token" target="_blank" rel="noopener noreferrer">How to get your token</a>.
Sign in with Meta (desktop app) opens Meta's login page in its own Frame Control window and takes the token
from there; it never reads your browser.
Frame Control checks it with Meta, then keeps it in a private file on this computer (never on the Frame, and
never in reports). Disconnect deletes it.</div>
</div>
<div class="sub" id="metaShare" hidden style="margin-top:6px"></div>
<label class="sub" id="metaOlderBox" hidden style="display:block;margin-top:4px"><input type="checkbox" id="metaOlder"> Allow replacing a newer version on the Frame</label>
<progress id="metaProgress" max="100" hidden aria-label="Install progress" style="width:100%;margin-top:8px"></progress>
<div class="list" id="metaApps" style="margin-top:8px"></div>
<div class="hint">Install downloads the game from Meta and installs it like Import from my Quest; a game with OBB
data takes a second Install while it is open. Install and test also starts it, adds its data, watches it for
about 30 seconds and records whether it ran; Test again does just the test. Results go to the compatibility
reports, shared only if you share compatibility results. <a href="https://github.com/saphid/frame-control/blob/main/docs/meta-library.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>
@@ -1435,6 +1485,8 @@ 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;
$("metaLib").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`;
@@ -3306,6 +3358,7 @@ async function loadAndroid() {
try {
androidApps = (await api("/api/android")).apps;
} catch (e) { return failed($("andApps"), e); }
if (metaLib.connected) renderMetaApps(); // "On the Frame" marks in My Meta library
$("andCount").textContent = androidApps.length ? `${androidApps.length} installed` : "";
$("andApps").innerHTML = androidApps.length ? androidApps.map(a => {
const c = cat.apps && cat.apps.find(x => x.p === a.package);
@@ -3329,6 +3382,204 @@ 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 use Meta's licence check install with a warning: they may quit or hang on the Frame.`;
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.`; } });
});
// ---- My Meta library, Experimental (frame_meta.py): the person's own token, games they own ----
// The token is only ever in the password box until Connect sends it; the server never sends it back.
const metaLib = { apps: [], results: new Map(), connected: false };
const META_VERDICT = { works: ["v-works", "Works on Frame"], maybe: ["v-maybe", "Works with problems"], no: ["v-no", "Doesn't work"] };
function metaShareNote(s) {
const box = $("metaShare");
if (!metaLib.connected || !s) { box.hidden = true; return; }
box.hidden = false;
box.innerHTML = s.shared ? "Test results are shared with the compatibility database (Privacy settings)."
: s.blocked ? `Test results are saved on this computer only (${esc(s.blocked)}).`
: `Test results are saved on this computer only. <button class="small" id="metaShareOn">Share test results</button>`;
const on = $("metaShareOn");
if (on) on.onclick = async () => {
if (!confirm("Share compatibility results?\n\nYour test results (package, version, whether it ran, and why not) go to "
+ "Frame Control's shared compatibility database, so everyone sees better verdicts. Never your token, "
+ "account or game files. You can turn it off in Privacy settings.")) return;
await setTelemetry({ compat: true });
loadMeta();
};
}
function renderMeta(d) {
metaLib.connected = !!d.connected;
$("metaConnectBox").hidden = metaLib.connected;
$("metaDisconnect").hidden = $("metaRefresh").hidden = $("metaOlderBox").hidden = !metaLib.connected;
$("metaState").textContent = metaLib.connected
? `Connected${d.user && d.user.name ? ` as ${d.user.name}` : ""}.` : "Off. Nothing is sent to Meta until you connect.";
metaShareNote(d.sharing);
}
function renderMetaApps() {
// Meta lists everything the account owns: games, but also apps and environments.
$("metaCount").textContent = metaLib.connected ? `${metaLib.apps.length} app${metaLib.apps.length === 1 ? "" : "s"}` : "";
if (!metaLib.connected) { $("metaApps").innerHTML = ""; return; }
const installed = new Set(androidApps.map(a => a.package));
$("metaApps").innerHTML = metaLib.apps.length ? metaLib.apps.map(a => {
// Sizes first: the line is cut short with "…" in a narrow panel, and the package is the least needed.
const bits = [a.size ? questSize(a.size) : "", a.data_files ? `OBB ${questSize(a.data_size)}` : "",
a.version ? `v${a.version}${a.version_code != null ? ` (${a.version_code})` : ""}` : "", a.package || "package unknown"];
const line = bits.filter(Boolean).join(" · ");
const problem = a.problem ? `<div class="s v-no" title="${esc(a.problem)}">${esc(a.problem)}</div>` : "";
// Required game data Frame Control can't place as an OBB (data_plan in frame_meta.py): the game may not run without it.
const skipped = (a.skipped_files || []).length
? `<div class="s v-maybe" title="${esc(a.skipped_files.join("\n"))}">Not installed: ${a.skipped_files.length} required data file${a.skipped_files.length === 1 ? "" : "s"} (${esc(a.skipped_files.join(", "))}); the game may not run without ${a.skipped_files.length === 1 ? "it" : "them"}.</div>` : "";
const v = a.verdict && META_VERDICT[a.verdict.verdict];
const verdict = v ? `<div class="s ${v[0]}" title="${esc((a.verdict.lines || []).join("\n"))}">Community: ${v[1]}</div>` : "";
const r = metaLib.results.get(a.package);
const result = r ? `<div class="s">${esc(r)}</div>` : "";
const busy = installing.has("meta:" + a.id) ? " disabled" : "";
const on = installed.has(a.package);
const can = a.package && !a.problem;
return `<div class="item"><div class="grow"><div class="t">${esc(a.title)}</div><div class="s" title="${esc(line)}">${esc(line)}</div>${problem}${skipped}${verdict}${result}</div>
${on ? `<span class="tag">On the Frame</span>` : ""}
${can ? `<button class="small" data-meta-install="${esc(a.id)}"${busy}>${on ? "Install again" : "Install"}</button>
<button class="small" data-meta-install="${esc(a.id)}" data-test="1"${busy} title="Install, start it, watch it for about 30 s and record whether it ran">Install and test</button>` : ""}
${on ? `<button class="small" data-meta-test="${esc(a.package)}"${busy} title="Start it again, watch it for about 30 s and record whether it ran">Test again</button>` : ""}</div>`;
}).join("") : `<div class="sub">No Quest apps in this Meta account.</div>`;
}
async function loadMeta(refresh = false) {
let d;
try { d = await api("/api/meta"); } catch (e) { $("metaState").textContent = e.message; return; }
renderMeta(d);
if (!d.connected) { metaLib.apps = []; renderMetaApps(); return; }
$("metaState").textContent += " Reading your library…";
try { d = await api(`/api/meta?library=1${refresh ? "&refresh=1" : ""}`); }
catch (e) { $("metaState").textContent = e.message; return; }
renderMeta(d);
metaLib.apps = d.apps || [];
renderMetaApps();
}
// Sign in with Meta: the desktop app only (app/meta-signin.js); a plain browser pastes the token.
const metaSignInOk = !!(window.frameApp && window.frameApp.meta && window.frameApp.meta.signIn);
$("metaSignInRow").hidden = !metaSignInOk;
let metaDisconnecting = false; // from pressing Disconnect until the server has deleted the token
$("metaAck").onchange = e => {
$("metaToken").disabled = $("metaConnect").disabled = !e.target.checked;
$("metaSignIn").disabled = !e.target.checked || metaDisconnecting;
};
$("metaSignIn").onclick = async () => {
if (!$("metaAck").checked || metaDisconnecting) return;
$("metaSignIn").disabled = true;
$("metaState").textContent = "Sign in to Meta in the window that opened…";
try {
const r = await window.frameApp.meta.signIn(true);
if (r && r.ok) { toast(r.message || "Connected"); loadMeta(); }
else { await loadMeta(); $("metaState").textContent = (r && r.error) || "Sign-in didn't finish."; }
} catch (e) { $("metaState").textContent = e.message; }
finally { $("metaSignIn").disabled = !$("metaAck").checked || metaDisconnecting; }
};
$("metaSignOut").onclick = async () => {
try { await window.frameApp.meta.signOut(); toast("Signed out of Meta in Frame Control"); }
catch (e) { toast(e.message, true); }
};
$("metaConnect").onclick = async () => {
const token = $("metaToken").value;
$("metaConnect").disabled = true;
try {
const d = await api("/api/meta", { action: "connect", token, acknowledged: $("metaAck").checked });
$("metaToken").value = "";
toast(d.message || "Connected");
loadMeta();
} catch (e) { $("metaState").textContent = e.message; }
finally { $("metaConnect").disabled = !$("metaAck").checked; }
};
$("metaDisconnect").onclick = async () => {
// One Disconnect at a time, guarded from before the confirm until the delete returns.
if (metaDisconnecting) return;
metaDisconnecting = true; $("metaDisconnect").disabled = $("metaSignIn").disabled = true;
if (!confirm("Disconnect your Meta account? Its token is deleted from this computer. Games already installed stay."
+ (metaSignInOk ? "\n\nThis also signs out of Meta in Frame Control (its Sign in with Meta window), so the next sign-in starts fresh." : ""))) {
metaDisconnecting = false; $("metaDisconnect").disabled = false;
$("metaSignIn").disabled = !$("metaAck").checked;
return;
}
// No sign-in from here until the token is deleted. Sign-out first: it stops the sign-in
// window (and any connect it started) before the account is forgotten.
try {
if (metaSignInOk) {
try { await window.frameApp.meta.signOut(); } catch (e) { toast(`Couldn't sign out of Meta in Frame Control: ${e.message}`, true); }
}
try { const d = await api("/api/meta", { action: "disconnect" }); toast(d.message || "Disconnected"); }
catch (e) { toast(e.message, true); }
} finally { metaDisconnecting = false; $("metaDisconnect").disabled = false; }
$("metaAck").checked = false; $("metaToken").disabled = $("metaConnect").disabled = $("metaSignIn").disabled = true;
metaLib.results.clear(); loadMeta();
};
$("metaRefresh").onclick = () => loadMeta(true);
$("metaApps").addEventListener("click", async e => {
const b = e.target.closest("[data-meta-install],[data-meta-test]");
if (!b) return;
const bar = $("metaProgress");
if (b.dataset.metaTest) {
const pkg = b.dataset.metaTest, key = "meta:" + (metaLib.apps.find(a => a.package === pkg) || {}).id;
if (!confirm("Test it now?\n\nIt opens in the headset for about 30 seconds and records whether it stays up.")) return;
b.disabled = true;
const r = await runJob(`Test ${pkg}`, key, () => api("/api/meta", { action: "test", package: pkg }));
if (r && r.message) metaLib.results.set(pkg, r.message);
renderMetaApps(); loadReports();
return;
}
const app = metaLib.apps.find(a => a.id === b.dataset.metaInstall), test = !!b.dataset.test;
if (!app || installing.has("meta:" + app.id)) return;
if (test && !confirm(`Install and test ${app.title}?\n\nFrame Control starts it in the headset (twice if it has game data to add), `
+ "watches it for about 30 seconds and records whether it ran.")) return;
b.disabled = true; bar.hidden = false; bar.value = 0;
const r = await runJob(`${test ? "Install and test" : "Install"} ${app.title} from Meta`, "meta:" + app.id,
() => api("/api/meta", { action: "install", app: app.id, test, allowDowngrade: $("metaOlder").checked }),
j => { if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; if (j.percent != null) bar.value = j.percent; });
bar.hidden = true;
if (r && r.message) { $("metaState").textContent = r.message; metaLib.results.set(app.package, r.test ? r.test.message : r.message); }
else $("metaState").textContent = `${app.title} wasn't installed; the reason is in the message log below.`;
loadAndroid(); renderMetaApps(); if (test) loadReports();
});
loadMeta();
async function loadCatalog() {
try { cat.apps = (await api("/api/android/catalog")).apps; }
catch (e) { $("catGrid").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
+150 -1
View File
@@ -57,6 +57,8 @@ 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_meta # noqa: E402
import frame_report # noqa: E402
import frame_store # noqa: E402
import frame_telemetry # noqa: E402
@@ -1325,6 +1327,149 @@ 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)
# ---- My Meta library, Experimental (frame_meta.py, docs/meta-library.md) ------
#
# Never echoes the token: answers come from frame_meta.status() and _public(), and every
# error passes through frame_meta.redact().
META_APP_ID = re.compile(r"^[0-9]{1,25}$")
def _meta_failure(e):
if isinstance(e, frame_meta.SignInCancelled):
return Failure(frame_meta.redact(e, 600), 409)
status = 400 if isinstance(e, frame_meta.TokenRejected) or "token" in str(e).lower() else 502
return Failure(frame_meta.redact(e, 600), status)
def meta_sharing():
"""Whether test results reach the shared compatibility database (Privacy settings)."""
blocked = frame_telemetry.blocked()
return {"shared": bool(frame_catalog.compat_db.shared() or frame_telemetry.enabled("compat")),
"blocked": blocked or None}
def meta_status(query):
"""The connected account (never its token) and, with library=1, the Quest games it owns
with any community verdicts."""
q = parse_qs(query)
out = dict(frame_meta.status(), sharing=meta_sharing())
if out["connected"] and q.get("library") == ["1"]:
try:
apps = frame_meta.with_verdicts(frame_meta.library(refresh=q.get("refresh") == ["1"]))
out["apps"] = [frame_meta._public(a) for a in apps]
except frame_android.FrameError as e:
raise _meta_failure(e) from None
return out
def meta(body):
"""{"action": "connect", "token", "acknowledged": true, "attempt"?} | {"action": "disconnect"} |
{"action": "cancel-signin", "attempt"} |
{"action": "install", "app", "test"?, "allowDowngrade"?} | {"action": "test", "package"}."""
action = body.get("action")
try:
if action == "connect":
if body.get("acknowledged") is not True:
raise Failure("My Meta library is experimental: tick that you understand the warning first", 400)
if not isinstance(body.get("token"), str):
raise Failure("paste your Meta access token", 400)
attempt = body.get("attempt")
if attempt is not None and not (isinstance(attempt, str) and frame_meta.SIGNIN_ATTEMPT.fullmatch(attempt)):
raise Failure("invalid sign-in attempt id", 400)
return dict(frame_meta.connect(body["token"], attempt=attempt), sharing=meta_sharing(),
message="Connected your Meta account (Experimental)")
if action == "cancel-signin":
attempt = body.get("attempt")
if not (isinstance(attempt, str) and frame_meta.SIGNIN_ATTEMPT.fullmatch(attempt)):
raise Failure("invalid sign-in attempt id", 400)
return dict(frame_meta.cancel_signin(attempt), sharing=meta_sharing(),
message="Cancelled that Sign in with Meta; it won't be saved")
if action == "disconnect":
return dict(frame_meta.disconnect(), sharing=meta_sharing(),
message="Disconnected your Meta account; its token was deleted from this computer")
if action == "install":
app_id, test, older = str(body.get("app", "")), body.get("test", False), body.get("allowDowngrade", False)
if not META_APP_ID.match(app_id):
raise Failure("invalid Meta app id", 400)
if type(test) is not bool or type(older) is not bool:
raise Failure("test and allowDowngrade must be true or false", 400)
ensure_master()
def work(report):
try:
return frame_meta.install(app_id, test=test, progress=report, allow_downgrade=older)
except frame_android.FrameError as e:
raise frame_meta.MetaError(frame_meta.redact(e, 1200)) from None
except Exception as e: # never let an unexpected error's text carry the token
raise frame_meta.MetaError(frame_meta.redact(f"{type(e).__name__}: {e}", 600)) from None
return start_job(f"{'Install and test' if test else 'Install'} {app_id} from Meta", work, progress=True)
if action == "test":
pkg = str(body.get("package", ""))
if not frame_android.PKG_RE.match(pkg):
raise Failure("invalid Android package id", 400)
ensure_master()
def test():
# run_test's own errors are fixed texts; anything else could carry the game's
# log (an ssh error is its output), so it is never shown, not even redacted.
try:
return frame_meta.run_test(pkg)
except frame_meta.MetaError:
raise
except frame_android.FrameError:
raise frame_meta.MetaError(frame_meta.LOG_UNREADABLE) from None
except Exception as e:
raise frame_meta.MetaError(f"The test stopped unexpectedly ({type(e).__name__}); "
"try again.") from None
return start_job(f"Test {pkg}", test)
except frame_meta.MetaError as e:
raise _meta_failure(e) from None
raise Failure("unknown action", 400)
# 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 +2382,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/meta": meta, "/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 +2601,10 @@ 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/meta":
self.send_json(meta_status(url.query))
elif path == "/api/titles":
ensure_master()
titles_list = frame_titles.list_titles()