mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-18 02:01:20 +02:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b3cbf705 | ||
|
|
e89912d936 | ||
|
|
4c27a3397c | ||
|
|
6aab62983e | ||
|
|
bcdd9d0830 | ||
|
|
ddd2d1c295 | ||
|
|
8f2e34a40e | ||
|
|
351230fcad | ||
|
|
5b35e9cc5d | ||
|
|
671c4c06e7 | ||
|
|
443813a088 | ||
|
|
92a68285b5 | ||
|
|
5878c45ddb | ||
|
|
ba0ac6022b | ||
|
|
8b9bd2119c | ||
|
|
d773babaa3 | ||
|
|
a8888cb2fc | ||
|
|
1f51cc89d2 | ||
|
|
bcdb4c3b78 | ||
|
|
e9b02d61e7 | ||
|
|
02419ffbd4 | ||
|
|
5db9631571 | ||
|
|
c060df4b50 | ||
|
|
c7125bc3de | ||
|
|
9c33711e6f | ||
|
|
9d92396d49 | ||
|
|
be2eda8304 | ||
|
|
f5bed08931 | ||
|
|
e144325375 | ||
|
|
291b8ec295 | ||
|
|
0fc6fbe596 | ||
|
|
f2261bac1c | ||
|
|
7c45d0ff24 | ||
|
|
19233fbf7d | ||
|
|
3ef978e232 | ||
|
|
8afa1113c9 | ||
|
|
7fe7e504da | ||
|
|
f678b4c8a2 | ||
|
|
33e4d2c092 | ||
|
|
621330d397 | ||
|
|
15e14c0d47 | ||
|
|
024ec593f9 | ||
|
|
0373555519 | ||
|
|
3539215517 | ||
|
|
9f3fb89e30 | ||
|
|
dc7854d10b | ||
|
|
73b97fedc5 | ||
|
|
924e37aa30 | ||
|
|
bd24436fbc | ||
|
|
895cc0ebbd | ||
|
|
f7dbad500a | ||
|
|
3f20e5f9d3 | ||
|
|
8051687e1e | ||
|
|
5a6b925448 | ||
|
|
60ade8c2e8 | ||
|
|
aa1ac7368c | ||
|
|
46b6966cf7 | ||
|
|
be2e770e66 | ||
|
|
597f98af6d | ||
|
|
ac07efa8f1 | ||
|
|
0f4f0b863f | ||
|
|
e7c81733b1 |
No files matched your search
@@ -5,6 +5,11 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
# A newer push to the same pull request supersedes a run still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -17,7 +22,9 @@ jobs:
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: Script syntax
|
||||
run: |
|
||||
sh -n ui/local-bin/ssh
|
||||
@@ -84,6 +91,8 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install zsh
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -qq zsh
|
||||
# The runner image's third-party apt sources (packages.microsoft.com) sometimes
|
||||
# fail to refresh; Ubuntu's own lists still update, and the install fails if not.
|
||||
run: (sudo apt-get update -qq || true) && sudo apt-get install -y -qq zsh
|
||||
- name: End-to-end tests
|
||||
run: scripts/e2e.sh
|
||||
@@ -2,14 +2,22 @@ name: release
|
||||
|
||||
# Pushing a v* tag builds Frame Control for macOS, Windows and Linux and attaches
|
||||
# the installers to that tag's GitHub release (created as a draft if missing).
|
||||
# Pull requests that touch the app build the same installers as artifacts.
|
||||
# Pull requests that change how the installers are built (the Electron app, the
|
||||
# macOS helper it compiles, or this workflow) build the same installers as
|
||||
# artifacts. ui/, scripts/, frame/ and apk-catalog/ are copied in as they are and
|
||||
# are tested by the checks workflow; run this one by hand to package them early.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
paths: ["app/**", "ui/**", "scripts/**", "frame/**", "apk-catalog/**", ".github/workflows/release.yml"]
|
||||
paths: ["app/**", "mac/frame-mac-view/**", ".github/workflows/release.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same pull request supersedes a build still in progress.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -94,7 +102,9 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "${GITHUB_REF_NAME}" ${{ matrix.files }} --clobber
|
||||
# Tagged builds are attached to the release above; only other builds need artifacts.
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
with:
|
||||
name: frame-control-${{ matrix.os }}
|
||||
path: |
|
||||
|
||||
@@ -73,7 +73,7 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
|
||||
Browse the shots you take in the headset and save them to your Pictures folder.
|
||||
|
||||
**⌨️ Keyboard and trackpad**<br>
|
||||
Type and point in the Frame's apps from your computer or phone, through KDE Connect, which Frame Control brings along and sets up on the Frame. Nothing else to install, anywhere.
|
||||
Type and point in the headset from your computer or phone, through Valve's own input path, with nothing to install. Accents and emoji use KDE Connect, which Frame Control brings along and sets up the first time you type one.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -213,7 +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 don't |
|
||||
| [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 |
|
||||
@@ -223,7 +224,7 @@ Frame's software fits together, all checked against a real headset and labelled
|
||||
| [Frame Control for iPhone](docs/iphone.md) | The iPhone and iPad app, how it runs the server on the Frame, pairing |
|
||||
| [Recovery and OS images](docs/recovery-and-images.md) | Where to download the Frame's OS, what's inside, testing without the headset |
|
||||
| [AI agents and assistant](docs/agents.md) | Key-free MCP tools, human approvals, and an opt-in assistant panel |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, and the headset smoke test |
|
||||
| [Testing](docs/testing.md) | Unit tests, end-to-end tests against a fake Frame in Docker, the headset smoke test and a Windows test VM |
|
||||
| [Open questions](docs/open-questions.md) | What's still unchecked |
|
||||
|
||||
<details>
|
||||
|
||||
+46
-3
@@ -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);
|
||||
|
||||
@@ -115,10 +116,11 @@ function ping(target) {
|
||||
}
|
||||
|
||||
async function startServer() {
|
||||
// The version and whether this is a built app go to ui/frame_telemetry.py, which
|
||||
// sends nothing from a source checkout.
|
||||
// The version, the time zone and whether this is a built app go to ui/frame_telemetry.py,
|
||||
// which sends nothing from a source checkout.
|
||||
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
|
||||
FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
|
||||
FRAME_CONTROL_TIMEZONE: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
|
||||
...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
|
||||
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
|
||||
python = await findPython(env);
|
||||
@@ -347,6 +349,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
|
||||
|
||||
@@ -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 };
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frame-control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"electron": "^44.4.5",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frame-control",
|
||||
"productName": "Frame Control",
|
||||
"version": "0.4.5",
|
||||
"version": "0.4.7",
|
||||
"description": "Desktop app for managing a Valve Steam Frame over SSH",
|
||||
"private": true,
|
||||
"main": "main.js",
|
||||
@@ -39,6 +39,7 @@
|
||||
"preload.js",
|
||||
"install-link.js",
|
||||
"updater.js",
|
||||
"meta-signin.js",
|
||||
"package.json",
|
||||
"build/icon.png"
|
||||
],
|
||||
@@ -50,6 +51,7 @@
|
||||
"*.py",
|
||||
"*.html",
|
||||
"*.js",
|
||||
"*.css",
|
||||
"telemetry.json"
|
||||
]
|
||||
},
|
||||
|
||||
+8
-1
@@ -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 }));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
+59
-3
@@ -17,11 +17,12 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
||||
|
||||
## Features
|
||||
|
||||
The window has five tabs: **Home** (headset view, status, screenshots),
|
||||
The window has six tabs: **Home** (headset view, status, screenshots),
|
||||
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||
Flatpaks, remote and power), **Devices** (your headsets and their addresses),
|
||||
and **Control** (the searchable Control Center, metrics, supported settings and
|
||||
headset guidance). Keys 1–6 switch between them. Files can be dropped anywhere in the window. A
|
||||
connection pill in the header always shows which headset, which network this
|
||||
computer is on, the address in use or being tried, and each step of connecting
|
||||
as it happens; click it for the whole timeline. When the Frame can't be
|
||||
@@ -96,6 +97,61 @@ counts them while they run.
|
||||
shut down open a terminal window because SteamOS asks for the sudo password
|
||||
over SSH.
|
||||
|
||||
## Control Center
|
||||
|
||||
Open **Control** or `#control` to search controls, status and help. Categories
|
||||
cover device/audio, VR/comfort, apps/files, connection/remote and Frame Control
|
||||
preferences. Search is local: it matches labels, descriptions and familiar terms
|
||||
such as “DPI”, “microphone”, “recenter” and “keep awake”. Queries are not sent to
|
||||
Steam, app catalogues, analytics or models. Choosing a workflow link opens its
|
||||
existing screen; it does not install, cast, open a helper or change a setting.
|
||||
Home, Games, Android, Tools and Devices retain their existing features.
|
||||
|
||||
The overview and detail cards use the shared `/api/status` sample: charge and
|
||||
power, user/system storage, memory, Wi-Fi, IP, OS/build, uptime, detected services
|
||||
and VR timings. Values refresh with the normal 30-second poll. A failed status
|
||||
request, lost connection, headset switch or sample received more than 90 seconds
|
||||
ago clears current readings and disables audio writes. The client receipt time
|
||||
and failed-read state are retained through page startup and UI redraws. Only a
|
||||
new successful status read clears a failure; reusing the retained sample does not.
|
||||
The headset timestamp is shown without assuming
|
||||
its clock matches this computer. Missing sensors show **Unavailable**;
|
||||
they are not converted to zero. Compositor FPS is separate from application FPS;
|
||||
GPU time is render time, not GPU utilisation. The temperature is the hottest
|
||||
readable sensor. Thermal trip warnings use per-zone kernel hot/critical limits,
|
||||
not a guessed universal temperature threshold.
|
||||
|
||||
**Audio** controls the default output sink through the existing `/api/volume`
|
||||
endpoint. Moving its slider prepares a value; **Apply volume** sends it. **Mute**
|
||||
and **Unmute** send explicit mute states. The UI captures the selected headset,
|
||||
blocks duplicate/pending actions, and requests status after command acknowledgment
|
||||
for readback, draining an earlier poll first. Mute preserves an unapplied volume
|
||||
draft; switching headsets discards it. It reports
|
||||
unconfirmed or differing output separately from confirmed state. API validation
|
||||
rejects invalid types, out-of-range levels and invalid mute states before SSH.
|
||||
|
||||
Other settings link to the existing forms and their validation, progress,
|
||||
confirmations and reset behavior: per-instance Android resolution/density/text,
|
||||
comfort sessions and alerts, media layouts, Mac streaming quality, privacy,
|
||||
sources and artwork. Android display links reveal **On your Frame** before
|
||||
scrolling to the form. Mac streaming remains searchable on all platforms, with
|
||||
its action shown only when the server is on macOS.
|
||||
|
||||
Hardware guidance is deliberately separate from controls. Microphone/routing,
|
||||
brightness, refresh selection, optics, controllers, Bluetooth, passthrough,
|
||||
tracking-origin changes, saved spatial layouts, firmware updates and recovery
|
||||
have no verified remote setting here. Use the headset's own controls or the
|
||||
documented manual procedures. Movement comfort remains per-game; automatic
|
||||
sleep is in Steam Settings → Power → Sleep after inactivity. Keep-awake and
|
||||
Android data backup/restore remain script/MCP capabilities, not new UI switches.
|
||||
|
||||
**Verification:** the Control Center's JavaScript runs under Node with mocked
|
||||
API and DOM boundaries; the server tests mock SSH and reject invalid values
|
||||
before any device command. Packaging tests cover the shared CSS/JS in desktop
|
||||
and iPhone bundles. These checks establish wiring, search, navigation, offline,
|
||||
error and device-switch behavior; they do not establish wearer experience,
|
||||
physical settings persistence or a new hardware capability.
|
||||
|
||||
## How it works
|
||||
|
||||
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 374 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 483 KiB |
@@ -0,0 +1,371 @@
|
||||
# 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. Choose **Install** on as many games as you like: they wait in the
|
||||
**Install queue** and install one after another, with Quest imports and
|
||||
Android store installs. **Remove** takes one out before it starts.
|
||||
6. **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`); an install waits its turn in the
|
||||
install queue (`/api/queue`, see [Import from my Quest](quest-import.md#using-it)). 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.
|
||||
+20
-5
@@ -33,6 +33,13 @@ computer, exactly as they were sent.
|
||||
yours.
|
||||
- Every event includes the app version, OS name (macOS, Windows or Linux),
|
||||
CPU architecture and Python version.
|
||||
- `app_installed`, `app_updated` and `app_opened` carry `country`: a
|
||||
two-letter code such as `DE`, worked out on your computer from its time zone
|
||||
setting (`Europe/Berlin` becomes `DE`), so we can see roughly where Frame
|
||||
Control is used without knowing your address. The time zone itself isn't
|
||||
sent, and a setting that isn't a country's zone (such as UTC) sends nothing.
|
||||
Copies that showed the first-run notice before it mentioned this (before
|
||||
0.4.6) show it again, and don't send a country until it has been seen.
|
||||
- `frame_connected` carries `headset_id`: the first 16 hex digits of a
|
||||
SHA-256 hash of the headset's `/etc/machine-id`, worked out on the headset,
|
||||
so the machine-id itself never leaves it. It is the same for one headset on
|
||||
@@ -44,9 +51,9 @@ computer, exactly as they were sent.
|
||||
|
||||
| Event | When | Properties besides the common ones |
|
||||
|---|---|---|
|
||||
| `app_installed` | First run | |
|
||||
| `app_updated` | First run of a new version | `from_version` |
|
||||
| `app_opened` | At most once a day | |
|
||||
| `app_installed` | First run | `country` |
|
||||
| `app_updated` | First run of a new version | `from_version`, `country` |
|
||||
| `app_opened` | At most once a day | `country` |
|
||||
| `frame_connected` | The first time a SteamOS build is seen on each headset | `steamos_build`, `steamos_version`, `headset_id`, `headsets_saved` |
|
||||
| `tab_viewed` | The first click on each tab in a session | `tab` |
|
||||
| `install_finished` | Any install finishes, working or not | `kind` (apk, flatpak, steam, title, web), `ok`, `seconds`, `error_category` (only when `ok` is false), `installer_code`, `xr_layer_missing`, and see below |
|
||||
@@ -83,13 +90,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.
|
||||
|
||||
+43
-19
@@ -6,9 +6,12 @@ adb, checks it can run in Lepton, and installs it on the Frame like any other
|
||||
APK, with its own Lepton instance and Steam library entry
|
||||
([apks.md](apks.md), [vr-apks.md](vr-apks.md)).
|
||||
|
||||
There is no licence-check bypass, and there won't be. A game that checks its
|
||||
Meta store licence is refused, with the reason. See
|
||||
[What won't run](#what-wont-run-and-why) below.
|
||||
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
|
||||
|
||||
@@ -42,6 +45,13 @@ 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.
|
||||
|
||||
You can choose **Import** on several games at once: they wait in the **Install
|
||||
queue** (above the Quest panel) and import one after another, together with
|
||||
installs from the Android store and My Meta library. **Remove** takes a game
|
||||
out of the queue before it starts. Each queued import runs on the headset that
|
||||
was selected when you queued it; if you switch headsets first, it stops with a
|
||||
message instead.
|
||||
|
||||
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
|
||||
@@ -66,7 +76,11 @@ 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.
|
||||
which returns a background job (`/api/job`) with a stage and percentage. The
|
||||
job waits in the install queue first (`"queued": true`, stage `Queued: N
|
||||
installs before it`). `GET /api/queue` lists the running install and those
|
||||
waiting; `POST /api/queue` with `{"action": "remove", "job": "..."}` removes a
|
||||
waiting one (409 once it has started).
|
||||
|
||||
## What an import does
|
||||
|
||||
@@ -84,13 +98,19 @@ which returns a background job (`/api/job`) with a stage and percentage.
|
||||
into a per-user cache folder (`Frame Control/quest-import` under the
|
||||
system's cache folder).
|
||||
4. **Checks the APK before anything else is copied**, refusing:
|
||||
- Meta's Platform SDK licence check (`libovrplatformloader.so`, in the base
|
||||
or any split);
|
||||
- 32-bit-only builds (native code but no `arm64-v8a`);
|
||||
- apps that need an Android newer than Lepton's (API 30);
|
||||
- split APKs Frame Control can't merge (below).
|
||||
VrApi-only builds (`libvrapi.so`, no OpenXR loader) are flagged, as for any
|
||||
APK: they install, but won't run.
|
||||
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
|
||||
@@ -153,16 +173,20 @@ copy signed by the publisher.
|
||||
|
||||
## What won't run, and why
|
||||
|
||||
- **Games that check their Meta store licence.** Store games call Meta's
|
||||
Platform SDK (`libovrplatformloader.so`) to confirm you bought them. That
|
||||
needs Meta's services, which only exist on Meta headsets, so on the Frame
|
||||
the game quits. Frame Control refuses these and **won't remove or work
|
||||
around the check**, and doesn't bundle, call or link to any tool that does.
|
||||
Ask the developer for a build without it, or get the game from Steam or
|
||||
itch.io. Most Meta Store games have it; most SideQuest, itch.io and
|
||||
open-source games don't.
|
||||
- **32-bit games** (Quest 1 era): the Frame runs 64-bit ARM only.
|
||||
- **VrApi-only games**: the Frame has OpenXR, not Meta's older VrApi.
|
||||
- **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)).
|
||||
@@ -179,7 +203,7 @@ Only import games you own, for your own use.
|
||||
**Verified locally** with a fake adb (`tests/fakeadb.py`, `tests/test_quest.py`):
|
||||
device detection (Quest, the Frame's tunnel, the Frame over USB, other
|
||||
Android devices, unauthorised, no permissions, offline, none, several),
|
||||
package listing and split detection, the refusals, the order of copies
|
||||
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
|
||||
|
||||
+21
-4
@@ -21,6 +21,20 @@ The confidence labels are the same as in [ssh.md](ssh.md).
|
||||
| **ADB + scrcpy (Lepton only)** | A mirror of the Android container | **Guess** | `brew install scrcpy android-platform-tools`, then `adb connect frame.local:5555` while Lepton Development is running ([adb_lepton](https://partner.steamgames.com/doc/steamhardware/steamframe/adb_lepton)), then `scrcpy`. This only shows Android apps, not SteamOS. |
|
||||
| VNC server on the Frame (krfb / wayvnc) | A mirror of the Plasma desktop | **Inferred (SteamOS)** | Deck users run krfb in Desktop Mode ([one.vg](https://one.vg/blog/remote-control-your-steam-deck)). On the Frame, the in-headset desktop is a virtual screen, and krfb isn't known to be preinstalled. RDP and Steam Link cover this case, so it's not recommended. |
|
||||
|
||||
**RDP from Windows, verified 2026-09-30:** Windows 11 25H2's Remote Desktop
|
||||
(`mstsc`) against BUILD_ID 20260925.6191901. xrdp picks TLS, not NLA, so
|
||||
Remote Desktop never asks for a user or password. It warns that the certificate
|
||||
(`www.xrdp.org`) can't be verified. After **Yes**, xrdp shows its own "Login to
|
||||
frame" box with the username blank. Any user but `steamos` gets "User does not
|
||||
exist, or could not be authenticated". Signing in as `steamos` with the
|
||||
Developer Mode password opens a Plasma (X11) desktop within about 6 seconds.
|
||||
It's a new session on display `:10`, separate from what the headset shows,
|
||||
and it uses about 1.3 GB of the Frame's memory. Closing Remote Desktop leaves
|
||||
it running, and the next sign-in reconnects to it. To end it, find its
|
||||
session with `loginctl list-sessions` over SSH (its leader is `xrdp-sesexec`)
|
||||
and run `loginctl terminate-session <id>`; the headset's own session keeps
|
||||
running.
|
||||
|
||||
**Recommendation for A:** start with Steam Link for macOS, because Valve
|
||||
documents it. Use Windows App (RDP) when you want a proper Linux desktop on the
|
||||
Mac with keyboard, mouse, and clipboard.
|
||||
@@ -137,10 +151,13 @@ two fingers to scroll, two-finger tap to right-click) plus a text field that
|
||||
types on the Frame. On a computer, clicking the pad passes your mouse and
|
||||
keyboard through to the Frame until you press Esc (⌘ is sent as Ctrl on a Mac).
|
||||
|
||||
It goes through **KDE Connect**, the first-party route (KDE makes the Frame's
|
||||
desktop): Frame Control's server runs [`ui/frame_input_agent.py`](../ui/frame_input_agent.py)
|
||||
on the Frame, which talks KDE Connect's own LAN protocol to the Frame's
|
||||
`kdeconnectd` as if it were a phone. KDE Connect does the typing and clicking.
|
||||
Since Control (below), the pad goes through **gamescope's own input socket**
|
||||
([`ui/frame_touch.py`](../ui/frame_touch.py)), so it reaches whichever panel has
|
||||
focus, Steam's own menus included, and needs nothing installed on the Frame.
|
||||
Only characters that aren't on a US keyboard (accents, emoji) go through
|
||||
**KDE Connect** as below; it starts the first time you type one. (Before
|
||||
Control, everything went through KDE Connect, which is why the notes below
|
||||
describe it in detail.)
|
||||
|
||||
**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
|
||||
|
||||
|
||||
+104
@@ -8,6 +8,7 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
| Unit tests (`tests/*.py`) | `python3 -m unittest discover -s tests` | Nothing | Parsing, validation, request guards; SSH and HTTP are mocked |
|
||||
| Fake Frame (`tests/e2e`) | `scripts/e2e.sh` | Linux with Docker | The real server and scripts against a container that behaves like a Frame |
|
||||
| Headset smoke test | `scripts/frame-smoke.sh` | A Frame on the `frame` alias | Install, launch and remove on the real device, recorded with its BUILD_ID |
|
||||
| [Windows test VM](#windows-test-vm) | `scripts/windows-vm.sh` | A Linux machine with KVM and Docker | The Windows build on a real Windows desktop, against a real Frame when needed |
|
||||
|
||||
## Unit tests
|
||||
|
||||
@@ -15,6 +16,29 @@ A fourth, a SteamOS VM, may come later ([issue #6](https://github.com/saphid/ste
|
||||
python3 -m unittest discover -s tests
|
||||
```
|
||||
|
||||
The Control Center is covered by `tests/test_control_center.py` and its Node
|
||||
behavior suite `tests/control_center.cjs` (also run by unittest discovery).
|
||||
They exercise production search, metrics, navigation and action handlers with
|
||||
mocked API/DOM boundaries: volume Apply/mute/readback, offline and failed reads,
|
||||
stale samples, selected-headset capture, duplicate actions and validation before
|
||||
SSH. Integration regressions run the production shared refresh, Home renderer,
|
||||
host callback and Control Center bootstrap: a pre-command status request must be
|
||||
drained before audio readback starts, and delayed startup retains sample receipt
|
||||
age, failed-read state and early host capabilities. The actual Flatpak install
|
||||
redraw cannot revive a failed sample; a new successful poll restores metrics and
|
||||
audio after either redraw or delayed mount. Draft regressions cover a pending headset
|
||||
switch and mute followed by Apply. Static asset HTTP tests and desktop/iPhone bundle checks catch missing CSS
|
||||
or JavaScript. A focused run is:
|
||||
|
||||
```sh
|
||||
python3 -m unittest discover -s tests -p test_control_center.py -v
|
||||
node --test tests/control_center.cjs
|
||||
node --check ui/control-center.js
|
||||
```
|
||||
|
||||
Responsive layout, actual browser rendering and keyboard/touch behavior still
|
||||
need browser verification. No Control Center test connects to a real headset.
|
||||
|
||||
About 120 tests, a few seconds, on Python 3.9 and newer. GitHub Actions runs
|
||||
them on macOS, Windows and Linux. They don't pick up `tests/e2e`.
|
||||
|
||||
@@ -163,6 +187,86 @@ refuses ids with a hyphen (`missing/invalid arguments`), which the fake had
|
||||
accepted. The fake now refuses them the same way, and Frame Control makes ids
|
||||
Steam accepts.
|
||||
|
||||
## Windows test VM
|
||||
|
||||
The unit tests run on Windows in CI, but the app itself doesn't. Anything that
|
||||
depends on the Windows desktop (Remote Desktop, the installer, the bundled
|
||||
Python, file dialogs) needs a real Windows machine. This is a Windows 11 VM in a
|
||||
[dockur/windows](https://github.com/dockur/windows) container on a Linux machine
|
||||
with KVM, driven from the Mac with `scripts/windows-vm.sh`.
|
||||
|
||||
Setting it up, once, on the Linux machine:
|
||||
|
||||
- **Windows comes from Microsoft.** The container downloads the Windows 11
|
||||
image from Microsoft on first start. Windows runs unactivated, which is fine
|
||||
for testing. Don't use activation workarounds or third-party Windows images.
|
||||
- **Publish its ports on loopback only.** Map `127.0.0.1:2222:22` (SSH),
|
||||
`127.0.0.1:8006:8006` (the web console) and, if you need it,
|
||||
`127.0.0.1:13389:3389`. The Mac reaches them through `ssh -J`. Use
|
||||
`restart: "no"` and `stop_grace_period: 2m` so it only runs when someone is
|
||||
testing, and a `docker stop` shuts Windows down cleanly.
|
||||
- **Give it SSH on first sign-in.** The container runs `/oem/install.bat`
|
||||
once. Have it add the OpenSSH Server capability, start `sshd`, set PowerShell
|
||||
as its default shell, and put a dedicated public key (for example
|
||||
`~/.ssh/id_ed25519_winvm` on the Mac) in
|
||||
`C:\ProgramData\ssh\administrators_authorized_keys`.
|
||||
- Give it 4 cores, 8 GB of memory and a 32 GB disk. That's enough for the app
|
||||
and the tests.
|
||||
|
||||
Using it, with `WINVM_HOST` set to the Linux machine's ssh alias:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh up # start it and wait for SSH (1-2 minutes)
|
||||
scripts/windows-vm.sh put Frame-Control-Setup-x64.exe
|
||||
scripts/windows-vm.sh ps 'Start-Process "$env:USERPROFILE\Downloads\Frame-Control-Setup-x64.exe" /S -Wait'
|
||||
scripts/windows-vm.sh shot screen.png # what's on its screen
|
||||
scripts/windows-vm.sh click 723 359 # screen pixels, as in the screenshot
|
||||
scripts/windows-vm.sh keys s t e a m o s ret # QEMU key names
|
||||
scripts/windows-vm.sh down # shut Windows down
|
||||
```
|
||||
|
||||
- **Clicks go through a scheduled task.** Commands over SSH run in a
|
||||
session with no desktop, so `click` and `scroll` write the position to a
|
||||
file, and a scheduled task running as the signed-in user replays it. The
|
||||
VM's screen must be signed in; it is after `up`. Clicks and scrolls sent at
|
||||
the same time run one after another. QEMU's own `mouse_move` is relative
|
||||
and drifts, so the script doesn't use it.
|
||||
- **Screenshots may not show the pointer.** Check the result of a click (a
|
||||
menu that opens, a button that changes) rather than the pointer's position.
|
||||
- **Windows' `ssh` waits for stdin.** The script closes it for every command.
|
||||
Do the same if you run `ssh` in the VM by hand.
|
||||
- **Starting the app.** Run Frame Control in the signed-in session, not over
|
||||
SSH. Use its Start-menu shortcut via `click`, or a scheduled task like the
|
||||
one `click` uses.
|
||||
|
||||
**Testing against a real Frame.** The VM reaches the headset on the LAN like
|
||||
any other computer. Run **Set Up Connection** in the VM's Frame Control once;
|
||||
that makes the VM's keys. So the VM doesn't keep access between test runs,
|
||||
afterwards take the lines it added out of the headset's
|
||||
`~/.ssh/authorized_keys`: the ones matching the VM's
|
||||
`~/.ssh/id_ed25519_frame.pub` and, if pairing made one,
|
||||
`~/.ssh/id_rsa_frame_devkit.pub`. Check that `ssh -o BatchMode=yes frame true`
|
||||
in the VM now fails. Then, around each run:
|
||||
|
||||
```sh
|
||||
scripts/windows-vm.sh frame-key add # let the VM's key into the headset
|
||||
# ... test ...
|
||||
scripts/windows-vm.sh frame-key remove # and take it out again
|
||||
```
|
||||
|
||||
`add` appends the VM's key tagged `windows-vm-test`, unless the headset
|
||||
already trusts that key through another line. `remove` deletes only the exact
|
||||
line `add` wrote, so it doesn't undo what Set Up Connection did, and it leaves
|
||||
the file alone if it can't rewrite it. Follow the shared-device
|
||||
procedure in [Headset smoke test](#headset-smoke-test) before installing or
|
||||
launching anything on the headset.
|
||||
|
||||
**Verified 2026-09-30:** Windows 11 Pro 25H2 (build 26200) against a Frame on
|
||||
BUILD_ID 20260925.6191901. The script was used to install Frame Control 0.4.0,
|
||||
connect it to the Frame and follow Remote Desktop through to the Frame's
|
||||
desktop ([what it does](streaming.md#a-see-and-control-the-frame-from-the-mac)).
|
||||
`frame-key` left `authorized_keys` byte-for-byte as it was.
|
||||
|
||||
## Owned media player
|
||||
|
||||
`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
|
||||
|
||||
+9
-3
@@ -214,10 +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. [Import from my
|
||||
Quest](quest-import.md) refuses these before copying anything to the Frame.
|
||||
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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
PATTERNS = ["ui/*.py", "ui/*.html", "ui/*.js", "ui/*.css", "ui/local-bin/*", "scripts/*.sh", "frame/android/*.sh", "frame/android/*.py",
|
||||
"frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js",
|
||||
"frame/kdeconnect/packages.json", "frame/kdeconnect/NOTICE.md", "frame/kdeconnect/LICENSES/*/*",
|
||||
"frame/kdeconnect/packages/*.pkg.tar.zst", "LICENSE", "THIRD_PARTY_NOTICES.md"]
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ started=$SECONDS
|
||||
|
||||
print "==> Building fakeframe-frame (from $base) and fakeframe-host"
|
||||
# Quiet when it works; if a build fails, build again with the full log so CI shows why.
|
||||
build() { docker build -q "$@" >/dev/null || { docker build --progress=plain "$@"; exit 2 } }
|
||||
# That second build also rides out a flaky package mirror: only its failure is fatal.
|
||||
build() { docker build -q "$@" >/dev/null || docker build --progress=plain "$@" || exit 2 }
|
||||
build --build-arg BASE="$base" -t fakeframe-frame -f tests/fakeframe/Containerfile tests/fakeframe
|
||||
build -t fakeframe-host -f tests/fakeframe/host.Containerfile tests/fakeframe
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regenerate ui/frame_timezones.py, the time zone -> country table usage statistics use.
|
||||
|
||||
From the IANA tz database's zone.tab (each zone's country) and tzdata.zi (the old
|
||||
names that link to them), as installed on macOS and most Linux systems. It prints the
|
||||
old names left out because their zone is shared by several countries: check any new
|
||||
one, and add it to OLD_SPELLINGS if it's just an old spelling of a zone.tab zone.
|
||||
|
||||
Run: python3 scripts/timezones.py [/usr/share/zoneinfo]
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src = Path(sys.argv[1] if len(sys.argv) > 1 else '/usr/share/zoneinfo')
|
||||
country = {}
|
||||
for line in (src / 'zone.tab').read_text().splitlines():
|
||||
if line and not line.startswith('#'):
|
||||
cc, _, zone = line.split('\t')[:3]
|
||||
country[zone] = cc
|
||||
links = {}
|
||||
for line in (src / 'tzdata.zi').read_text().splitlines():
|
||||
if line.startswith('L '):
|
||||
_, target, alias = line.split()
|
||||
links[alias] = target
|
||||
def resolve(z):
|
||||
for _ in range(5):
|
||||
z = links.get(z, z)
|
||||
return z
|
||||
|
||||
|
||||
# An old name links to a zone with the same clocks, which can be in another country
|
||||
# (Africa/Asmera, Eritrea, links to Africa/Nairobi), so it only gets a country when every
|
||||
# zone.tab zone sharing that target is in one country.
|
||||
countries_of = {}
|
||||
for zone, cc in country.items():
|
||||
countries_of.setdefault(resolve(zone), set()).add(cc)
|
||||
# Clock rules rather than places (EET links to Athens, WET to Lisbon): no country.
|
||||
GENERIC = {'CET', 'EET', 'MET', 'WET', 'EST', 'MST', 'HST', 'CST6CDT', 'EST5EDT', 'MST7MDT', 'PST8PDT'}
|
||||
# Places that have left zone.tab, so their target's group looks like another country's.
|
||||
PLACES = {'Pacific/Johnston': 'UM'}
|
||||
dropped = []
|
||||
for alias in links:
|
||||
if alias in country or alias in GENERIC or alias.startswith('Etc/'):
|
||||
continue
|
||||
if alias in PLACES:
|
||||
country[alias] = PLACES[alias]
|
||||
continue
|
||||
ccs = countries_of.get(resolve(alias), set())
|
||||
if len(ccs) == 1:
|
||||
country[alias] = next(iter(ccs))
|
||||
else:
|
||||
dropped.append(alias)
|
||||
# Old names for a place in a zone.tab zone whose target is shared, checked by hand.
|
||||
OLD_SPELLINGS = {
|
||||
'Africa/Asmera': 'Africa/Asmara', 'Africa/Timbuktu': 'Africa/Bamako', 'America/Coral_Harbour': 'America/Atikokan',
|
||||
'America/Montreal': 'America/Toronto', 'America/Nipigon': 'America/Toronto', 'America/Thunder_Bay': 'America/Toronto',
|
||||
'Canada/Eastern': 'America/Toronto', 'America/Virgin': 'America/St_Thomas', 'Antarctica/South_Pole': 'Antarctica/McMurdo',
|
||||
'Asia/Rangoon': 'Asia/Yangon', 'Atlantic/Jan_Mayen': 'Arctic/Longyearbyen', 'Europe/Belfast': 'Europe/London',
|
||||
'GB': 'Europe/London', 'GB-Eire': 'Europe/London', 'Iceland': 'Atlantic/Reykjavik', 'NZ': 'Pacific/Auckland',
|
||||
'Pacific/Samoa': 'Pacific/Pago_Pago', 'US/Samoa': 'Pacific/Pago_Pago', 'Singapore': 'Asia/Singapore',
|
||||
'US/Arizona': 'America/Phoenix', 'Pacific/Truk': 'Pacific/Chuuk', 'Pacific/Ponape': 'Pacific/Pohnpei',
|
||||
'Pacific/Yap': 'Pacific/Chuuk'}
|
||||
for alias, zone in OLD_SPELLINGS.items():
|
||||
if alias in links and zone in country:
|
||||
country[alias] = country[zone]
|
||||
dropped.remove(alias)
|
||||
version = (src / '+VERSION').read_text().strip() if (src / '+VERSION').exists() else 'unknown'
|
||||
rows = ''.join(f' {z!r}: {c!r},\n' for z, c in sorted(country.items()))
|
||||
out = Path(__file__).resolve().parent.parent / 'ui' / 'frame_timezones.py'
|
||||
out.write_text(f'''"""Time zone name -> two-letter country, from the IANA tz database {version} (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {{
|
||||
{rows}}}
|
||||
''')
|
||||
print(f'{len(country)} zones, tz {version} -> {out}')
|
||||
print('no country (shared by several):', ' '.join(sorted(dropped)) or 'none')
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env zsh
|
||||
# Mac or Linux: drive a Windows 11 test VM for Frame Control's Windows build.
|
||||
# The VM is a dockur/windows container on another machine; this reaches it over
|
||||
# SSH through that machine. See docs/testing.md#windows-test-vm.
|
||||
#
|
||||
# Usage: scripts/windows-vm.sh <command> [args]
|
||||
# up | down | status start it (waits for SSH), shut Windows down cleanly, show state
|
||||
# ps '<PowerShell>' run PowerShell as the VM's user
|
||||
# put <file> [<dir>] copy a file in (default: the user's Downloads)
|
||||
# shot <out.png> save the VM's screen
|
||||
# click <x> <y> click screen pixel x,y in the signed-in session
|
||||
# scroll <x> <y> <n> turn the wheel n notches at x,y (negative scrolls down)
|
||||
# keys <key>... type QEMU key names: a, shift-a, ret, tab, esc, spc ...
|
||||
# frame-key add|remove let the VM's Frame Control key into the headset (`frame`
|
||||
# alias) for a test run, then take it out again
|
||||
#
|
||||
# Set WINVM_HOST to the ssh alias of the machine running the container. Optional:
|
||||
# WINVM_CONTAINER (frame-winvm), WINVM_USER (frame), WINVM_PORT (2222: the VM's
|
||||
# sshd, published on that machine's loopback), WINVM_KEY (~/.ssh/id_ed25519_winvm).
|
||||
set -euo pipefail
|
||||
setopt extendedglob
|
||||
|
||||
host=${WINVM_HOST:?set WINVM_HOST to the ssh alias of the machine running the VM}
|
||||
ctr=${WINVM_CONTAINER:-frame-winvm}
|
||||
user=${WINVM_USER:-frame}
|
||||
port=${WINVM_PORT:-2222}
|
||||
key=${WINVM_KEY:-$HOME/.ssh/id_ed25519_winvm}
|
||||
opts=(-o ConnectTimeout=20 -o StrictHostKeyChecking=accept-new
|
||||
-o UserKnownHostsFile=${TMPDIR:-/tmp}/windows-vm-known_hosts -i $key -J $host)
|
||||
TAG=windows-vm-test # comment on the VM's key in the headset's authorized_keys
|
||||
|
||||
die() { print -u2 "windows-vm: $*"; exit 1 }
|
||||
int() { [[ $1 == (-|)<-99999> ]] || die "not a whole number (up to 99999): $1" }
|
||||
# These go into commands run by a shell on the other machine, so keep them plain.
|
||||
for v in $host $ctr $user; do [[ $v == [A-Za-z0-9_.]##[A-Za-z0-9_.-]# ]] || die "not a plain name: $v"; done
|
||||
[[ $port == <1-65535> ]] || die "WINVM_PORT isn't a port: $port"
|
||||
|
||||
# Windows' OpenSSH waits for stdin to close, so it always gets /dev/null. The
|
||||
# script travels UTF-16 base64-encoded, so no quoting survives two shells.
|
||||
vm_ps() {
|
||||
local b64=$(print -rn -- "\$ProgressPreference = 'SilentlyContinue'"$'\n'"$1" |
|
||||
iconv -f UTF-8 -t UTF-16LE | base64 | tr -d '\n')
|
||||
ssh $opts -p $port $user@127.0.0.1 "powershell -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand $b64" </dev/null
|
||||
}
|
||||
|
||||
# QEMU's monitor inside the container: one command per line on stdin.
|
||||
monitor() { ssh $host "docker exec -i $ctr nc -q 1 -U /run/shm/monitor.sock" >/dev/null }
|
||||
|
||||
# Input has to come from the signed-in desktop session, not SSH's session 0, so
|
||||
# a scheduled task running as the user replays one click or wheel turn written
|
||||
# to input.txt, then deletes the file to say it's done. Any error stops it
|
||||
# before that, so the caller times out instead of reporting a click.
|
||||
INPUT_PS1='$ErrorActionPreference = "Stop"
|
||||
$a = (Get-Content "$PSScriptRoot\input.txt").Trim() -split "\s+"
|
||||
Add-Type -Namespace WinVm -Name Input -MemberDefinition @"
|
||||
[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
|
||||
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
|
||||
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, int dx, int dy, int data, System.IntPtr extra);
|
||||
"@
|
||||
[WinVm.Input]::SetProcessDPIAware() | Out-Null
|
||||
if (-not [WinVm.Input]::SetCursorPos([int]$a[0], [int]$a[1])) { throw "SetCursorPos failed" }
|
||||
Start-Sleep -Milliseconds 150
|
||||
if ($a[2] -eq "click") {
|
||||
[WinVm.Input]::mouse_event(0x2, 0, 0, 0, [IntPtr]::Zero); Start-Sleep -Milliseconds 60
|
||||
[WinVm.Input]::mouse_event(0x4, 0, 0, 0, [IntPtr]::Zero)
|
||||
} else { [WinVm.Input]::mouse_event(0x800, 0, 0, 120 * [int]$a[3], [IntPtr]::Zero) }
|
||||
Remove-Item "$PSScriptRoot\input.txt"'
|
||||
|
||||
pointer() { # x y click|wheel [notches]
|
||||
local b64=$(print -rn -- $INPUT_PS1 | base64 | tr -d '\n')
|
||||
vm_ps '$ErrorActionPreference = "Stop"
|
||||
$d = Join-Path $env:LOCALAPPDATA "windows-vm"; $req = "$d\input.txt"; $mine = $false
|
||||
# Giving up: end the helper first, or it could wake up and act in a later request.
|
||||
function Abandon {
|
||||
Stop-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue
|
||||
foreach ($i in 1..25) {
|
||||
if ((Get-ScheduledTask -TaskName WindowsVmInput -ErrorAction SilentlyContinue).State -ne "Running") { break }
|
||||
Start-Sleep -Milliseconds 200
|
||||
}
|
||||
Remove-Item $req -ErrorAction SilentlyContinue
|
||||
}
|
||||
trap { [Console]::Error.WriteLine("windows-vm: $_"); if ($mine) { Abandon }; exit 1 }
|
||||
# One request at a time: the helper, the task and input.txt are shared. Windows
|
||||
# frees the mutex when this process ends, however it ends.
|
||||
$lock = New-Object Threading.Mutex($false, "windows-vm-input")
|
||||
try { $mine = $lock.WaitOne(15000) } catch [Threading.AbandonedMutexException] { $mine = $true }
|
||||
if (-not $mine) { [Console]::Error.WriteLine("windows-vm: another click or scroll is still in progress"); exit 1 }
|
||||
New-Item -ItemType Directory -Force $d | Out-Null
|
||||
[IO.File]::WriteAllText($req, "'"$*"'")
|
||||
[IO.File]::WriteAllText("$d\input.ps1", [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String("'$b64'")))
|
||||
$act = New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File `"$d\input.ps1`""
|
||||
$who = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive
|
||||
Register-ScheduledTask -TaskName WindowsVmInput -Action $act -Principal $who -Force | Out-Null
|
||||
Start-ScheduledTask -TaskName WindowsVmInput
|
||||
foreach ($i in 1..50) { if (-not (Test-Path $req)) { exit 0 }; Start-Sleep -Milliseconds 200 }
|
||||
Abandon
|
||||
[Console]::Error.WriteLine("windows-vm: no input after 10 s: is $env:USERNAME signed in on the VM screen, and is x,y on it?"); exit 1'
|
||||
}
|
||||
|
||||
(( $# )) || die "usage: see the top of $0"
|
||||
cmd=$1; shift
|
||||
case $cmd in
|
||||
up)
|
||||
ssh $host "docker start $ctr" >/dev/null
|
||||
for i in {1..60}; do
|
||||
vm_ps 'exit 0' 2>/dev/null && { print "up"; exit 0 }
|
||||
sleep 5
|
||||
done
|
||||
die "Windows didn't answer on SSH within 5 minutes" ;;
|
||||
down) # the container turns SIGTERM into an ACPI shutdown and waits for Windows
|
||||
ssh $host "docker stop -t 150 $ctr" >/dev/null && print "down" ;;
|
||||
status) ssh $host "docker ps -a --filter 'name=^$ctr\$' --format '{{.Names}}: {{.Status}}'" ;;
|
||||
ps) (( $# == 1 )) || die "usage: ps '<PowerShell>'"; vm_ps "$1" ;;
|
||||
put)
|
||||
[[ -f ${1:-} ]] || die "usage: put <file> [<dir>]"
|
||||
scp -q $opts -P $port $1 "$user@127.0.0.1:${2:-C:/Users/$user/Downloads}/${1:t}" </dev/null ;;
|
||||
shot)
|
||||
(( $# == 1 )) || die "usage: shot <out.png>"
|
||||
print "screendump /tmp/windows-vm-shot.ppm" | monitor
|
||||
sleep 1
|
||||
ssh $host "docker exec $ctr sh -c 'cat /tmp/windows-vm-shot.ppm && rm /tmp/windows-vm-shot.ppm'" | python3 -c '
|
||||
import re, struct, sys, zlib
|
||||
d = sys.stdin.buffer.read()
|
||||
m = re.match(rb"P6\s+(\d+)\s+(\d+)\s+255\s", d) or sys.exit("windows-vm: no screen dump")
|
||||
w, h = int(m[1]), int(m[2]); px = d[m.end():]
|
||||
raw = b"".join(b"\0" + px[y * w * 3:(y + 1) * w * 3] for y in range(h))
|
||||
chunk = lambda t, b: struct.pack(">I", len(b)) + t + b + struct.pack(">I", zlib.crc32(t + b))
|
||||
open(sys.argv[1], "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
|
||||
+ chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
|
||||
' $1
|
||||
print $1 ;;
|
||||
click) (( $# == 2 )) || die "usage: click <x> <y>"; int $1; int $2; pointer $1 $2 click ;;
|
||||
scroll) (( $# == 3 )) || die "usage: scroll <x> <y> <n>"; int $1; int $2; int $3; pointer $1 $2 wheel $3 ;;
|
||||
keys)
|
||||
(( $# )) || die "usage: keys <key>..."
|
||||
for k; do [[ $k == [a-z0-9_.,/=-]## ]] || die "not a QEMU key name: $k"; done
|
||||
for k; do print "sendkey $k"; done | monitor ;;
|
||||
frame-key)
|
||||
[[ ${1:-} == (add|remove) ]] || die "usage: frame-key add|remove"
|
||||
pub=(${=$(vm_ps 'Get-Content (Join-Path $env:USERPROFILE ".ssh\id_ed25519_frame.pub")' | tr -d '\r')})
|
||||
[[ ${pub[1]:-} == ssh-ed25519 && ${pub[2]:-} == [A-Za-z0-9+/=]## ]] ||
|
||||
die "the VM has no Frame Control key yet: run Set Up Connection in the app first"
|
||||
# $1: add|remove, $2: the key's base64, $3: the exact line this script owns.
|
||||
ssh frame "sh -s -- $1 '$pub[2]' '$pub[1] $pub[2] $TAG'" <<'EOF'
|
||||
f=~/.ssh/authorized_keys
|
||||
if [ "$1" = add ]; then
|
||||
if grep -qxF "$3" "$f"; then exit 0; fi
|
||||
if grep -qF "$2" "$f"; then echo "the headset already trusts this key through another entry; left as it is"; exit 0; fi
|
||||
[ -z "$(tail -c 1 "$f")" ] || echo >> "$f" # a last line without a newline would swallow ours
|
||||
echo "$3" >> "$f"
|
||||
else
|
||||
t=$(mktemp "$f.XXXXXX") || exit 1
|
||||
grep -vxF "$3" "$f" > "$t" # 0: lines left, 1: none left, more: couldn't read or write
|
||||
if [ $? -gt 1 ] || ! chmod 600 "$t" || ! mv "$t" "$f"; then
|
||||
rm -f "$t"; echo "couldn't rewrite $f; it's unchanged" >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
EOF
|
||||
print "frame-key $1: done" ;;
|
||||
*) die "unknown command: $cmd (see the top of $0)" ;;
|
||||
esac
|
||||
@@ -0,0 +1,471 @@
|
||||
// Real Control Center logic with a minimal DOM; no network, browser or headset.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const cc = require('../ui/control-center.js');
|
||||
|
||||
const sample = () => ({time:Date.now()/1000, hostname:'Test Frame', battery:{percent:99,status:'Discharging',watts:0,tempC:33,health:'Good'},
|
||||
power:{type:'C PD [PD_PPS]',watts:18}, disk:{home:{total:256*1073741824,free:128*1073741824}},
|
||||
memory:{total:16*1073741824,available:6*1073741824}, wifi:{ssid:'Test LAN',signal:80},
|
||||
performance:{compositorFps:90,appFps:45,frameMs:11.11,gpuMs:3,cpuPercent:12,gpuMHz:600},
|
||||
volume:{level:.4,muted:false}, os:{version:'0.4.1',build:'test-build'},services:{steamvr:true}, thermalAlerts:[],games:[],flatpaks:[]});
|
||||
function fixture() {
|
||||
const elements = new Map();
|
||||
function element(id) {
|
||||
if (!elements.has(id)) elements.set(id, {id,hidden:false,disabled:false,textContent:'',innerHTML:'',value:'',dataset:{},
|
||||
classList:{toggle(){}},querySelectorAll(){return [];},setAttribute(key,value){this[key]=value;},addEventListener(key,callback){this[key]=callback;},focus(){this.focused=true;}});
|
||||
return elements.get(id);
|
||||
}
|
||||
const cards = cc.catalog.map(item=>{const e=element(item.id);e.dataset.ccCard=item.id;return e;});
|
||||
const buttons = cc.categories.map(([id])=>{const e=element('category-'+id);e.dataset.ccCategory=id;e.querySelector=()=>element('count-'+id);return e;});
|
||||
const metrics = Object.keys(cc.metrics(null)).map(id=>{const e=element('metric-'+id);e.dataset.ccMetric=id;return e;});
|
||||
const page = element('controlCenter');
|
||||
page.querySelectorAll = selector => selector === '[data-cc-category]' ? buttons : selector === '[data-cc-metric]' ? metrics : selector === '[data-cc-mac-link]' ? [element('macLink')] : [];
|
||||
page.querySelector = selector => selector === '[href="#connection"]' ? element('connectionLink') : cards.find(e=>selector === `[data-cc-card="${e.dataset.ccCard}"]`);
|
||||
return {document:{getElementById:element},element,cards,buttons};
|
||||
}
|
||||
function mounted(options = {}) {
|
||||
const f = fixture(), calls = [];
|
||||
let context = {online:true,generation:1,device:'frame-a'}, refreshes = 0, opened = 0;
|
||||
const ui = cc.mount({document:f.document,platform:'darwin',api:async(...args)=>{calls.push(args);return {message:'Volume updated'};},
|
||||
refresh:async()=>{refreshes++;},getContext:()=>context,openConnection:()=>opened++,...options});
|
||||
return {...f,ui,calls,get context(){return context;},set context(v){context=v;},get refreshes(){return refreshes;},get opened(){return opened;}};
|
||||
}
|
||||
const pageSource = fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const deferred = () => { let resolve, reject; const promise=new Promise((ok,bad)=>{resolve=ok;reject=bad;});return {promise,resolve,reject}; };
|
||||
const flush = async () => { for(let i=0;i<12;i++)await Promise.resolve(); };
|
||||
// Execute the actual shared refresh/deduplication, host callback and mount
|
||||
// bootstrap, including the real Home-to-Control status handoff. Only unrelated
|
||||
// rendering helpers, timers and transport are stubbed.
|
||||
function sharedPage({productionApi=false} = {}) {
|
||||
const f=fixture(), requests=[], writes=[], host=deferred(), job=deferred();
|
||||
const context={document:f.document,FrameControlCenter:cc,window:{frameApp:{setUpConnection(){}}},
|
||||
Date,setInterval,clearInterval,setTimeout:()=>1,clearTimeout(){},$:f.element,QUICK:[],installing:new Set(),
|
||||
battery(){},setVolume(){},gb:String,dur:String,esc:String,meter(){},toast(){},
|
||||
runJob(title,id){context.installing.add(id);return job.promise;},
|
||||
api:(path,body,device)=>{
|
||||
if(path==='/api/host')return host.promise;
|
||||
if(path==='/api/status'){const request=deferred();requests.push(request);return request.promise;}
|
||||
writes.push({path,body,device});return Promise.resolve({message:'Volume updated'});
|
||||
},renderPerformance(){},setConn(){},setOnline(ok){vm.runInContext(`online = ${ok}`,context);},log(){},failed(){},
|
||||
watchConnection(){context.watched=true;}};
|
||||
vm.createContext(context);
|
||||
const stateDeclaration=pageSource.match(/let state = null[^\n]*/)[0];
|
||||
vm.runInContext(stateDeclaration+'\nlet online = true, devGen = 1; const HOST = {computer:"computer"}; window.connDevice="frame-a";',context);
|
||||
const refreshStart=pageSource.indexOf('let refreshing = null'),refreshEnd=pageSource.indexOf('// ---- our VR HUD',refreshStart);
|
||||
const renderStart=pageSource.indexOf('function render(s) {'),renderEnd=pageSource.indexOf('// ---- view ----',renderStart);
|
||||
vm.runInContext(pageSource.slice(renderStart,renderEnd)+pageSource.slice(refreshStart,refreshEnd),context);
|
||||
if(productionApi) {
|
||||
// Keep api/onConnection/setOnline intact: defer fetch itself, not api(), so
|
||||
// rejection classification and the real switch/reset notifications run.
|
||||
context.fetch=(path,opts)=>{
|
||||
if(path==='/api/host')return host.promise.then(data=>({ok:true,status:200,json:async()=>data}));
|
||||
const request=deferred();request.path=path;request.opts=opts;requests.push(request);return request.promise;
|
||||
};
|
||||
Object.assign(context,{renderPill(){},renderConnDlg(){},renderBanner(){},viaText(){},netLabel(){},
|
||||
resetShots(){},loadDevices(){},renderDevices(){},renderTests(){},reloadAll(){},checkContactPrompt(){}});
|
||||
vm.runInContext(`const UI_KEY = 'test-only';
|
||||
const link = {s:null,device:'frame-a',phase:'connected',rev:0,net:null};
|
||||
const disp = {}, gm = {seq:0}, cat = {}, wi = {};
|
||||
let viewGen = 0, lastImg = null, lastShot = null, androidApps = [], titlesSeq = 0, page = 'control';`,context);
|
||||
const apiStart=pageSource.indexOf('const HEADSET_FREE ='),apiEnd=pageSource.indexOf('// What a panel shows',apiStart);
|
||||
const onlineStart=pageSource.indexOf('function setOnline('),onlineEnd=pageSource.indexOf('function reloadAll()',onlineStart);
|
||||
const connectionStart=pageSource.indexOf('function onConnection('),connectionEnd=pageSource.indexOf('// Read the event stream',connectionStart);
|
||||
vm.runInContext(pageSource.slice(apiStart,apiEnd)+pageSource.slice(onlineStart,onlineEnd)+pageSource.slice(connectionStart,connectionEnd),context);
|
||||
}
|
||||
const installStart=pageSource.indexOf('function installFlatpak(id) {'),installEnd=pageSource.indexOf('$("fpInstall").onclick',installStart);
|
||||
vm.runInContext(pageSource.slice(installStart,installEnd),context);
|
||||
const hostStart=pageSource.lastIndexOf('api("/api/host").then(h =>'),hostEnd=pageSource.indexOf('</script>',hostStart);
|
||||
vm.runInContext(pageSource.slice(hostStart,hostEnd),context);
|
||||
const bootstrapStart=pageSource.indexOf('window.frameControlCenter = FrameControlCenter.mount('),bootstrapEnd=pageSource.indexOf('</script>',bootstrapStart);
|
||||
return {...f,context,requests,writes,host,refresh:()=>vm.runInContext('refresh()',context),
|
||||
switchHeadset:()=>vm.runInContext('onConnection({now:Date.now()/1000,phase:"connected",devices_rev:0,device:{id:"frame-b",name:"Second Frame"}})',context),
|
||||
installFlatpak:()=>vm.runInContext('installFlatpak("test.app")',context),
|
||||
mount(){vm.runInContext(pageSource.slice(bootstrapStart,bootstrapEnd),context);return context.window.frameControlCenter;}};
|
||||
}
|
||||
const response = data => ({ok:true,status:200,json:async()=>data});
|
||||
for(const rejection of ['transport','body'])for(const delayedMount of [false,true]) {
|
||||
test(`production ${rejection} rejection from A preserves B ${delayedMount ? 'before delayed mount' : 'after mount'}`, async t => {
|
||||
const f=sharedPage({productionApi:true});
|
||||
if(!delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const old=f.refresh(),body=deferred();
|
||||
assert.equal(f.requests[0].path,'/api/status');
|
||||
if(rejection==='body') {
|
||||
f.requests[0].resolve({ok:true,status:200,json:()=>body.promise});await flush();
|
||||
}
|
||||
f.switchHeadset();
|
||||
assert.equal(f.requests.length,2,'real connected B callback starts its own status fetch');
|
||||
const next=sample();next.hostname='Second Frame';next.battery.percent=20;next.volume.level=.2;
|
||||
f.requests[1].resolve(response(next));await flush();
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(vm.runInContext('stateRead.valid && online && devGen === 2',f.context),true);
|
||||
if(!delayedMount)assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
(rejection==='body' ? body : f.requests[0]).reject(Error('Old A request failed'));await old;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true,'A must not invalidate B');
|
||||
assert.equal(vm.runInContext('stateRead.error',f.context),null);
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
if(delayedMount){const ui=f.mount();t.after(()=>ui.destroy());}
|
||||
const assertB=()=>{
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('metric-battery').textContent,'20%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/No live sample|local server|Old A/);
|
||||
};
|
||||
assertB();vm.runInContext('render(state)',f.context);assertB();
|
||||
assert.equal(f.requests.length,2,'preservation and redraw need no recovery poll');
|
||||
// An ordinary failure on B must still invalidate the current sample.
|
||||
const failed=f.refresh();
|
||||
if(rejection==='body')f.requests[2].resolve({ok:true,status:200,json:async()=>{throw Error('B body failed');}});
|
||||
else f.requests[2].reject(Error('B transport failed'));
|
||||
await failed;
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),false);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
});
|
||||
}
|
||||
test('production headset-free API keeps transport errors and responses across a switch', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const f=sharedPage({productionApi:true}),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const pending=vm.runInContext('api("/api/devices")',f.context);
|
||||
const result=pending.then(value=>({value}),error=>({error}));
|
||||
f.switchHeadset();f.requests[1].resolve(response(sample()));await flush();
|
||||
if(failure)f.requests[0].reject(Error('Device list transport failed'));
|
||||
else f.requests[0].resolve(response({devices:['frame-b']}));
|
||||
const settled=await result;
|
||||
if(failure){assert.equal(settled.error.stale,undefined);assert.match(settled.error.message,/local server isn't running/);}
|
||||
else assert.deepEqual(settled.value,{devices:['frame-b']});
|
||||
assert.equal(vm.runInContext('stateRead.valid',f.context),true);
|
||||
}
|
||||
});
|
||||
test('search finds settings by aliases, all words, category and guidance', () => {
|
||||
const find = (q,category='all')=>cc.catalog.filter(item=>cc.matches(item,q,category)).map(item=>item.id);
|
||||
assert.ok(find('DPI').includes('display'));
|
||||
assert.ok(find('sleep inactivity').includes('power'));
|
||||
assert.ok(find('MOTION SICKNESS').includes('playspace'));
|
||||
assert.ok(find('font','apps').includes('display'));
|
||||
assert.deepEqual(find('font','vr'),[]);
|
||||
assert.deepEqual(find('<script>alert(1)</script>'),[]);
|
||||
assert.ok(find(' ').length > 20);
|
||||
});
|
||||
test('metrics use real fields, preserve zero, and distinguish unavailable values', () => {
|
||||
const m = cc.metrics(sample());
|
||||
assert.equal(m.battery,'99%');
|
||||
assert.equal(m.power,'0 W');
|
||||
assert.match(m.batteryDetail,/not charging/);
|
||||
assert.equal(m.fps,'90'); assert.equal(m.appFps,'45');
|
||||
assert.equal(m.storage,'128 GiB'); assert.equal(m.memory,'10 GiB');
|
||||
assert.equal(m.thermal,'No hot / critical trip reported');
|
||||
assert.equal(cc.metrics(null).fps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{appFps:null,gpuMHz:0,cpuPercent:0}}).appFps,'Unavailable');
|
||||
assert.equal(cc.metrics({performance:{cpuPercent:0}}).cpu,'0%');
|
||||
assert.equal(cc.metrics({battery:{percent:0,status:'Discharging'}}).battery,'0%');
|
||||
assert.match(cc.metrics({thermalAlerts:[{zone:'battery',tempC:45,limitC:44}]}).thermal,/battery 45 °C ≥ 44 °C/);
|
||||
});
|
||||
test('audio controller validates before any API call and rejects offline or expired readings', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},calls=[];
|
||||
const controller=cc.createController({api:async(...args)=>{calls.push(args);return {};},getContext:()=>context});
|
||||
for (const change of [{level:-.1},{level:1.1},{level:NaN},{level:Infinity},{level:'0.5'},{level:null},{muted:'false'},{unknown:true},{}]) {
|
||||
await assert.rejects(controller.audio(change),/valid mute state/);
|
||||
}
|
||||
context.online=false;await assert.rejects(controller.audio({level:.5}),/Connect and refresh/);
|
||||
context.online=true;context.fresh=false;await assert.rejects(controller.audio({muted:true}),/Connect and refresh/);
|
||||
assert.equal(calls.length,0);
|
||||
});
|
||||
test('audio uses the captured device, prevents duplicate writes, and ignores switched-headset success', async () => {
|
||||
let context={online:true,fresh:true,sample:sample(),device:'frame-a',generation:1},complete,calls=[];
|
||||
const controller=cc.createController({api:(...args)=>{calls.push(args);return new Promise(r=>complete=r);},getContext:()=>context});
|
||||
const pending=controller.audio({level:.7});
|
||||
await assert.rejects(controller.audio({muted:true}),/still in progress/);
|
||||
assert.deepEqual(calls,[['/api/volume',{level:.7},'frame-a']]);
|
||||
context={...context,device:'frame-b',generation:2};complete({message:'Done'});
|
||||
assert.equal(await pending,null);assert.equal(controller.busy,false);
|
||||
});
|
||||
test('controller surfaces real API errors and allows a deliberate retry', async () => {
|
||||
let calls=0;
|
||||
const controller=cc.createController({api:async()=>{if(++calls===1)throw Error('SSH connection lost');return {message:'Done'};},
|
||||
getContext:()=>({online:true,fresh:true,sample:sample(),device:'frame-a',generation:1})});
|
||||
await assert.rejects(controller.audio({muted:true}),/SSH connection lost/);
|
||||
assert.equal(controller.busy,false);
|
||||
assert.deepEqual(await controller.audio({muted:true}),{message:'Done'});
|
||||
});
|
||||
test('mounted controls only write on Apply or Mute, show readback, and reset on switch', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.ui.status(sample());
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccVolume').value,40);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
f.ui.status(sample()); // a poll must not overwrite an unapplied value
|
||||
assert.equal(f.element('ccVolume').value,'65');
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls,[['/api/volume',{level:.65},'frame-a']]);
|
||||
assert.equal(f.refreshes,1);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{muted:true},'frame-a']);
|
||||
f.context={online:null,generation:2,device:'frame-b'};f.ui.reset();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
});
|
||||
test('offline/errors clear live readings, disable audio and keep companion navigation usable', async t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.context.online=false;f.ui.connection(false);
|
||||
assert.equal(f.element('metric-fps').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccVolume').disabled,true);
|
||||
await f.element('ccMute').onclick();
|
||||
assert.equal(f.calls.length,0);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Connect and refresh/);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='privacy').hidden,false);
|
||||
f.ui.unavailable(Error('Local server is unavailable'));
|
||||
assert.match(f.element('ccSampleState').textContent,/Local server is unavailable/);
|
||||
f.context.online=true;f.ui.status(sample());
|
||||
assert.equal(f.element('ccVolume').disabled,false);
|
||||
});
|
||||
test('mounted audio failure is visible and never reports success', async t => {
|
||||
const f=mounted({api:async()=>{throw Error('Permission denied');}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Couldn’t apply audio: Permission denied/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.refreshes,0);
|
||||
});
|
||||
test('audio confirmation depends on fresh readback rather than command acknowledgment', async t => {
|
||||
let expected=.65, mode='confirmed';
|
||||
const f=mounted({refresh:async()=>{
|
||||
if(mode==='offline')return f.ui.unavailable(Error('Headset unreachable'));
|
||||
const reading=sample();reading.volume.level=mode==='different' ? .64 : expected;f.ui.status(reading);
|
||||
}});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
mode='different';f.element('ccVolume').value='65';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/reported output differs: 64%/);
|
||||
mode='offline';await f.element('ccApplyVolume').onclick();
|
||||
assert.match(f.element('ccAudioResult').textContent,/command sent; current output could not be confirmed/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('pending audio never overwrites the new headset with old success or error', async t => {
|
||||
let complete;
|
||||
const f=mounted({api:()=>new Promise(resolve=>{complete=resolve;})});t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
const pending=f.element('ccMute').onclick();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const newSample=sample();newSample.hostname='Second Frame';newSample.volume.muted=true;f.ui.status(newSample);
|
||||
complete({message:'Old headset muted'});await pending;
|
||||
assert.equal(f.element('ccDeviceName').textContent,'Second Frame');
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
assert.equal(f.refreshes,0);
|
||||
assert.equal(f.element('ccMute').textContent,'Unmute');
|
||||
f.ui.unavailable({stale:true,message:'Switched headsets'});
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccMute').disabled,false);
|
||||
});
|
||||
test('pending old-headset volume cannot become a draft on the new headset', async t => {
|
||||
for(const failure of [false,true]) {
|
||||
const request=deferred(),f=mounted({api:()=>request.promise});t.after(()=>f.ui.destroy());
|
||||
f.ui.status(sample());f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const pending=f.element('ccApplyVolume').onclick();
|
||||
f.context={online:true,generation:2,device:'frame-b'};f.ui.reset();
|
||||
const next=sample();next.volume.level=.2;f.ui.status(next);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failure)request.reject(Error('Old headset failed'));else request.resolve({message:'Done'});
|
||||
await pending;
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccVolumeValue').textContent,'20%');
|
||||
assert.equal(f.element('ccAudioReadback').textContent,'Reported 20% · unmuted');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('ccAudioResult').textContent,'');
|
||||
}
|
||||
});
|
||||
test('production host response works before and after mounting with the actual bridge shape', async t => {
|
||||
for(const early of [true,false]) {
|
||||
const f=sharedPage();let ui;
|
||||
if(!early){ui=f.mount();t.after(()=>ui.destroy());assert.equal(f.element('macLink').hidden,true);}
|
||||
f.host.resolve({os:'macOS',mobile:false});await flush();
|
||||
if(early){ui=f.mount();t.after(()=>ui.destroy());}
|
||||
assert.equal(f.context.watched,true);
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
f.element('ccSearch').value='mac';f.element('ccSearch').input();
|
||||
assert.equal(f.element('macLink').hidden,false);
|
||||
ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
}
|
||||
});
|
||||
test('production shared refresh drains a pre-command request then samples after acknowledgment', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();assert.equal(f.requests.length,1);
|
||||
assert.equal(f.refresh(),prior); // the ordinary poll/manual deduplication still holds
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
assert.equal(f.writes.length,1);assert.equal(f.requests.length,1);
|
||||
f.requests[0].resolve(sample());await prior;await flush();
|
||||
assert.equal(f.requests.length,2,'readback must issue a real post-command status request');
|
||||
assert.doesNotMatch(f.element('ccAudioResult').textContent,/Confirmed|reported output differs/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
const after=sample();after.volume.level=.65;f.requests[1].resolve(after);await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, unmuted/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('production audio readback survives an old poll failure and treats its own failed read as unconfirmed', async t => {
|
||||
for(const failedReadback of [false,true]) {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());ui.status(sample());
|
||||
const prior=f.refresh();f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
const command=f.element('ccApplyVolume').onclick();await flush();
|
||||
f.requests[0].reject(Error('Pre-command poll failed'));await prior;await flush();
|
||||
assert.equal(f.requests.length,2);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
if(failedReadback)f.requests[1].reject(Error('Readback unavailable'));
|
||||
else {const after=sample();after.volume.level=.64;f.requests[1].resolve(after);}
|
||||
await command;
|
||||
assert.match(f.element('ccAudioResult').textContent,failedReadback ? /command sent; current output could not be confirmed/ : /reported output differs: 64%/);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,failedReadback);
|
||||
}
|
||||
});
|
||||
test('production Flatpak redraw cannot revive a failed status; only a fresh successful poll recovers', async t => {
|
||||
const f=sharedPage(),ui=f.mount();t.after(()=>ui.destroy());
|
||||
const first=f.refresh(),reading=sample();f.requests[0].resolve(reading);await first;
|
||||
const receipt=vm.runInContext('stateReceivedAt',f.context);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
const failed=f.refresh();f.requests[1].reject(Error('Synthetic status parsing error'));await failed;
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.installFlatpak(); // actual production render(state), with job held pending
|
||||
assert.equal(f.requests.length,2,'redraw must not start a replacement reading');
|
||||
assert.equal(vm.runInContext('stateReceivedAt',f.context),receipt);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true,'starting a poll does not clear failure');
|
||||
const next=sample();next.battery.percent=98;next.volume.level=.2;
|
||||
f.requests[2].resolve(next);await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'98%');
|
||||
assert.equal(f.element('ccVolume').value,20);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Synthetic status parsing error/);
|
||||
});
|
||||
test('delayed production bootstrap retains failed-read state and fresh-poll recovery', async t => {
|
||||
for(const priorSuccess of [true,false]) {
|
||||
const f=sharedPage();
|
||||
if(priorSuccess){const first=f.refresh();f.requests[0].resolve(sample());await first;}
|
||||
const failed=f.refresh();f.requests.at(-1).reject(Error('Status failed before mount'));await failed;
|
||||
const calls=f.requests.length,ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.requests.length,calls);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccStatusLabel').textContent,'No live sample');
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
for(const id of ['ccVolume','ccApplyVolume','ccMute'])assert.equal(f.element(id).disabled,true);
|
||||
const fresh=f.refresh();assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
f.requests.at(-1).resolve(sample());await fresh;
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Status failed before mount/);
|
||||
}
|
||||
});
|
||||
test('same-object replay preserves invalidation and receipt age after a failed reading', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();
|
||||
f.ui.status(reading);f.ui.unavailable(Error('Status failure'));
|
||||
f.ui.status(reading,Date.now()+1000);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/Status failure/);
|
||||
f.ui.status(sample());assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
});
|
||||
test('delayed production bootstrap preserves client receipt age despite headset clock skew', async t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=sharedPage(),prior=f.refresh();const reading=sample();reading.time-=3600;
|
||||
f.requests[0].resolve(reading);await prior;
|
||||
now+=91000;const ui=f.mount();t.after(()=>ui.destroy());
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.match(f.element('ccSampleState').textContent,/expired/);
|
||||
const fresh=f.refresh();f.requests[1].resolve(sample());await fresh;
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.equal(f.element('metric-battery').textContent,'99%');
|
||||
});
|
||||
test('sample replay cannot renew receipt age; fresh receipts tolerate missing or skewed headset time', t => {
|
||||
const original=Date.now;let now=1000000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());const reading=sample();f.ui.status(reading);
|
||||
now+=91000;f.ui.status(reading);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
assert.equal(f.element('metric-battery').textContent,'Unavailable');
|
||||
for(const time of [undefined,null,'invalid',NaN,Infinity,now/1000-3600,now/1000+3600]) {
|
||||
f.ui.status({...sample(),time});
|
||||
assert.equal(f.element('ccApplyVolume').disabled,false);
|
||||
assert.doesNotMatch(f.element('ccSampleState').textContent,/Invalid Date/);
|
||||
}
|
||||
for(const receipt of [null,NaN,Infinity,now+1000,now-91000]) {
|
||||
f.ui.status(sample(),receipt);assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
}
|
||||
});
|
||||
test('mute-only readback preserves an unapplied volume until Apply sends it', async t => {
|
||||
let reading=sample();const f=mounted({api:async(path,change,device)=>{
|
||||
f.calls.push([path,change,device]);reading={...reading,volume:{...reading.volume,...change}};return {message:'Done'};
|
||||
},refresh:async()=>f.ui.status({...reading})});t.after(()=>f.ui.destroy());f.ui.status(reading);
|
||||
f.element('ccVolume').value='65';f.element('ccVolume').oninput();
|
||||
await f.element('ccMute').onclick();
|
||||
assert.deepEqual(f.calls[0],['/api/volume',{muted:true},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,'65');assert.equal(f.element('ccVolumeValue').textContent,'65%');
|
||||
assert.match(f.element('ccAudioReadback').textContent,/Reported 40% · muted/);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 40%, muted/);
|
||||
await f.element('ccApplyVolume').onclick();
|
||||
assert.deepEqual(f.calls[1],['/api/volume',{level:.65},'frame-a']);
|
||||
assert.equal(f.element('ccVolume').value,65);
|
||||
assert.match(f.element('ccAudioResult').textContent,/Confirmed: 65%, muted/);
|
||||
});
|
||||
test('live samples expire even when the status poll is paused', t => {
|
||||
const original=Date.now;let now=100000;Date.now=()=>now;t.after(()=>{Date.now=original;});
|
||||
const f=mounted();t.after(()=>f.ui.destroy());f.ui.status(sample());
|
||||
now+=91000;
|
||||
// A connection update must also re-check freshness before enabling a control.
|
||||
f.ui.connection(true);
|
||||
assert.equal(f.element('ccApplyVolume').disabled,true);
|
||||
});
|
||||
test('mounted filters show empty state, counts, reset and host-dependent features', t => {
|
||||
const f=mounted();t.after(()=>f.ui.destroy());
|
||||
f.element('ccSearch').value='dpi';f.element('ccSearch').input();
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='display').hidden,false);
|
||||
assert.equal(f.element('ccResultCount').textContent,'1 section');
|
||||
f.element('ccSearch').value='nothing-matches-this';f.element('ccSearch').input();
|
||||
assert.equal(f.element('ccEmpty').hidden,false);
|
||||
f.element('ccResetFilter').onclick();assert.equal(f.element('ccEmpty').hidden,true);
|
||||
f.ui.host({os:'Windows'});assert.equal(f.element('macLink').hidden,true);
|
||||
assert.equal(f.cards.find(c=>c.dataset.ccCard==='mac').hidden,false);
|
||||
f.ui.host({os:'macOS'});assert.equal(f.element('macLink').hidden,false);
|
||||
f.ui.host({os:'SteamOS',mobile:true});assert.equal(f.element('macLink').hidden,true);
|
||||
f.element('connectionLink').onclick({preventDefault(){}});assert.equal(f.opened,1);
|
||||
});
|
||||
test('every linked workflow selects its real page and section, preserving shortcuts', () => {
|
||||
const html=fs.readFileSync(require.resolve('../ui/index.html'),'utf8');
|
||||
const start=html.indexOf('const PAGES ='),end=html.indexOf('window.addEventListener("hashchange", showPage);',start);
|
||||
const ids=new Set([...html.matchAll(/\bid="([^"]+)"/g)].map(m=>m[1]));
|
||||
let activePage,scrolled;
|
||||
const nodes=['home','games','android','tools','devices','control'].map(page=>({dataset:{page},classList:{toggle(key,on){if(on)activePage=page;}}}));
|
||||
const sections=new Map();
|
||||
const context={location:{hash:'#control'},document:{querySelectorAll:s=>s==='.page'?nodes:[],title:''},
|
||||
window:{scrollTo(){}},$:id=>{
|
||||
if(!ids.has(id))return null;
|
||||
if(!sections.has(id))sections.set(id,{hidden:false,scrollIntoView(){scrolled=id;}});
|
||||
return sections.get(id);
|
||||
},live:false,toggleLive(){},loadMacView(){},loadPanelSwitcher(){}};
|
||||
const androidStart=html.indexOf('function showAndroidTab(');
|
||||
const androidEnd=html.indexOf("document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick",androidStart);
|
||||
vm.createContext(context);vm.runInContext(html.slice(androidStart,androidEnd)+html.slice(start,end)+'\nPAGES.push("devices", "control");',context);
|
||||
vm.runInContext('showPage()',context);assert.equal(activePage,'control');
|
||||
const expected={view:'home',shots:'home',vrTools:'home',comfort:'home',pad:'home',library:'games',getgames:'games',sideloaded:'games',android:'android',display:'android',androidLibrary:'android',appStore:'android',
|
||||
power:'tools',transfer:'tools',media:'tools',apps:'tools',artworkSettings:'tools',privacy:'tools',panels:'tools',macview:'tools',devices:'devices'};
|
||||
for(const item of cc.catalog) for(const [href] of item.links||[]) {
|
||||
if(!href.startsWith('#')||href==='#connection')continue;
|
||||
const id=href.slice(1);assert.ok(ids.has(id)||['android','devices'].includes(id),href);
|
||||
context.location.hash=href;scrolled=null;vm.runInContext('showPage()',context);
|
||||
assert.equal(activePage,expected[id],href);
|
||||
if(!['android','devices'].includes(id))assert.equal(scrolled,id,href);
|
||||
if(id==='display'||id==='androidLibrary') { assert.equal(sections.get('androidLibrary').hidden,false);assert.equal(sections.get('appStore').hidden,true); }
|
||||
if(id==='appStore') { assert.equal(sections.get('appStore').hidden,false);assert.equal(sections.get('androidLibrary').hidden,true); }
|
||||
}
|
||||
assert.equal(vm.runInContext('PAGES[4]',context),'devices');
|
||||
assert.equal(vm.runInContext('PAGES[5]',context),'control');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
// 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", "queued",
|
||||
grab("esc") + grab("questSize") + "const META_VERDICT = {};\n" + grab("renderMetaApps") + "renderMetaApps();");
|
||||
render(id => els[id], metaLib, [], new Set(["meta:1"]), new Set(["meta:1"]));
|
||||
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'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");
|
||||
// Waiting in the install queue: marked, and not offered again.
|
||||
if (!/<span class="tag">Queued<\/span>/.test(climb) || !/data-meta-install="1" disabled/.test(climb)) fail("queued not shown: " + climb);
|
||||
if (/Queued|disabled/.test(saber)) fail("queued on the wrong row: " + saber);
|
||||
metaLib.apps = metaLib.apps.slice(0, 1);
|
||||
render(id => els[id], metaLib, [], new Set(), new Set());
|
||||
if (els.metaCount.textContent !== "1 app") fail("singular count: " + els.metaCount.textContent);
|
||||
console.log("ok");
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Control Center API guards and shared-page behavior, without a real headset."""
|
||||
import sandbox # noqa: F401 (first: isolates app state, SSH and telemetry)
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server
|
||||
import frame_status
|
||||
|
||||
|
||||
class AudioValidation(unittest.TestCase):
|
||||
def test_invalid_values_do_not_touch_device_even_with_valid_mute(self):
|
||||
invalid = [{}, {'level': None}, {'level': True}, {'level': '0.5'}, {'level': []},
|
||||
{'level': float('nan')}, {'level': float('inf')}, {'level': -1}, {'level': 2},
|
||||
{'muted': 'false'}, {'muted': 1}, {'muted': None}, {'action': 'mute'},
|
||||
{'level': -1, 'muted': True}, {'level': .5, 'muted': 'yes'}]
|
||||
for body in invalid:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh') as ssh:
|
||||
with self.assertRaises(server.Failure) as raised:
|
||||
server.set_volume(body)
|
||||
self.assertEqual(raised.exception.status, 400)
|
||||
ssh.assert_not_called()
|
||||
|
||||
def test_supported_actions_use_wpctl_with_exact_values(self):
|
||||
for body, commands in [({'level': 0}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.00']),
|
||||
({'level': 1}, ['wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.00']),
|
||||
({'muted': False}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 0']),
|
||||
({'level': .65, 'muted': True}, ['wpctl set-mute @DEFAULT_AUDIO_SINK@ 1',
|
||||
'wpctl set-volume @DEFAULT_AUDIO_SINK@ 0.65'])]:
|
||||
with self.subTest(body=body), mock.patch.object(server, 'ssh', return_value='') as ssh:
|
||||
self.assertEqual(server.set_volume(body)['message'], 'Volume updated')
|
||||
self.assertEqual([c.args[0] for c in ssh.call_args_list], commands)
|
||||
|
||||
def test_device_error_propagates_without_claiming_success(self):
|
||||
with mock.patch.object(server, 'ssh', side_effect=server.Failure('Headset offline')):
|
||||
with self.assertRaisesRegex(server.Failure, 'Headset offline'):
|
||||
server.set_volume({'level': .5})
|
||||
|
||||
|
||||
class Coverage(unittest.TestCase):
|
||||
@unittest.skipUnless(shutil.which('node'), 'Node exercises production page JS')
|
||||
def test_control_center_behavior(self):
|
||||
result = subprocess.run(['node', '--test', str(ROOT / 'tests/control_center.cjs')],
|
||||
capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
def test_shared_assets_ship_to_iphone_and_desktop(self):
|
||||
spec = importlib.util.spec_from_file_location('frame_bundle_cc', ROOT / 'ios/scripts/make_frame_bundle.py')
|
||||
bundle = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(bundle)
|
||||
files = set(bundle.files())
|
||||
for name in ('control-center.js', 'control-center.css', 'artwork-settings.js'):
|
||||
self.assertIn(ROOT / 'ui' / name, files)
|
||||
config = json.loads((ROOT / 'app/package.json').read_text())
|
||||
ui = next(item for item in config['build']['extraResources'] if item['from'] == '../ui')
|
||||
self.assertIn('*.js', ui['filter'])
|
||||
self.assertIn('*.css', ui['filter'])
|
||||
|
||||
def test_status_exposes_kernel_thermal_trips_without_guessing_thresholds(self):
|
||||
# Run the real aggregator with every external probe stubbed.
|
||||
names = ('read', 'process_names', 'performance', 'os_release', 'battery', 'power_source',
|
||||
'disk', 'memory', 'max_temp', 'wifi', 'ip_addr', 'volume', 'port_listening', 'games', 'flatpaks')
|
||||
with mock.patch.multiple(frame_status, **{name: mock.DEFAULT for name in names}) as probes, \
|
||||
mock.patch.object(frame_status, 'thermal_alerts', return_value=[{'zone':'battery','tempC':45,'limitC':44}]):
|
||||
probes['read'].return_value = '120 0'
|
||||
probes['process_names'].return_value = set()
|
||||
result = frame_status.status()
|
||||
self.assertEqual(result['thermalAlerts'], [{'zone':'battery','tempC':45,'limitC':44}])
|
||||
@@ -121,7 +121,27 @@ class LauncherTests(unittest.TestCase):
|
||||
FIXTURES = ROOT / 'tests/fixtures/library'
|
||||
|
||||
|
||||
def wait_for_idle_resolvers(timeout=10):
|
||||
# Name lookups that outlast their deadline give their slot back from their own thread,
|
||||
# which a busy runner may not schedule before the next test; wait until all are back.
|
||||
from apk_sources import _images
|
||||
held = []
|
||||
try:
|
||||
end = time.monotonic() + timeout
|
||||
while _images._resolvers.acquire(timeout=max(0, end - time.monotonic())):
|
||||
held.append(1)
|
||||
if len(held) == 4:
|
||||
return
|
||||
raise AssertionError('artwork name lookups from an earlier test are still running')
|
||||
finally:
|
||||
for _ in held:
|
||||
_images._resolvers.release()
|
||||
|
||||
|
||||
class ArtworkTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
wait_for_idle_resolvers()
|
||||
|
||||
def test_source_inputs_and_url(self):
|
||||
from apk_sources import _images
|
||||
data = (FIXTURES / 'icon.png').read_bytes()
|
||||
@@ -232,10 +252,7 @@ class ArtworkTests(unittest.TestCase):
|
||||
self.assertEqual(sum('Too many' in e for e in errors), 2)
|
||||
finally:
|
||||
gate.set()
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline and not _images._resolvers.acquire(blocking=False):
|
||||
time.sleep(0.01)
|
||||
_images._resolvers.release() # the stuck lookups finished and gave their slots back
|
||||
wait_for_idle_resolvers() # the stuck lookups finished and gave their slots back
|
||||
|
||||
def test_resolver_slot_released_when_thread_cannot_start(self):
|
||||
from apk_sources import _images
|
||||
@@ -243,10 +260,14 @@ class ArtworkTests(unittest.TestCase):
|
||||
for _ in range(6):
|
||||
with self.assertRaises(RuntimeError):
|
||||
_images.get('https://example.org/a.png', deadline=time.monotonic() + 1)
|
||||
for _ in range(4): # every slot came back
|
||||
self.assertTrue(_images._resolvers.acquire(blocking=False))
|
||||
for _ in range(4):
|
||||
_images._resolvers.release()
|
||||
held = 0
|
||||
try:
|
||||
while held < 4 and _images._resolvers.acquire(blocking=False):
|
||||
held += 1
|
||||
self.assertEqual(held, 4) # every slot came back
|
||||
finally:
|
||||
for _ in range(held): # even on failure, so later tests don't inherit the leak
|
||||
_images._resolvers.release()
|
||||
|
||||
def test_deadline_covers_name_resolution(self):
|
||||
import threading
|
||||
|
||||
@@ -18,6 +18,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -631,3 +632,257 @@ class PageQueue(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class PadEvents(unittest.TestCase):
|
||||
"""The trackpad and key row go through gamescope; accents and emoji go the KDE Connect way."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
import server
|
||||
cls.pad = staticmethod(server.pad_events)
|
||||
|
||||
def test_moves_clicks_and_scroll(self):
|
||||
out, extra = self.pad([{"dx": 3, "dy": -2}, {"singleclick": True}, {"singlehold": True},
|
||||
{"singlerelease": True}, {"scroll": True, "dy": 1}, {"rightclick": True}])
|
||||
self.assertEqual(out[0], {"dx": 3, "dy": -2})
|
||||
self.assertEqual(out[1:3], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||
self.assertEqual(out[3:5], [{"button": "left", "down": True}, {"button": "left", "down": False}])
|
||||
self.assertEqual(out[5], {"scroll": [0, -15]}) # up = content follows the finger
|
||||
self.assertEqual(out[6:], [{"button": "right", "down": True}, {"button": "right", "down": False}])
|
||||
self.assertEqual(extra, [])
|
||||
|
||||
def test_keys_and_modifiers(self):
|
||||
out, _ = self.pad([{"specialKey": 12}, {"specialKey": 1, "ctrl": True}, {"key": "c", "ctrl": True}])
|
||||
self.assertEqual(out[:2], [{"key": 28, "down": True}, {"key": 28, "down": False}])
|
||||
self.assertEqual([e["key"] for e in out[2:6]], [29, 14, 14, 29]) # Ctrl+Backspace
|
||||
self.assertEqual([(e["key"], e["down"]) for e in out[6:]],
|
||||
[(29, True), (46, True), (46, False), (29, False)]) # Ctrl+C
|
||||
|
||||
def test_text_stays_ascii_through_gamescope(self):
|
||||
out, slow = self.pad([{"key": "Hi "}, {"key": "there"}])
|
||||
self.assertEqual(out, [{"text": "Hi there"}])
|
||||
self.assertEqual(slow, [])
|
||||
|
||||
def test_keyboard_goes_to_kde_whole_and_in_order(self):
|
||||
events = [{"key": "a"}, {"key": "é"}, {"specialKey": 12}, {"key": "c", "ctrl": True}, {"dx": 2, "dy": 1}]
|
||||
out, slow = self.pad(events, kde=True)
|
||||
self.assertEqual(out, [{"dx": 2, "dy": 1}]) # the pointer still goes through gamescope
|
||||
self.assertEqual(slow, events[:4]) # the keyboard as sent, in order
|
||||
|
||||
def test_shifted_key_with_modifier(self):
|
||||
out, _ = self.pad([{"key": "A", "ctrl": True}])
|
||||
self.assertEqual([e["key"] for e in out], [29, 42, 30, 30, 42, 29])
|
||||
|
||||
|
||||
class PadDelivery(unittest.TestCase):
|
||||
"""remote_input's promises: "sent" means the whole batch is taken; accents go once, in order."""
|
||||
|
||||
class Agent:
|
||||
def __init__(self, sent=True, state="ready"):
|
||||
self.sent, self.state, self.got = sent, state, []
|
||||
|
||||
def send(self, events):
|
||||
if events and self.sent:
|
||||
self.got.append(events)
|
||||
return {"state": self.state, "sent": self.sent and bool(events)}
|
||||
|
||||
def setUp(self):
|
||||
import server
|
||||
self.s = server
|
||||
self.saved = (server._touch, server._input, server.KDE_WAIT)
|
||||
server._touch, server._input = self.Agent(), self.Agent()
|
||||
self.reset()
|
||||
|
||||
def tearDown(self):
|
||||
self.s._touch, self.s._input, self.s.KDE_WAIT = self.saved
|
||||
self.settle()
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.s._kde_queue.clear()
|
||||
self.s._kde_until = self.s._typed_until = 0.0
|
||||
self.s._kde_worker[0] = None
|
||||
|
||||
def settle(self):
|
||||
worker = self.s._kde_worker[0]
|
||||
if worker:
|
||||
worker.join(5)
|
||||
|
||||
def typed(self):
|
||||
return [e for batch in self.s._input.got for e in batch]
|
||||
|
||||
def test_accent_only_batch_is_acknowledged_and_typed_once(self):
|
||||
status = self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.assertTrue(status["sent"]) # so the page doesn't send it again
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
|
||||
def test_mixed_text_stays_in_one_ordered_stream(self):
|
||||
self.s.remote_input({"events": [{"key": "aéb"}, {"specialKey": 12}]})
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "aéb"}, {"specialKey": 12}])
|
||||
self.assertEqual(self.s._touch.got, []) # none of it split off through gamescope
|
||||
|
||||
def test_batch_gamescope_didnt_take_is_not_queued(self):
|
||||
self.s._touch = self.Agent(sent=False, state="starting")
|
||||
status = self.s.remote_input({"events": [{"dx": 1, "dy": 1}, {"key": "é"}]})
|
||||
self.assertFalse(status["sent"]) # the page tries the whole batch again...
|
||||
self.assertEqual(self.s._kde_queue, []) # ...so the accent isn't queued twice
|
||||
|
||||
def test_waits_for_kde_connect_then_sends_without_more_input(self):
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
time.sleep(0.2)
|
||||
self.assertEqual(self.s._kde_queue, [{"key": "é"}])
|
||||
self.s._input.sent = True
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
|
||||
def test_later_ascii_waits_behind_a_pending_accent(self):
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.s.remote_input({"events": [{"key": "x"}]})
|
||||
self.assertEqual(self.s._touch.got, []) # "x" can't overtake the "é"
|
||||
self.assertEqual(self.s._kde_queue, [{"key": "é"}, {"key": "x"}])
|
||||
self.s._input.sent = True
|
||||
self.settle()
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||
|
||||
def test_queue_is_bounded_and_dropped_on_error(self):
|
||||
self.s._input = self.Agent(sent=False, state="error")
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.s._input = self.Agent(sent=False, state="starting")
|
||||
self.s.KDE_WAIT = 0.3
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
self.assertLessEqual(len(self.s._kde_queue), self.s.KDE_QUEUE_LIMIT)
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
|
||||
def test_ascii_just_after_an_accent_stays_behind_it(self):
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.s.remote_input({"events": [{"key": "x"}]}) # KDE Connect may still be typing the é
|
||||
self.settle()
|
||||
self.assertEqual(self.s._touch.got, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "x"}])
|
||||
|
||||
def test_accent_just_after_gamescope_text_waits_for_it(self):
|
||||
self.s.remote_input({"events": [{"key": "x" * 50}]})
|
||||
self.assertEqual(self.s._touch.got, [[{"text": "x" * 50}]])
|
||||
start = time.time()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
self.settle()
|
||||
self.assertGreater(time.time() - start, 0.6) # 50 keys at 16 ms, plus the margin
|
||||
self.assertEqual(self.typed(), [{"key": "é"}])
|
||||
|
||||
def test_typing_time_counts_every_key_transition(self):
|
||||
t = self.s.typing_seconds
|
||||
self.assertAlmostEqual(t([{"text": "ab"}]), 4 * 0.008)
|
||||
self.assertAlmostEqual(t([{"text": "A"}]), 4 * 0.008) # shift down, key down, key up, shift up
|
||||
self.assertAlmostEqual(t([{"text": "x" * 500}]), 8.0) # a long paste
|
||||
self.assertAlmostEqual(t([{"key": 29, "down": True}, {"dx": 3, "dy": 0}]), 0.008)
|
||||
|
||||
def test_the_margin_is_added_once_not_per_request(self):
|
||||
for _ in range(10):
|
||||
self.s.remote_input({"events": [{"key": "ab"}]})
|
||||
# ten requests of 4 transitions each: the work queues up, with no margin added per request
|
||||
self.assertLess(self.s._typed_until - time.time(), 10 * 4 * 0.008 + 0.05)
|
||||
|
||||
def test_trimming_while_sending_doesnt_drop_unsent_events(self):
|
||||
slow = self.Agent()
|
||||
gate = threading.Event()
|
||||
send = slow.send
|
||||
|
||||
def held(events):
|
||||
gate.wait(5)
|
||||
return send(events)
|
||||
slow.send = held
|
||||
self.s._input = slow
|
||||
self.s.remote_input({"events": [{"key": "é"}] * 150})
|
||||
time.sleep(0.2) # the worker has taken its 150 and is mid-send
|
||||
self.s.remote_input({"events": [{"key": "ü"}] * 150})
|
||||
gate.set()
|
||||
self.settle()
|
||||
sent = self.typed()
|
||||
self.assertEqual(sent[:150], [{"key": "é"}] * 150)
|
||||
self.assertEqual(sent[150:], [{"key": "ü"}] * 150) # none of the new ones went missing
|
||||
|
||||
def test_input_queued_as_the_worker_finishes_is_still_sent(self):
|
||||
for _ in range(40):
|
||||
self.reset()
|
||||
self.s._input.got.clear()
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
time.sleep(0.002)
|
||||
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||
self.settle()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertEqual(self.typed(), [{"key": "é"}, {"key": "ü"}])
|
||||
|
||||
|
||||
class PadRoute(unittest.TestCase):
|
||||
def setUp(self):
|
||||
import server
|
||||
self.s = server
|
||||
self.agents = mock.patch.multiple(server, _touch=mock.Mock(), _input=mock.Mock())
|
||||
self.agents.start()
|
||||
self.addCleanup(self.agents.stop)
|
||||
self.s._touch.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s._input.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s.reset_pad_route()
|
||||
self.s._touch.reset_mock(); self.s._input.reset_mock()
|
||||
self.addCleanup(self.s.reset_pad_route)
|
||||
|
||||
def switch(self):
|
||||
with mock.patch.multiple(self.s, FRAME="frame-a", HOST_OPTS=[], MUX=[], SSH=[], macview=None), \
|
||||
mock.patch.multiple(self.s.frame_android, FRAME="frame-a", SSH_OPTS=[]):
|
||||
self.s.route("frame-b", [])
|
||||
|
||||
def test_switch_stops_both_input_agents_and_discards_old_queue(self):
|
||||
self.s._kde_queue.append({"key": "old"})
|
||||
self.s._kde_until = self.s._typed_until = time.time() + 60
|
||||
cancel = self.s._kde_cancel
|
||||
self.switch()
|
||||
self.s._input.stop.assert_called_once()
|
||||
self.s._touch.stop.assert_called_once()
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
self.assertEqual((self.s._kde_until, self.s._typed_until), (0, 0))
|
||||
self.assertTrue(cancel.is_set())
|
||||
|
||||
def test_switch_cancels_delayed_and_retrying_text_before_new_input(self):
|
||||
for delaying in (True, False):
|
||||
with self.subTest(delaying=delaying):
|
||||
waiting, cancel = threading.Event(), threading.Event()
|
||||
class ObservedCancel:
|
||||
def wait(self, timeout):
|
||||
waiting.set()
|
||||
return cancel.wait(timeout)
|
||||
def set(self):
|
||||
cancel.set()
|
||||
self.s.reset_pad_route()
|
||||
self.s._kde_cancel = ObservedCancel()
|
||||
self.s._typed_until = time.time() + 60 if delaying else 0
|
||||
self.s._input.reset_mock()
|
||||
self.s._input.send.return_value = {"state": "starting", "sent": False}
|
||||
self.s.remote_input({"events": [{"key": "é"}]})
|
||||
worker = self.s._kde_worker[0]
|
||||
self.assertTrue(waiting.wait(1), "worker never reached its cancellable delay")
|
||||
self.switch()
|
||||
worker.join(1)
|
||||
self.assertFalse(worker.is_alive())
|
||||
attempts = self.s._input.send.call_count
|
||||
self.assertEqual(attempts, 0 if delaying else 1)
|
||||
self.s._input.send.return_value = {"state": "ready", "sent": True}
|
||||
self.s.remote_input({"events": [{"key": "ü"}]})
|
||||
next_worker = self.s._kde_worker[0]
|
||||
if next_worker:
|
||||
next_worker.join(1)
|
||||
self.assertFalse(next_worker.is_alive())
|
||||
self.assertEqual(self.s._input.send.call_count, attempts + 1)
|
||||
self.assertEqual(self.s._input.send.call_args.args[0], [{"key": "ü"}])
|
||||
self.assertEqual(self.s._kde_queue, [])
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The install queue (server.queue_job): Android, Quest and Meta installs run one after another."""
|
||||
import sandbox # noqa: F401 (first: keeps tests off real data and services)
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / 'ui'))
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
def wait(job, timeout=5):
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
j = server.job_status(f'id={job}')
|
||||
if j['done']:
|
||||
return j
|
||||
time.sleep(0.01)
|
||||
raise AssertionError(f'job {job} never finished')
|
||||
|
||||
|
||||
class InstallQueue(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertEqual(server._queue, [])
|
||||
|
||||
def blocker(self):
|
||||
"""A queued install that runs until released."""
|
||||
go, started = threading.Event(), threading.Event()
|
||||
|
||||
def work():
|
||||
started.set()
|
||||
go.wait(5)
|
||||
return {'message': 'first done'}
|
||||
job = server.queue_job('Install first', work)['job']
|
||||
self.assertTrue(started.wait(5))
|
||||
self.addCleanup(go.set)
|
||||
return job, go
|
||||
|
||||
def test_runs_in_order_one_at_a_time(self):
|
||||
order, running = [], []
|
||||
|
||||
def work(name):
|
||||
def run(report):
|
||||
running.append(name)
|
||||
self.assertEqual(len(running), 1, 'two installs ran at once')
|
||||
report('Copying', 50)
|
||||
time.sleep(0.02)
|
||||
order.append(name)
|
||||
running.remove(name)
|
||||
return {'message': f'{name} done'}
|
||||
return run
|
||||
first, go = self.blocker()
|
||||
started = [server.queue_job(f'Install {n}', work(n), progress=True) for n in ('a', 'b', 'c')]
|
||||
self.assertEqual([s['queued'] for s in started], [1, 2, 3])
|
||||
self.assertIn('queued, 3 before it', started[2]['message'])
|
||||
q = server.install_queue()['jobs']
|
||||
self.assertEqual([j['label'] for j in q], ['Install first', 'Install a', 'Install b', 'Install c'])
|
||||
self.assertEqual([j['queued'] for j in q], [False, True, True, True])
|
||||
self.assertEqual(q[1]['stage'], 'Queued: 1 install before it')
|
||||
self.assertEqual(q[3]['stage'], 'Queued: 3 installs before it')
|
||||
go.set()
|
||||
self.assertEqual(wait(first)['message'], 'first done')
|
||||
for s, n in zip(started, 'abc'):
|
||||
j = wait(s['job'])
|
||||
self.assertEqual((j['message'], j['error'], j['queued']), (f'{n} done', None, False))
|
||||
self.assertEqual(order, ['a', 'b', 'c'])
|
||||
self.assertEqual(server.install_queue(), {'jobs': []})
|
||||
|
||||
def test_a_failure_does_not_stop_the_rest(self):
|
||||
def fail():
|
||||
raise server.Failure('no room on the Frame')
|
||||
bad = server.queue_job('Install bad', fail)['job']
|
||||
good = server.queue_job('Install good', lambda: {'message': 'ok'})['job']
|
||||
self.assertEqual(wait(bad)['error'], 'no room on the Frame')
|
||||
self.assertEqual(wait(good)['message'], 'ok')
|
||||
|
||||
def test_a_waiting_install_can_be_removed_but_not_a_running_one(self):
|
||||
ran = []
|
||||
first, go = self.blocker()
|
||||
second = server.queue_job('Install second', lambda: ran.append(2) or {})['job']
|
||||
third = server.queue_job('Install third', lambda: ran.append(3) or {})['job']
|
||||
self.assertIn('Removed Install second', server.queue_action({'action': 'remove', 'job': second})['message'])
|
||||
j = server.job_status(f'id={second}')
|
||||
self.assertTrue(j['done'] and j['cancelled'])
|
||||
self.assertEqual(server.job_status(f'id={third}')['stage'], 'Queued: 1 install before it')
|
||||
for body, status in (({'action': 'remove', 'job': first}, 409), ({'action': 'remove', 'job': second}, 409),
|
||||
({'action': 'nope', 'job': third}, 400)):
|
||||
with self.assertRaises(server.Failure) as e:
|
||||
server.queue_action(body)
|
||||
self.assertEqual(e.exception.status, status)
|
||||
go.set()
|
||||
wait(third)
|
||||
self.assertEqual(ran, [3])
|
||||
|
||||
def test_runs_only_on_the_headset_it_was_queued_for(self):
|
||||
class Link:
|
||||
device = 'frame-a'
|
||||
|
||||
def active_device(self):
|
||||
return {'id': self.device}
|
||||
link = Link()
|
||||
with patch.object(server, 'LINK', link):
|
||||
first, go = self.blocker()
|
||||
ran = []
|
||||
later = server.queue_job('Install later', lambda: ran.append(1) or {})['job']
|
||||
link.device = 'frame-b'
|
||||
go.set()
|
||||
self.assertIn('switched headsets', wait(later)['error'])
|
||||
self.assertEqual(ran, [])
|
||||
|
||||
def test_installs_from_each_place_are_queued(self):
|
||||
with patch.object(server, 'ensure_master'), patch.object(server, 'queue_job', return_value={'job': 'j'}) as q:
|
||||
server.quest({'action': 'import', 'package': 'com.example.game'})
|
||||
server.meta({'action': 'install', 'app': '123'})
|
||||
with patch.object(server.frame_catalog, 'app'):
|
||||
server.android({'action': 'install', 'package': 'com.example.game'})
|
||||
server.android({'action': 'install', 'package': 'com.example.game', 'url': 'https://f-droid.org/x.apk'})
|
||||
with patch.object(server.apk_search, 'resolve', return_value=(None, {'enabled': True})):
|
||||
server.source_install({'source': 'fdroid', 'id': 'com.example.game'})
|
||||
self.assertEqual(q.call_count, 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+1266
File diff suppressed because it is too large.
Load diff
+30
-15
@@ -240,17 +240,22 @@ class Inspection(unittest.TestCase):
|
||||
self.addCleanup(os.unlink, tmp.name)
|
||||
return tmp.name
|
||||
|
||||
def test_meta_licence_check_refused_in_base_or_split(self):
|
||||
def test_meta_licence_check_warned_not_refused_in_base_or_split(self):
|
||||
# Linking the Platform SDK doesn't mean the check is enforced: install, with a warning.
|
||||
base = self.write(apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''})))
|
||||
with self.assertRaises(quest.QuestError) as e:
|
||||
quest.inspect(PKG, base)
|
||||
self.assertIn('libovrplatformloader.so', str(e.exception))
|
||||
self.assertIn("won't remove or work around", str(e.exception))
|
||||
self.assertIn('only exist on Meta headsets', str(e.exception))
|
||||
result = quest.inspect(PKG, base)
|
||||
self.assertTrue(result['platform_sdk'])
|
||||
self.assertEqual(len(result['warnings']), 1)
|
||||
warning = result['warnings'][0]
|
||||
self.assertIn("uses meta's licence check", warning.lower())
|
||||
self.assertIn('may quit, hang, or run without online features', warning)
|
||||
self.assertIn("Frame Control won't remove the check", warning)
|
||||
base = self.write(apk_bytes(split_required=True))
|
||||
split = self.write(apk_bytes(split='config.arm64_v8a', files={'lib/arm64-v8a/libovrplatformloader.so': b''}))
|
||||
with self.assertRaisesRegex(quest.QuestError, 'licence'):
|
||||
quest.inspect(PKG, base, [split])
|
||||
result = quest.inspect(PKG, base, [split])
|
||||
self.assertTrue(result['platform_sdk'])
|
||||
self.assertIn('licence check', result['warnings'][0])
|
||||
self.assertFalse(quest.inspect(PKG, self.write(apk_bytes(files=OPENXR)))['platform_sdk'])
|
||||
|
||||
def test_32_bit_only_refused(self):
|
||||
base = self.write(apk_bytes(files={'lib/armeabi-v7a/libgame.so': b''}))
|
||||
@@ -261,6 +266,7 @@ class Inspection(unittest.TestCase):
|
||||
base = self.write(apk_bytes(files={'lib/arm64-v8a/libvrapi.so': b'', 'lib/arm64-v8a/libgame.so': b''}))
|
||||
result = quest.inspect(PKG, base)
|
||||
self.assertIn('VrApi', result['warnings'][0])
|
||||
self.assertIn('may not run', result['warnings'][0])
|
||||
result = quest.inspect(PKG, self.write(apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libvrapi.so': b''}))))
|
||||
self.assertEqual(result['warnings'], [])
|
||||
self.assertTrue(result['vr'])
|
||||
@@ -707,18 +713,27 @@ class Import(unittest.TestCase):
|
||||
self.assertEqual(len(self.pulls(fake)), 2)
|
||||
self.assertEqual(self.cache_left(), [])
|
||||
|
||||
def test_licence_check_installs_with_a_warning(self):
|
||||
fake = FakeQuest(self)
|
||||
fake.add(PKG, [apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''}))])
|
||||
result = quest.import_game(PKG)
|
||||
self.assertEqual(self.order, [('install', 'base.apk', [])])
|
||||
self.assertIn("Meta's licence check", result['message'])
|
||||
self.assertIn("Frame Control won't remove the check", result['message'])
|
||||
self.assertEqual(result['warnings'], [quest.ENTITLEMENT])
|
||||
self.assertEqual(self.cache_left(), [])
|
||||
|
||||
def test_refusals_copy_nothing_more_and_clean_up(self):
|
||||
fake = FakeQuest(self)
|
||||
fake.add(PKG, [apk_bytes(files=dict(OPENXR, **{'lib/arm64-v8a/libovrplatformloader.so': b''}))],
|
||||
obb={f'main.1.{PKG}.obb': b'big'})
|
||||
self.open_on_frame()
|
||||
with self.assertRaisesRegex(quest.QuestError, 'licence'):
|
||||
quest.import_game(PKG)
|
||||
self.running = False
|
||||
fake.add('org.example.old', [apk_bytes('org.example.old', files={'lib/armeabi-v7a/libgame.so': b''})],
|
||||
obb={'main.1.org.example.old.obb': b'big'})
|
||||
with self.assertRaisesRegex(quest.QuestError, '32-bit'):
|
||||
quest.import_game('org.example.old')
|
||||
fake.add(PKG, [apk_bytes(files=OPENXR)], obb={f'main.1.{PKG}.obb': b'big'})
|
||||
real_info = android.apk_info
|
||||
with patch.object(android, 'apk_info', side_effect=lambda p: dict(real_info(p), min_sdk=31)):
|
||||
with self.assertRaisesRegex(android.FrameError, 'API 31'):
|
||||
quest.import_game(PKG)
|
||||
self.assertEqual(self.order, [])
|
||||
self.assertFalse([p for p in self.pulls(fake) if '/obb/' in p])
|
||||
self.assertEqual(self.cache_left(), [])
|
||||
@@ -1115,7 +1130,7 @@ class Server(unittest.TestCase):
|
||||
with self.assertRaises(s.Failure) as e:
|
||||
s.quest(body)
|
||||
self.assertEqual(e.exception.status, 400, body)
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'start_job', return_value={'job': 'j'}) as job:
|
||||
with patch.object(s, 'ensure_master'), patch.object(s, 'queue_job', return_value={'job': 'j'}) as job:
|
||||
self.assertEqual(s.quest({'action': 'import', 'package': PKG, 'serial': QUEST['serial'], 'data': True}),
|
||||
{'job': 'j'})
|
||||
self.assertTrue(job.call_args.kwargs['progress'])
|
||||
|
||||
@@ -79,6 +79,15 @@ class ServerGuards(unittest.TestCase):
|
||||
self.assertEqual(headers["X-Frame-Options"], "DENY")
|
||||
self.assertIn(b"<html", payload.lower())
|
||||
|
||||
def test_control_center_assets_served_with_correct_types(self):
|
||||
for path, mime, marker in (('/control-center.js', 'text/javascript', b'createController'),
|
||||
('/control-center.css', 'text/css', b'.cc-workspace')):
|
||||
with self.subTest(path=path):
|
||||
status, headers, payload = self.request('GET', path)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(mime, headers['Content-Type'])
|
||||
self.assertIn(marker, payload)
|
||||
|
||||
def test_foreign_host_rejected(self):
|
||||
# DNS rebinding: a hostile name pointed at 127.0.0.1.
|
||||
for path in ("/", "/api/status"):
|
||||
@@ -463,6 +472,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):
|
||||
|
||||
@@ -159,6 +159,42 @@ class Lifecycle(Base):
|
||||
e = self.queued()[-1]
|
||||
self.assertEqual((e["event"], e["properties"]["from_version"]), ("app_updated", "9.9.9"))
|
||||
|
||||
def test_opens_installs_and_updates_carry_the_country(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_VERSION": "10.0.0"}):
|
||||
tm.app_started()
|
||||
self.assertEqual([(e["event"], e["properties"].get("country")) for e in self.queued()],
|
||||
[("app_installed", "DE"), ("app_opened", "DE"), ("app_updated", "DE")])
|
||||
self.assertNotIn("Europe/Berlin", tm.OUTBOX.read_text())
|
||||
|
||||
def test_only_zones_in_the_tz_table_give_a_country(self):
|
||||
for tz, want in (("America/Argentina/Buenos_Aires", "AR"), ("Asia/Calcutta", "IN"), ("US/Pacific", "US"),
|
||||
("Europe/Vaduz", "LI"), ("Africa/Asmera", "ER"), ("Africa/Asmara", "ER"), ("Pacific/Truk", "FM"),
|
||||
("Pacific/Ponape", "FM"), ("America/Montreal", "CA"), ("America/Nassau", "BS"),
|
||||
("Europe/Amsterdam", "NL"), ("Etc/GMT+10", None), ("CET", None),
|
||||
("EET", None), ("WET", None), ("PST8PDT", None), ("HST", None), ("Pacific/Johnston", "UM"),
|
||||
("Japan", "JP"), ("UTC", None), ("alice", None),
|
||||
("home/alex/private", None), ("America/alex", None), ("../etc/passwd", None)):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": tz}):
|
||||
self.assertEqual(tm.country(), want, tz)
|
||||
|
||||
def test_country_from_tz_or_localtime_in_a_checkout(self):
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ":Asia/Tokyo"}):
|
||||
self.assertEqual(tm.country(), "JP")
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "", "TZ": ""}):
|
||||
for target, want in (("/usr/share/zoneinfo/Europe/Paris", "FR"),
|
||||
("/var/db/timezone/zoneinfo/Australia/Sydney", "AU"),
|
||||
("/home/alex/zoneinfo/alex/private", None), ("/etc/mytime", None)):
|
||||
with mock.patch.object(tm.os.path, "realpath", return_value=target):
|
||||
self.assertEqual(tm.country(), want, target)
|
||||
|
||||
def test_a_new_user_who_opens_settings_first_still_gets_the_notice(self):
|
||||
tm.update_settings({"compat": True})
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
|
||||
def test_frame_build_once(self):
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
tm.frame_seen("20260922.1", "3.8")
|
||||
@@ -230,6 +266,39 @@ class Sending(Base):
|
||||
self.assertEqual(self.queued(), [])
|
||||
self.assertEqual([e["event"] for e in tm.state()["sent"]], ["app_opened"])
|
||||
|
||||
def test_an_older_notice_still_sends(self):
|
||||
got = self.serve()
|
||||
tm._save({**tm._defaults(), "notice_shown": True, "notice_version": 1})
|
||||
tm.capture("app_opened")
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual(len(got), 1)
|
||||
|
||||
def test_an_older_notice_is_shown_again_and_holds_back_the_country(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # as 0.4.5 saved it
|
||||
self.assertFalse(tm.state()["noticeShown"])
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1) # the usual statistics still go
|
||||
tm.update_settings({"noticeShown": True})
|
||||
self.assertTrue(tm.state()["noticeShown"])
|
||||
tm._save({**tm.settings(), "last_open_day": None}) # the next day
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertEqual([b["batch"][0]["properties"].get("country") for _, b in got], [None, "DE"])
|
||||
|
||||
def test_turning_usage_on_still_waits_for_the_new_notice(self):
|
||||
got = self.serve()
|
||||
tm.SETTINGS.write_text(json.dumps({"id": "x", "usage": False, "notice_shown": True, "installed_sent": True,
|
||||
"last_version": "9.9.9"})) # 0.4.5, usage off
|
||||
tm.update_settings({"usage": True})
|
||||
self.assertFalse(tm.state()["noticeShown"]) # so the page shows it
|
||||
with mock.patch.dict(os.environ, {"FRAME_CONTROL_TIMEZONE": "Europe/Berlin"}):
|
||||
tm.app_started()
|
||||
self.assertEqual(tm.flush(), 1)
|
||||
self.assertNotIn("country", got[0][1]["batch"][0]["properties"])
|
||||
|
||||
def test_a_failed_send_keeps_the_events(self):
|
||||
self.serve(status=500)
|
||||
tm.update_settings({"noticeShown": True})
|
||||
|
||||
@@ -150,6 +150,14 @@ class Apply(unittest.TestCase):
|
||||
self.assertFalse(self.t.STALE[0])
|
||||
self.assertEqual(self.said[-1][1].get("stale"), None)
|
||||
|
||||
def test_stale_survives_a_bare_release(self):
|
||||
gs = FakeGamescope()
|
||||
panel = self.t.apply(gs, {"button": "left", "down": True, "window": 99, "display": ":1"}, None)
|
||||
self.assertTrue(self.t.STALE[0])
|
||||
self.t.apply(gs, {"button": "left", "down": False}, panel) # the page's release names no panel
|
||||
self.assertTrue(self.t.STALE[0])
|
||||
self.assertEqual(self.said[-1][1].get("stale"), True)
|
||||
|
||||
def test_same_window_id_on_the_other_display_is_another_panel(self):
|
||||
gs = FakeGamescope()
|
||||
self.t.apply(gs, {"fx": 0.5, "fy": 0.5, "window": 7, "display": ":0"}, None)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Local to the sixth tab: the existing Home, Games, Android and Tools keep
|
||||
their layouts. The same Steam palette gives the dashboard a quieter rhythm. */
|
||||
.cc { gap:24px; }
|
||||
.cc :focus-visible { outline:2px solid var(--link); outline-offset:4px; }
|
||||
.cc-hero { display:flex; justify-content:space-between; align-items:center; gap:24px; padding:28px 30px;
|
||||
border:1px solid #66c0f421; border-radius:12px; background:radial-gradient(ellipse at 90% 0%,#1a9fff19,transparent 65%),linear-gradient(125deg,#21364a,#152230); }
|
||||
.cc-eyebrow { font-size:11px; font-weight:700; letter-spacing:2px; text-transform:uppercase; color:var(--link); margin-bottom:7px; }
|
||||
.cc-hero h1 { font-size:clamp(25px,3vw,36px); font-weight:500; letter-spacing:-.7px; margin:0 0 8px; color:white; }
|
||||
.cc-hero p { color:#adb9c5; margin:0; max-width:580px; }
|
||||
.cc-hero > div { min-width:0; }
|
||||
.cc-hero-mark { flex:none; width:114px; height:82px; color:var(--link); opacity:.85; }
|
||||
.cc-topline { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.cc-topline h2 { font-size:17px; font-weight:500; margin:0; color:var(--bright); min-width:0; max-width:100%; overflow-wrap:anywhere; }
|
||||
.cc-topline button { margin-left:auto; border-radius:6px; }
|
||||
.cc-sample { font-size:12px; color:var(--muted); }.cc-offline { color:#cfb787; }
|
||||
.cc-summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:14px; }
|
||||
.cc-metric { border:1px solid #ffffff0c; border-radius:9px; padding:18px 20px; background:linear-gradient(145deg,#25374a80,#18232f); min-width:0; }
|
||||
.cc-metric > span { color:#a7b8c8; font-size:12px; }.cc-metric > strong { display:block; color:white; font-size:clamp(20px,2.2vw,30px); font-weight:500; margin:7px 0 3px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-metric > small { color:var(--muted); display:block; font-size:11.5px; }
|
||||
.cc-device-facts { margin:14px 0 0; padding:16px 20px; border:1px solid #ffffff0a; border-radius:9px; background:#10192366; }
|
||||
.cc-device-facts summary { cursor:pointer; color:#b8cbd9; font-size:12px; }.cc-device-facts dl { margin:18px 0 0; }
|
||||
.cc-readings { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px 18px; padding:0; }
|
||||
.cc-readings div { min-width:0; }.cc-readings dt { color:var(--muted); font-size:11px; }.cc-readings dd { margin:3px 0 0; color:#d9e7f2; font-size:13px; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
|
||||
.cc-device-facts .cc-readings { grid-template-columns:repeat(4,minmax(0,1fr)); }
|
||||
.cc-services { display:flex; flex-wrap:wrap; gap:12px 22px; border-top:1px solid #ffffff0b; margin-top:18px; padding-top:14px; }
|
||||
.cc-services > span { display:flex; gap:7px; align-items:center; font-size:11px; color:#b3c5d4; }.cc-services b { color:var(--muted); font-weight:400; }
|
||||
.cc-workspace { display:grid; grid-template-columns:220px minmax(0,1fr); gap:26px; align-items:start; }
|
||||
.cc-workspace > * { min-width:0; width:100%; max-width:100%; }
|
||||
.cc-sidebar { position:sticky; top:90px; }.cc-sidebar > p { margin:0 0 12px 12px; color:var(--muted); font-size:10px; text-transform:uppercase; letter-spacing:1.5px; }
|
||||
.cc-categories { display:flex; flex-direction:column; gap:5px; min-width:0; max-width:100%; }.cc-categories button { display:flex; align-items:center; text-align:left; gap:10px; border-radius:7px; background:transparent; min-height:43px; padding:10px 12px; color:#a8b8c8; font-size:12px; }
|
||||
.cc-categories svg { width:17px; height:17px; flex:none; }.cc-category-count { margin-left:auto; font-size:11px; opacity:.65; }
|
||||
.cc-categories button:hover { background:#66c0f40a; color:white; }.cc-categories button[aria-pressed=true] { background:#1a9fff1a; color:var(--link); box-shadow:inset 2px 0 var(--blue); }
|
||||
.cc-status { margin-top:24px; padding:18px 14px; border-top:1px solid #ffffff10; }.cc-status > div { display:flex; align-items:center; gap:8px; font-size:12px; color:#c4d2dc; }.cc-status p { margin:10px 0 0; color:var(--muted); font-size:11.5px; line-height:1.6; }
|
||||
.cc-search-row { display:flex; align-items:center; gap:14px; margin-bottom:18px; }.cc-search { display:flex; align-items:center; gap:10px; border:1px solid #66c0f426; border-radius:8px; padding:0 12px; background:#0e1824; flex:1; min-width:0; }
|
||||
.cc-search:focus-within { border-color:var(--blue); }.cc-search svg { width:18px; color:var(--muted); flex:none; }.cc-search input { width:100%; min-width:0; padding:13px 0; border:0; background:transparent; font-size:13px; }.cc-search input:focus { outline:none; }.cc-search button { padding:3px 7px; background:transparent; color:var(--muted); font-size:18px; }.cc-results { color:var(--muted); font-size:11px; white-space:nowrap; }
|
||||
.cc-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.cc-card { display:flex; flex-direction:column; min-width:0; padding:22px; background:linear-gradient(150deg,#202e3d,#17212c); border:1px solid #ffffff0e; border-radius:10px; }
|
||||
.cc-card-head { display:flex; gap:10px; align-items:center; justify-content:space-between; margin-bottom:16px; }.cc-icon { width:34px; height:34px; display:grid; place-items:center; background:#66c0f40c; border-radius:8px; color:#85bfe2; }.cc-icon svg { width:20px; height:20px; }
|
||||
.cc-kind { font-size:10px; padding:3px 7px; border-radius:4px; color:#a2b3c2; background:#ffffff06; letter-spacing:.2px; }.cc-kind-control { color:#92cdeb; background:#1a9fff12; }.cc-kind-headset { color:#d3c095; background:#d9a23a0b; }.cc-kind-read { color:#9fceb9; background:#59bf4009; }
|
||||
.cc-card h2 { font-size:17px; font-weight:500; color:white; margin:0 0 8px; }.cc-card > p { font-size:12.5px; color:#a4b4c3; line-height:1.65; margin:0 0 14px; }
|
||||
.cc-guidance { padding:11px 12px; border-left:2px solid #9e876544; border-radius:0 4px 4px 0; background:#ffffff03; color:#bcb7aa; font-size:11.5px; line-height:1.6; margin-bottom:14px; }
|
||||
.cc-links { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:auto; padding-top:8px; }.cc-links a { display:inline-flex; gap:8px; align-items:center; color:var(--link); font-size:12px; text-decoration:none; }.cc-links a:hover { color:white; text-decoration:underline; }.cc-links a span { font-size:14px; opacity:.65; }
|
||||
.cc-note { font-size:11px; line-height:1.6; color:var(--muted); }.cc-card p.cc-note { margin:10px 0 0; }.cc-audio label { display:flex; justify-content:space-between; font-size:12px; color:#b7c8d6; }.cc-audio output { color:var(--link); font-variant-numeric:tabular-nums; }.cc-audio input { width:100%; margin:15px 0; }.cc-audio-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }.cc-audio-actions button { border-radius:5px; }.cc-audio-actions span { flex-basis:100%; margin-top:4px; }.cc-audio input:disabled { opacity:.35; }
|
||||
.cc-empty { padding:40px 24px; border:1px dashed #66c0f433; border-radius:10px; text-align:center; color:var(--muted); }.cc-empty h2 { color:white; font-size:18px; font-weight:500; }.cc-empty button { margin-top:12px; border-radius:5px; }
|
||||
@media (min-width:641px) and (max-width:1250px) { .brand b { display:none; } header { gap:14px; } nav a { padding:0 9px; font-size:13px; } .pill { min-width:120px; } }
|
||||
@media (min-width:641px) and (max-width:900px) { html { scroll-padding-top:116px; } header { flex-wrap:wrap; height:auto; min-height:64px; padding-top:8px; padding-bottom:8px; gap:6px 12px; } nav { order:3; width:100%; height:36px; } nav a { flex:1; justify-content:center; padding:0 6px; } .hdr-end { flex:1; justify-content:flex-end; } .cc-sidebar { top:115px; } }
|
||||
@media (max-width:1100px) { .cc-workspace { grid-template-columns:185px minmax(0,1fr); gap:18px; }.cc-card { padding:18px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media (max-width:900px) { .cc-workspace { grid-template-columns:minmax(0,1fr); }.cc-sidebar { position:static; }.cc-sidebar > p,.cc-status { display:none; }.cc-categories { flex-direction:row; overflow-x:auto; padding-bottom:6px; }.cc-categories button { flex:none; white-space:nowrap; }.cc-category-count { margin-left:2px; }.cc-categories button[aria-pressed=true] { box-shadow:inset 0 -2px var(--blue); }.cc-summary { gap:10px; }.cc-metric { padding:15px; } }
|
||||
@media (max-width:640px) { .cc { gap:20px; }.cc-hero { padding:22px 20px; }.cc-hero p { font-size:12px; }.cc-hero-mark { width:72px; height:55px; }.cc-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-metric > strong { font-size:24px; }.cc-topline { gap:8px; }.cc-sample { order:3; width:100%; }.cc-device-facts { padding:14px; }.cc-device-facts .cc-readings { grid-template-columns:repeat(2,minmax(0,1fr)); }.cc-cards { grid-template-columns:minmax(0,1fr); gap:12px; }.cc-search-row { gap:8px; }.cc-results { font-size:10px; }.cc-card { padding:20px; }.cc-card h2 { font-size:18px; } }
|
||||
@media (max-width:640px), (max-height:500px) and (hover:none) { .cc-links a { min-height:44px; }.cc-audio-actions button,.cc-topline button { min-height:44px; }.cc-search button { min-width:36px; min-height:44px; } }
|
||||
@@ -0,0 +1,318 @@
|
||||
/* The Control Center uses the shared page's guarded api() and status poll.
|
||||
* Browsing never starts a stream, installs a helper or changes the headset. */
|
||||
(function (root, factory) {
|
||||
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||
else root.FrameControlCenter = factory();
|
||||
})(typeof window === 'object' ? window : globalThis, function () {
|
||||
'use strict';
|
||||
const categories = [
|
||||
['all', 'Everything', 'grid'], ['device', 'Device & audio', 'device'],
|
||||
['vr', 'VR & comfort', 'eye'], ['apps', 'Apps & files', 'apps'],
|
||||
['network', 'Connection & remote', 'network'], ['companion', 'Frame Control', 'settings'],
|
||||
];
|
||||
// Each destination is an existing workflow, with its own guards and confirmations.
|
||||
const catalog = [
|
||||
{id:'audio', category:'device', icon:'audio', title:'Audio', kind:'control', label:'Available here',
|
||||
description:'Set the default headset output volume or mute it. Changes apply only when you choose Apply or Mute.',
|
||||
keywords:'sound speakers headphones loud quiet', special:'audio'},
|
||||
{id:'health', category:'device', icon:'pulse', title:'Battery & thermal health', kind:'read', label:'Read-only metrics',
|
||||
description:'Inspect charge, signed battery power, charger capability, battery temperature and health. Heat warnings use the kernel’s own hot and critical trip points.',
|
||||
keywords:'battery charging charger charge time estimate remaining power watts usb health heat temperature thermal sensor', special:'health', links:[['#comfort','Configure battery & heat alerts']]},
|
||||
{id:'storage', category:'device', icon:'files', title:'Storage & memory', kind:'read', label:'Read-only metrics',
|
||||
description:'Check free user and system storage plus available RAM. Manage installed apps in their existing libraries, or use SFTP for files.',
|
||||
keywords:'disk space root home free total ram memory capacity cleanup usage storage', special:'storage', links:[['#library','Installed games'],['#apps','Linux apps'],['#power','SFTP & remote tools']]},
|
||||
{id:'microphone', category:'device', icon:'audio', title:'Microphone & audio routing', kind:'headset', label:'In the headset',
|
||||
description:'Frame Control controls the default output volume only. Microphone mute, input gain, output selection, balance and per-app mixers have no verified API here.',
|
||||
keywords:'mic input microphone audio sink output device gain mute eq equalizer spatial audio balance headphones',
|
||||
guidance:'Check the headset’s audio controls or the app’s own audio settings for these adjustments.'},
|
||||
{id:'power', category:'device', icon:'power', title:'Power & sleep', kind:'flow', label:'Existing controls',
|
||||
description:'Sleep, restart or shut down with confirmation and your Developer Mode password. A sleeping Frame must be woken on the device.',
|
||||
keywords:'battery suspend reboot off awake idle timeout keep awake inhibitor', links:[['#power','Power controls']],
|
||||
guidance:'Set automatic sleep in the headset: Steam Settings → Power → Sleep after inactivity. Keep-awake is available through the existing script/MCP tools; there is no switch here.'},
|
||||
{id:'system', category:'device', icon:'device', title:'SteamOS & hardware', kind:'headset', label:'In the headset',
|
||||
description:'Check your current OS build above. Manage system updates, Developer Mode and hardware settings in Steam Settings on the Frame.',
|
||||
keywords:'firmware update beta stable channel developer password bluetooth controller battery pairing calibration brightness refresh rate display IPD lenses passthrough camera eye hand tracking foveation recovery factory reset',
|
||||
guidance:'Brightness, refresh rate, optics, controllers, Bluetooth and passthrough have no verified remote controls here. Check the headset’s own controls. Recovery and factory reset are manual procedures.'},
|
||||
{id:'performance', category:'vr', icon:'pulse', title:'Performance monitor', kind:'read', label:'Read-only metrics',
|
||||
description:'CPU, GPU clock, app and compositor timings come from the Frame. Unavailable timings stay empty, including when SteamVR is in standby.',
|
||||
keywords:'fps frame rate gpu cpu heat temperature thermal sensor render memory', special:'performance', links:[['#vrTools','HUD & performance tools']]},
|
||||
{id:'view', category:'vr', icon:'eye', title:'Headset view & casting', kind:'flow', label:'Existing controls',
|
||||
description:'Capture the lenses or desktop, stream live video, and control a focused panel. Casting can reveal private content.',
|
||||
keywords:'screenshot camera video capture live stream desktop mirror control eye', links:[['#view','Open headset view'],['#shots','Screenshots']]},
|
||||
{id:'comfort', category:'vr', icon:'clock', title:'Family & comfort', kind:'flow', label:'Existing controls',
|
||||
description:'Set a session limit, breaks and check-ins, with battery and heat alerts. Headset reminders continue if your companion disconnects.',
|
||||
keywords:'parent child timer minutes notifications health break low battery', links:[['#comfort','Configure a session']],
|
||||
guidance:'A reminder, not a parental lock. Save and pause before the session returns to Steam Home.'},
|
||||
{id:'playspace', category:'vr', icon:'space', title:'Playspace & movement', kind:'headset', label:'In the headset',
|
||||
description:'Use SteamVR’s recenter and room setup. Choose seated play, snap-turn, teleport and vignettes in each game’s own settings.',
|
||||
keywords:'boundary guardian tracking origin floor standing seated motion sickness locomotion turn recenter room',
|
||||
guidance:'No verified universal locomotion switch or remote tracking-origin control is available.'},
|
||||
{id:'panels', category:'vr', icon:'grid', title:'Panels & workspace', kind:'flow', label:'Existing controls',
|
||||
description:'Find an open panel and bring it forward, or open the panel switcher in the headset. Move, size and dock panels with SteamVR’s dashboard.',
|
||||
keywords:'overlay window layout float world curvature docking theatre multitasking', links:[['#panels','Open panel switcher']],
|
||||
guidance:'Saved spatial layouts are not available. Panel placement stays in SteamVR.'},
|
||||
{id:'steam', category:'apps', icon:'game', title:'Steam library', kind:'flow', label:'Existing controls',
|
||||
description:'Browse owned games and Frame ratings, install with progress, or launch an installed game. Purchases and license prompts use Steam.',
|
||||
keywords:'games download install store owned compatibility rating verified proton', links:[['#library','Installed games'],['#getgames','Library & store']]},
|
||||
{id:'android', category:'apps', icon:'apps', title:'Android apps', kind:'flow', label:'Existing controls',
|
||||
description:'Discover apps, manage Lepton instances, and launch, stop, test or remove installed apps. View compatibility reports before installing.',
|
||||
keywords:'apk fdroid lepton application sideload sources repository reports', links:[['#appStore','Discover apps'],['#androidLibrary','Manage installed apps']]},
|
||||
{id:'sources', category:'apps', icon:'shield', title:'App sources & compatibility', kind:'flow', label:'Existing controls',
|
||||
description:'Choose catalogue sources or add a signed F-Droid repository from Sources. Review install availability and compatibility reports for the exact app.',
|
||||
keywords:'fdroid f-droid repository source fingerprint trust signature compatibility report apk sidequest data backup restore', links:[['#appStore','Catalogue & sources'],['#androidLibrary','Apps & reports']],
|
||||
guidance:'Android data backup and restore are script-only. This interface does not provide an app-data recovery workflow.'},
|
||||
{id:'display', category:'apps', icon:'display', title:'Android display & text', kind:'flow', label:'Existing controls',
|
||||
description:'Choose a running Android instance, then set resolution, UI density and text size, or reset to its defaults.',
|
||||
keywords:'dpi scaling font scale sharp native 1920 1080 2560 1440 resolution screen text accessibility', links:[['#display','Configure app display']],
|
||||
guidance:'Applies to that Lepton instance, not the headset’s lenses. Persistence after relaunch is unverified.'},
|
||||
{id:'sideload', category:'apps', icon:'game', title:'Sideloaded games', kind:'flow', label:'Existing controls',
|
||||
description:'Send a game folder, ZIP or executable. Frame Control selects Proton or the Linux runtime and adds a Steam library entry.',
|
||||
keywords:'windows exe zip linux devkit runtime shortcuts launch remove', links:[['#sideloaded','Manage titles'],['#transfer','Send a game']]},
|
||||
{id:'files', category:'apps', icon:'files', title:'Files & clipboard', kind:'flow', label:'Existing controls',
|
||||
description:'Send files to ~/Downloads, or send text to the Frame’s clipboard. Clipboard transfer needs the desktop panel open.',
|
||||
keywords:'upload transfer downloads paste copy text sftp storage', links:[['#transfer','Send files & text']]},
|
||||
{id:'media', category:'apps', icon:'play', title:'Video & stereo photos', kind:'flow', label:'Preview feature',
|
||||
description:'Send and play H.264/H.265 video, SBS/OU photos or small splats with the built-in player. Choose a layout and theatre surround.',
|
||||
keywords:'movie photo sbs ou 3d splat gaussian theatre media player', links:[['#media','Open media player']],
|
||||
guidance:'Seeking, subtitles, native HEIC/MPO and large splat scenes are not supported yet.'},
|
||||
{id:'flatpak', category:'apps', icon:'apps', title:'Linux desktop apps', kind:'flow', label:'Existing controls',
|
||||
description:'Install user Flatpaks by Flathub ID, choose quick picks like VLC and Moonlight, and manage installed apps.',
|
||||
keywords:'flatpak firefox vlc remmina moonlight flathub install remove', links:[['#apps','Manage Linux apps']]},
|
||||
{id:'connection', category:'network', icon:'network', title:'Headsets & addresses', kind:'flow', label:'Existing controls',
|
||||
description:'Manage headsets, LAN, USB-C, mDNS and Tailscale addresses. Inspect connection attempts and choose the device you want to control.',
|
||||
keywords:'wifi wi-fi network ip ssh pairing keys discovery alias tailscale usb offline reconnect', links:[['#devices','Manage headsets','desktop'],['#connection','Connection details']],
|
||||
guidance:'Join Wi-Fi networks on the headset. Frame Control manages connection addresses; it does not change Wi-Fi credentials.'},
|
||||
{id:'remote', category:'network', icon:'display', title:'Remote access', kind:'flow', label:'Existing controls',
|
||||
description:'Open SSH, SFTP, Steam Link or remote desktop using the host’s existing tools. Developer Mode enables headset SSH and xrdp.',
|
||||
keywords:'terminal shell remote desktop rdp xrdp streaming steam link sftp', links:[['#power','Open remote tools']]},
|
||||
{id:'input', category:'network', icon:'keyboard', title:'Keyboard & trackpad', kind:'flow', label:'Existing controls',
|
||||
description:'Type and point in the Frame’s apps with KDE Connect, or use Control on the live panel view. First use sets up the input helper.',
|
||||
keywords:'mouse pointer keyboard trackpad touch click kde input', links:[['#pad','Open keyboard & trackpad'],['#view','Panel control']]},
|
||||
{id:'mac', category:'network', icon:'display', title:'Mac in the headset', kind:'flow', label:'macOS only', platform:'mac',
|
||||
description:'Stream Mac screens or individual windows into headset panels. Choose quality and give each window its own display.',
|
||||
keywords:'macos mac window screen sharing stream quality sharp balanced light permissions', links:[['#macview','Configure Mac streaming']],
|
||||
guidance:'Requires Frame Control running on a Mac. It is unavailable when the server runs on Windows, Linux or the headset.'},
|
||||
{id:'privacy', category:'companion', icon:'shield', title:'Privacy & sharing', kind:'flow', label:'Companion settings',
|
||||
description:'Choose anonymous usage statistics, compatibility sharing and error details separately. Inspect what was sent and manage your optional contact email.',
|
||||
keywords:'telemetry analytics diagnostics consent data tracking email contact opt out', links:[['#privacy','Privacy settings']]},
|
||||
{id:'artwork', category:'companion', icon:'image', title:'Library artwork', kind:'flow', label:'Companion settings',
|
||||
description:'Configure an optional SteamGridDB key or refresh artwork. Without a key, source images and generated art remain available.',
|
||||
keywords:'cover image steamgriddb api key library art refresh', links:[['#artworkSettings','Artwork settings']]},
|
||||
{id:'assistant', category:'companion', icon:'spark', title:'Assistant & agents', kind:'flow', label:'Opt-in tools',
|
||||
description:'Use your own model endpoint and review proposed actions. Nothing goes to a model until you opt in; protected actions need approval.',
|
||||
keywords:'ai mcp model endpoint agent approvals chat assistant', links:[['/assistant','Open assistant']]},
|
||||
{id:'support', category:'companion', icon:'help', title:'Updates & diagnostics', kind:'flow', label:'Companion settings',
|
||||
description:'Check Frame Control updates in the desktop app or prepare a private problem report. Review the diagnostic preview before sending.',
|
||||
keywords:'app version bug feedback support update log error report licenses', links:[['#privacy','Updates & problem reports']]},
|
||||
];
|
||||
const paths = {
|
||||
grid:'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
|
||||
device:'M3 7h18v10H3z M8 11v2 M16 11v2', eye:'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0',
|
||||
apps:'M4 4h6v6H4z M14 4h6v6h-6z M4 14h6v6H4z M17 14v6 M14 17h6',
|
||||
network:'M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01',
|
||||
settings:'M4 6h16 M4 12h16 M4 18h16 M8 3v6 M16 9v6 M10 15v6',
|
||||
audio:'M3 9h4l5-4v14l-5-4H3z M16 8a6 6 0 0 1 0 8 M19 5a10 10 0 0 1 0 14',
|
||||
power:'M12 2v10 M6 5a9 9 0 1 0 12 0', pulse:'M2 12h5l3-8 4 16 3-8h5',
|
||||
clock:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2',
|
||||
space:'M3 8l9-5 9 5v8l-9 5-9-5z M3 8l9 5 9-5 M12 13v8',
|
||||
game:'M6 8h12l3 9-3 2-4-4h-4l-4 4-3-2z M7 10v4 M5 12h4 M16 11h.01 M18 13h.01',
|
||||
display:'M3 4h18v13H3z M8 21h8 M12 17v4', files:'M3 5h7l2 3h9v12H3z',
|
||||
play:'M8 4l12 8-12 8z', keyboard:'M2 6h20v12H2z M6 10h.01 M10 10h.01 M14 10h.01 M18 10h.01 M6 14h12',
|
||||
shield:'M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6z M8 12l3 3 5-6',
|
||||
image:'M3 3h18v18H3z M3 17l6-6 5 5 3-3 4 4 M16 7h.01',
|
||||
spark:'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
|
||||
help:'M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5 M12 17h.01',
|
||||
};
|
||||
const icon = name => `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${paths[name] || paths.settings}"/></svg>`;
|
||||
function matches(item, query, category) {
|
||||
const text = [item.title, item.description, item.keywords, item.guidance, item.label,
|
||||
categories.find(([id])=>id === item.category)?.[1]].join(' ').toLowerCase();
|
||||
return (category === 'all' || item.category === category) && query.toLowerCase().trim().split(/\s+/).every(word => text.includes(word));
|
||||
}
|
||||
const number = (value, suffix = '', digits = 0) => Number.isFinite(value) ? value.toLocaleString(undefined, {maximumFractionDigits:digits}) + suffix : 'Unavailable';
|
||||
const gib = value => Number.isFinite(value) ? number(value / 1073741824, ' GiB', 1) : 'Unavailable';
|
||||
const duration = seconds => Number.isFinite(seconds) && seconds >= 0 ? seconds < 3600 ? `${Math.floor(seconds / 60)} min` : `${Math.floor(seconds / 3600)} h ${Math.floor(seconds % 3600 / 60)} min` : 'Unavailable';
|
||||
function metrics(sample) {
|
||||
const s = sample || {}, b = s.battery || {}, p = s.performance || {}, h = s.disk?.home, m = s.memory;
|
||||
const capacity = b.percent;
|
||||
const idle = !!s.power && Number.isFinite(b.watts) && Math.abs(b.watts) < .5;
|
||||
const charging = b.status === 'Charging';
|
||||
const full = b.status === 'Full' || (!!s.power && capacity >= 100);
|
||||
const batteryState = !sample ? 'Connect to read charge and power' : !s.battery ? 'Battery sensor unavailable' :
|
||||
full ? 'Fully charged' : charging ? 'Charging' : idle ? 'Plugged in · not charging' : s.power ? 'Plugged in · on battery' : 'On battery';
|
||||
return {
|
||||
battery:number(capacity, '%'), batteryDetail:batteryState,
|
||||
storage:h ? gib(h.free) : 'Unavailable', storageDetail:h ? `${gib(h.total)} total · user storage` : 'User storage unavailable',
|
||||
memory:m && Number.isFinite(m.total) && Number.isFinite(m.available) ? gib(m.total - m.available) : 'Unavailable',
|
||||
memoryDetail:m ? `${gib(m.total)} total · system memory used` : 'Memory sensor unavailable',
|
||||
rootFree:gib(s.disk?.root?.free), rootTotal:gib(s.disk?.root?.total), memoryAvailable:gib(m?.available),
|
||||
fps:number(p.compositorFps, '', 1), fpsDetail:'Compositor FPS · not game FPS',
|
||||
power:number(b.watts, ' W', 1), charger:s.power ? `${s.power.type || 'Connected'} · ${number(s.power.watts, ' W', 1)}` : sample ? 'No charger reported' : 'Unavailable',
|
||||
batteryTemp:number(b.tempC, ' °C', 1), batteryHealth:b.health || 'Unavailable',
|
||||
estimate:charging ? (b.timeToFull > 0 ? `${duration(b.timeToFull)} to full` : 'Unavailable') : !full && !idle && b.timeToEmpty > 0 && b.timeToEmpty < 86400 ? `${duration(b.timeToEmpty)} remaining` : 'Unavailable',
|
||||
wifi:s.wifi?.ssid || 'Unavailable', signal:number(s.wifi?.signal, '%'), ip:s.ip || 'Unavailable',
|
||||
uptime:duration(s.uptime), os:s.os?.version || 'Unavailable', build:s.os?.build || 'Unavailable',
|
||||
temperature:number(s.temp, ' °C', 1), cpu:number(p.cpuPercent, '%', 1), gpu:number(p.gpuMHz, ' MHz', 1),
|
||||
appFps:number(p.appFps, '', 1), frame:number(p.frameMs, ' ms', 2), render:number(p.gpuMs, ' ms', 2), compositorCpu:number(p.compositorCpuMs, ' ms', 2),
|
||||
thermal:!Array.isArray(s.thermalAlerts) ? 'Trip data unavailable' : s.thermalAlerts.length ? s.thermalAlerts.map(a => `${a.zone || 'Sensor'} ${number(a.tempC, ' °C')} ≥ ${number(a.limitC, ' °C')}`).join('; ') : 'No hot / critical trip reported',
|
||||
};
|
||||
}
|
||||
// Strict validation and captured device identity apply even if a stale enabled
|
||||
// button is clicked during a disconnect, headset switch, or pending request.
|
||||
function createController({api, getContext}) {
|
||||
let busy = false;
|
||||
return {
|
||||
get busy() { return busy; },
|
||||
async audio(change) {
|
||||
if (busy) throw new Error('An audio change is still in progress.');
|
||||
const context = getContext();
|
||||
if (context.online !== true || !context.fresh || !context.sample?.volume) throw new Error('Connect and refresh the Frame before changing audio.');
|
||||
const keys = Object.keys(change);
|
||||
if (!keys.length || keys.some(k => !['level','muted'].includes(k)) ||
|
||||
('level' in change && (typeof change.level !== 'number' || !Number.isFinite(change.level) || change.level < 0 || change.level > 1)) ||
|
||||
('muted' in change && typeof change.muted !== 'boolean')) throw new Error('Use a volume from 0 to 100% and a valid mute state.');
|
||||
busy = true;
|
||||
try {
|
||||
const result = await api('/api/volume', change, context.device);
|
||||
if (getContext().generation !== context.generation) return null;
|
||||
return result;
|
||||
} finally { busy = false; }
|
||||
},
|
||||
};
|
||||
}
|
||||
function mount({document, api, refresh, refreshAudio = refresh, getContext, openConnection, platform}) {
|
||||
const get = id => document.getElementById(id), page = get('controlCenter');
|
||||
let category = 'all', sample = null, receivedAt = null, available = false, error = '', dirty = false, sampleGeneration = null, audioPending = false;
|
||||
const metric = (id, label) => `<div><dt>${label}</dt><dd data-cc-metric="${id}">Unavailable</dd></div>`;
|
||||
const perf = `<dl class="cc-readings">${[['appFps','Application FPS'],['frame','Compositor period'],['render','Render GPU time'],['compositorCpu','Compositor CPU'],['cpu','System CPU'],['gpu','GPU clock'],['temperature','Hottest sensor']].map(([id,label])=>metric(id,label)).join('')}</dl><p class="cc-note">GPU time is render time, not utilisation. The hottest sensor is not necessarily CPU temperature.</p><p class="cc-note" data-cc-metric="thermal">Trip data unavailable</p>`;
|
||||
const audio = `<div class="cc-audio"><label for="ccVolume">Output volume <output id="ccVolumeValue" for="ccVolume">—</output></label><input id="ccVolume" type="range" min="0" max="100" step="1" value="0" disabled><div class="cc-audio-actions"><button class="action small" id="ccApplyVolume" disabled>Apply volume</button><button class="small" id="ccMute" disabled>Mute</button><span id="ccAudioReadback" class="cc-note">Waiting for an audio reading</span></div><p class="cc-note" id="ccAudioResult" role="status"></p></div>`;
|
||||
const health = `<dl class="cc-readings">${[['battery','Charge'],['power','Battery power · signed'],['charger','Charger'],['estimate','Battery estimate'],['batteryTemp','Battery temperature'],['batteryHealth','Battery health']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
const storage = `<dl class="cc-readings">${[['storage','User storage free'],['rootFree','System storage free'],['rootTotal','System storage total'],['memoryAvailable','Memory available']].map(([id,label])=>metric(id,label)).join('')}</dl>`;
|
||||
get('ccCategories').innerHTML = categories.map(([id,label,symbol])=>`<button type="button" data-cc-category="${id}" aria-pressed="${id === 'all'}">${icon(symbol)}<span>${label}</span><span class="cc-category-count"></span></button>`).join('');
|
||||
const specials = {audio, performance:perf, health, storage};
|
||||
get('ccCards').innerHTML = catalog.map(item=>`<section class="cc-card" data-cc-card="${item.id}"><div class="cc-card-head"><span class="cc-icon">${icon(item.icon)}</span><span class="cc-kind cc-kind-${item.kind}">${item.label}</span></div><h2>${item.title}</h2><p>${item.description}</p>${specials[item.special] || ''}${item.guidance ? `<div class="cc-guidance">${item.guidance}</div>` : ''}${item.links ? `<div class="cc-links">${item.links.map(([href,label,desktop])=>`<a href="${href}"${desktop ? ' class="desk-only"' : ''}${item.platform === 'mac' ? ' data-cc-mac-link' : ''}>${label}<span aria-hidden="true">↗</span></a>`).join('')}</div>` : ''}</section>`).join('');
|
||||
// Host-only features stay searchable with a reason; only their unsupported
|
||||
// action disappears. The host API, rather than viewport/UA, decides availability.
|
||||
let macSupported = platform === 'darwin';
|
||||
const visibleCatalog = catalog;
|
||||
function filter() {
|
||||
const query = get('ccSearch').value;
|
||||
let count = 0;
|
||||
catalog.forEach(item => {
|
||||
const visible = visibleCatalog.includes(item) && matches(item, query, category);
|
||||
page.querySelector(`[data-cc-card="${item.id}"]`).hidden = !visible;
|
||||
if (visible) count++;
|
||||
});
|
||||
get('ccResultCount').textContent = `${count} ${count === 1 ? 'section' : 'sections'}`;
|
||||
get('ccEmpty').hidden = count > 0;
|
||||
get('ccClear').hidden = !query;
|
||||
page.querySelectorAll('[data-cc-category]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.ccCategory === category));
|
||||
button.querySelector('.cc-category-count').textContent = visibleCatalog.filter(item=>matches(item, query, button.dataset.ccCategory)).length;
|
||||
});
|
||||
page.querySelectorAll('[data-cc-mac-link]').forEach(link=>{link.hidden = !macSupported;});
|
||||
}
|
||||
get('ccSearch').addEventListener('input', filter);
|
||||
function resetFilter() { get('ccSearch').value = ''; category = 'all'; filter(); get('ccSearch').focus(); }
|
||||
get('ccClear').onclick = () => { get('ccSearch').value = ''; filter(); get('ccSearch').focus(); };
|
||||
get('ccResetFilter').onclick = resetFilter;
|
||||
get('ccCategories').onclick = event => {
|
||||
const button = event.target.closest('[data-cc-category]');
|
||||
if (!button) return;
|
||||
category = button.dataset.ccCategory; filter();
|
||||
};
|
||||
page.querySelector('[href="#connection"]').onclick = event => { event.preventDefault(); openConnection(); };
|
||||
// The headset clock can differ from this client's. Age uses the client
|
||||
// receipt time, retained by the shared poll when it buffers an early sample.
|
||||
const isFresh = () => available && Number.isFinite(receivedAt) && Date.now() >= receivedAt && Date.now() - receivedAt <= 90000;
|
||||
const context = () => ({...getContext(), sample, fresh:isFresh() && sampleGeneration === getContext().generation});
|
||||
const controller = createController({api, getContext:context});
|
||||
function audioAvailability() {
|
||||
const enabled = context().online === true && context().fresh && !!sample?.volume && !controller.busy && !audioPending;
|
||||
['ccVolume','ccApplyVolume','ccMute'].forEach(id=>{get(id).disabled = !enabled;});
|
||||
}
|
||||
function renderSample() {
|
||||
const values = metrics(isFresh() ? sample : null);
|
||||
page.querySelectorAll('[data-cc-metric]').forEach(el=>{el.textContent = values[el.dataset.ccMetric];});
|
||||
get('ccDeviceName').textContent = isFresh() ? sample.hostname || 'Steam Frame' : 'Steam Frame';
|
||||
const sampledAt = Number.isFinite(sample?.time) && sample.time > 0 && sample.time * 1000 <= 8640000000000000 ? new Date(sample.time * 1000) : null;
|
||||
get('ccSampleState').textContent = isFresh() ? `${sampledAt ? 'Sampled ' + sampledAt.toLocaleTimeString() : 'Received ' + new Date(receivedAt).toLocaleTimeString()} · updates every 30 seconds` :
|
||||
error || (receivedAt !== null ? 'Sample expired · refresh to read current metrics' : 'Connect your Frame to read live metrics');
|
||||
get('ccSampleState').classList.toggle('cc-offline', !isFresh());
|
||||
get('ccStatusLabel').textContent = isFresh() ? 'Connected' : 'No live sample';
|
||||
get('ccStatusDot').classList.toggle('on', isFresh());
|
||||
get('ccStatusNote').textContent = isFresh() ? 'Read-only status over SSH. Browse freely; controls run only when you use them.' :
|
||||
'Settings guides and companion tools remain available. Wake the Frame on the device, then reconnect.';
|
||||
get('ccBuild').textContent = isFresh() ? `SteamOS ${values.os} · build ${values.build}` : 'System information unavailable';
|
||||
const services = isFresh() ? sample.services || {} : {};
|
||||
get('ccServices').innerHTML = [['steamvr','SteamVR'],['desktop','Desktop'],['lepton','Lepton · port 5555'],['rdp','RDP process']].map(([key,label])=>`<span><i class="dot ${services[key] === true ? 'on' : ''}"></i>${label}<b>${typeof services[key] === 'boolean' ? services[key] ? 'Running' : 'Not detected' : 'Unavailable'}</b></span>`).join('');
|
||||
if (sample?.volume && isFresh()) {
|
||||
const pct = Math.round(sample.volume.level * 100);
|
||||
if (!dirty && !controller.busy && !audioPending) get('ccVolume').value = Math.min(100,Math.max(0,pct));
|
||||
get('ccVolumeValue').textContent = `${get('ccVolume').value}%`;
|
||||
get('ccAudioReadback').textContent = `Reported ${pct}% · ${sample.volume.muted ? 'muted' : 'unmuted'}`;
|
||||
get('ccMute').textContent = sample.volume.muted ? 'Unmute' : 'Mute';
|
||||
} else {
|
||||
get('ccAudioReadback').textContent = isFresh() ? 'Audio reading unavailable' : 'Waiting for an audio reading';
|
||||
get('ccVolumeValue').textContent = '—';
|
||||
}
|
||||
audioAvailability();
|
||||
}
|
||||
async function changeAudio(change) {
|
||||
if (audioPending) return;
|
||||
const generation = getContext().generation;
|
||||
audioPending = true;
|
||||
get('ccAudioResult').textContent = 'Applying…';
|
||||
try {
|
||||
const pending = controller.audio(change);
|
||||
audioAvailability();
|
||||
const result = await pending;
|
||||
if (!result || generation !== getContext().generation) return;
|
||||
if ('level' in change) dirty = false;
|
||||
get('ccAudioResult').textContent = `${result.message || 'Audio updated'}. Refreshing the reading…`;
|
||||
await refreshAudio();
|
||||
if (generation !== getContext().generation) return;
|
||||
if (!isFresh() || !sample?.volume) {
|
||||
get('ccAudioResult').textContent = 'Audio command sent; current output could not be confirmed. Reconnect and refresh.';
|
||||
} else {
|
||||
const reported = sample.volume;
|
||||
const matched = (!('level' in change) || Math.round(reported.level * 100) === Math.round(change.level * 100)) &&
|
||||
(!('muted' in change) || reported.muted === change.muted);
|
||||
get('ccAudioResult').textContent = `${matched ? 'Confirmed' : 'Command sent · reported output differs'}: ${Math.round(reported.level * 100)}%, ${reported.muted ? 'muted' : 'unmuted'}.${matched ? '' : ' Refresh to check again.'}`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (generation !== getContext().generation) return;
|
||||
get('ccAudioResult').textContent = `Couldn’t apply audio: ${e.message}`;
|
||||
} finally { audioPending = false; renderSample(); }
|
||||
}
|
||||
get('ccVolume').oninput = () => { dirty = true; get('ccVolumeValue').textContent = `${get('ccVolume').value}%`; };
|
||||
get('ccApplyVolume').onclick = () => changeAudio({level:Number(get('ccVolume').value) / 100});
|
||||
get('ccMute').onclick = () => changeAudio({muted:!sample?.volume?.muted});
|
||||
get('ccRefresh').onclick = async () => {
|
||||
get('ccRefresh').disabled = true;
|
||||
try { await refresh(); } finally { get('ccRefresh').disabled = false; }
|
||||
};
|
||||
filter(); renderSample();
|
||||
// Expire UI readings even when the shared poll is paused in a hidden tab.
|
||||
const timer = setInterval(()=>{if (receivedAt !== null && available && !isFresh()) { available = false; renderSample(); }}, 5000);
|
||||
return {
|
||||
host(value) { macSupported = !value.mobile && value.os === 'macOS'; filter(); },
|
||||
status(value, receipt = Date.now(), read = {valid: true, error: null}) {
|
||||
// A redraw is not a successful read: preserve receipt age and any local
|
||||
// invalidation. Shared validity also carries failures across late mount.
|
||||
const replay = sample === value;
|
||||
receivedAt = replay ? (Number.isFinite(receivedAt) && Number.isFinite(receipt) ? Math.min(receivedAt, receipt) : receivedAt) : receipt;
|
||||
sample = value;
|
||||
if (!read.valid) {
|
||||
available = false; error = `No live sample${read.error?.message ? ' · ' + read.error.message : ''}`;
|
||||
} else if (!replay) { available = true; error = ''; }
|
||||
sampleGeneration = getContext().generation; renderSample();
|
||||
},
|
||||
unavailable(reason) { if (reason?.stale) return; available = false; error = `No live sample${reason?.message ? ' · ' + reason.message : ''}`; renderSample(); },
|
||||
connection(ok) { if (!ok) { available = false; renderSample(); } else audioAvailability(); },
|
||||
reset() { sample = null; receivedAt = null; available = false; error = ''; dirty = false; get('ccVolume').value = 0; get('ccAudioResult').textContent = ''; renderSample(); },
|
||||
destroy() { clearInterval(timer); },
|
||||
};
|
||||
}
|
||||
return {catalog, categories, matches, metrics, createController, mount};
|
||||
});
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+1442
File diff suppressed because it is too large.
Load diff
+32
-18
@@ -6,9 +6,10 @@ before anything goes to the Frame, then installed through frame_android.install
|
||||
like any other APK. Game data (the OBB folder and, if asked, the external
|
||||
app-data folder) is copied by a second import while the person has the game
|
||||
open, since only a running Lepton instance can take it; Frame Control never
|
||||
starts the game itself. The cache is always removed. Games that check their Meta store licence
|
||||
(libovrplatformloader.so) are refused: that check needs Meta's services, and
|
||||
Frame Control does not remove it or work around it. See docs/quest-import.md.
|
||||
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]
|
||||
@@ -44,10 +45,11 @@ PULL_TIMEOUT = 3 * 3600
|
||||
READY_TIMEOUT = 150
|
||||
BATCH = 15 # packages per adb shell command while listing
|
||||
|
||||
ENTITLEMENT = ("{label} checks your Meta Quest store licence with Meta's Platform SDK "
|
||||
"(libovrplatformloader.so). That check needs Meta's services, which only exist on Meta "
|
||||
"headsets, so the game would quit on the Frame. Frame Control won't remove or work around "
|
||||
"the check. Ask the developer for a build without it, or look for the game on Steam or itch.io.")
|
||||
# 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 "
|
||||
@@ -246,7 +248,8 @@ def status(serial=None, with_apps=True):
|
||||
# ---- Checking what was copied ---------------------------------------------------
|
||||
|
||||
def inspect(package_name, base, splits=()):
|
||||
"""Refuse what can't or mustn't run on the Frame, before anything is copied to it."""
|
||||
"""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:
|
||||
@@ -258,8 +261,7 @@ def inspect(package_name, base, splits=()):
|
||||
label = info['label']
|
||||
if info['package'] != package_name:
|
||||
raise QuestError(f"The APK copied for {package_name} says it is {info['package']}; not importing it")
|
||||
if any(n.rsplit('/', 1)[-1] == 'libovrplatformloader.so' for n in names):
|
||||
raise QuestError(ENTITLEMENT.format(label=label))
|
||||
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 "
|
||||
@@ -268,14 +270,13 @@ def inspect(package_name, base, splits=()):
|
||||
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 = []
|
||||
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("Uses Meta's legacy VrApi and no OpenXR. The Frame doesn't have VrApi, so it "
|
||||
"will install but won't run.")
|
||||
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), 'warnings': warnings}
|
||||
'vr': bool(info.get('vr') or openxr), 'platform_sdk': platform_sdk, 'warnings': warnings}
|
||||
|
||||
|
||||
# ---- Importing ------------------------------------------------------------------
|
||||
@@ -557,9 +558,22 @@ def _import(serial, app, data, work, report, allow_downgrade=False):
|
||||
(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
|
||||
# The pulls can take a long time: check the target again, and hold the shared install
|
||||
# lock (install, remove and install_obb take it too) until the game is closed.
|
||||
# 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.')
|
||||
@@ -592,7 +606,7 @@ def _import(serial, app, data, work, report, allow_downgrade=False):
|
||||
'and import again.')
|
||||
raise QuestError(message) from failure
|
||||
report('Done', 100)
|
||||
message = f"Added {meta['label']}'s game data from your Quest"
|
||||
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.')
|
||||
@@ -601,7 +615,7 @@ def _import(serial, app, data, work, report, allow_downgrade=False):
|
||||
if copied_files:
|
||||
message += f' Copied {copied_files} app file{"s" if copied_files != 1 else ""}.'
|
||||
return {'message': ' '.join([message, *warnings]), 'app': meta, 'obb': copied_obb, 'files': copied_files,
|
||||
'warnings': warnings, 'split': bool(splits), 'needs_game_data': False, 'closed': closed}
|
||||
'warnings': warnings, 'split': False, 'needs_game_data': False, 'closed': closed}
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -302,6 +302,7 @@ def status():
|
||||
"disk": {"root": disk("/"), "home": disk("/home")},
|
||||
"memory": memory(),
|
||||
"temp": max_temp(),
|
||||
"thermalAlerts": thermal_alerts(),
|
||||
"wifi": wifi(),
|
||||
"ip": ip_addr(),
|
||||
"volume": volume(),
|
||||
|
||||
+43
-13
@@ -18,7 +18,9 @@ Report a problem dialog (frame_report.py) sends bug reports privately to the
|
||||
same project whatever is chosen here.
|
||||
|
||||
Events are identified by a random id made on first run, not by the person or
|
||||
computer, and sent without person profiles or GeoIP. Nothing is sent without a
|
||||
computer, and sent without person profiles or GeoIP. Opens, installs and updates
|
||||
carry a country instead, worked out on this computer from its time zone setting
|
||||
(frame_timezones.py), to see roughly where Frame Control is used. Nothing is sent without a
|
||||
project key (ui/telemetry.json or $FRAME_CONTROL_POSTHOG_KEY), from a source
|
||||
checkout unless $FRAME_CONTROL_TELEMETRY=1, or when $DO_NOT_TRACK=1 or
|
||||
$FRAME_CONTROL_TELEMETRY=0.
|
||||
@@ -43,6 +45,7 @@ from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import frame_host
|
||||
import frame_timezones
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATE = frame_host.data_dir('telemetry')
|
||||
@@ -65,10 +68,11 @@ EXPECTED_CATEGORIES = ('frame_unreachable', 'frame_not_set_up', 'frame_auth')
|
||||
DEFAULT_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
LEVELS = ('usage', 'compat', 'diagnostics')
|
||||
NOTICE_VERSION = 2 # raise when the notice lists something new: it's shown again (2: country)
|
||||
# Events the page may send through /api/telemetry, and the properties each may carry.
|
||||
PAGE_EVENTS = {'tab_viewed': {'tab'}, 'update_offered': {'to_version'},
|
||||
'update_started': {'to_version'}, 'update_failed': {'to_version', 'error_category'}}
|
||||
TABS = {'home', 'games', 'android', 'tools'}
|
||||
TABS = {'home', 'games', 'android', 'tools', 'devices', 'control'}
|
||||
|
||||
_lock = threading.RLock()
|
||||
_send_lock = threading.Lock() # held while sending; consent changes wait for it
|
||||
@@ -105,7 +109,7 @@ def blocked():
|
||||
|
||||
def _defaults():
|
||||
return {'id': str(uuid.uuid4()), 'usage': True, 'compat': False, 'diagnostics': False,
|
||||
'notice_shown': False, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'notice_shown': False, 'notice_version': 0, 'installed_sent': False, 'last_version': None, 'last_open_day': None,
|
||||
'frames_seen': [], 'compat_sent': []}
|
||||
|
||||
|
||||
@@ -147,12 +151,12 @@ def update_settings(changes):
|
||||
under way finishes first, so nothing leaves after this returns."""
|
||||
with _send_lock, _lock:
|
||||
s = settings()
|
||||
if 'noticeShown' in changes:
|
||||
s['notice_shown'] = bool(changes['noticeShown']) or s['notice_shown']
|
||||
if changes.get('noticeShown'):
|
||||
s['notice_shown'], s['notice_version'] = True, NOTICE_VERSION
|
||||
for level in LEVELS:
|
||||
if level in changes:
|
||||
s[level] = bool(changes[level])
|
||||
s['notice_shown'] = True
|
||||
s['notice_shown'] = True # only the notice itself moves notice_version on
|
||||
_save(s)
|
||||
_drop_unwanted(s)
|
||||
if changes.get('compat'):
|
||||
@@ -161,10 +165,16 @@ def update_settings(changes):
|
||||
return state()
|
||||
|
||||
|
||||
def _notice_current(s):
|
||||
"""Whether the notice this person saw lists everything that's sent now. If not, the page
|
||||
shows it again, and what it added waits until it has been seen."""
|
||||
return s['notice_shown'] and s['notice_version'] >= NOTICE_VERSION
|
||||
|
||||
|
||||
def state():
|
||||
"""What the page shows: the choices, why sending is blocked, and what was sent."""
|
||||
s = settings()
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': s['notice_shown'],
|
||||
return {'usage': s['usage'], 'compat': s['compat'], 'noticeShown': _notice_current(s),
|
||||
'diagnostics': s['diagnostics'],
|
||||
'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
|
||||
'sent': list(reversed(_read_lines(SENT)))[:50]}
|
||||
@@ -372,20 +382,36 @@ def page_event(body):
|
||||
return {'queued': capture(name, props)}
|
||||
|
||||
|
||||
def country():
|
||||
"""The two-letter country of the computer's time zone setting, or None. Only names in the
|
||||
tz database's table count, so nothing else in the setting can be sent. The app passes the
|
||||
zone from Electron, which knows it on every OS; from a checkout it's read from /etc/localtime."""
|
||||
tz = os.environ.get('FRAME_CONTROL_TIMEZONE') or os.environ.get('TZ', '').lstrip(':')
|
||||
if not tz:
|
||||
try:
|
||||
tz = os.path.realpath('/etc/localtime').split('/zoneinfo/', 1)[1]
|
||||
except (OSError, IndexError):
|
||||
return None
|
||||
return frame_timezones.COUNTRY.get(tz)
|
||||
|
||||
|
||||
def app_started():
|
||||
"""Once per server start: first install, an update, and one open a day."""
|
||||
"""Once per server start: first install, an update, and one open a day, with the country
|
||||
(left out when sending if the notice this person saw came before it: flush)."""
|
||||
if blocked():
|
||||
return
|
||||
with _lock:
|
||||
s = settings()
|
||||
version, today = app_version(), time.strftime('%Y-%m-%d')
|
||||
cc = country()
|
||||
where = {'country': cc} if cc else {}
|
||||
if not s['installed_sent']:
|
||||
capture('app_installed')
|
||||
capture('app_installed', where)
|
||||
s['installed_sent'] = True
|
||||
elif s['last_version'] and s['last_version'] != version:
|
||||
capture('app_updated', {'from_version': s['last_version']})
|
||||
capture('app_updated', {'from_version': s['last_version'], **where})
|
||||
if s['last_open_day'] != today:
|
||||
capture('app_opened')
|
||||
capture('app_opened', where)
|
||||
s['last_open_day'] = today
|
||||
s['last_version'] = version
|
||||
_save(s)
|
||||
@@ -486,7 +512,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)
|
||||
@@ -580,10 +606,14 @@ def flush(timeout=20):
|
||||
if blocked() or not settings()['notice_shown']:
|
||||
return 0
|
||||
with _lock:
|
||||
_drop_unwanted(settings())
|
||||
s = settings()
|
||||
_drop_unwanted(s)
|
||||
batch = _read_lines(OUTBOX)[:100]
|
||||
if not batch:
|
||||
return 0
|
||||
if not _notice_current(s): # what a newer notice added stays here until it's been seen
|
||||
for e in batch:
|
||||
e['properties'].pop('country', None)
|
||||
try:
|
||||
post(batch, timeout)
|
||||
except SendError:
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Time zone name -> two-letter country, from the IANA tz database 2026c (zone.tab, and
|
||||
the old names in tzdata.zi that link to its zones). Generated by scripts/timezones.py.
|
||||
Zones that aren't in one country, such as UTC, aren't here."""
|
||||
COUNTRY = {
|
||||
'Africa/Abidjan': 'CI',
|
||||
'Africa/Accra': 'GH',
|
||||
'Africa/Addis_Ababa': 'ET',
|
||||
'Africa/Algiers': 'DZ',
|
||||
'Africa/Asmara': 'ER',
|
||||
'Africa/Asmera': 'ER',
|
||||
'Africa/Bamako': 'ML',
|
||||
'Africa/Bangui': 'CF',
|
||||
'Africa/Banjul': 'GM',
|
||||
'Africa/Bissau': 'GW',
|
||||
'Africa/Blantyre': 'MW',
|
||||
'Africa/Brazzaville': 'CG',
|
||||
'Africa/Bujumbura': 'BI',
|
||||
'Africa/Cairo': 'EG',
|
||||
'Africa/Casablanca': 'MA',
|
||||
'Africa/Ceuta': 'ES',
|
||||
'Africa/Conakry': 'GN',
|
||||
'Africa/Dakar': 'SN',
|
||||
'Africa/Dar_es_Salaam': 'TZ',
|
||||
'Africa/Djibouti': 'DJ',
|
||||
'Africa/Douala': 'CM',
|
||||
'Africa/El_Aaiun': 'EH',
|
||||
'Africa/Freetown': 'SL',
|
||||
'Africa/Gaborone': 'BW',
|
||||
'Africa/Harare': 'ZW',
|
||||
'Africa/Johannesburg': 'ZA',
|
||||
'Africa/Juba': 'SS',
|
||||
'Africa/Kampala': 'UG',
|
||||
'Africa/Khartoum': 'SD',
|
||||
'Africa/Kigali': 'RW',
|
||||
'Africa/Kinshasa': 'CD',
|
||||
'Africa/Lagos': 'NG',
|
||||
'Africa/Libreville': 'GA',
|
||||
'Africa/Lome': 'TG',
|
||||
'Africa/Luanda': 'AO',
|
||||
'Africa/Lubumbashi': 'CD',
|
||||
'Africa/Lusaka': 'ZM',
|
||||
'Africa/Malabo': 'GQ',
|
||||
'Africa/Maputo': 'MZ',
|
||||
'Africa/Maseru': 'LS',
|
||||
'Africa/Mbabane': 'SZ',
|
||||
'Africa/Mogadishu': 'SO',
|
||||
'Africa/Monrovia': 'LR',
|
||||
'Africa/Nairobi': 'KE',
|
||||
'Africa/Ndjamena': 'TD',
|
||||
'Africa/Niamey': 'NE',
|
||||
'Africa/Nouakchott': 'MR',
|
||||
'Africa/Ouagadougou': 'BF',
|
||||
'Africa/Porto-Novo': 'BJ',
|
||||
'Africa/Sao_Tome': 'ST',
|
||||
'Africa/Timbuktu': 'ML',
|
||||
'Africa/Tripoli': 'LY',
|
||||
'Africa/Tunis': 'TN',
|
||||
'Africa/Windhoek': 'NA',
|
||||
'America/Adak': 'US',
|
||||
'America/Anchorage': 'US',
|
||||
'America/Anguilla': 'AI',
|
||||
'America/Antigua': 'AG',
|
||||
'America/Araguaina': 'BR',
|
||||
'America/Argentina/Buenos_Aires': 'AR',
|
||||
'America/Argentina/Catamarca': 'AR',
|
||||
'America/Argentina/ComodRivadavia': 'AR',
|
||||
'America/Argentina/Cordoba': 'AR',
|
||||
'America/Argentina/Jujuy': 'AR',
|
||||
'America/Argentina/La_Rioja': 'AR',
|
||||
'America/Argentina/Mendoza': 'AR',
|
||||
'America/Argentina/Rio_Gallegos': 'AR',
|
||||
'America/Argentina/Salta': 'AR',
|
||||
'America/Argentina/San_Juan': 'AR',
|
||||
'America/Argentina/San_Luis': 'AR',
|
||||
'America/Argentina/Tucuman': 'AR',
|
||||
'America/Argentina/Ushuaia': 'AR',
|
||||
'America/Aruba': 'AW',
|
||||
'America/Asuncion': 'PY',
|
||||
'America/Atikokan': 'CA',
|
||||
'America/Atka': 'US',
|
||||
'America/Bahia': 'BR',
|
||||
'America/Bahia_Banderas': 'MX',
|
||||
'America/Barbados': 'BB',
|
||||
'America/Belem': 'BR',
|
||||
'America/Belize': 'BZ',
|
||||
'America/Blanc-Sablon': 'CA',
|
||||
'America/Boa_Vista': 'BR',
|
||||
'America/Bogota': 'CO',
|
||||
'America/Boise': 'US',
|
||||
'America/Buenos_Aires': 'AR',
|
||||
'America/Cambridge_Bay': 'CA',
|
||||
'America/Campo_Grande': 'BR',
|
||||
'America/Cancun': 'MX',
|
||||
'America/Caracas': 'VE',
|
||||
'America/Catamarca': 'AR',
|
||||
'America/Cayenne': 'GF',
|
||||
'America/Cayman': 'KY',
|
||||
'America/Chicago': 'US',
|
||||
'America/Chihuahua': 'MX',
|
||||
'America/Ciudad_Juarez': 'MX',
|
||||
'America/Coral_Harbour': 'CA',
|
||||
'America/Cordoba': 'AR',
|
||||
'America/Costa_Rica': 'CR',
|
||||
'America/Coyhaique': 'CL',
|
||||
'America/Creston': 'CA',
|
||||
'America/Cuiaba': 'BR',
|
||||
'America/Curacao': 'CW',
|
||||
'America/Danmarkshavn': 'GL',
|
||||
'America/Dawson': 'CA',
|
||||
'America/Dawson_Creek': 'CA',
|
||||
'America/Denver': 'US',
|
||||
'America/Detroit': 'US',
|
||||
'America/Dominica': 'DM',
|
||||
'America/Edmonton': 'CA',
|
||||
'America/Eirunepe': 'BR',
|
||||
'America/El_Salvador': 'SV',
|
||||
'America/Ensenada': 'MX',
|
||||
'America/Fort_Nelson': 'CA',
|
||||
'America/Fort_Wayne': 'US',
|
||||
'America/Fortaleza': 'BR',
|
||||
'America/Glace_Bay': 'CA',
|
||||
'America/Godthab': 'GL',
|
||||
'America/Goose_Bay': 'CA',
|
||||
'America/Grand_Turk': 'TC',
|
||||
'America/Grenada': 'GD',
|
||||
'America/Guadeloupe': 'GP',
|
||||
'America/Guatemala': 'GT',
|
||||
'America/Guayaquil': 'EC',
|
||||
'America/Guyana': 'GY',
|
||||
'America/Halifax': 'CA',
|
||||
'America/Havana': 'CU',
|
||||
'America/Hermosillo': 'MX',
|
||||
'America/Indiana/Indianapolis': 'US',
|
||||
'America/Indiana/Knox': 'US',
|
||||
'America/Indiana/Marengo': 'US',
|
||||
'America/Indiana/Petersburg': 'US',
|
||||
'America/Indiana/Tell_City': 'US',
|
||||
'America/Indiana/Vevay': 'US',
|
||||
'America/Indiana/Vincennes': 'US',
|
||||
'America/Indiana/Winamac': 'US',
|
||||
'America/Indianapolis': 'US',
|
||||
'America/Inuvik': 'CA',
|
||||
'America/Iqaluit': 'CA',
|
||||
'America/Jamaica': 'JM',
|
||||
'America/Jujuy': 'AR',
|
||||
'America/Juneau': 'US',
|
||||
'America/Kentucky/Louisville': 'US',
|
||||
'America/Kentucky/Monticello': 'US',
|
||||
'America/Knox_IN': 'US',
|
||||
'America/Kralendijk': 'BQ',
|
||||
'America/La_Paz': 'BO',
|
||||
'America/Lima': 'PE',
|
||||
'America/Los_Angeles': 'US',
|
||||
'America/Louisville': 'US',
|
||||
'America/Lower_Princes': 'SX',
|
||||
'America/Maceio': 'BR',
|
||||
'America/Managua': 'NI',
|
||||
'America/Manaus': 'BR',
|
||||
'America/Marigot': 'MF',
|
||||
'America/Martinique': 'MQ',
|
||||
'America/Matamoros': 'MX',
|
||||
'America/Mazatlan': 'MX',
|
||||
'America/Mendoza': 'AR',
|
||||
'America/Menominee': 'US',
|
||||
'America/Merida': 'MX',
|
||||
'America/Metlakatla': 'US',
|
||||
'America/Mexico_City': 'MX',
|
||||
'America/Miquelon': 'PM',
|
||||
'America/Moncton': 'CA',
|
||||
'America/Monterrey': 'MX',
|
||||
'America/Montevideo': 'UY',
|
||||
'America/Montreal': 'CA',
|
||||
'America/Montserrat': 'MS',
|
||||
'America/Nassau': 'BS',
|
||||
'America/New_York': 'US',
|
||||
'America/Nipigon': 'CA',
|
||||
'America/Nome': 'US',
|
||||
'America/Noronha': 'BR',
|
||||
'America/North_Dakota/Beulah': 'US',
|
||||
'America/North_Dakota/Center': 'US',
|
||||
'America/North_Dakota/New_Salem': 'US',
|
||||
'America/Nuuk': 'GL',
|
||||
'America/Ojinaga': 'MX',
|
||||
'America/Panama': 'PA',
|
||||
'America/Pangnirtung': 'CA',
|
||||
'America/Paramaribo': 'SR',
|
||||
'America/Phoenix': 'US',
|
||||
'America/Port-au-Prince': 'HT',
|
||||
'America/Port_of_Spain': 'TT',
|
||||
'America/Porto_Acre': 'BR',
|
||||
'America/Porto_Velho': 'BR',
|
||||
'America/Puerto_Rico': 'PR',
|
||||
'America/Punta_Arenas': 'CL',
|
||||
'America/Rainy_River': 'CA',
|
||||
'America/Rankin_Inlet': 'CA',
|
||||
'America/Recife': 'BR',
|
||||
'America/Regina': 'CA',
|
||||
'America/Resolute': 'CA',
|
||||
'America/Rio_Branco': 'BR',
|
||||
'America/Rosario': 'AR',
|
||||
'America/Santa_Isabel': 'MX',
|
||||
'America/Santarem': 'BR',
|
||||
'America/Santiago': 'CL',
|
||||
'America/Santo_Domingo': 'DO',
|
||||
'America/Sao_Paulo': 'BR',
|
||||
'America/Scoresbysund': 'GL',
|
||||
'America/Shiprock': 'US',
|
||||
'America/Sitka': 'US',
|
||||
'America/St_Barthelemy': 'BL',
|
||||
'America/St_Johns': 'CA',
|
||||
'America/St_Kitts': 'KN',
|
||||
'America/St_Lucia': 'LC',
|
||||
'America/St_Thomas': 'VI',
|
||||
'America/St_Vincent': 'VC',
|
||||
'America/Swift_Current': 'CA',
|
||||
'America/Tegucigalpa': 'HN',
|
||||
'America/Thule': 'GL',
|
||||
'America/Thunder_Bay': 'CA',
|
||||
'America/Tijuana': 'MX',
|
||||
'America/Toronto': 'CA',
|
||||
'America/Tortola': 'VG',
|
||||
'America/Vancouver': 'CA',
|
||||
'America/Virgin': 'VI',
|
||||
'America/Whitehorse': 'CA',
|
||||
'America/Winnipeg': 'CA',
|
||||
'America/Yakutat': 'US',
|
||||
'America/Yellowknife': 'CA',
|
||||
'Antarctica/Casey': 'AQ',
|
||||
'Antarctica/Davis': 'AQ',
|
||||
'Antarctica/DumontDUrville': 'AQ',
|
||||
'Antarctica/Macquarie': 'AU',
|
||||
'Antarctica/Mawson': 'AQ',
|
||||
'Antarctica/McMurdo': 'AQ',
|
||||
'Antarctica/Palmer': 'AQ',
|
||||
'Antarctica/Rothera': 'AQ',
|
||||
'Antarctica/South_Pole': 'AQ',
|
||||
'Antarctica/Syowa': 'AQ',
|
||||
'Antarctica/Troll': 'AQ',
|
||||
'Antarctica/Vostok': 'AQ',
|
||||
'Arctic/Longyearbyen': 'SJ',
|
||||
'Asia/Aden': 'YE',
|
||||
'Asia/Almaty': 'KZ',
|
||||
'Asia/Amman': 'JO',
|
||||
'Asia/Anadyr': 'RU',
|
||||
'Asia/Aqtau': 'KZ',
|
||||
'Asia/Aqtobe': 'KZ',
|
||||
'Asia/Ashgabat': 'TM',
|
||||
'Asia/Ashkhabad': 'TM',
|
||||
'Asia/Atyrau': 'KZ',
|
||||
'Asia/Baghdad': 'IQ',
|
||||
'Asia/Bahrain': 'BH',
|
||||
'Asia/Baku': 'AZ',
|
||||
'Asia/Bangkok': 'TH',
|
||||
'Asia/Barnaul': 'RU',
|
||||
'Asia/Beirut': 'LB',
|
||||
'Asia/Bishkek': 'KG',
|
||||
'Asia/Brunei': 'BN',
|
||||
'Asia/Calcutta': 'IN',
|
||||
'Asia/Chita': 'RU',
|
||||
'Asia/Choibalsan': 'MN',
|
||||
'Asia/Chongqing': 'CN',
|
||||
'Asia/Chungking': 'CN',
|
||||
'Asia/Colombo': 'LK',
|
||||
'Asia/Dacca': 'BD',
|
||||
'Asia/Damascus': 'SY',
|
||||
'Asia/Dhaka': 'BD',
|
||||
'Asia/Dili': 'TL',
|
||||
'Asia/Dubai': 'AE',
|
||||
'Asia/Dushanbe': 'TJ',
|
||||
'Asia/Famagusta': 'CY',
|
||||
'Asia/Gaza': 'PS',
|
||||
'Asia/Harbin': 'CN',
|
||||
'Asia/Hebron': 'PS',
|
||||
'Asia/Ho_Chi_Minh': 'VN',
|
||||
'Asia/Hong_Kong': 'HK',
|
||||
'Asia/Hovd': 'MN',
|
||||
'Asia/Irkutsk': 'RU',
|
||||
'Asia/Istanbul': 'TR',
|
||||
'Asia/Jakarta': 'ID',
|
||||
'Asia/Jayapura': 'ID',
|
||||
'Asia/Jerusalem': 'IL',
|
||||
'Asia/Kabul': 'AF',
|
||||
'Asia/Kamchatka': 'RU',
|
||||
'Asia/Karachi': 'PK',
|
||||
'Asia/Kashgar': 'CN',
|
||||
'Asia/Kathmandu': 'NP',
|
||||
'Asia/Katmandu': 'NP',
|
||||
'Asia/Khandyga': 'RU',
|
||||
'Asia/Kolkata': 'IN',
|
||||
'Asia/Krasnoyarsk': 'RU',
|
||||
'Asia/Kuala_Lumpur': 'MY',
|
||||
'Asia/Kuching': 'MY',
|
||||
'Asia/Kuwait': 'KW',
|
||||
'Asia/Macao': 'MO',
|
||||
'Asia/Macau': 'MO',
|
||||
'Asia/Magadan': 'RU',
|
||||
'Asia/Makassar': 'ID',
|
||||
'Asia/Manila': 'PH',
|
||||
'Asia/Muscat': 'OM',
|
||||
'Asia/Nicosia': 'CY',
|
||||
'Asia/Novokuznetsk': 'RU',
|
||||
'Asia/Novosibirsk': 'RU',
|
||||
'Asia/Omsk': 'RU',
|
||||
'Asia/Oral': 'KZ',
|
||||
'Asia/Phnom_Penh': 'KH',
|
||||
'Asia/Pontianak': 'ID',
|
||||
'Asia/Pyongyang': 'KP',
|
||||
'Asia/Qatar': 'QA',
|
||||
'Asia/Qostanay': 'KZ',
|
||||
'Asia/Qyzylorda': 'KZ',
|
||||
'Asia/Rangoon': 'MM',
|
||||
'Asia/Riyadh': 'SA',
|
||||
'Asia/Saigon': 'VN',
|
||||
'Asia/Sakhalin': 'RU',
|
||||
'Asia/Samarkand': 'UZ',
|
||||
'Asia/Seoul': 'KR',
|
||||
'Asia/Shanghai': 'CN',
|
||||
'Asia/Singapore': 'SG',
|
||||
'Asia/Srednekolymsk': 'RU',
|
||||
'Asia/Taipei': 'TW',
|
||||
'Asia/Tashkent': 'UZ',
|
||||
'Asia/Tbilisi': 'GE',
|
||||
'Asia/Tehran': 'IR',
|
||||
'Asia/Tel_Aviv': 'IL',
|
||||
'Asia/Thimbu': 'BT',
|
||||
'Asia/Thimphu': 'BT',
|
||||
'Asia/Tokyo': 'JP',
|
||||
'Asia/Tomsk': 'RU',
|
||||
'Asia/Ujung_Pandang': 'ID',
|
||||
'Asia/Ulaanbaatar': 'MN',
|
||||
'Asia/Ulan_Bator': 'MN',
|
||||
'Asia/Urumqi': 'CN',
|
||||
'Asia/Ust-Nera': 'RU',
|
||||
'Asia/Vientiane': 'LA',
|
||||
'Asia/Vladivostok': 'RU',
|
||||
'Asia/Yakutsk': 'RU',
|
||||
'Asia/Yangon': 'MM',
|
||||
'Asia/Yekaterinburg': 'RU',
|
||||
'Asia/Yerevan': 'AM',
|
||||
'Atlantic/Azores': 'PT',
|
||||
'Atlantic/Bermuda': 'BM',
|
||||
'Atlantic/Canary': 'ES',
|
||||
'Atlantic/Cape_Verde': 'CV',
|
||||
'Atlantic/Faeroe': 'FO',
|
||||
'Atlantic/Faroe': 'FO',
|
||||
'Atlantic/Jan_Mayen': 'SJ',
|
||||
'Atlantic/Madeira': 'PT',
|
||||
'Atlantic/Reykjavik': 'IS',
|
||||
'Atlantic/South_Georgia': 'GS',
|
||||
'Atlantic/St_Helena': 'SH',
|
||||
'Atlantic/Stanley': 'FK',
|
||||
'Australia/ACT': 'AU',
|
||||
'Australia/Adelaide': 'AU',
|
||||
'Australia/Brisbane': 'AU',
|
||||
'Australia/Broken_Hill': 'AU',
|
||||
'Australia/Canberra': 'AU',
|
||||
'Australia/Currie': 'AU',
|
||||
'Australia/Darwin': 'AU',
|
||||
'Australia/Eucla': 'AU',
|
||||
'Australia/Hobart': 'AU',
|
||||
'Australia/LHI': 'AU',
|
||||
'Australia/Lindeman': 'AU',
|
||||
'Australia/Lord_Howe': 'AU',
|
||||
'Australia/Melbourne': 'AU',
|
||||
'Australia/NSW': 'AU',
|
||||
'Australia/North': 'AU',
|
||||
'Australia/Perth': 'AU',
|
||||
'Australia/Queensland': 'AU',
|
||||
'Australia/South': 'AU',
|
||||
'Australia/Sydney': 'AU',
|
||||
'Australia/Tasmania': 'AU',
|
||||
'Australia/Victoria': 'AU',
|
||||
'Australia/West': 'AU',
|
||||
'Australia/Yancowinna': 'AU',
|
||||
'Brazil/Acre': 'BR',
|
||||
'Brazil/DeNoronha': 'BR',
|
||||
'Brazil/East': 'BR',
|
||||
'Brazil/West': 'BR',
|
||||
'Canada/Atlantic': 'CA',
|
||||
'Canada/Central': 'CA',
|
||||
'Canada/Eastern': 'CA',
|
||||
'Canada/Mountain': 'CA',
|
||||
'Canada/Newfoundland': 'CA',
|
||||
'Canada/Pacific': 'CA',
|
||||
'Canada/Saskatchewan': 'CA',
|
||||
'Canada/Yukon': 'CA',
|
||||
'Chile/Continental': 'CL',
|
||||
'Chile/EasterIsland': 'CL',
|
||||
'Cuba': 'CU',
|
||||
'Egypt': 'EG',
|
||||
'Eire': 'IE',
|
||||
'Europe/Amsterdam': 'NL',
|
||||
'Europe/Andorra': 'AD',
|
||||
'Europe/Astrakhan': 'RU',
|
||||
'Europe/Athens': 'GR',
|
||||
'Europe/Belfast': 'GB',
|
||||
'Europe/Belgrade': 'RS',
|
||||
'Europe/Berlin': 'DE',
|
||||
'Europe/Bratislava': 'SK',
|
||||
'Europe/Brussels': 'BE',
|
||||
'Europe/Bucharest': 'RO',
|
||||
'Europe/Budapest': 'HU',
|
||||
'Europe/Busingen': 'DE',
|
||||
'Europe/Chisinau': 'MD',
|
||||
'Europe/Copenhagen': 'DK',
|
||||
'Europe/Dublin': 'IE',
|
||||
'Europe/Gibraltar': 'GI',
|
||||
'Europe/Guernsey': 'GG',
|
||||
'Europe/Helsinki': 'FI',
|
||||
'Europe/Isle_of_Man': 'IM',
|
||||
'Europe/Istanbul': 'TR',
|
||||
'Europe/Jersey': 'JE',
|
||||
'Europe/Kaliningrad': 'RU',
|
||||
'Europe/Kiev': 'UA',
|
||||
'Europe/Kirov': 'RU',
|
||||
'Europe/Kyiv': 'UA',
|
||||
'Europe/Lisbon': 'PT',
|
||||
'Europe/Ljubljana': 'SI',
|
||||
'Europe/London': 'GB',
|
||||
'Europe/Luxembourg': 'LU',
|
||||
'Europe/Madrid': 'ES',
|
||||
'Europe/Malta': 'MT',
|
||||
'Europe/Mariehamn': 'AX',
|
||||
'Europe/Minsk': 'BY',
|
||||
'Europe/Monaco': 'MC',
|
||||
'Europe/Moscow': 'RU',
|
||||
'Europe/Nicosia': 'CY',
|
||||
'Europe/Oslo': 'NO',
|
||||
'Europe/Paris': 'FR',
|
||||
'Europe/Podgorica': 'ME',
|
||||
'Europe/Prague': 'CZ',
|
||||
'Europe/Riga': 'LV',
|
||||
'Europe/Rome': 'IT',
|
||||
'Europe/Samara': 'RU',
|
||||
'Europe/San_Marino': 'SM',
|
||||
'Europe/Sarajevo': 'BA',
|
||||
'Europe/Saratov': 'RU',
|
||||
'Europe/Simferopol': 'UA',
|
||||
'Europe/Skopje': 'MK',
|
||||
'Europe/Sofia': 'BG',
|
||||
'Europe/Stockholm': 'SE',
|
||||
'Europe/Tallinn': 'EE',
|
||||
'Europe/Tirane': 'AL',
|
||||
'Europe/Tiraspol': 'MD',
|
||||
'Europe/Ulyanovsk': 'RU',
|
||||
'Europe/Uzhgorod': 'UA',
|
||||
'Europe/Vaduz': 'LI',
|
||||
'Europe/Vatican': 'VA',
|
||||
'Europe/Vienna': 'AT',
|
||||
'Europe/Vilnius': 'LT',
|
||||
'Europe/Volgograd': 'RU',
|
||||
'Europe/Warsaw': 'PL',
|
||||
'Europe/Zagreb': 'HR',
|
||||
'Europe/Zaporozhye': 'UA',
|
||||
'Europe/Zurich': 'CH',
|
||||
'GB': 'GB',
|
||||
'GB-Eire': 'GB',
|
||||
'Hongkong': 'HK',
|
||||
'Iceland': 'IS',
|
||||
'Indian/Antananarivo': 'MG',
|
||||
'Indian/Chagos': 'IO',
|
||||
'Indian/Christmas': 'CX',
|
||||
'Indian/Cocos': 'CC',
|
||||
'Indian/Comoro': 'KM',
|
||||
'Indian/Kerguelen': 'TF',
|
||||
'Indian/Mahe': 'SC',
|
||||
'Indian/Maldives': 'MV',
|
||||
'Indian/Mauritius': 'MU',
|
||||
'Indian/Mayotte': 'YT',
|
||||
'Indian/Reunion': 'RE',
|
||||
'Iran': 'IR',
|
||||
'Israel': 'IL',
|
||||
'Jamaica': 'JM',
|
||||
'Japan': 'JP',
|
||||
'Kwajalein': 'MH',
|
||||
'Libya': 'LY',
|
||||
'Mexico/BajaNorte': 'MX',
|
||||
'Mexico/BajaSur': 'MX',
|
||||
'Mexico/General': 'MX',
|
||||
'NZ': 'NZ',
|
||||
'NZ-CHAT': 'NZ',
|
||||
'Navajo': 'US',
|
||||
'PRC': 'CN',
|
||||
'Pacific/Apia': 'WS',
|
||||
'Pacific/Auckland': 'NZ',
|
||||
'Pacific/Bougainville': 'PG',
|
||||
'Pacific/Chatham': 'NZ',
|
||||
'Pacific/Chuuk': 'FM',
|
||||
'Pacific/Easter': 'CL',
|
||||
'Pacific/Efate': 'VU',
|
||||
'Pacific/Enderbury': 'KI',
|
||||
'Pacific/Fakaofo': 'TK',
|
||||
'Pacific/Fiji': 'FJ',
|
||||
'Pacific/Funafuti': 'TV',
|
||||
'Pacific/Galapagos': 'EC',
|
||||
'Pacific/Gambier': 'PF',
|
||||
'Pacific/Guadalcanal': 'SB',
|
||||
'Pacific/Guam': 'GU',
|
||||
'Pacific/Honolulu': 'US',
|
||||
'Pacific/Johnston': 'UM',
|
||||
'Pacific/Kanton': 'KI',
|
||||
'Pacific/Kiritimati': 'KI',
|
||||
'Pacific/Kosrae': 'FM',
|
||||
'Pacific/Kwajalein': 'MH',
|
||||
'Pacific/Majuro': 'MH',
|
||||
'Pacific/Marquesas': 'PF',
|
||||
'Pacific/Midway': 'UM',
|
||||
'Pacific/Nauru': 'NR',
|
||||
'Pacific/Niue': 'NU',
|
||||
'Pacific/Norfolk': 'NF',
|
||||
'Pacific/Noumea': 'NC',
|
||||
'Pacific/Pago_Pago': 'AS',
|
||||
'Pacific/Palau': 'PW',
|
||||
'Pacific/Pitcairn': 'PN',
|
||||
'Pacific/Pohnpei': 'FM',
|
||||
'Pacific/Ponape': 'FM',
|
||||
'Pacific/Port_Moresby': 'PG',
|
||||
'Pacific/Rarotonga': 'CK',
|
||||
'Pacific/Saipan': 'MP',
|
||||
'Pacific/Samoa': 'AS',
|
||||
'Pacific/Tahiti': 'PF',
|
||||
'Pacific/Tarawa': 'KI',
|
||||
'Pacific/Tongatapu': 'TO',
|
||||
'Pacific/Truk': 'FM',
|
||||
'Pacific/Wake': 'UM',
|
||||
'Pacific/Wallis': 'WF',
|
||||
'Pacific/Yap': 'FM',
|
||||
'Poland': 'PL',
|
||||
'Portugal': 'PT',
|
||||
'ROC': 'TW',
|
||||
'ROK': 'KR',
|
||||
'Singapore': 'SG',
|
||||
'Turkey': 'TR',
|
||||
'US/Alaska': 'US',
|
||||
'US/Aleutian': 'US',
|
||||
'US/Arizona': 'US',
|
||||
'US/Central': 'US',
|
||||
'US/East-Indiana': 'US',
|
||||
'US/Eastern': 'US',
|
||||
'US/Hawaii': 'US',
|
||||
'US/Indiana-Starke': 'US',
|
||||
'US/Michigan': 'US',
|
||||
'US/Mountain': 'US',
|
||||
'US/Pacific': 'US',
|
||||
'US/Samoa': 'AS',
|
||||
'W-SU': 'RU',
|
||||
}
|
||||
+3
-1
@@ -506,8 +506,10 @@ def apply(gs, event, panel):
|
||||
say("ready", focus=panel.get("window"), display=panel.get("display"), stale=True) # the page re-syncs
|
||||
STALE[0] = True
|
||||
return panel
|
||||
if STALE[0] and not stale:
|
||||
release = event.get("down") is False and ("button" in event or "key" in event)
|
||||
if STALE[0] and not stale and not (release and "window" not in event):
|
||||
# Anything that goes through (a trackpad move names no panel) means caught up: stop re-syncing.
|
||||
# A bare release doesn't: the page leaves the panel off releases, so it says nothing about focus.
|
||||
STALE[0] = False
|
||||
say("ready", focus=(panel or {}).get("window"), display=(panel or {}).get("display"))
|
||||
if "fx" in event and panel and panel.get("window"):
|
||||
|
||||
+364
-36
@@ -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) ---- */
|
||||
@@ -454,6 +455,12 @@
|
||||
backdrop-filter: none; background: rgb(23, 29, 37); }
|
||||
.brand svg { width: 26px; height: 26px; }
|
||||
header .chip { height: 30px; padding: 0 10px; }
|
||||
/* Let connection text yield before battery and header actions. */
|
||||
.hdr-end { flex: 1; gap: 6px; }
|
||||
.hdr-end .pill, .hdr-end .devsel { min-width: 0; }
|
||||
.hdr-end .pill { padding: 0 8px; gap: 6px; }
|
||||
.hdr-end .pill > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
#reportBtn, #refreshAll { width: 38px; padding: 0; justify-content: center; }
|
||||
nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 25; gap: 0;
|
||||
height: calc(58px + env(safe-area-inset-bottom)); padding: 0 env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
|
||||
background: rgba(23,29,37,.97); backdrop-filter: blur(12px); box-shadow: 0 -1px 0 rgba(255,255,255,.07); }
|
||||
@@ -480,6 +487,9 @@
|
||||
.and-btns { padding-left: 0; }
|
||||
.banner { flex-wrap: wrap; }
|
||||
.banner .s { white-space: normal; }
|
||||
/* Reserve the message row; connection actions wrap below it. */
|
||||
#offline { align-items: flex-start; }
|
||||
#offline > .grow { flex: 1 0 calc(100% - 36px); overflow-wrap: anywhere; }
|
||||
button { height: 38px; }
|
||||
button.small { height: 32px; }
|
||||
.actions button { height: 46px; }
|
||||
@@ -641,6 +651,7 @@
|
||||
@media(prefers-reduced-motion:reduce) { html { scroll-behavior:auto; } .store-card, .source-toggle::after { transition:none; } .store-skeleton { animation:none; } }
|
||||
|
||||
</style>
|
||||
<link rel="stylesheet" href="/control-center.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -659,6 +670,7 @@
|
||||
<a href="#android" title="Android apps and their display (3)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="9" width="14" height="11" rx="2"/><path d="M8 9a4 4 0 0 1 8 0M8 5l1.5 2M16 5l-1.5 2M9.5 13h.01M14.5 13h.01"/></svg>Android<kbd>3</kbd></a>
|
||||
<a href="#tools" title="Files, clipboard, Linux apps, remote and power (4)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.2 5.2L3.5 17.5a2.1 2.1 0 0 0 3 3l6-6a4 4 0 0 0 5.2-5.2l-2.5 2.5-2.5-2.5z"/></svg>Tools<kbd>4</kbd></a>
|
||||
<a href="#devices" class="desk-only" title="Headsets, their addresses and the connection (5)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0M8.5 16a5 5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/><path d="M2 9a15 15 0 0 1 20 0"/></svg>Devices<kbd>5</kbd></a>
|
||||
<a href="#control" title="Search controls, settings and live metrics (6)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true"><path d="M3 6h18M3 12h18M3 18h18M8 3v6M16 9v6M10 15v6"/></svg>Control<kbd>6</kbd></a>
|
||||
</nav>
|
||||
<div class="hdr-end">
|
||||
<button class="pill" id="conn" role="status" aria-live="polite" title="Connection details"><span class="dot wait"></span><span class="pt"><b>Connecting…</b><span></span></span></button>
|
||||
@@ -683,7 +695,7 @@
|
||||
</div>
|
||||
<div class="notice" id="privacyNotice" hidden>
|
||||
<div class="grow">Frame Control sends anonymous usage statistics: that it was installed and opened, its version,
|
||||
your operating system, which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
your operating system, your country (from the computer's time zone setting), which tabs you use, whether installs on the Frame worked, and how many headsets you use
|
||||
(a one-way hash of each headset's machine ID, the same on every computer, so they're counted once). Never file names, paths,
|
||||
addresses or anything you've typed, and it isn't linked to you. You can also share whether Android apps
|
||||
worked and the details of errors, which helps fix problems faster.</div>
|
||||
@@ -712,6 +724,42 @@
|
||||
<button class="action small" id="offRetry">Retry now</button>
|
||||
</div>
|
||||
|
||||
<div class="page cc" data-page="control" id="controlCenter">
|
||||
<section class="cc-hero" aria-labelledby="ccTitle">
|
||||
<div><div class="cc-eyebrow">Your Frame, at a glance</div><h1 id="ccTitle">Control Center</h1><p>Everything you can see, change and configure. Find a setting, check your headset, or pick up where you left off.</p></div>
|
||||
<svg class="cc-hero-mark" viewBox="0 0 120 84" fill="none" aria-hidden="true"><path d="M14 28C14 15 106 15 106 28v23c0 18-33 23-39 11h-14c-6 12-39 7-39-11z" stroke="currentColor" stroke-width="1.5"/><path d="M26 32c0-6 25-6 25 0v15c0 11-25 11-25 0zM69 32c0-6 25-6 25 0v15c0 11-25 11-25 0z" stroke="currentColor" stroke-opacity=".4"/><path d="M7 33h7m92 0h7M48 18l4-7h16l4 7" stroke="currentColor" stroke-width="1.5"/><circle cx="60" cy="38" r="2" fill="currentColor"/></svg>
|
||||
</section>
|
||||
<section aria-labelledby="ccDeviceName">
|
||||
<div class="cc-topline"><h2 id="ccDeviceName">Steam Frame</h2><span class="cc-sample" id="ccSampleState" role="status">Connect your Frame to read live metrics</span><button class="small" id="ccRefresh">Refresh metrics</button></div>
|
||||
<div class="cc-summary">
|
||||
<div class="cc-metric"><span>Battery</span><strong data-cc-metric="battery">Unavailable</strong><small data-cc-metric="batteryDetail">Connect to read charge and power</small></div>
|
||||
<div class="cc-metric"><span>Storage free</span><strong data-cc-metric="storage">Unavailable</strong><small data-cc-metric="storageDetail">User storage unavailable</small></div>
|
||||
<div class="cc-metric"><span>Memory used</span><strong data-cc-metric="memory">Unavailable</strong><small data-cc-metric="memoryDetail">Memory sensor unavailable</small></div>
|
||||
<div class="cc-metric"><span>Compositor FPS</span><strong data-cc-metric="fps">Unavailable</strong><small data-cc-metric="fpsDetail">Compositor FPS · not game FPS</small></div>
|
||||
</div>
|
||||
<details class="cc-device-facts"><summary id="ccBuild">System information unavailable</summary>
|
||||
<dl class="cc-readings">
|
||||
<div><dt>Wi-Fi network</dt><dd data-cc-metric="wifi">Unavailable</dd></div><div><dt>Wi-Fi signal</dt><dd data-cc-metric="signal">Unavailable</dd></div>
|
||||
<div><dt>IP address</dt><dd data-cc-metric="ip">Unavailable</dd></div><div><dt>Uptime</dt><dd data-cc-metric="uptime">Unavailable</dd></div>
|
||||
<div><dt>Battery power · signed</dt><dd data-cc-metric="power">Unavailable</dd></div><div><dt>Charger</dt><dd data-cc-metric="charger">Unavailable</dd></div>
|
||||
<div><dt>Battery temperature</dt><dd data-cc-metric="batteryTemp">Unavailable</dd></div><div><dt>Battery health</dt><dd data-cc-metric="batteryHealth">Unavailable</dd></div>
|
||||
<div><dt>Battery estimate</dt><dd data-cc-metric="estimate">Unavailable</dd></div>
|
||||
</dl><p class="cc-note">Positive battery watts mean charging; negative means discharge. Estimates are reported by the battery and may be unavailable.</p>
|
||||
<div class="cc-services" id="ccServices"></div>
|
||||
</details>
|
||||
</section>
|
||||
<div class="cc-workspace">
|
||||
<aside class="cc-sidebar" aria-label="Control Center categories"><p>Browse controls</p><div class="cc-categories" id="ccCategories"></div>
|
||||
<div class="cc-status"><div><i class="dot" id="ccStatusDot"></i><span id="ccStatusLabel">No live sample</span></div><p id="ccStatusNote">Read-only status over SSH. Controls run only when you use them.</p></div>
|
||||
</aside>
|
||||
<div>
|
||||
<div class="cc-search-row"><div class="cc-search"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true"><circle cx="10" cy="10" r="6.5"/><path d="m15 15 6 6"/></svg><input id="ccSearch" type="search" placeholder="Search settings, controls and guides…" aria-label="Search Control Center" autocomplete="off"><button id="ccClear" type="button" aria-label="Clear search" hidden>×</button></div><span class="cc-results" id="ccResultCount" role="status" aria-live="polite"></span></div>
|
||||
<div class="cc-cards" id="ccCards"></div>
|
||||
<div class="cc-empty" id="ccEmpty" hidden><h2>No matching controls</h2><p>Try a different word or browse all categories.</p><button class="small" id="ccResetFilter">Reset search & category</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page on" data-page="home">
|
||||
<div class="grid-top" id="view">
|
||||
<section class="panel">
|
||||
@@ -884,10 +932,9 @@
|
||||
<button data-pad-key="12" title="Enter">Enter</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hint">Types and points in apps on the Frame, such as Chromium or the desktop's Linux apps; Steam's own VR menus
|
||||
don't take it. It works through KDE Connect, which comes with Frame Control: the first time, it copies Valve's build
|
||||
for the Frame (3.6 MB, 18 MB unpacked) into your home folder there and pairs with it. No internet needed, and nothing
|
||||
else to install. <a href="#" data-about>Licences</a></div>
|
||||
<div class="hint">Types and points in whichever panel has focus in the headset, through Valve's own input path: nothing to
|
||||
install and no internet needed. Accents and emoji go through KDE Connect, which comes with Frame Control and starts
|
||||
the first time you type one (it copies Valve's build for the Frame, 3.6 MB, into your home folder there). <a href="#" data-about>Licences</a></div>
|
||||
</section>
|
||||
|
||||
<section id="shots">
|
||||
@@ -958,6 +1005,12 @@
|
||||
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="installQueue" hidden>
|
||||
<div class="shelf-head"><h2>Install queue</h2><span class="count" id="queueCount"></span></div>
|
||||
<div class="list" id="queueList"></div>
|
||||
<div class="hint">Games from the store, your Quest and your Meta library install one after another.
|
||||
Remove takes a game out before it starts.</div>
|
||||
</section>
|
||||
<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>
|
||||
@@ -969,8 +1022,43 @@
|
||||
<div class="hint">Copies games you own from your own Quest (developer mode on) to the Frame. A game with
|
||||
OBB data takes two imports: the first installs it; start it from the Steam library, then import again to
|
||||
copy its data in. Frame Control never starts the game itself. Games that
|
||||
check their Meta store licence need Meta's services, which only Meta headsets have, so they are refused:
|
||||
Frame Control won't remove that check. <a href="https://github.com/saphid/frame-control/blob/main/docs/quest-import.md" target="_blank" rel="noopener noreferrer">How it works</a></div>
|
||||
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>
|
||||
@@ -1162,7 +1250,7 @@
|
||||
<section class="panel" id="privacy">
|
||||
<div class="shelf-head"><h2>Privacy & updates</h2><span class="spacer"></span><span class="sub" id="appVersion"></span></div>
|
||||
<label class="popt"><input type="checkbox" id="tUsage"><b>Anonymous usage statistics</b>
|
||||
<span class="sub">Installs, opens, version, operating system, tabs used, and whether installs on the Frame
|
||||
<span class="sub">Installs, opens, version, operating system, country (from the time zone setting, to see roughly where it's used), tabs used, and whether installs on the Frame
|
||||
worked (with an error category, never the message). F-Droid package names only. Each headset's SteamOS
|
||||
build, a one-way hash of its machine ID (to count headsets, not just installs), and how many you've set up.</span></label>
|
||||
<label class="popt"><input type="checkbox" id="tCompat"><b>Share compatibility results</b>
|
||||
@@ -1408,7 +1496,7 @@ const HINTS = {
|
||||
flat: "The app panel in use in the headset, flat and still: it doesn't move as the wearer looks around. Pick another panel to watch it. Control lets you tap or click right where you want on it.",
|
||||
};
|
||||
const SOURCE_LABEL = { steamvr: "Headset view", gamescope: "Desktop panel", panel: "Desktop", shot: "Screenshot" };
|
||||
let state = null, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let state = null, stateReceivedAt = null, stateRead = {valid: false, error: null}, view = "headset", eye = "left", live = false, liveTimer = null, volTimer = null;
|
||||
let lastImg = null, lastSource = null, lastShot = null, viewGen = 0;
|
||||
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
||||
@@ -1446,10 +1534,12 @@ $("bottombar").onclick = () => {
|
||||
const HOST = { computer: "computer" };
|
||||
function applyHostWording(h) {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
// The iPhone app: the server runs on the Frame, and saving goes to the phone's Photos.
|
||||
document.body.classList.toggle("mobile", !!h.mobile);
|
||||
$("shotsFolder").hidden = !!h.mobile;
|
||||
$("quest").hidden = !!h.mobile; // the Quest plugs into a computer; the phone app's server runs on the Frame
|
||||
$("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`;
|
||||
@@ -1477,6 +1567,13 @@ let devGen = 0;
|
||||
const HEADSET_FREE = /^\/api\/(devices|connection|host|job|titles\/job|webinstall\/job|apk-versions|android\/(catalog|reports)|steam\/search)\b/;
|
||||
async function api(path, body, device = window.connDevice) {
|
||||
const gen = devGen;
|
||||
const checkGeneration = () => {
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // leave the panel for the new headset's answer
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
// Changes name the headset the page was showing, so the server refuses one meant
|
||||
// for a headset it has since switched away from.
|
||||
const dev = device ? { "X-Frame-Device": device } : {};
|
||||
@@ -1485,15 +1582,12 @@ async function api(path, body, device = window.connDevice) {
|
||||
let r;
|
||||
try { r = await fetch(path, opts); }
|
||||
catch {
|
||||
checkGeneration(); // a late transport failure belongs to the old headset too
|
||||
throw new Error(window.frameApp?.platform === "ios" ? "Lost the connection to the Frame. The app reconnects on its own."
|
||||
: "Frame Control's local server isn't running. Restart the app (Frame → Restart Server).");
|
||||
}
|
||||
const data = await r.json().catch(() => ({ error: `HTTP ${r.status}` }));
|
||||
if (gen !== devGen && !HEADSET_FREE.test(path)) {
|
||||
const err = new Error("Switched headsets");
|
||||
err.offline = err.stale = true; // failed() leaves the panel alone: the new headset's answer fills it
|
||||
throw err;
|
||||
}
|
||||
checkGeneration();
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.error || `HTTP ${r.status}`);
|
||||
err.offline = !!data.offline;
|
||||
@@ -1532,6 +1626,7 @@ let online = null;
|
||||
function setOnline(ok, why) {
|
||||
const was = online;
|
||||
online = ok;
|
||||
window.frameControlCenter?.connection(ok);
|
||||
$("offline").hidden = ok;
|
||||
if (!ok) {
|
||||
$("offMsg").textContent = why || "Can't reach the Frame";
|
||||
@@ -1563,11 +1658,15 @@ function refresh() {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
state = await api("/api/status");
|
||||
stateReceivedAt = Date.now();
|
||||
stateRead = {valid: true, error: null};
|
||||
render(state);
|
||||
setConn(true, state.hostname);
|
||||
setOnline(true);
|
||||
} catch (e) {
|
||||
renderPerformance(null);
|
||||
if (!e.stale) stateRead = {valid: false, error: e};
|
||||
window.frameControlCenter?.unavailable(e);
|
||||
if (!e.offline) { setConn(false, "Error"); log("Status failed: " + e.message, "e"); }
|
||||
if (!state) ["games", "flatpaks"].forEach(id => failed($(id), e));
|
||||
} finally {
|
||||
@@ -1577,6 +1676,12 @@ function refresh() {
|
||||
})();
|
||||
return refreshing;
|
||||
}
|
||||
async function refreshAfterAudio() {
|
||||
// A request already underway may contain pre-command audio. Preserve normal
|
||||
// poll deduplication, but drain it before requesting the command's readback.
|
||||
if (refreshing) await refreshing;
|
||||
return refresh();
|
||||
}
|
||||
|
||||
// ---- our VR HUD and optional third-party catalogue ----
|
||||
function renderPerformance(s) {
|
||||
@@ -1754,6 +1859,7 @@ function battery(b, power) {
|
||||
|
||||
function render(s) {
|
||||
renderPerformance(s);
|
||||
window.frameControlCenter?.status(s, stateReceivedAt, stateRead);
|
||||
battery(s.battery, s.power);
|
||||
$("devName").textContent = `${s.hostname} · ${s.ip ?? "no IP"}`;
|
||||
$("updated").textContent = new Date(s.time * 1000).toLocaleTimeString();
|
||||
@@ -2586,32 +2692,65 @@ document.body.addEventListener("click", async (e) => {
|
||||
// Installs run as server jobs: start one, then poll until it's done. The
|
||||
// bottom bar counts what's running; `installing` keeps re-rendered lists from
|
||||
// offering Install again for something already on its way.
|
||||
const installing = new Set();
|
||||
// Android installs wait their turn in the server's install queue (queue_job in
|
||||
// server.py); `queued` holds the ones still waiting.
|
||||
const installing = new Set(), queued = new Set();
|
||||
let jobsRunning = 0;
|
||||
function showJobs() {
|
||||
const waiting = queued.size, running = jobsRunning - waiting;
|
||||
$("jobs").hidden = !jobsRunning;
|
||||
$("jobsText").textContent = `${jobsRunning} install${jobsRunning === 1 ? "" : "s"} running`;
|
||||
$("jobsText").textContent = [running ? `${running} install${running === 1 ? "" : "s"} running` : "",
|
||||
waiting ? `${waiting} queued` : ""].filter(Boolean).join(", ");
|
||||
}
|
||||
async function runJob(label, key, start, onProgress = () => {}) {
|
||||
installing.add(key); jobsRunning++; showJobs();
|
||||
log(label + "…");
|
||||
try {
|
||||
const { job } = await start();
|
||||
const started = await start();
|
||||
const { job } = started;
|
||||
log(started.queued ? started.message : label + "…");
|
||||
if (started.queued) { queued.add(key); showJobs(); }
|
||||
if (started.queued != null) loadQueue();
|
||||
for (let misses = 0; ;) {
|
||||
await new Promise(ok => setTimeout(ok, 1500));
|
||||
let j;
|
||||
try { j = await api(`/api/job?id=${encodeURIComponent(job)}`); misses = 0; }
|
||||
catch (e) { if (++misses >= 20 || /no such job/.test(e.message)) throw e; continue; }
|
||||
if (j.queued !== queued.has(key)) { j.queued ? queued.add(key) : queued.delete(key); showJobs(); }
|
||||
onProgress(j);
|
||||
if (!j.done) continue;
|
||||
if (j.cancelled) { log(`${label}: removed from the install queue`); return null; }
|
||||
if (j.error) throw new Error(j.error);
|
||||
log(j.message, "ok"); toast(j.message);
|
||||
return j.result || {};
|
||||
}
|
||||
} catch (e) {
|
||||
log(`${label} failed: ${e.message}`, "e"); toast(`${label} failed: ${e.message}`, true);
|
||||
} finally { installing.delete(key); jobsRunning--; showJobs(); }
|
||||
} finally { installing.delete(key); queued.delete(key); jobsRunning--; showJobs(); }
|
||||
}
|
||||
// The install queue as the server has it: also what an earlier visit to this page queued.
|
||||
let queueTimer;
|
||||
async function loadQueue() {
|
||||
clearTimeout(queueTimer);
|
||||
let jobs;
|
||||
try { ({ jobs } = await api("/api/queue")); } catch { jobs = null; }
|
||||
if (jobs) {
|
||||
$("installQueue").hidden = !jobs.length;
|
||||
$("queueCount").textContent = jobs.length ? `${jobs.length}` : "";
|
||||
$("queueList").innerHTML = jobs.map(j => `<div class="item"><div class="grow"><div class="t">${esc(j.label)}</div>
|
||||
<div class="s">${esc(j.queued ? j.stage || "Queued" : `${j.stage || "Starting"}${j.percent != null ? ` · ${Math.round(j.percent)}%` : ""}`)}</div></div>
|
||||
${j.queued ? `<button class="small" data-unqueue="${esc(j.job)}">Remove</button>` : `<span class="tag">Installing</span>`}</div>`).join("");
|
||||
}
|
||||
if (!jobs || jobs.length) queueTimer = setTimeout(loadQueue, 2000);
|
||||
}
|
||||
$("queueList").addEventListener("click", async e => {
|
||||
const b = e.target.closest("[data-unqueue]");
|
||||
if (!b) return;
|
||||
b.disabled = true;
|
||||
try { const d = await api("/api/queue", { action: "remove", job: b.dataset.unqueue }); toast(d.message); }
|
||||
catch (err) { toast(err.message, true); }
|
||||
loadQueue();
|
||||
});
|
||||
loadQueue();
|
||||
const POWER = { suspend: ["Put the Frame to sleep?", "Sleep"], reboot: ["Restart the Frame?", "Restart"],
|
||||
poweroff: ["Shut the Frame down?", "Shut down"] };
|
||||
function askPower(what) {
|
||||
@@ -3280,6 +3419,7 @@ async function installStoreApp() {
|
||||
if(existing&&!existing.done)return;
|
||||
const state={stage:'Downloading',percent:null,done:false};sourceState.jobs.set(key,state);updateInstall();
|
||||
const result=await runJob(`Install ${o.name}`,key,()=>api('/api/sources/install',{source:o.source,id:o.id,version_code:o.version_code??null}),j=>{Object.assign(state,{stage:j.stage||'Downloading',percent:j.percent??null,error:j.error});updateInstall();});
|
||||
if(result===null){sourceState.jobs.delete(key);updateInstall();return;} // removed from the install queue
|
||||
Object.assign(state,{done:true,result,error:result?null:state.error||"We couldn't finish the installation. Try again."});updateInstall();
|
||||
if(result)loadAndroid();
|
||||
}
|
||||
@@ -3309,7 +3449,19 @@ async function addSource(body) {
|
||||
$("addSource").onsubmit=e=>{e.preventDefault();const f=new FormData(e.target);addSource({action:'add',url:f.get('url'),fingerprint:f.get('fingerprint')});};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>$(b.dataset.close).close());
|
||||
$('appDetail').addEventListener('close',()=>{sourceState.detailRequest++;});
|
||||
document.querySelectorAll('[data-store-tab]').forEach(b=>b.onclick=()=>{const browse=b.dataset.storeTab==='browse';$('appStore').hidden=!browse;$('androidLibrary').hidden=browse;$('repNew').hidden=browse;$('sourcesOpen').hidden=!browse;document.querySelectorAll('[data-store-tab]').forEach(t=>{t.classList.toggle('on',t===b);t.setAttribute('aria-pressed',t===b);});if(!browse)loadAndroid();});
|
||||
function showAndroidTab(tab) {
|
||||
const browse = tab === 'browse';
|
||||
$('appStore').hidden = !browse; $('androidLibrary').hidden = browse;
|
||||
$('repNew').hidden = browse; $('sourcesOpen').hidden = !browse;
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => {
|
||||
const selected = button.dataset.storeTab === tab;
|
||||
button.classList.toggle('on', selected); button.setAttribute('aria-pressed', String(selected));
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('[data-store-tab]').forEach(button => button.onclick = () => {
|
||||
showAndroidTab(button.dataset.storeTab);
|
||||
if (button.dataset.storeTab === 'library') loadAndroid();
|
||||
});
|
||||
loadSources().then(()=>searchSources());
|
||||
|
||||
// ---- Android apps (own Lepton instance each) + catalogue ----
|
||||
@@ -3321,6 +3473,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);
|
||||
@@ -3361,7 +3514,7 @@ async function loadQuest() {
|
||||
quest.serial = d.quest ? d.quest.serial : null;
|
||||
$("questCount").textContent = d.quest ? `${d.apps.length} app${d.apps.length === 1 ? "" : "s"}` : "";
|
||||
if (!d.quest) { $("questState").textContent = d.guidance || "No Quest found."; return; }
|
||||
$("questState").textContent = `${d.quest.model || "Quest"} connected (${d.quest.serial}). Games that check their Meta licence are refused when you import them.`;
|
||||
$("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"];
|
||||
@@ -3370,7 +3523,7 @@ async function loadQuest() {
|
||||
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>`;
|
||||
<button class="small" data-quest-import="${esc(a.package)}"${installing.has("quest:" + a.package) ? " disabled" : ""}>${queued.has("quest:" + a.package) ? "Queued" : 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;
|
||||
@@ -3379,16 +3532,174 @@ $("questApps").addEventListener("click", e => {
|
||||
if (!b) return;
|
||||
const pkg = b.dataset.questImport, key = "quest:" + pkg;
|
||||
if (installing.has(key)) return;
|
||||
b.disabled = true;
|
||||
b.disabled = true; b.textContent = "Queued";
|
||||
const bar = $("questProgress");
|
||||
bar.hidden = false; bar.value = 0;
|
||||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||||
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.`; } });
|
||||
j => { if (j.queued || j.done) return; b.textContent = "Importing…"; bar.hidden = false; bar.dataset.owner = key;
|
||||
if (j.stage) $("questState").textContent = `${pkg}: ${j.stage}`; bar.value = j.percent ?? 0; })
|
||||
.then(r => { if (bar.dataset.owner === key) bar.hidden = true; if (r && r.app) { b.textContent = "Imported"; $("questState").textContent = r.message || `Imported ${pkg}`; loadAndroid(); }
|
||||
else { b.disabled = false; b.textContent = "Import";
|
||||
if (r !== null) $("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" : "", waits = queued.has("meta:" + a.id);
|
||||
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>` : ""}
|
||||
${waits ? `<span class="tag">Queued</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;
|
||||
// Several can be queued: the bar and the status line follow whichever is installing.
|
||||
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 => { renderMetaApps(); if (j.queued || j.done) return; bar.hidden = false; bar.dataset.owner = "meta:" + app.id;
|
||||
if (j.stage) $("metaState").textContent = `${app.title}: ${j.stage}`; bar.value = j.percent ?? 0; });
|
||||
if (bar.dataset.owner === "meta:" + app.id) bar.hidden = true; // not while another install still shows its progress
|
||||
if (r && r.message) { $("metaState").textContent = r.message; metaLib.results.set(app.package, r.test ? r.test.message : r.message); }
|
||||
else if (r !== null) $("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; }
|
||||
@@ -3988,8 +4299,8 @@ $("macview").onclick = async e => {
|
||||
|
||||
// ---- pages: #home, #games, #android, #tools (older section links still work) ----
|
||||
const PAGES = ["home", "games", "android", "tools"];
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", transfer: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
const SECTION_PAGE = { view: "home", device: "home", shots: "home", vrTools: "home", comfort: "home", pad: "home", library: "games", sideloaded: "games", getgames: "games",
|
||||
display: "android", androidLibrary: "android", appStore: "android", transfer: "tools", media: "tools", artworkSettings: "tools", apps: "tools", power: "tools", macview: "tools", privacy: "tools", panels: "tools" };
|
||||
let page = "home";
|
||||
function showPage() {
|
||||
const id = location.hash.slice(1);
|
||||
@@ -3999,6 +4310,8 @@ function showPage() {
|
||||
document.querySelectorAll(".page").forEach(p => p.classList.toggle("on", p.dataset.page === page));
|
||||
document.querySelectorAll("nav a").forEach(a => a.classList.toggle("on", a.getAttribute("href") === "#" + page));
|
||||
document.title = page === "home" ? "Frame Control" : `${page[0].toUpperCase() + page.slice(1)} · Frame Control`;
|
||||
if (id === "display" || id === "androidLibrary") showAndroidTab("library");
|
||||
else if (id === "appStore") showAndroidTab("browse");
|
||||
const section = !PAGES.includes(id) && id && $(id);
|
||||
if (section) section.scrollIntoView(); else window.scrollTo(0, 0);
|
||||
if (page === "tools") { loadMacView(); loadPanelSwitcher(); }
|
||||
@@ -4009,10 +4322,10 @@ const typesText = el => el?.matches?.('textarea, input:not([type=range], [type=c
|
||||
document.addEventListener("focusin", e => document.body.classList.toggle("typing", !!typesText(e.target)));
|
||||
document.addEventListener("focusout", () => document.body.classList.remove("typing"));
|
||||
|
||||
// ---- keyboard and trackpad: events go to KDE Connect on the Frame (see ui/frame_input_agent.py) ----
|
||||
// ---- keyboard and trackpad: events go to the Frame through gamescope (ui/frame_touch.py); accents and emoji through KDE Connect ----
|
||||
const pad = { state: "off", queue: [], sending: false, poll: null, captured: false };
|
||||
const PAD_STATES = {
|
||||
off: "", starting: "Connecting…", installing: "Setting up KDE Connect on the Frame (first time only)…",
|
||||
off: "", starting: "Connecting…", installing: "Setting up (first time only)…",
|
||||
pairing: "Pairing with KDE Connect…", ready: "On",
|
||||
};
|
||||
// KDE Connect's specialKey numbers (plugins/mousepad in its source).
|
||||
@@ -4030,7 +4343,7 @@ function padShow(status) {
|
||||
$("padOn").hidden = ready || busy;
|
||||
$("padOn").textContent = pad.state === "error" ? "Try again" : "Turn on";
|
||||
$("padArea").classList.toggle("off", !ready);
|
||||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach KDE Connect on the Frame."
|
||||
$("padHint").textContent = pad.state === "error" ? status.message || "Couldn't reach the Frame."
|
||||
: !ready ? (busy ? PAD_STATES[pad.state] : "Turn on to use this as a trackpad for the Frame.")
|
||||
: TOUCH ? "Drag to move the pointer · tap to click · two fingers to scroll · two-finger tap to right-click"
|
||||
: pad.captured ? "Your mouse and keyboard now control the Frame. Press Esc to stop."
|
||||
@@ -4696,8 +5009,11 @@ function onConnection(s) {
|
||||
if (link.device !== null && devId !== link.device) {
|
||||
log(`Now using ${s.device.name}`, "ok");
|
||||
state = null;
|
||||
stateReceivedAt = null;
|
||||
stateRead = {valid: false, error: null};
|
||||
online = null;
|
||||
devGen++; // answers still on their way are about the other headset
|
||||
window.frameControlCenter?.reset();
|
||||
refreshing = null; // and the next status check must be a new one
|
||||
if (live) toggleLive(false); // the other headset's video or captures
|
||||
clearTimeout(volTimer); volTimer = null; // a volume change still waiting to be sent
|
||||
@@ -4804,7 +5120,7 @@ function setUpActive() {
|
||||
$("connSetup").onclick = setUpActive;
|
||||
|
||||
// ---- Devices tab ----
|
||||
PAGES.push("devices");
|
||||
PAGES.push("devices", "control");
|
||||
let devicesSeq = 0; // an older answer arriving late mustn't bring back the old selection
|
||||
async function loadDevices() {
|
||||
if (!link.live) return;
|
||||
@@ -5112,11 +5428,23 @@ if (window.frameApp && window.frameApp.onUseDevice) window.frameApp.onUseDevice(
|
||||
window.addEventListener("hashchange", () => { if (location.hash === "#devices") loadDevices(); });
|
||||
showPage(); // #devices is a page now
|
||||
api("/api/host").then(h => {
|
||||
Object.assign(HOST, h);
|
||||
window.frameControlCenter?.host(h);
|
||||
if (h.mobile) return;
|
||||
$("offSetup").hidden = false; $("offSetup").onclick = setUpActive; // the server opens it in a terminal
|
||||
watchConnection();
|
||||
}).catch(() => watchConnection());
|
||||
</script>
|
||||
<script src="/artwork-settings.js"></script>
|
||||
<script src="/control-center.js"></script>
|
||||
<script>
|
||||
window.frameControlCenter = FrameControlCenter.mount({document, api, refresh, refreshAudio: refreshAfterAudio,
|
||||
getContext: () => ({sample: state, online, generation: devGen, device: window.connDevice}),
|
||||
openConnection: () => $("conn").click(), platform: window.frameApp?.platform || ""});
|
||||
window.frameControlCenter.host(HOST);
|
||||
if (state) window.frameControlCenter.status(state, stateReceivedAt, stateRead);
|
||||
else if (stateRead.error) window.frameControlCenter.unavailable(stateRead.error);
|
||||
if (online === false) window.frameControlCenter.connection(false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+413
-37
@@ -58,10 +58,12 @@ 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
|
||||
import frame_titles # noqa: E402
|
||||
import frame_touch # noqa: E402
|
||||
import frame_webinstall # noqa: E402
|
||||
import frame_vr # noqa: E402
|
||||
import frame_utilities # noqa: E402
|
||||
@@ -114,6 +116,9 @@ def route(alias, host_opts):
|
||||
with _route_lock:
|
||||
moved = alias != FRAME
|
||||
if moved:
|
||||
reset_pad = globals().get("reset_pad_route")
|
||||
if reset_pad:
|
||||
reset_pad() # cancel old input before any agent can start on the new route
|
||||
frame_catalog._env.clear() # the SteamOS and Lepton builds reports record are per headset
|
||||
FRAME = frame_android.FRAME = alias
|
||||
HOST_OPTS = list(host_opts)
|
||||
@@ -124,9 +129,6 @@ def route(alias, host_opts):
|
||||
mv = globals().get("macview")
|
||||
if mv:
|
||||
mv.retarget(alias, host_opts) # its tunnel is its own ssh: it must follow the headset too
|
||||
agent = globals().get("_input")
|
||||
if moved and agent:
|
||||
agent.stop() # typing and pointing mustn't go on reaching the headset switched away from
|
||||
|
||||
|
||||
LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
@@ -136,7 +138,7 @@ LINK = None # the connector (frame_link.Link); None on the Frame itself
|
||||
# install's clean-up) to the other headset.
|
||||
_work_lock = threading.Lock()
|
||||
_work = [0]
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt"}
|
||||
NOT_HEADSET_WORK = {"/api/devices", "/api/contact", "/api/contact/prompt", "/api/queue"}
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -285,40 +287,120 @@ def backfill_art(apps=(), titles=()):
|
||||
return True
|
||||
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
def _new_job_locked(label, **fields):
|
||||
now = time.time()
|
||||
with _jobs_lock:
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now}
|
||||
for j in [j for j, v in _jobs.items() if v["done"] and now - v["time"] > JOB_TTL]:
|
||||
del _jobs[j]
|
||||
job = secrets.token_hex(8)
|
||||
_jobs[job] = {"label": label, "done": False, "error": None, "message": None, "result": None, "time": now, **fields}
|
||||
return job
|
||||
|
||||
|
||||
def _run_job(job, label, work, progress):
|
||||
def report(stage, percent=None):
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(stage=stage, percent=percent)
|
||||
|
||||
def run():
|
||||
fields = {}
|
||||
try:
|
||||
result = work(report) if progress else work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||
fields = {"error": str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
fields = {}
|
||||
try:
|
||||
result = work(report) if progress else work()
|
||||
fields = {"message": result.get("message") or f"{label}: done", "result": result}
|
||||
except (Failure, frame_android.FrameError) as e:
|
||||
fields = {"error": unreachable(str(e)) or str(e)}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
except SourceError as e: # already user-readable, and about a store, not the Frame
|
||||
fields = {"error": str(e)}
|
||||
except Exception as e:
|
||||
fields = {"error": f"{type(e).__name__}: {e}"}
|
||||
frame_telemetry.diagnostic(f"job {label.split()[0]}", e)
|
||||
finally:
|
||||
with _jobs_lock:
|
||||
_jobs[job].update(fields, done=True, time=time.time())
|
||||
|
||||
busy_thread(run).start()
|
||||
|
||||
def start_job(label, work, progress=False):
|
||||
"""Run work() in the background. It returns a dict with a "message"."""
|
||||
with _jobs_lock:
|
||||
job = _new_job_locked(label)
|
||||
busy_thread(_run_job, job, label, work, progress).start()
|
||||
return {"message": f"{label}…", "job": job}
|
||||
|
||||
|
||||
# ---- The install queue: Android games and apps, one after another ----------
|
||||
#
|
||||
# Installs from the Android store, Import from my Quest and My Meta library each copy
|
||||
# a game into a Lepton instance, and each refuses to start while another is running.
|
||||
# Queued, the page can line up several: one worker runs them in order, each as a
|
||||
# normal job (/api/job) that reads "queued" until its turn. A queued install can be
|
||||
# removed until it starts; each runs on the headset it was queued for, or not at all.
|
||||
|
||||
_queue = [] # [job, label, work, progress, headset id], in order; the running one is not here
|
||||
_queue_state = {"worker": False, "running": None}
|
||||
|
||||
|
||||
def _restage_locked():
|
||||
ahead = 1 if _queue_state["running"] else 0
|
||||
for i, (job, *_) in enumerate(_queue):
|
||||
n = ahead + i
|
||||
_jobs[job]["stage"] = f"Queued: {n} install{'' if n == 1 else 's'} before it" if n else "Queued: next"
|
||||
|
||||
|
||||
def queue_job(label, work, progress=False):
|
||||
"""Like start_job, but runs after every install queued before it."""
|
||||
device = LINK.active_device()["id"] if LINK else None
|
||||
with _jobs_lock:
|
||||
job = _new_job_locked(label, queued=True, stage=None, percent=None)
|
||||
_queue.append([job, label, work, progress, device])
|
||||
_restage_locked()
|
||||
ahead = len(_queue) - 1 + (1 if _queue_state["running"] else 0)
|
||||
start, _queue_state["worker"] = not _queue_state["worker"], True
|
||||
if start:
|
||||
threading.Thread(target=_drain_queue, daemon=True).start()
|
||||
return {"message": f"{label}: queued, {ahead} before it" if ahead else f"{label}…", "job": job, "queued": ahead}
|
||||
|
||||
|
||||
def _drain_queue():
|
||||
while True:
|
||||
with _jobs_lock:
|
||||
if not _queue:
|
||||
_queue_state.update(worker=False, running=None)
|
||||
return
|
||||
job, label, work, progress, device = _queue.pop(0)
|
||||
_queue_state["running"] = job
|
||||
_jobs[job].update(queued=False, stage=None)
|
||||
_restage_locked()
|
||||
|
||||
def guarded(*report, work=work, device=device):
|
||||
with working(device): # refused if the app switched headsets since it was queued
|
||||
return work(*report)
|
||||
_run_job(job, label, guarded, progress)
|
||||
|
||||
|
||||
def install_queue():
|
||||
"""The running install and those waiting, in order."""
|
||||
with _jobs_lock:
|
||||
ids = ([_queue_state["running"]] if _queue_state["running"] else []) + [q[0] for q in _queue]
|
||||
return {"jobs": [{"job": j, "label": _jobs[j]["label"], "queued": bool(_jobs[j].get("queued")),
|
||||
"stage": _jobs[j].get("stage"), "percent": _jobs[j].get("percent")}
|
||||
for j in ids if j in _jobs]}
|
||||
|
||||
|
||||
def queue_action(body):
|
||||
"""{"action": "remove", "job"}: take a waiting install out of the queue."""
|
||||
if body.get("action") != "remove":
|
||||
raise Failure("unknown action", 400)
|
||||
job = body.get("job")
|
||||
with _jobs_lock:
|
||||
for i, q in enumerate(_queue):
|
||||
if q[0] == job:
|
||||
del _queue[i]
|
||||
_jobs[job].update(done=True, queued=False, cancelled=True, stage=None, time=time.time(),
|
||||
error="Removed from the install queue")
|
||||
_restage_locked()
|
||||
return {"message": f"Removed {_jobs[job]['label']} from the install queue"}
|
||||
raise Failure("that install isn't waiting in the queue (it may have started or finished)", 409)
|
||||
|
||||
|
||||
def job_status(query):
|
||||
with _jobs_lock:
|
||||
job = _jobs.get((parse_qs(query).get("id") or [""])[0])
|
||||
@@ -700,9 +782,14 @@ def set_volume(body):
|
||||
# Validate everything before touching the headset.
|
||||
level = None
|
||||
if "level" in body:
|
||||
level = float(body["level"])
|
||||
if not 0 <= level <= 1:
|
||||
value = body["level"]
|
||||
if type(value) not in (int, float) or not 0 <= value <= 1:
|
||||
raise Failure("level must be 0..1", 400)
|
||||
level = float(value)
|
||||
if not body or set(body) - {"level", "muted"}:
|
||||
raise Failure("Provide level or muted", 400)
|
||||
if "muted" in body and type(body["muted"]) is not bool:
|
||||
raise Failure("muted must be true or false", 400)
|
||||
if "muted" in body:
|
||||
ssh(f"wpctl set-mute @DEFAULT_AUDIO_SINK@ {1 if body['muted'] else 0}")
|
||||
if level is not None:
|
||||
@@ -1067,12 +1154,192 @@ def licenses():
|
||||
return notices
|
||||
|
||||
|
||||
# KDE Connect's specialKey numbers -> Linux key codes (KEY_BACKSPACE, KEY_TAB, ...).
|
||||
PAD_KEYS = {1: 14, 2: 15, 4: 105, 5: 103, 6: 106, 7: 108, 8: 104, 9: 109, 10: 102, 11: 107, 12: 28, 13: 111, 14: 1,
|
||||
**{21 + i: code for i, code in enumerate([59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88])}}
|
||||
PAD_MODS = (("ctrl", 29), ("alt", 56), ("shift", 42), ("super", 125))
|
||||
PAD_CLICKS = {"singleclick": "left", "rightclick": "right", "middleclick": "middle"}
|
||||
PAD_NOTCH = 15 # scroll units for one wheel notch
|
||||
|
||||
|
||||
def typed_in_kde(events):
|
||||
"""Whether these events must go the KDE Connect way: they hold an accent or emoji, or
|
||||
earlier ones are queued, being sent or still being typed there (typing must stay in
|
||||
order, so it can't be split between the two ways)."""
|
||||
with _kde_lock:
|
||||
waiting = bool(_kde_queue) or time.time() < _kde_until
|
||||
return waiting or any(ch not in frame_touch.ASCII for e in events for ch in e.get("key", ""))
|
||||
|
||||
|
||||
def pad_events(events, kde=False):
|
||||
"""The trackpad's and key row's events as (gamescope events, KDE Connect events).
|
||||
|
||||
Gamescope's own input reaches every panel and needs nothing installed, but types only
|
||||
US-keyboard characters. With kde, the keyboard goes to KDE Connect as it was sent, so
|
||||
text stays in order; the pointer always goes through gamescope.
|
||||
"""
|
||||
ascii_keys = frame_touch.ASCII
|
||||
out, slow = [], []
|
||||
|
||||
def tap(code, mods):
|
||||
down = [{"key": c, "down": True} for c in mods]
|
||||
out.extend(down + [{"key": code, "down": True}, {"key": code, "down": False}]
|
||||
+ [{"key": c, "down": False} for c in reversed(mods)])
|
||||
for e in events:
|
||||
mods = [code for name, code in PAD_MODS if e.get(name)]
|
||||
if "dx" in e or "dy" in e:
|
||||
if not e.get("scroll"):
|
||||
out.append({k: e[k] for k in ("dx", "dy") if k in e})
|
||||
if e.get("scroll"):
|
||||
out.append({"scroll": [0, -(e.get("dy") or 0) * PAD_NOTCH]})
|
||||
for flag, button in PAD_CLICKS.items():
|
||||
if e.get(flag):
|
||||
out += [{"button": button, "down": True}, {"button": button, "down": False}]
|
||||
if e.get("doubleclick"):
|
||||
out += [{"button": "left", "down": d} for d in (True, False, True, False)]
|
||||
if e.get("singlehold"):
|
||||
out.append({"button": "left", "down": True})
|
||||
if e.get("singlerelease"):
|
||||
out.append({"button": "left", "down": False})
|
||||
if kde and ("key" in e or "specialKey" in e):
|
||||
slow.append({k: e[k] for k in ("key", "specialKey", "ctrl", "alt", "shift", "super") if k in e})
|
||||
continue
|
||||
if "specialKey" in e and e["specialKey"] in PAD_KEYS:
|
||||
tap(PAD_KEYS[e["specialKey"]], mods)
|
||||
if "key" in e:
|
||||
for ch in e["key"]:
|
||||
if ch not in ascii_keys:
|
||||
continue # only reached when kde is wrong; the caller checks typed_in_kde first
|
||||
elif mods:
|
||||
code, shifted = ascii_keys[ch]
|
||||
tap(code, mods + ([42] if shifted and 42 not in mods else []))
|
||||
else:
|
||||
if out and "text" in out[-1] and len(out[-1]["text"]) < 100:
|
||||
out[-1]["text"] += ch
|
||||
else:
|
||||
out.append({"text": ch})
|
||||
return out, slow
|
||||
|
||||
|
||||
def remote_input(body):
|
||||
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent."""
|
||||
"""{"events": [...]} sends keyboard and pointer events; {} (or none yet) just starts the agent.
|
||||
|
||||
"sent" in the answer means the whole batch is taken (the page keeps it and tries again
|
||||
if not), so the keyboard's share is queued only once that's so.
|
||||
"""
|
||||
events = body.get("events", [])
|
||||
if not isinstance(events, list) or len(events) > INPUT_BATCH_LIMIT:
|
||||
raise Failure(f"events must be a list of at most {INPUT_BATCH_LIMIT}", 400)
|
||||
return _input.send([input_event(e) for e in events])
|
||||
events = [input_event(e) for e in events]
|
||||
touch, slow = pad_events(events, typed_in_kde(events))
|
||||
status = _touch.send(touch)
|
||||
if touch and not status.get("sent"):
|
||||
return status
|
||||
if touch:
|
||||
_note_typed(touch)
|
||||
if slow:
|
||||
_queue_for_kde(slow)
|
||||
return {**status, "sent": True}
|
||||
|
||||
|
||||
# Typing KDE Connect is to do: it starts the first time it's needed, so this waits for it.
|
||||
# Nothing on the Frame says when typed text has landed, so each way holds the other back
|
||||
# for as long as what it was given should take (a short margin on a time estimate).
|
||||
_kde_queue = []
|
||||
_kde_lock = threading.Lock()
|
||||
_pad_send_lock = threading.Lock()
|
||||
_kde_worker = [None]
|
||||
_kde_until = 0.0 # keys typed through KDE Connect should have landed by then
|
||||
_typed_until = 0.0 # when gamescope's agent will have typed everything it's been given
|
||||
_kde_route = object()
|
||||
_kde_cancel = threading.Event()
|
||||
KDE_QUEUE_LIMIT = 200
|
||||
KDE_WAIT = 90 # seconds to wait for KDE Connect before giving up on what's queued
|
||||
TYPING_SETTLE = 0.6 # seconds past the estimate, once
|
||||
|
||||
|
||||
def reset_pad_route():
|
||||
"""Discard input for the headset being left, including batches waiting for an agent."""
|
||||
global _kde_route, _kde_cancel, _kde_until, _typed_until
|
||||
with _pad_send_lock, _kde_lock:
|
||||
_kde_cancel.set()
|
||||
_kde_cancel = threading.Event()
|
||||
_kde_route = object()
|
||||
_kde_queue.clear()
|
||||
_kde_worker[0] = None
|
||||
_kde_until = _typed_until = 0.0
|
||||
_input.stop()
|
||||
_touch.stop()
|
||||
|
||||
|
||||
def typing_seconds(touch):
|
||||
"""How long gamescope's agent takes over these: it sleeps 8 ms after every key transition."""
|
||||
transitions = 0
|
||||
for e in touch:
|
||||
if "text" in e:
|
||||
transitions += sum(2 + 2 * frame_touch.ASCII[ch][1] for ch in e["text"] if ch in frame_touch.ASCII)
|
||||
elif "key" in e:
|
||||
transitions += 1
|
||||
return transitions * 0.008
|
||||
|
||||
|
||||
def _note_typed(touch):
|
||||
global _typed_until
|
||||
seconds = typing_seconds(touch)
|
||||
if seconds:
|
||||
with _kde_lock: # the end of the work, which queues up; the margin is added once, where it's checked
|
||||
_typed_until = max(_typed_until, time.time()) + seconds
|
||||
|
||||
|
||||
def _queue_for_kde(events):
|
||||
with _kde_lock:
|
||||
_kde_queue.extend(events)
|
||||
del _kde_queue[:-KDE_QUEUE_LIMIT]
|
||||
if _kde_worker[0] is None:
|
||||
_kde_worker[0] = threading.Thread(target=_drain_kde, args=(_kde_route, _kde_cancel), daemon=True)
|
||||
_kde_worker[0].start()
|
||||
|
||||
|
||||
def _drain_kde(route, cancel):
|
||||
"""The only sender, in order. A batch leaves the queue while it's being sent (the queue
|
||||
can be trimmed meanwhile) and goes back to the front if it didn't go."""
|
||||
global _kde_until
|
||||
deadline = time.time() + KDE_WAIT
|
||||
while True:
|
||||
with _kde_lock:
|
||||
if route is not _kde_route:
|
||||
return
|
||||
batch = _kde_queue[:INPUT_BATCH_LIMIT]
|
||||
del _kde_queue[:len(batch)]
|
||||
if not batch:
|
||||
_kde_worker[0] = None # retired under the lock, so a new request starts another
|
||||
return
|
||||
wait = _typed_until + TYPING_SETTLE - time.time()
|
||||
_kde_until = time.time() + 3600 # held while in flight
|
||||
if wait > 0 and cancel.wait(wait):
|
||||
return # a switch cancels the delay and its old-headset batch
|
||||
# Serialize sending with route resets without blocking callers from queuing input.
|
||||
with _pad_send_lock:
|
||||
with _kde_lock:
|
||||
if route is not _kde_route:
|
||||
return
|
||||
status = _input.send(batch)
|
||||
with _kde_lock:
|
||||
if route is not _kde_route:
|
||||
return
|
||||
if status.get("sent"):
|
||||
_kde_until = time.time() + len(batch) * 0.03 + TYPING_SETTLE
|
||||
deadline = time.time() + KDE_WAIT
|
||||
continue
|
||||
_kde_until = 0.0
|
||||
if status.get("state") == "error" or time.time() > deadline:
|
||||
_kde_queue.clear() # not coming; don't hold it forever
|
||||
_kde_worker[0] = None
|
||||
return
|
||||
_kde_queue[:0] = batch
|
||||
del _kde_queue[:-KDE_QUEUE_LIMIT] # as when queuing: the oldest go first
|
||||
if cancel.wait(0.4):
|
||||
return
|
||||
|
||||
|
||||
# ---- touch: the headset's panels, through gamescope's own input (frame_touch.py) ----
|
||||
@@ -1292,7 +1559,7 @@ def android(body):
|
||||
m = frame_apk_versions.install(pkg, url) if url else frame_catalog.install(pkg)
|
||||
message = f"Installed {m['label']}. It's in the Steam library; launching it opens its own panel."
|
||||
return {"message": f"{message} {frame_android.layer_note(m)}".strip(), "app": m}
|
||||
return start_job(f"Install {pkg}", work)
|
||||
return queue_job(f"Install {pkg}", work)
|
||||
if action == "refresh-art":
|
||||
if not pkg and not body.get("all"):
|
||||
raise Failure('choose a package or all apps', 400)
|
||||
@@ -1363,11 +1630,112 @@ def quest(body):
|
||||
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",
|
||||
return queue_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 queue_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 +2605,7 @@ def source_install(body):
|
||||
raise SourceError('This source is disabled')
|
||||
except SourceError as e:
|
||||
raise Failure(str(e), 400)
|
||||
return start_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||
return queue_job('Install ' + entry, lambda report: apk_search.install(source, entry, version, report), progress=True)
|
||||
|
||||
|
||||
def source_manage(body):
|
||||
@@ -2280,7 +2648,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/quest": quest, "/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/queue": queue_action, "/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,
|
||||
@@ -2457,6 +2825,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_json(frame_steamgriddb.settings())
|
||||
elif path == "/artwork-settings.js":
|
||||
self.send_bytes((HERE / 'artwork-settings.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.js":
|
||||
self.send_bytes((HERE / 'control-center.js').read_bytes(), 'text/javascript; charset=utf-8')
|
||||
elif path == "/control-center.css":
|
||||
self.send_bytes((HERE / 'control-center.css').read_bytes(), 'text/css; charset=utf-8')
|
||||
elif path == "/assistant":
|
||||
page = (HERE / "assistant.html").read_text().replace("__FRAME_KEY__", json.dumps(UI_KEY).replace("<", "\\u003c"))
|
||||
self.send_bytes(page.encode(), "text/html; charset=utf-8")
|
||||
@@ -2501,6 +2873,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
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()
|
||||
@@ -2515,9 +2889,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
elif path == "/api/touch":
|
||||
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||
elif path == "/api/input":
|
||||
self.send_json(_input.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_input.status))
|
||||
self.send_json(_touch.send([]) if parse_qs(url.query).get("start") == ["1"] else dict(_touch.status))
|
||||
elif path == "/api/job":
|
||||
self.send_json(job_status(url.query))
|
||||
elif path == "/api/queue":
|
||||
self.send_json(install_queue())
|
||||
elif path == "/api/android/displays":
|
||||
self.send_json(android_displays())
|
||||
elif path == "/api/android/reports":
|
||||
|
||||
Reference in new issue
Block a user