diff --git a/.claude/skills/steam-frame/SKILL.md b/.claude/skills/steam-frame/SKILL.md
index 0437825..48c9f1c 100644
--- a/.claude/skills/steam-frame/SKILL.md
+++ b/.claude/skills/steam-frame/SKILL.md
@@ -48,3 +48,9 @@ running `--help`: `paste-to-frame.sh`, `serve-bootstrap.sh` and
- `sudo` on the Frame asks for the user's Developer Mode password. Hand those
steps to the user (open Terminal) and keep automation to non-sudo commands.
- The Mac uses BSD userland and zsh (no `timeout`, use `head -n`).
+- **First-party first.** For any new capability, investigate the first-party
+ way before anything else: Valve (SteamOS, Steam, Steam Link), Apple (the Mac
+ and iPhone), and KDE (the Frame's desktop is Plasma). It's usually the best
+ answer. If it isn't, write down why not. If it is, find what Frame Control
+ can do to make it easier to set up (install over SSH, pre-seed settings,
+ pair automatically, tell the user the one setting to turn on).
diff --git a/.gitattributes b/.gitattributes
index 6a5ef04..bad39d7 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,3 +6,8 @@
*.json text eol=lf
*.md text eol=lf
*.bat text eol=crlf
+# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
+tests/fixtures/** -text
+*.apk binary
+*.jar binary
+*.obb binary
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index fb1e7fd..f131fe4 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -35,7 +35,9 @@ jobs:
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
- run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js
+ run: node --check app/main.js && node --check app/build/make-icon.js && node --check app/build/fetch-deps.js && node --check app/preload.js && node --check app/install-link.js && node --check app/updater.js
+ - name: Updater tests
+ run: node --test app/test/updater.test.js
- name: Website
run: node --test site/test/*.test.mjs && node --check site/public/js/site.js && node --check site/public/js/feedback.js
diff --git a/.gitignore b/.gitignore
index 230bfe5..ba02656 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,7 @@ apk-catalog/data/index-v2*.json*
compat-db/.env.lakebed.server
compat-db/.lakebed/
tests/smoke/results/
+mac/bin/
+
+# Downloaded at build time (frame/kdeconnect/fetch.py, app/build/fetch-deps.js)
+frame/kdeconnect/packages/
diff --git a/README.md b/README.md
index eb425ad..8c8a113 100644
--- a/README.md
+++ b/README.md
@@ -35,8 +35,8 @@ See what the headset sees, install games and Android apps, move files and text a
-**👓 Headset view**
-Live video of what the lenses show (about 30 fps), or a still of both eyes. Zoom, pan, full screen, save as PNG.
+**👓 Headset view and Desktop**
+Live video of what the lenses show, or of the app panel in use, flat and still however the wearer looks around. Turn on Control and tap or click right on it to use the Frame from your phone or computer.
@@ -72,6 +72,9 @@ Drag files onto the window to send them. Drop a game's .zip, folder or .exe to a
**📸 Screenshots**
Browse the shots you take in the headset and save them to your Pictures folder.
+**⌨️ Keyboard and trackpad**
+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.
+
@@ -90,9 +93,15 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
-Nothing is installed on the Frame for any of this: the app uses what SteamOS
+The optional [Family and comfort](docs/family-comfort.md) card adds session
+limits, breaks, local alerts and one-click casting. A session copies a small
+Frame Control worker into your headset user account.
+
+For the other features, nothing is installed on the Frame: the app uses what SteamOS
already ships (sideloading a game copies Valve's own devkit scripts to
-`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
+`~/devkit-utils`, as Valve's Devkit Client does). The optional
+[performance HUD](docs/vr-utilities.md) copies our own Python helpers into
+`~/.local/share/frame-control/vr/`. [How each feature works](docs/frame-control.md).
## Install
@@ -107,6 +116,10 @@ already ships (sideloading a game copies Valve's own devkit scripts to
a computer. Build it from [`ios/`](ios) in Xcode; see [docs/iphone.md](docs/iphone.md).
The app brings its own Python and `adb`; SSH is built into macOS and Windows.
+From 0.4 it updates itself: when a new version is published, a banner offers
+**Update and restart**. It sends anonymous usage statistics, which you can turn
+off. Sharing compatibility results and error details is opt-in. See
+[docs/privacy.md](docs/privacy.md).
Google doesn't publish `adb` for arm64 Linux, so that build uses your
distribution's. If you already have `adb`, the app uses yours.
@@ -175,9 +188,11 @@ entry to `~/.ssh/config` and keys at `~/.ssh/id_ed25519_frame` and
## Feedback
This is a first public test, so reports are really useful, especially from
-Windows and Linux. The quickest way is the
-[feedback form](https://frame-control.pages.dev/feedback/): no GitHub account
-needed, and it opens an issue here. Please include:
+Windows and Linux. The quickest way is **Report a problem** in the app (the
+warning-sign button at the top, or **Help → Report a Problem…**). It adds
+diagnostics with personal details removed, shows you exactly what's included,
+and sends it privately to the maintainer; nothing is published. Without the app,
+use the [feedback form](https://frame-control.pages.dev/feedback/). Please include:
- what you tried and what happened
- your computer's OS and your SteamOS build (Steam Settings → System)
@@ -200,11 +215,13 @@ Frame's software fits together, all checked against a real headset and labelled
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [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 |
-| [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 |
+| [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 |
+| [Mac in the headset](docs/mac-in-headset.md) | Mac windows and screens as panels in the Frame, with laser and keyboard input |
| [VR mods and custom songs](docs/mods.md) | UEVR for Unreal games from Frame Control (install, play in VR, remove), the per-game table, Beat Saber blockers |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [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 |
| [Open questions](docs/open-questions.md) | What's still unchecked |
@@ -238,9 +255,12 @@ cd app && npm install && npm start # run the app from the checkout
The server is Python stdlib only; the app is Electron. GitHub Actions runs the
tests on macOS, Windows and Linux, and a `v*` tag builds all three installers
-into the release. See [building](docs/frame-control.md#building).
+into a draft release, which reaches users once published. See
+[building](docs/frame-control.md#building) and [releasing](docs/releasing.md).
## License
-[MIT](LICENSE). Steam, Steam Frame and SteamVR are trademarks of Valve
+[MIT](LICENSE). The apps also ship other people's software under its own
+licence, notably KDE Connect (GPL) for the keyboard and trackpad; see
+[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Steam, Steam Frame and SteamVR are trademarks of Valve
Corporation. This project isn't affiliated with or endorsed by Valve.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..8c2a8a5
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,15 @@
+# Third-party software in Frame Control
+
+Frame Control's own code is under the [MIT licence](LICENSE). The apps also
+ship other people's software, unchanged, each under its own licence:
+
+| What | Where | Licence | Details |
+|---|---|---|---|
+| KDE Connect 24.02.2 and five libraries, as Valve builds them for the Frame | Desktop apps and the iPhone app; copied to the Frame for the keyboard and trackpad | GPL and LGPL (per package) | [frame/kdeconnect/NOTICE.md](frame/kdeconnect/NOTICE.md), licence texts in [frame/kdeconnect/LICENSES](frame/kdeconnect/LICENSES), complete source in the [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1) |
+| Python 3.12 ([python-build-standalone](https://github.com/astral-sh/python-build-standalone)) | Desktop apps | PSF License and others | Included with it, in the app's `python` folder |
+| adb (Android SDK Platform-Tools) | Desktop apps (not Linux arm64) | Apache-2.0 and others | `NOTICE.txt` in the app's `tools` folder |
+| Mozilla's CA certificate list, as published by curl | Desktop apps | MPL-2.0 | https://curl.se/docs/caextract.html |
+| Electron | Desktop apps | MIT (Chromium: BSD-3-Clause and others) | `LICENSE` and `LICENSES.chromium.html` in the app |
+
+Frame Control starts KDE Connect and talks to it over its network protocol;
+it doesn't link to it or include its code.
diff --git a/app/build/fetch-deps.js b/app/build/fetch-deps.js
index 8460b06..ea6c616 100644
--- a/app/build/fetch-deps.js
+++ b/app/build/fetch-deps.js
@@ -1,7 +1,9 @@
// Downloads what the app bundles so users install nothing else: a standalone
// Python (python-build-standalone), adb (Android platform-tools) and a CA
// bundle. Each goes in build/deps/-/{python,tools}, which package.json
-// copies into the app's resources. Everything is pinned by version and SHA-256.
+// copies into the app's resources. Also KDE Connect for the Frame (the same arm64
+// packages for every build), into ../frame/kdeconnect/packages as listed in
+// ../frame/kdeconnect/packages.json. Everything is pinned by version and SHA-256.
// node build/fetch-deps.js mac arm64 | win x64 | linux x64 arm64
const crypto = require("crypto");
const fs = require("fs");
@@ -127,8 +129,26 @@ async function fetch(os, arch) {
console.log(`${key}: ${version} -> ${out}`);
}
+// As frame/kdeconnect/fetch.py does for the iPhone app's bundle.
+async function fetchKdeConnect() {
+ const dir = path.join(__dirname, "..", "..", "frame", "kdeconnect");
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, "packages.json"), "utf8"));
+ const out = path.join(dir, "packages");
+ fs.mkdirSync(out, { recursive: true });
+ const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
+ for (const p of manifest.packages) {
+ const file = path.join(out, p.file);
+ if (fs.existsSync(file) && sha(file) === p.sha256) continue;
+ await download(manifest.release + p.file, p.sha256, file);
+ }
+ const wanted = new Set(manifest.packages.map((p) => p.file));
+ for (const f of fs.readdirSync(out)) if (!wanted.has(f)) fs.rmSync(path.join(out, f), { recursive: true, force: true });
+ console.log(`KDE Connect for the Frame: ${manifest.packages.length} packages -> ${out}`);
+}
+
(async () => {
const [os, ...archs] = process.argv.slice(2);
if (!os || !archs.length) throw new Error("usage: node build/fetch-deps.js ...");
for (const arch of archs) await fetch(os, arch);
+ await fetchKdeConnect();
})().catch((e) => { console.error(e.message); process.exit(1); });
diff --git a/app/main.js b/app/main.js
index bf6ff66..4497136 100644
--- a/app/main.js
+++ b/app/main.js
@@ -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, clipboard, dialog, ipcMain, shell } = require("electron");
+const { app, BrowserWindow, Menu, Notification, clipboard, dialog, ipcMain, shell } = require("electron");
const { execFile, spawn } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
@@ -10,6 +10,7 @@ const net = require("net");
const os = require("os");
const path = require("path");
const { SCHEME, parseInstallLink, linkFromArgv } = require("./install-link");
+const updater = require("./updater");
const run = promisify(execFile);
@@ -114,7 +115,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.
const env = { ...process.env, PATH: await loginPath(), FRAME_CONTROL_APP: "1",
+ FRAME_CONTROL_VERSION: app.getVersion(), FRAME_CONTROL_LOG: LOG,
+ ...(app.isPackaged ? { FRAME_CONTROL_PACKAGED: "1" } : {}),
...(fs.existsSync(TOOLS) ? { FRAME_CONTROL_TOOLS: TOOLS } : {}) };
python = await findPython(env);
if (!python) throw new Error(`Frame Control needs Python 3.8 or later. ${PYTHON_HELP}`);
@@ -154,10 +159,16 @@ async function startServer() {
// Closing stdin lets server.py close its SSH connections and exit (the only clean
// way on Windows); SIGTERM does the same elsewhere.
+// Resolves once it has exited (or after 20 s), so a replacement can take the server
+// lock: server.py allows one per user.
function endServer(child) {
+ const gone = child.exitCode !== null || child.signalCode !== null ? Promise.resolve()
+ : new Promise((resolve) => child.once("exit", resolve));
try { child.stdin.end(); } catch {}
if (!IS_WIN) child.kill("SIGTERM");
- setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill(); }, 5000).unref();
+ // server.py ignores a second SIGTERM while it shuts down, so the fallback is a hard kill.
+ setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 12000).unref();
+ return Promise.race([gone, new Promise((resolve) => setTimeout(resolve, 20000).unref())]);
}
function stopServer() {
@@ -178,27 +189,37 @@ function serverDied(why) {
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
}
-async function restartServer() {
- const old = server;
- server = null;
- url = null;
- if (old) endServer(old);
- await load();
+// Restarts that overlap share one: two could each start a server, and the one
+// that lost the lock would leave the app pointing at nothing.
+let restarting = null;
+function restartServer() {
+ if (!restarting) {
+ restarting = (async () => {
+ const old = server;
+ server = null;
+ url = null;
+ if (old) await endServer(old);
+ if (starting) await starting.catch(() => {}); // a start it cut short: then start afresh
+ await load();
+ })().finally(() => { restarting = null; });
+ }
+ return restarting;
}
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
const CHROME_CSS = IS_MAC && `
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
- header a, header button, header input, header .chip { -webkit-app-region: no-drag; }
+ header a, header button, header input, header select, header .chip { -webkit-app-region: no-drag; }
`;
// Restart Server can start a new load while an older one is still waiting for
// its server; only the newest load may touch the window.
let loadGen = 0;
+let starting = null; // loads that overlap share one server start
async function load() {
const gen = ++loadGen;
try {
- if (!url) await startServer();
+ if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
} catch (e) {
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
@@ -244,6 +265,65 @@ function fromUi(e) {
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
+ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
+ipcMain.handle("update:get", (e) => fromUi(e) ? publicUpdate() : null);
+ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
+ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
+
+// The page reports the headsets it knows (the server's Devices tab), so the Frame
+// menu can switch between them. Only plain names and ids go into the menu.
+const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
+let devices = [];
+ipcMain.on("devices:changed", (e, list) => {
+ if (!fromUi(e) || !Array.isArray(list)) return;
+ const next = list.slice(0, 20).filter(d => d && typeof d.id === "string" && ALIAS_RE.test(d.alias || ""))
+ .map(d => ({ id: d.id.slice(0, 80), name: String(d.name || d.alias).slice(0, 60), alias: d.alias, active: !!d.active }));
+ if (JSON.stringify(next) === JSON.stringify(devices)) return;
+ devices = next;
+ buildMenu();
+});
+const activeAlias = () => (devices.find(d => d.active) || {}).alias || FRAME;
+
+// SSH through the server, so it goes to the headset and address the app is using
+// (with its own pinned identity), and refuses when there's none.
+function openSsh() {
+ if (!url) return dialog.showErrorBox("Couldn't open SSH", "Frame Control's server isn't running.");
+ const body = JSON.stringify({ what: "terminal" });
+ const req = http.request(new URL("/api/open", url), {
+ method: "POST", timeout: 15000,
+ headers: { "Content-Type": "application/json", "X-Frame-UI": "1", "Content-Length": Buffer.byteLength(body) },
+ }, (res) => {
+ let data = "";
+ res.on("data", (c) => { data += c; });
+ res.on("end", () => {
+ if (res.statusCode === 200) return;
+ let why = `HTTP ${res.statusCode}`;
+ try { why = JSON.parse(data).error || why; } catch {}
+ dialog.showErrorBox("Couldn't open SSH", why);
+ });
+ });
+ req.on("error", (e) => dialog.showErrorBox("Couldn't open SSH", e.message));
+ req.on("timeout", () => req.destroy(new Error("the server didn't answer")));
+ req.end(body);
+}
+function showDevices() {
+ if (win && url) win.webContents.executeJavaScript('location.hash = "devices"').catch(() => {});
+}
+
+ipcMain.handle("comfort:notify", (e, message) => {
+ if (!fromUi(e) || typeof message !== "string" || message.length > 500) throw new Error("Invalid notification");
+ if (!Notification.isSupported()) throw new Error("System notifications are unavailable");
+ return new Promise((resolve, reject) => {
+ const notification = new Notification({title: "Frame Control", body: message});
+ const timer = setTimeout(() => reject(new Error("Notification delivery was not confirmed. Check system notification settings.")), 5000);
+ notification.once("show", () => { clearTimeout(timer); resolve(true); });
+ notification.once("failed", (_event, error) => {
+ clearTimeout(timer);
+ reject(new Error("Notification delivery failed. Check system notification settings: " + error));
+ });
+ notification.show();
+ });
+});
// 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),
@@ -276,6 +356,81 @@ ipcMain.on("install-link:ready", (e) => {
deliverLinks();
});
+// ---- updates (app/updater.js, docs/releasing.md) ----
+// Checked shortly after launch and every few hours; the page shows a banner and
+// the Update button calls installUpdate.
+const UPDATE_EVERY = 6 * 3600 * 1000;
+const update = { status: "idle", current: app.getVersion(), latest: null, error: null, progress: 0, how: null };
+
+function publicUpdate() {
+ const r = update.latest;
+ return { status: update.status, current: update.current, error: update.error, progress: update.progress,
+ latest: r && { version: r.version, notes: r.notes, page: r.page },
+ canInstall: !!update.how && update.how.method !== "manual", why: update.how && update.how.why };
+}
+
+function setUpdate(fields) {
+ Object.assign(update, fields);
+ if (win && linkPage === win.webContents) win.webContents.send("update:state", publicUpdate());
+}
+
+async function checkForUpdate({ manual = false } = {}) {
+ if (["checking", "downloading", "ready"].includes(update.status)) return;
+ setUpdate({ status: "checking", error: null });
+ try {
+ const latest = await updater.latestRelease();
+ const how = updater.updateMethod({ platform: process.platform, isPackaged: app.isPackaged,
+ execPath: process.execPath, env: process.env,
+ exists: fs.existsSync, writable: updater.writable });
+ if (updater.isNewer(latest.version, update.current)) {
+ setUpdate({ status: "available", latest, how });
+ if (manual) offerUpdateDialog();
+ } else {
+ setUpdate({ status: "none", latest, how });
+ if (manual) dialog.showMessageBox(win, { type: "info", message: "Frame Control is up to date",
+ detail: `You have ${update.current}, the newest version.` });
+ }
+ } catch (e) {
+ // A failed check: nothing to install, and never an older release kept from before.
+ setUpdate({ status: "check-failed", error: e.message, latest: null });
+ if (manual) dialog.showMessageBox(win, { type: "warning", message: "Couldn't check for updates", detail: e.message });
+ }
+}
+
+async function offerUpdateDialog() {
+ const r = update.latest;
+ const { response } = await dialog.showMessageBox(win, {
+ type: "info", message: `Frame Control ${r.version} is available`,
+ detail: `You have ${update.current}.` + (update.how.method === "manual" ? ` Download it from the release page (${update.how.why}).` : ""),
+ buttons: [update.how.method === "manual" ? "Open Release Page" : "Update and Restart", "Later"], defaultId: 0, cancelId: 1,
+ });
+ if (response === 0) installUpdate();
+}
+
+async function installUpdate() {
+ // "error" here only ever means an install failed, so trying again is safe.
+ if (update.status !== "available" && update.status !== "error") return;
+ if (!update.latest || !updater.isNewer(update.latest.version, update.current)) return;
+ if (!update.how || update.how.method === "manual") { shell.openExternal(update.latest.page); return; }
+ setUpdate({ status: "downloading", progress: 0, error: null });
+ try {
+ const start = await updater.prepare(update.latest, update.how,
+ (done, total) => { if (total) setUpdate({ progress: done / total }); }, update.current);
+ setUpdate({ status: "ready", progress: 1 });
+ start();
+ quitting = true;
+ app.quit();
+ } catch (e) {
+ setUpdate({ status: "error", error: e.message });
+ }
+}
+
+function scheduleUpdateChecks() {
+ if (process.env.FRAME_CONTROL_NO_UPDATE_CHECK === "1") return;
+ setTimeout(checkForUpdate, 8000);
+ setInterval(checkForUpdate, UPDATE_EVERY).unref();
+}
+
function registerScheme() {
// A checkout runs as `electron .`, so the OS must be told the script too.
// (macOS takes the scheme from Info.plist, which only the built app has.)
@@ -292,7 +447,7 @@ function createWindow() {
title: "Frame Control", backgroundColor: BG, show: false,
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
: { icon: path.join(__dirname, "build", "icon.png") }),
- webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true,
+ webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false,
preload: path.join(__dirname, "preload.js") },
});
win.once("ready-to-show", () => win.show());
@@ -326,25 +481,37 @@ async function runInTerminal(argv) {
}
}
-async function setUpConnection() {
- const alias = `FRAME_ALIAS=${FRAME}`;
+// Set Up Connection for the headset in use (or another alias, from the Devices tab).
+async function setUpConnection(name = activeAlias()) {
+ if (!ALIAS_RE.test(name)) return;
+ const alias = `FRAME_ALIAS=${name}`;
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
- const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
- // A new console inherits our environment on Windows; Linux terminals may not.
- runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
+ // --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
+ runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
}
function buildMenu() {
const template = [
- ...(IS_MAC ? [{ role: "appMenu" }] : []),
+ ...(IS_MAC ? [{ label: app.name, submenu: [
+ { role: "about" }, { label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) },
+ { type: "separator" }, { role: "services" }, { type: "separator" },
+ { role: "hide" }, { role: "hideOthers" }, { role: "unhide" }, { type: "separator" }, { role: "quit" },
+ ] }] : []),
{ role: "fileMenu" },
{ role: "editMenu" },
{
label: "Frame",
submenu: [
- { label: "Set Up Connection…", click: setUpConnection },
- { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
+ { label: "Set Up Connection…", click: () => setUpConnection() },
+ { label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: openSsh },
+ { type: "separator" },
+ ...(devices.length > 1 ? [{
+ label: "Headset",
+ submenu: devices.map(d => ({ label: d.name, type: "radio", checked: d.active,
+ click: () => { if (win) win.webContents.send("use-device", d.id); } })),
+ }] : []),
+ { label: "Devices…", accelerator: "CmdOrCtrl+5", click: showDevices },
{ type: "separator" },
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
@@ -364,7 +531,15 @@ function buildMenu() {
...(IS_MAC ? [{ role: "windowMenu" }] : []),
{
role: "help",
- submenu: [{ label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") }],
+ submenu: [
+ ...(IS_MAC ? [] : [{ label: "Check for Updates…", click: () => checkForUpdate({ manual: true }) }]),
+ { label: "Report a Problem…", click: () => {
+ if (win && url && linkPage === win.webContents) win.webContents.send("report:open");
+ else shell.openExternal("https://frame-control.pages.dev/feedback/"); // the page isn't up
+ } },
+ { label: "Release Notes", click: () => shell.openExternal(updater.RELEASES) },
+ { label: "Project on GitHub", click: () => shell.openExternal("https://github.com/saphid/steam-frame") },
+ ],
},
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
@@ -387,6 +562,7 @@ if (!app.requestSingleInstanceLock()) {
registerScheme();
buildMenu();
createWindow();
+ scheduleUpdateChecks();
});
app.on("activate", () => { if (!win) createWindow(); });
app.on("window-all-closed", () => app.quit());
diff --git a/app/package-lock.json b/app/package-lock.json
index f8f2d77..264b1dd 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frame-control",
- "version": "0.3.1",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frame-control",
- "version": "0.3.1",
+ "version": "0.4.0",
"license": "MIT",
"devDependencies": {
"electron": "^44.4.5",
diff --git a/app/package.json b/app/package.json
index bd5701a..f59e889 100644
--- a/app/package.json
+++ b/app/package.json
@@ -1,7 +1,7 @@
{
"name": "frame-control",
"productName": "Frame Control",
- "version": "0.3.1",
+ "version": "0.4.0",
"description": "Desktop app for managing a Valve Steam Frame over SSH",
"private": true,
"main": "main.js",
@@ -9,8 +9,8 @@
"scripts": {
"start": "env -u ELECTRON_RUN_AS_NODE electron .",
"icon": "env -u ELECTRON_RUN_AS_NODE electron build/make-icon.js",
- "dist": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
- "dist:dir": "node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
+ "dist": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --publish never",
+ "dist:dir": "sh ../mac/frame-mac-view/build.sh && node build/fetch-deps.js mac arm64 && electron-builder --mac --arm64 --dir",
"dist:linux": "node build/fetch-deps.js linux x64 arm64 && electron-builder --linux --x64 --arm64 --publish never",
"dist:win": "node build/fetch-deps.js win x64 && electron-builder --win --x64 --publish never"
},
@@ -37,6 +37,7 @@
"main.js",
"preload.js",
"install-link.js",
+ "updater.js",
"package.json",
"build/icon.png"
],
@@ -46,7 +47,17 @@
"to": "ui",
"filter": [
"*.py",
- "*.html"
+ "*.html",
+ "*.js",
+ "telemetry.json"
+ ]
+ },
+ {
+ "from": "../ui/apk_sources",
+ "to": "ui/apk_sources",
+ "filter": [
+ "*.py",
+ "*.json"
]
},
{
@@ -61,7 +72,16 @@
"to": "frame/android",
"filter": [
"*.sh",
- "*.py"
+ "*.py",
+ "*.js"
+ ]
+ },
+ {
+ "from": "../frame/openxr-compat",
+ "to": "frame/openxr-compat",
+ "filter": [
+ "XrApiLayer_FRAME_compat.json",
+ "prebuilt/**/*.so"
]
},
{
@@ -72,6 +92,16 @@
"!**/__pycache__/**"
]
},
+ {
+ "from": "../frame/kdeconnect",
+ "to": "frame/kdeconnect",
+ "filter": [
+ "packages.json",
+ "NOTICE.md",
+ "LICENSES/**/*",
+ "packages/*.pkg.tar.zst"
+ ]
+ },
{
"from": "../apk-catalog",
"to": "apk-catalog",
@@ -94,6 +124,14 @@
"filter": [
"**/*"
]
+ },
+ {
+ "from": "../THIRD_PARTY_NOTICES.md",
+ "to": "THIRD_PARTY_NOTICES.md"
+ },
+ {
+ "from": "../LICENSE",
+ "to": "LICENSE"
}
],
"mac": {
@@ -105,9 +143,19 @@
"dmg",
"zip"
],
+ "extraResources": [
+ {
+ "from": "../mac/bin",
+ "to": "mac/bin",
+ "filter": [
+ "frame-mac-view"
+ ]
+ }
+ ],
"extendInfo": {
"NSAppleEventsUsageDescription": "Frame Control opens Terminal for SSH sessions and for power actions that need the Developer Mode password.",
- "NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network."
+ "NSLocalNetworkUsageDescription": "Frame Control connects to your Steam Frame over SSH on the local network.",
+ "NSScreenCaptureUsageDescription": "Frame Control shows your Mac's windows and screens inside the Steam Frame when you ask it to."
},
"artifactName": "Frame-Control-mac-${arch}.${ext}"
},
diff --git a/app/preload.js b/app/preload.js
index 43bc34d..8ab5b8f 100644
--- a/app/preload.js
+++ b/app/preload.js
@@ -2,15 +2,41 @@
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
-// It can open Set Up Connection when the headset can't be reached.
+// It can open Set Up Connection when 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.
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
+ notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
+ // The Frame menu's headset switcher: the page tells it the headsets, and hears picks.
+ devicesChanged: (list) => ipcRenderer.send("devices:changed", list),
+ onUseDevice: (cb) => {
+ ipcRenderer.removeAllListeners("use-device");
+ ipcRenderer.on("use-device", (_e, id) => cb(String(id)));
+ },
+ // While the keyboard-and-trackpad panel holds the keyboard, ⌘W, ⌘R and the rest go to the Frame.
+ captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
+ // Updates (app/updater.js): the page shows a banner and an Update button.
+ update: {
+ get: () => ipcRenderer.invoke("update:get"),
+ check: () => ipcRenderer.invoke("update:check"),
+ install: () => ipcRenderer.invoke("update:install"),
+ onState: (cb) => {
+ ipcRenderer.removeAllListeners("update:state");
+ ipcRenderer.on("update:state", (_e, s) => cb(s));
+ },
+ },
+ // Help → Report a Problem… opens the page's report dialog (ui/frame_report.py).
+ onReportProblem: (cb) => {
+ ipcRenderer.removeAllListeners("report:open");
+ ipcRenderer.on("report:open", () => cb());
+ },
onInstallLink: (cb) => {
ipcRenderer.removeAllListeners("install-link");
ipcRenderer.on("install-link", (_e, req) => cb({ kind: req.kind, target: req.target }));
diff --git a/app/test/updater.test.js b/app/test/updater.test.js
new file mode 100644
index 0000000..4bf9594
--- /dev/null
+++ b/app/test/updater.test.js
@@ -0,0 +1,59 @@
+// Run: node --test app/test/
+const test = require("node:test");
+const assert = require("node:assert");
+const { isNewer, assetName, updateMethod, macBundle } = require("../updater");
+
+test("versions compare numerically, and a release beats its pre-releases", () => {
+ assert.ok(isNewer("0.3.10", "0.3.9"));
+ assert.ok(isNewer("v1.0.0", "0.9.9"));
+ assert.ok(!isNewer("0.3.1", "0.3.1"));
+ assert.ok(!isNewer("0.3.0", "0.3.1"));
+ assert.ok(isNewer("1.0.0", "1.0.0-beta.1"));
+ assert.ok(!isNewer("1.0.0-beta.1", "1.0.0"));
+ assert.ok(!isNewer("garbage", "0.1.0"));
+});
+
+test("asset names match what electron-builder publishes", () => {
+ assert.strictEqual(assetName("darwin", "arm64", "mac-zip"), "Frame-Control-mac-arm64.zip");
+ assert.strictEqual(assetName("win32", "x64", "nsis"), "Frame-Control-Setup-x64.exe");
+ assert.strictEqual(assetName("linux", "x64", "appimage"), "Frame-Control-linux-x86_64.AppImage");
+ assert.strictEqual(assetName("linux", "arm64", "appimage"), "Frame-Control-linux-arm64.AppImage");
+});
+
+const base = { isPackaged: true, env: {}, exists: () => false, writable: () => true };
+
+test("macOS updates in place only from a writable, non-translocated location", () => {
+ const exe = "/Applications/Frame Control.app/Contents/MacOS/Frame Control";
+ assert.strictEqual(macBundle(exe), "/Applications/Frame Control.app");
+ assert.deepStrictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe }),
+ { method: "mac-zip", bundle: "/Applications/Frame Control.app" });
+ const dmg = "/Volumes/Frame Control 0.3.1/Frame Control.app/Contents/MacOS/Frame Control";
+ assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: dmg }).method, "manual");
+ const trans = "/private/var/folders/x/AppTranslocation/ABC/d/Frame Control.app/Contents/MacOS/Frame Control";
+ assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: trans }).method, "manual");
+ assert.strictEqual(updateMethod({ ...base, platform: "darwin", execPath: exe, writable: () => false }).method, "manual");
+});
+
+test("Windows needs the installer's copy; Linux needs an AppImage", () => {
+ const exe = "C:\\Users\\a\\AppData\\Local\\Programs\\Frame Control\\Frame Control.exe";
+ assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe, exists: () => true }).method, "nsis");
+ assert.strictEqual(updateMethod({ ...base, platform: "win32", execPath: exe }).method, "manual");
+ assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/x", env: { APPIMAGE: "/home/a/F.AppImage" } }).method,
+ "appimage");
+ assert.strictEqual(updateMethod({ ...base, platform: "linux", execPath: "/opt/Frame Control/frame-control" }).method, "manual");
+ assert.strictEqual(updateMethod({ ...base, isPackaged: false, platform: "darwin", execPath: "x" }).method, "manual");
+});
+
+test("update.json assets always download from this repository's release", () => {
+ const r = require("../updater").fromManifest({ version: "0.4.0", notes: "n", assets: [
+ { name: "Frame-Control-mac-arm64.zip", url: "https://evil.example/x.zip", digest: "sha256:" + "a".repeat(64) }] });
+ assert.strictEqual(r.assets[0].url, "https://github.com/saphid/frame-control/releases/download/v0.4.0/Frame-Control-mac-arm64.zip");
+ assert.throws(() => require("../updater").fromManifest({ version: "nope", assets: [] }));
+});
+
+test("prepare refuses a release that isn't newer (no downgrades)", async () => {
+ const { prepare } = require("../updater");
+ const release = { version: "0.3.1", assets: [] };
+ await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.4.0"), /isn't newer/);
+ await assert.rejects(prepare(release, { method: "appimage", appImage: "/nonexistent/x" }, null, "0.3.1"), /isn't newer/);
+});
diff --git a/app/updater.js b/app/updater.js
new file mode 100644
index 0000000..85caab5
--- /dev/null
+++ b/app/updater.js
@@ -0,0 +1,250 @@
+// Update checks and self-update for the desktop app (docs/releasing.md).
+//
+// The newest version is GitHub's "latest" release of saphid/frame-control. Drafts
+// and pre-releases never count, so a build reaches people only when the
+// maintainer publishes it after testing (scripts/publish-release.sh). That
+// script attaches update.json (version, notes, each asset's SHA-256), read
+// through github.com's latest/download link: the REST API allows only 60
+// unauthenticated requests an hour per IP address, shared by everyone behind
+// the same router, so it's only the fallback.
+//
+// Every download is checked against the SHA-256 digest GitHub records for the
+// asset before anything is replaced. How the update is applied:
+// macOS the .zip: unpacked next to the running app, swapped in by a small
+// script once the app has quit, then reopened.
+// Windows the NSIS installer, run silently over the current install; it
+// reopens the app. A copy unpacked from the .zip is updated by hand.
+// Linux the AppImage replaces itself; .deb installs are updated by hand.
+// When the app can't update itself it opens the release page instead.
+const { execFile, spawn } = require("child_process");
+const crypto = require("crypto");
+const fs = require("fs");
+const https = require("https");
+const os = require("os");
+const path = require("path");
+
+const REPO = "saphid/frame-control"; // renamed from saphid/steam-frame; GitHub redirects the old name
+const LATEST = `https://api.github.com/repos/${REPO}/releases/latest`;
+const MANIFEST = `https://github.com/${REPO}/releases/latest/download/update.json`;
+const RELEASES = `https://github.com/${REPO}/releases`;
+
+// "0.3.1" or "v0.3.1" -> [0, 3, 1]; pre-release suffixes sort before the release.
+function parseVersion(v) {
+ const m = String(v || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
+ return m ? { nums: [+m[1], +m[2], +m[3]], pre: m[4] || null } : null;
+}
+
+function isNewer(candidate, current) {
+ const a = parseVersion(candidate), b = parseVersion(current);
+ if (!a || !b) return false;
+ for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
+ if (a.pre === b.pre) return false;
+ if (!a.pre) return true; // 1.0.0 is newer than 1.0.0-beta
+ if (!b.pre) return false;
+ return a.pre > b.pre;
+}
+
+// The asset this copy of the app updates from, by the names electron-builder gives them.
+function assetName(platform, arch, method) {
+ if (method === "mac-zip") return `Frame-Control-mac-${arch}.zip`;
+ if (method === "nsis") return `Frame-Control-Setup-${arch}.exe`;
+ if (method === "appimage") return `Frame-Control-linux-${arch === "x64" ? "x86_64" : arch}.AppImage`;
+ return null;
+}
+
+// How this copy can update itself: mac-zip, nsis, appimage, or manual (with why).
+function updateMethod({ platform, isPackaged, execPath, env, exists, writable }) {
+ if (!isPackaged) return { method: "manual", why: "running from a source checkout" };
+ if (platform === "darwin") {
+ const bundle = macBundle(execPath);
+ if (!bundle) return { method: "manual", why: "can't find the app bundle" };
+ if (bundle.includes("/AppTranslocation/") || bundle.startsWith("/Volumes/")) {
+ return { method: "manual", why: "move Frame Control to Applications first" };
+ }
+ if (!writable(path.dirname(bundle))) return { method: "manual", why: `${path.dirname(bundle)} isn't writable` };
+ return { method: "mac-zip", bundle };
+ }
+ if (platform === "win32") {
+ // electron-builder's NSIS install puts its uninstaller next to the app.
+ const dir = path.dirname(execPath);
+ if (exists(path.join(dir, "Uninstall Frame Control.exe"))) return { method: "nsis" };
+ return { method: "manual", why: "not installed with the installer" };
+ }
+ if (platform === "linux" && env.APPIMAGE) {
+ if (!writable(path.dirname(env.APPIMAGE))) return { method: "manual", why: "the AppImage's folder isn't writable" };
+ return { method: "appimage", appImage: env.APPIMAGE };
+ }
+ return { method: "manual", why: "installed from a package" };
+}
+
+function macBundle(execPath) {
+ const i = execPath.indexOf(".app/Contents/MacOS/");
+ return i < 0 ? null : execPath.slice(0, i + 4);
+}
+
+function get(url, { headers = {}, timeout = 20000, redirects = 5 } = {}) {
+ return new Promise((resolve, reject) => {
+ const req = https.get(url, { headers: { "user-agent": "FrameControl-updater", ...headers }, timeout }, (res) => {
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirects > 0) {
+ res.resume();
+ const next = new URL(res.headers.location, url);
+ if (next.protocol !== "https:") return reject(new Error("refusing a non-HTTPS redirect"));
+ return resolve(get(next.href, { headers, timeout, redirects: redirects - 1 }));
+ }
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode} from ${new URL(url).host}`)); }
+ resolve(res);
+ });
+ req.on("timeout", () => req.destroy(new Error("timed out")));
+ req.on("error", reject);
+ });
+}
+
+async function getJson(url, headers) {
+ const res = await get(url, { headers });
+ let body = "";
+ for await (const chunk of res) body += chunk;
+ return JSON.parse(body);
+}
+
+// update.json and the API's release both become { version, notes, page, assets }.
+function fromManifest(m) {
+ if (!parseVersion(m.version) || !Array.isArray(m.assets)) throw new Error("update.json is malformed");
+ const base = `https://github.com/${REPO}/releases/download/v${String(m.version).replace(/^v/i, "")}/`;
+ return { version: String(m.version).replace(/^v/i, ""), notes: String(m.notes || "").slice(0, 4000),
+ page: m.page || RELEASES,
+ // Assets always come from this repository's release, whatever the manifest says.
+ assets: m.assets.map((a) => ({ name: String(a.name), url: base + encodeURIComponent(String(a.name)),
+ size: a.size, digest: a.digest || null })) };
+}
+
+function fromApi(r) {
+ if (r.draft || r.prerelease) throw new Error("GitHub returned an unpublished release");
+ return { version: String(r.tag_name || "").replace(/^v/i, ""), notes: String(r.body || "").slice(0, 4000),
+ page: r.html_url || RELEASES,
+ assets: (r.assets || []).map((a) => ({ name: a.name, url: a.browser_download_url, size: a.size,
+ digest: a.digest || null })) };
+}
+
+async function latestRelease() {
+ try {
+ return fromManifest(await getJson(MANIFEST));
+ } catch (e) {
+ if (!/HTTP 404/.test(e.message)) throw e; // releases before update.json existed
+ }
+ return fromApi(await getJson(LATEST, { accept: "application/vnd.github+json" }));
+}
+
+async function download(asset, dest, onProgress) {
+ const m = /^sha256:([0-9a-f]{64})$/.exec(asset.digest || "");
+ if (!m) throw new Error(`GitHub has no SHA-256 for ${asset.name}, so it can't be checked`);
+ const res = await get(asset.url, { timeout: 60000 });
+ const total = +res.headers["content-length"] || asset.size || 0;
+ const hash = crypto.createHash("sha256");
+ // "wx": a new file only, never through an existing file or symlink at that path.
+ const out = fs.createWriteStream(dest, { mode: 0o755, flags: "wx" });
+ let done = 0;
+ await new Promise((resolve, reject) => {
+ res.on("data", (chunk) => { hash.update(chunk); done += chunk.length; onProgress && onProgress(done, total); });
+ res.on("error", reject);
+ out.on("error", reject);
+ out.on("finish", resolve);
+ res.pipe(out);
+ });
+ if (hash.digest("hex") !== m[1]) {
+ fs.rmSync(dest, { force: true });
+ throw new Error(`${asset.name} didn't match its SHA-256; nothing was changed`);
+ }
+}
+
+const run = (cmd, args) => new Promise((resolve, reject) =>
+ execFile(cmd, args, { timeout: 120000 }, (err, stdout, stderr) => err ? reject(new Error((stderr || err.message).trim())) : resolve(stdout)));
+
+// Waits for this process to exit, swaps the new bundle in (putting the old one
+// back if that fails), and reopens the app.
+const MAC_SWAP = `set -u
+pid="$1"; app="$2"; new="$3"; stage="$4"
+while kill -0 "$pid" 2>/dev/null; do sleep 0.2; done
+old="$stage/old.app"
+if mv "$app" "$old"; then
+ if mv "$new" "$app"; then rm -rf "$old"; else mv "$old" "$app"; fi
+fi
+xattr -dr com.apple.quarantine "$app" 2>/dev/null
+rm -rf "$stage"
+open "$app"
+`;
+
+async function applyMac(release, bundle, onProgress) {
+ const asset = release.assets.find((a) => a.name === assetName("darwin", process.arch, "mac-zip"));
+ if (!asset) throw new Error(`the release has no ${assetName("darwin", process.arch, "mac-zip")}`);
+ // Staged beside the app, so the final move stays on one volume.
+ const stage = fs.mkdtempSync(path.join(path.dirname(bundle), ".frame-control-update-"));
+ try {
+ const zip = path.join(stage, asset.name);
+ await download(asset, zip, onProgress);
+ await run("/usr/bin/ditto", ["-x", "-k", zip, stage]);
+ fs.rmSync(zip, { force: true });
+ const name = fs.readdirSync(stage).find((n) => n.endsWith(".app"));
+ if (!name) throw new Error("the download has no app in it");
+ const fresh = path.join(stage, name);
+ const version = (await run("/usr/bin/plutil", ["-extract", "CFBundleShortVersionString", "raw",
+ path.join(fresh, "Contents", "Info.plist")])).trim();
+ if (version !== release.version) throw new Error(`the download is version ${version}, not ${release.version}`);
+ const script = path.join(stage, "swap.sh");
+ fs.writeFileSync(script, MAC_SWAP);
+ return () => spawn("/bin/sh", [script, String(process.pid), bundle, fresh, stage],
+ { detached: true, stdio: "ignore" }).unref();
+ } catch (e) {
+ fs.rmSync(stage, { recursive: true, force: true });
+ throw e;
+ }
+}
+
+async function applyNsis(release, onProgress) {
+ const asset = release.assets.find((a) => a.name === assetName("win32", process.arch, "nsis"));
+ if (!asset) throw new Error(`the release has no ${assetName("win32", process.arch, "nsis")}`);
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "frame-control-update-"));
+ const exe = path.join(dir, asset.name);
+ await download(asset, exe, onProgress);
+ // /S: silent, into the existing install. --force-run: open the app afterwards.
+ return () => spawn(exe, ["--updated", "/S", "--force-run"], { detached: true, stdio: "ignore" }).unref();
+}
+
+async function applyAppImage(release, appImage, onProgress) {
+ const asset = release.assets.find((a) => a.name === assetName("linux", process.arch, "appimage"));
+ if (!asset) throw new Error(`the release has no ${assetName("linux", process.arch, "appimage")}`);
+ // A private folder beside the AppImage, so the final rename stays on one filesystem.
+ const stage = fs.mkdtempSync(path.join(path.dirname(appImage), ".frame-control-update-"));
+ try {
+ const next = path.join(stage, asset.name);
+ await download(asset, next, onProgress);
+ fs.chmodSync(next, 0o755);
+ fs.renameSync(next, appImage); // the running copy keeps its open file
+ } finally {
+ fs.rmSync(stage, { recursive: true, force: true });
+ }
+ // Without FUSE the AppImage runs extracted (--appimage-extract-and-run, which isn't passed
+ // on to the app); a FUSE mount lives under /tmp/.mount_*. Keep the same mode on restart.
+ const extracted = process.env.APPIMAGE_EXTRACT_AND_RUN === "1" || !process.execPath.includes("/.mount_");
+ const env = { ...process.env, APPIMAGE: appImage, ...(extracted ? { APPIMAGE_EXTRACT_AND_RUN: "1" } : {}) };
+ // Started only once this process has exited, or the new copy would lose the single-instance lock.
+ return () => spawn("/bin/sh", ["-c", 'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; exec "$2"',
+ "sh", String(process.pid), appImage], { detached: true, stdio: "ignore", env }).unref();
+}
+
+// Downloads and prepares the update; returns a function that starts the swap,
+// to be called just before the app quits.
+async function prepare(release, how, onProgress, current) {
+ if (!isNewer(release.version, current)) throw new Error(`${release.version} isn't newer than ${current}`);
+ if (how.method === "mac-zip") return applyMac(release, how.bundle, onProgress);
+ if (how.method === "nsis") return applyNsis(release, onProgress);
+ if (how.method === "appimage") return applyAppImage(release, how.appImage, onProgress);
+ throw new Error(how.why || "this copy can't update itself");
+}
+
+function writable(dir) {
+ try { fs.accessSync(dir, fs.constants.W_OK); return true; } catch { return false; }
+}
+
+module.exports = { REPO, RELEASES, parseVersion, isNewer, assetName, updateMethod, macBundle, latestRelease,
+ fromManifest, fromApi,
+ download, prepare, writable };
diff --git a/bench/pages/present.html b/bench/pages/present.html
new file mode 100644
index 0000000..2e783fd
--- /dev/null
+++ b/bench/pages/present.html
@@ -0,0 +1,24 @@
+
+
+fmv-present
+
+
diff --git a/bench/pages/scroll.html b/bench/pages/scroll.html
new file mode 100644
index 0000000..602ced9
--- /dev/null
+++ b/bench/pages/scroll.html
@@ -0,0 +1,32 @@
+
+
+fmv-bench scroll
+
+
diff --git a/bench/pages/type.html b/bench/pages/type.html
new file mode 100644
index 0000000..288904c
--- /dev/null
+++ b/bench/pages/type.html
@@ -0,0 +1,9 @@
+
+
+fmv-bench type
+
+
diff --git a/bench/results/2026-09-28-1d05f57-usb1.json b/bench/results/2026-09-28-1d05f57-usb1.json
new file mode 100644
index 0000000..47c65d3
--- /dev/null
+++ b/bench/results/2026-09-28-1d05f57-usb1.json
@@ -0,0 +1,677 @@
+{
+ "label": "usb1",
+ "date": "2026-09-28T21:16:03",
+ "commit": "1d05f57",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "10.86.200.233",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 911,
+ "frames_drawn": 911,
+ "frames_shown": 610,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 911
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 911
+ },
+ "encode": {
+ "p50": 4.4,
+ "p95": 5.0,
+ "p99": 5.2,
+ "n": 911
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 911
+ },
+ "network": {
+ "p50": 1.1,
+ "p95": 1.6,
+ "p99": 2.1,
+ "n": 911
+ },
+ "decode": {
+ "p50": 1.2,
+ "p95": 2.6,
+ "p99": 3.9,
+ "n": 911
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.9,
+ "p99": 1.5,
+ "n": 911
+ },
+ "present": {
+ "p50": 5.8,
+ "p95": 13.6,
+ "p99": 16.7,
+ "n": 610
+ },
+ "content": {
+ "p50": 7.3,
+ "p95": 8.8,
+ "p99": 10.0,
+ "n": 911
+ },
+ "content_shown": {
+ "p50": 13.6,
+ "p95": 20.4,
+ "p99": 24.9,
+ "n": 610
+ }
+ },
+ "fps": 59.9,
+ "fps_shown": 40.1,
+ "late_pct": 0.22,
+ "stall_max": 32.3,
+ "stalls_over_100ms": 0,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 912,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 16.6,
+ "p95": 24.3,
+ "p99": 24.5,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 20.8,
+ "p95": 31.8,
+ "p99": 33.8,
+ "n": 22
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 0.7,
+ "p95": 1.0,
+ "p99": 1.0,
+ "n": 30
+ },
+ "mac": {
+ "p50": 9.1,
+ "p95": 15.0,
+ "p99": 15.2,
+ "n": 30
+ },
+ "back": {
+ "p50": 7.2,
+ "p95": 8.5,
+ "p99": 12.7,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "asked": 30,
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 7.3,
+ "content_p95": 8.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 7.1,
+ "content_p95": 8.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 59,
+ "mbps": 0.48,
+ "content_p50": 7.2,
+ "content_p95": 8.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 7.1,
+ "content_p95": 9.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 6.9,
+ "content_p95": 8.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 7.3,
+ "content_p95": 8.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 7.4,
+ "content_p95": 8.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 7.4,
+ "content_p95": 8.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 7.3,
+ "content_p95": 8.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 7.3,
+ "content_p95": 8.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 7.3,
+ "content_p95": 8.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 7.4,
+ "content_p95": 9.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 7.4,
+ "content_p95": 8.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 7.6,
+ "content_p95": 9.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 7.6,
+ "content_p95": 8.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.09,
+ "content_p50": 7.5,
+ "content_p95": 9.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 7.3,
+ "content_p95": 8.8,
+ "input_p50": 16.6,
+ "input_p95": 24.3,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 0.917,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 60.0,
+ "cpu": {
+ "mac_agent_pct": 16.7,
+ "frame_viewer_pct": 48.5,
+ "frame_tailscaled_pct": 0.5,
+ "frame_sshd_pct": 1.1,
+ "frame_gamescope_pct": 12.0,
+ "frame_total_pct": 39.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
+ "show_s": 1.15,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 848,
+ "frames_drawn": 848,
+ "frames_shown": 617,
+ "duration_s": 15.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.9,
+ "p95": 4.2,
+ "p99": 7.3,
+ "n": 848
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 848
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.7,
+ "p99": 17.7,
+ "n": 848
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 848
+ },
+ "network": {
+ "p50": 1.7,
+ "p95": 2.7,
+ "p99": 5.7,
+ "n": 848
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 10.3,
+ "p99": 13.1,
+ "n": 848
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.6,
+ "p99": 2.2,
+ "n": 848
+ },
+ "present": {
+ "p50": 4.4,
+ "p95": 15.8,
+ "p99": 20.6,
+ "n": 617
+ },
+ "content": {
+ "p50": 15.7,
+ "p95": 23.0,
+ "p99": 39.3,
+ "n": 848
+ },
+ "content_shown": {
+ "p50": 21.3,
+ "p95": 34.1,
+ "p99": 45.6,
+ "n": 617
+ }
+ },
+ "fps": 56.1,
+ "fps_shown": 40.7,
+ "late_pct": 3.3,
+ "stall_max": 238.5,
+ "stalls_over_100ms": 1,
+ "mbps": 9.01,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 857,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 44,
+ "mbps": 8.13,
+ "content_p50": 15.7,
+ "content_p95": 35.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 56,
+ "mbps": 7.04,
+ "content_p50": 16.3,
+ "content_p95": 47.3,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 8.58,
+ "content_p50": 16.0,
+ "content_p95": 22.6,
+ "bitrate": 10055362,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 56,
+ "mbps": 9.29,
+ "content_p50": 16.4,
+ "content_p95": 21.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 9.01,
+ "content_p50": 15.6,
+ "content_p95": 21.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 9.13,
+ "content_p50": 15.9,
+ "content_p95": 21.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 56,
+ "mbps": 11.63,
+ "content_p50": 16.1,
+ "content_p95": 22.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.7,
+ "content_p50": 16.3,
+ "content_p95": 22.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 57,
+ "mbps": 9.24,
+ "content_p50": 15.7,
+ "content_p95": 21.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.21,
+ "content_p50": 15.0,
+ "content_p95": 20.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 56,
+ "mbps": 8.55,
+ "content_p50": 15.7,
+ "content_p95": 20.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 9.51,
+ "content_p50": 14.7,
+ "content_p95": 19.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 57,
+ "mbps": 8.75,
+ "content_p50": 16.0,
+ "content_p95": 20.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 58,
+ "mbps": 9.25,
+ "content_p50": 15.0,
+ "content_p95": 20.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.13,
+ "content_p50": 15.3,
+ "content_p95": 21.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 7,
+ "mbps": 1.17,
+ "content_p50": 14.2,
+ "content_p95": 15.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 15.7,
+ "content_p95": 23.0,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 1.25,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 1.06,
+ "e": "down to 7430 kbit/s: queue 35 ms, held 4, oldest 32 ms, base 1 ms, sent 6282 got 4914 wants 11892"
+ }
+ ],
+ "source_fps": 56.8,
+ "cpu": {
+ "mac_agent_pct": 8.8,
+ "frame_viewer_pct": 74.8,
+ "frame_tailscaled_pct": 0.3,
+ "frame_sshd_pct": 1.2,
+ "frame_gamescope_pct": 10.5,
+ "frame_total_pct": 39.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 137 Hz",
+ "show_s": 1.16,
+ "panel": "valve.steam.desktopgame.2001968301",
+ "src": "separate:7914"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-1d05f57-usb2.json b/bench/results/2026-09-28-1d05f57-usb2.json
new file mode 100644
index 0000000..88464f7
--- /dev/null
+++ b/bench/results/2026-09-28-1d05f57-usb2.json
@@ -0,0 +1,690 @@
+{
+ "label": "usb2",
+ "date": "2026-09-28T21:17:48",
+ "commit": "1d05f57",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "10.86.200.233",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 913,
+ "frames_drawn": 913,
+ "frames_shown": 856,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 913
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 913
+ },
+ "encode": {
+ "p50": 4.3,
+ "p95": 4.9,
+ "p99": 5.2,
+ "n": 913
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 913
+ },
+ "network": {
+ "p50": 0.9,
+ "p95": 1.2,
+ "p99": 1.7,
+ "n": 913
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.5,
+ "p99": 3.8,
+ "n": 913
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 913
+ },
+ "present": {
+ "p50": 2.6,
+ "p95": 8.6,
+ "p99": 15.9,
+ "n": 856
+ },
+ "content": {
+ "p50": 6.9,
+ "p95": 8.4,
+ "p99": 9.6,
+ "n": 913
+ },
+ "content_shown": {
+ "p50": 9.7,
+ "p95": 15.8,
+ "p99": 23.1,
+ "n": 856
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 56.3,
+ "late_pct": 0.0,
+ "stall_max": 22.0,
+ "stalls_over_100ms": 0,
+ "mbps": 0.57,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 913,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 16.9,
+ "p95": 24.1,
+ "p99": 26.1,
+ "n": 33
+ },
+ "input_shown_ms": {
+ "p50": 19.7,
+ "p95": 26.6,
+ "p99": 29.2,
+ "n": 31
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 0.7,
+ "p95": 2.5,
+ "p99": 5.7,
+ "n": 33
+ },
+ "mac": {
+ "p50": 8.1,
+ "p95": 15.4,
+ "p99": 17.2,
+ "n": 33
+ },
+ "back": {
+ "p50": 7.3,
+ "p95": 8.3,
+ "p99": 8.9,
+ "n": 33
+ }
+ },
+ "inputs": {
+ "asked": 30,
+ "sent": 33,
+ "seen": 33
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 7.1,
+ "content_p95": 8.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 7.0,
+ "content_p95": 8.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 6.9,
+ "content_p95": 8.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 7.0,
+ "content_p95": 9.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 6.8,
+ "content_p95": 8.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.67,
+ "content_p50": 7.0,
+ "content_p95": 8.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 7.0,
+ "content_p95": 8.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.63,
+ "content_p50": 6.7,
+ "content_p95": 7.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.62,
+ "content_p50": 6.9,
+ "content_p95": 8.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.64,
+ "content_p50": 6.8,
+ "content_p95": 8.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.67,
+ "content_p50": 6.8,
+ "content_p95": 8.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.62,
+ "content_p50": 6.7,
+ "content_p95": 7.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 6.8,
+ "content_p95": 7.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 6.7,
+ "content_p95": 8.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 7.0,
+ "content_p95": 8.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 13,
+ "mbps": 0.13,
+ "content_p50": 6.4,
+ "content_p95": 7.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 6.9,
+ "content_p95": 8.4,
+ "input_p50": 16.9,
+ "input_p95": 24.1,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 0.709,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 60.1,
+ "cpu": {
+ "mac_agent_pct": 17.9,
+ "frame_viewer_pct": 51.9,
+ "frame_tailscaled_pct": 0.2,
+ "frame_sshd_pct": 1.1,
+ "frame_gamescope_pct": 9.6,
+ "frame_total_pct": 27.9
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 134 Hz",
+ "show_s": 1.15,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 868,
+ "frames_drawn": 868,
+ "frames_shown": 868,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.2,
+ "p95": 0.5,
+ "p99": 6.7,
+ "n": 868
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.2,
+ "n": 868
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 10.2,
+ "p99": 17.9,
+ "n": 868
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 868
+ },
+ "network": {
+ "p50": 1.5,
+ "p95": 2.3,
+ "p99": 7.6,
+ "n": 868
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 10.2,
+ "p99": 20.3,
+ "n": 868
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.4,
+ "p99": 2.1,
+ "n": 868
+ },
+ "present": {
+ "p50": 0.9,
+ "p95": 6.1,
+ "p99": 6.8,
+ "n": 868
+ },
+ "content": {
+ "p50": 15.3,
+ "p95": 23.5,
+ "p99": 53.1,
+ "n": 868
+ },
+ "content_shown": {
+ "p50": 17.7,
+ "p95": 25.6,
+ "p99": 57.5,
+ "n": 868
+ }
+ },
+ "fps": 57.2,
+ "fps_shown": 57.2,
+ "late_pct": 3.23,
+ "stall_max": 108.0,
+ "stalls_over_100ms": 1,
+ "mbps": 9.84,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 870,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 24.2,
+ "p95": 36.3,
+ "p99": 36.3,
+ "n": 6
+ },
+ "input_shown_ms": {
+ "p50": 24.8,
+ "p95": 38.5,
+ "p99": 38.5,
+ "n": 6
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 2.0,
+ "p95": 5.4,
+ "p99": 5.4,
+ "n": 6
+ },
+ "mac": {
+ "p50": 7.5,
+ "p95": 15.8,
+ "p99": 15.8,
+ "n": 6
+ },
+ "back": {
+ "p50": 15.3,
+ "p95": 16.1,
+ "p99": 16.1,
+ "n": 6
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 6,
+ "seen": 6
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 53,
+ "mbps": 9.21,
+ "content_p50": 16.1,
+ "content_p95": 71.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 57,
+ "mbps": 9.64,
+ "content_p50": 15.9,
+ "content_p95": 46.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 58,
+ "mbps": 8.99,
+ "content_p50": 14.8,
+ "content_p95": 19.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.72,
+ "content_p50": 15.2,
+ "content_p95": 22.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 9.64,
+ "content_p50": 16.1,
+ "content_p95": 23.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 11.27,
+ "content_p50": 15.6,
+ "content_p95": 20.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 10.44,
+ "content_p50": 15.1,
+ "content_p95": 19.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 9.31,
+ "content_p50": 15.0,
+ "content_p95": 19.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 11.69,
+ "content_p50": 15.9,
+ "content_p95": 22.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 58,
+ "mbps": 10.31,
+ "content_p50": 14.5,
+ "content_p95": 20.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 10.47,
+ "content_p50": 15.2,
+ "content_p95": 20.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 9.44,
+ "content_p50": 15.3,
+ "content_p95": 20.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 57,
+ "mbps": 9.17,
+ "content_p50": 15.9,
+ "content_p95": 18.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 58,
+ "mbps": 8.77,
+ "content_p50": 15.1,
+ "content_p95": 18.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 58,
+ "mbps": 9.72,
+ "content_p50": 15.2,
+ "content_p95": 19.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 9,
+ "mbps": 1.33,
+ "content_p50": 14.8,
+ "content_p95": 17.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 15.3,
+ "content_p95": 23.5,
+ "input_p50": 24.2,
+ "input_p95": 36.3,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 1.375,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 57.2,
+ "cpu": {
+ "mac_agent_pct": 11.4,
+ "frame_viewer_pct": 83.5,
+ "frame_tailscaled_pct": 0.3,
+ "frame_sshd_pct": 1.5,
+ "frame_gamescope_pct": 9.7,
+ "frame_total_pct": 32.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.15,
+ "panel": "valve.steam.desktopgame.2001786096",
+ "src": "separate:8187"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-1d05f57-wifi1.json b/bench/results/2026-09-28-1d05f57-wifi1.json
new file mode 100644
index 0000000..ba818b0
--- /dev/null
+++ b/bench/results/2026-09-28-1d05f57-wifi1.json
@@ -0,0 +1,682 @@
+{
+ "label": "wifi1",
+ "date": "2026-09-28T21:16:56",
+ "commit": "1d05f57",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 912,
+ "frames_drawn": 912,
+ "frames_shown": 611,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 912
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 912
+ },
+ "encode": {
+ "p50": 4.3,
+ "p95": 4.9,
+ "p99": 5.1,
+ "n": 912
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 912
+ },
+ "network": {
+ "p50": 4.0,
+ "p95": 5.3,
+ "p99": 7.8,
+ "n": 912
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.8,
+ "p99": 4.0,
+ "n": 912
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 912
+ },
+ "present": {
+ "p50": 5.9,
+ "p95": 14.0,
+ "p99": 18.5,
+ "n": 611
+ },
+ "content": {
+ "p50": 10.1,
+ "p95": 12.3,
+ "p99": 13.9,
+ "n": 912
+ },
+ "content_shown": {
+ "p50": 16.7,
+ "p95": 23.6,
+ "p99": 28.3,
+ "n": 611
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 40.1,
+ "late_pct": 0.11,
+ "stall_max": 25.8,
+ "stalls_over_100ms": 0,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 912,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 26.4,
+ "p95": 34.2,
+ "p99": 53.5,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 28.6,
+ "p95": 41.5,
+ "p99": 55.9,
+ "n": 18
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 8.9,
+ "p95": 20.4,
+ "p99": 28.0,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.1,
+ "p95": 14.5,
+ "p99": 16.2,
+ "n": 30
+ },
+ "back": {
+ "p50": 10.1,
+ "p95": 13.7,
+ "p99": 13.8,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "asked": 30,
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 10.3,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.5,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.1,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.9,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.7,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 10.2,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.2,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.7,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.4,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.9,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.3,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.2,
+ "content_p95": 12.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.0,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.2,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.1,
+ "content_p50": 10.7,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.1,
+ "content_p95": 12.3,
+ "input_p50": 26.4,
+ "input_p95": 34.2,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 4.958,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 47.417,
+ "target": 2764800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 16.58,
+ "e": "down to 2764 kbit/s: queue 19 ms, held 6, oldest 210 ms, base 4 ms, sent 322 got 265 wants 459"
+ }
+ ],
+ "source_fps": 60.0,
+ "cpu": {
+ "mac_agent_pct": 15.7,
+ "frame_viewer_pct": 42.5,
+ "frame_tailscaled_pct": 5.5,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 8.7,
+ "frame_total_pct": 26.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
+ "show_s": 1.26,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 852,
+ "frames_drawn": 852,
+ "frames_shown": 614,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.4,
+ "p95": 2.6,
+ "p99": 4.2,
+ "n": 852
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 852
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.8,
+ "p99": 16.7,
+ "n": 852
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 852
+ },
+ "network": {
+ "p50": 5.3,
+ "p95": 13.1,
+ "p99": 28.2,
+ "n": 852
+ },
+ "decode": {
+ "p50": 6.0,
+ "p95": 10.9,
+ "p99": 19.0,
+ "n": 852
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.4,
+ "p99": 1.8,
+ "n": 852
+ },
+ "present": {
+ "p50": 4.1,
+ "p95": 16.2,
+ "p99": 20.6,
+ "n": 614
+ },
+ "content": {
+ "p50": 19.5,
+ "p95": 31.4,
+ "p99": 64.5,
+ "n": 852
+ },
+ "content_shown": {
+ "p50": 25.4,
+ "p95": 38.8,
+ "p99": 76.7,
+ "n": 614
+ }
+ },
+ "fps": 56.2,
+ "fps_shown": 40.5,
+ "late_pct": 4.69,
+ "stall_max": 253.7,
+ "stalls_over_100ms": 2,
+ "mbps": 9.02,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 866,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 45,
+ "mbps": 7.77,
+ "content_p50": 19.0,
+ "content_p95": 163.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 51,
+ "mbps": 6.54,
+ "content_p50": 19.4,
+ "content_p95": 107.0,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 8.64,
+ "content_p50": 19.9,
+ "content_p95": 31.8,
+ "bitrate": 10055362,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 58,
+ "mbps": 9.46,
+ "content_p50": 19.1,
+ "content_p95": 24.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 9.11,
+ "content_p50": 19.4,
+ "content_p95": 22.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 9.18,
+ "content_p50": 19.9,
+ "content_p95": 25.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 11.64,
+ "content_p50": 19.8,
+ "content_p95": 49.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 9.12,
+ "content_p50": 20.5,
+ "content_p95": 31.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.29,
+ "content_p50": 19.5,
+ "content_p95": 25.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.43,
+ "content_p50": 19.8,
+ "content_p95": 49.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 58,
+ "mbps": 8.59,
+ "content_p50": 19.2,
+ "content_p95": 24.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 57,
+ "mbps": 9.42,
+ "content_p50": 19.9,
+ "content_p95": 27.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 58,
+ "mbps": 9.0,
+ "content_p50": 20.6,
+ "content_p95": 31.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 57,
+ "mbps": 8.84,
+ "content_p50": 19.6,
+ "content_p95": 25.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.17,
+ "content_p50": 19.3,
+ "content_p95": 27.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 9,
+ "mbps": 1.52,
+ "content_p50": 19.3,
+ "content_p95": 30.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.5,
+ "content_p95": 31.4,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.959,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 43.54,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 1.09,
+ "e": "down to 7430 kbit/s: queue 131 ms, held 3, oldest 231 ms, base 6 ms, sent 4272 got 3724 wants 9321"
+ }
+ ],
+ "source_fps": 57.0,
+ "cpu": {
+ "mac_agent_pct": 8.5,
+ "frame_viewer_pct": 71.7,
+ "frame_tailscaled_pct": 8.4,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 8.4,
+ "frame_total_pct": 30.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.25,
+ "panel": "valve.steam.desktopgame.2001195625",
+ "src": "separate:8050"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-1d05f57-wifi2.json b/bench/results/2026-09-28-1d05f57-wifi2.json
new file mode 100644
index 0000000..00f9504
--- /dev/null
+++ b/bench/results/2026-09-28-1d05f57-wifi2.json
@@ -0,0 +1,682 @@
+{
+ "label": "wifi2",
+ "date": "2026-09-28T21:18:40",
+ "commit": "1d05f57",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 902,
+ "frames_drawn": 902,
+ "frames_shown": 604,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 902
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 902
+ },
+ "encode": {
+ "p50": 4.3,
+ "p95": 5.0,
+ "p99": 5.3,
+ "n": 902
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 902
+ },
+ "network": {
+ "p50": 3.8,
+ "p95": 5.3,
+ "p99": 8.0,
+ "n": 902
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.8,
+ "p99": 3.8,
+ "n": 902
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 902
+ },
+ "present": {
+ "p50": 5.9,
+ "p95": 17.0,
+ "p99": 18.6,
+ "n": 604
+ },
+ "content": {
+ "p50": 9.8,
+ "p95": 12.1,
+ "p99": 14.4,
+ "n": 902
+ },
+ "content_shown": {
+ "p50": 15.2,
+ "p95": 27.0,
+ "p99": 28.4,
+ "n": 604
+ }
+ },
+ "fps": 59.3,
+ "fps_shown": 39.7,
+ "late_pct": 0.55,
+ "stall_max": 187.5,
+ "stalls_over_100ms": 1,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 913,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 27.8,
+ "p95": 34.9,
+ "p99": 206.9,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 35.1,
+ "p95": 52.6,
+ "p99": 207.2,
+ "n": 24
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 9.8,
+ "p95": 22.5,
+ "p99": 184.8,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.3,
+ "p95": 14.4,
+ "p99": 19.6,
+ "n": 30
+ },
+ "back": {
+ "p50": 9.8,
+ "p95": 13.4,
+ "p99": 13.9,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "asked": 30,
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.3,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 50,
+ "mbps": 0.39,
+ "content_p50": 9.1,
+ "content_p95": 14.4,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.2,
+ "content_p95": 11.8,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.4,
+ "content_p95": 10.5,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.5,
+ "content_p95": 12.5,
+ "bitrate": 3091280,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.0,
+ "content_p95": 12.8,
+ "bitrate": 4757991,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 9.5,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.2,
+ "content_p95": 12.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.0,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.2,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.9,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.0,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.3,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.8,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.1,
+ "content_p50": 9.3,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.8,
+ "content_p95": 12.1,
+ "input_p50": 27.8,
+ "input_p95": 34.9,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.084,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 49.603,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 1.77,
+ "e": "down to 2764 kbit/s: queue 23 ms, held 5, oldest 0 ms, base 5 ms, sent 315 got 315 wants 472"
+ }
+ ],
+ "source_fps": 60.1,
+ "cpu": {
+ "mac_agent_pct": 16.1,
+ "frame_viewer_pct": 40.9,
+ "frame_tailscaled_pct": 5.2,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 8.4,
+ "frame_total_pct": 25.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 136 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 832,
+ "frames_drawn": 832,
+ "frames_shown": 602,
+ "duration_s": 15.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.9,
+ "p95": 0.8,
+ "p99": 6.8,
+ "n": 832
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 1.9,
+ "n": 832
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 10.5,
+ "p99": 18.8,
+ "n": 832
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 832
+ },
+ "network": {
+ "p50": 5.8,
+ "p95": 18.7,
+ "p99": 56.7,
+ "n": 832
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 11.7,
+ "p99": 26.0,
+ "n": 832
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.4,
+ "p99": 1.7,
+ "n": 832
+ },
+ "present": {
+ "p50": 4.4,
+ "p95": 18.5,
+ "p99": 25.0,
+ "n": 602
+ },
+ "content": {
+ "p50": 20.2,
+ "p95": 36.9,
+ "p99": 90.7,
+ "n": 832
+ },
+ "content_shown": {
+ "p50": 26.4,
+ "p95": 45.4,
+ "p99": 101.0,
+ "n": 602
+ }
+ },
+ "fps": 54.9,
+ "fps_shown": 39.7,
+ "late_pct": 6.96,
+ "stall_max": 204.9,
+ "stalls_over_100ms": 2,
+ "mbps": 9.04,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 852,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 49,
+ "mbps": 8.68,
+ "content_p50": 19.8,
+ "content_p95": 119.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 47,
+ "mbps": 6.06,
+ "content_p50": 19.8,
+ "content_p95": 88.6,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 52,
+ "mbps": 8.07,
+ "content_p50": 19.4,
+ "content_p95": 25.5,
+ "bitrate": 10055362,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 56,
+ "mbps": 9.79,
+ "content_p50": 21.4,
+ "content_p95": 29.1,
+ "bitrate": 13549185,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 8.93,
+ "content_p50": 21.5,
+ "content_p95": 35.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 10.59,
+ "content_p50": 21.1,
+ "content_p95": 35.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 56,
+ "mbps": 11.55,
+ "content_p50": 19.7,
+ "content_p95": 66.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.8,
+ "content_p50": 18.8,
+ "content_p95": 24.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 57,
+ "mbps": 9.17,
+ "content_p50": 19.5,
+ "content_p95": 24.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.39,
+ "content_p50": 19.4,
+ "content_p95": 23.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 8.53,
+ "content_p50": 19.5,
+ "content_p95": 38.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 56,
+ "mbps": 9.23,
+ "content_p50": 20.7,
+ "content_p95": 30.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 53,
+ "mbps": 8.87,
+ "content_p50": 20.6,
+ "content_p95": 35.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 57,
+ "mbps": 8.94,
+ "content_p50": 20.4,
+ "content_p95": 28.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.14,
+ "content_p50": 21.0,
+ "content_p95": 35.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 7,
+ "mbps": 1.2,
+ "content_p50": 21.8,
+ "content_p95": 23.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 20.2,
+ "content_p95": 36.9,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.833,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 44.791,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 1.22,
+ "e": "down to 7430 kbit/s: queue 74 ms, held 7, oldest 235 ms, base 6 ms, sent 5436 got 4487 wants 11082"
+ }
+ ],
+ "source_fps": 56.4,
+ "cpu": {
+ "mac_agent_pct": 8.8,
+ "frame_viewer_pct": 69.5,
+ "frame_tailscaled_pct": 9.5,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 8.4,
+ "frame_total_pct": 31.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.25,
+ "panel": "valve.steam.desktopgame.2001116820",
+ "src": "separate:8327"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-39afb23-worn.json b/bench/results/2026-09-28-39afb23-worn.json
new file mode 100644
index 0000000..ee0abec
--- /dev/null
+++ b/bench/results/2026-09-28-39afb23-worn.json
@@ -0,0 +1,1180 @@
+{
+ "label": "worn",
+ "date": "2026-09-28T22:01:33",
+ "commit": "39afb23",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "usb": false,
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 1177,
+ "frames_drawn": 1177,
+ "frames_shown": 795,
+ "duration_s": 20.3,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1177
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1177
+ },
+ "encode": {
+ "p50": 4.4,
+ "p95": 5.3,
+ "p99": 5.7,
+ "n": 1177
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 1177
+ },
+ "network": {
+ "p50": 4.5,
+ "p95": 8.4,
+ "p99": 15.1,
+ "n": 1177
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.5,
+ "p99": 4.0,
+ "n": 1177
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.8,
+ "p99": 1.4,
+ "n": 1177
+ },
+ "present": {
+ "p50": 5.3,
+ "p95": 16.2,
+ "p99": 19.4,
+ "n": 795
+ },
+ "content": {
+ "p50": 10.8,
+ "p95": 15.3,
+ "p99": 24.4,
+ "n": 1177
+ },
+ "content_shown": {
+ "p50": 17.0,
+ "p95": 28.9,
+ "p99": 32.7,
+ "n": 795
+ }
+ },
+ "fps": 58.0,
+ "fps_shown": 39.2,
+ "late_pct": 1.02,
+ "stall_max": 262.4,
+ "stalls_over_100ms": 4,
+ "mbps": 0.45,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1217,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 25.7,
+ "p95": 34.4,
+ "p99": 47.3,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 29.8,
+ "p95": 44.3,
+ "p99": 47.7,
+ "n": 23
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 7.4,
+ "p95": 16.7,
+ "p99": 18.1,
+ "n": 40
+ },
+ "mac": {
+ "p50": 6.2,
+ "p95": 16.0,
+ "p99": 19.2,
+ "n": 40
+ },
+ "back": {
+ "p50": 11.4,
+ "p95": 15.3,
+ "p99": 22.9,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "asked": 40,
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.4,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.1,
+ "content_p95": 10.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.2,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.1,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.1,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.2,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.56,
+ "content_p50": 10.3,
+ "content_p95": 13.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 10.2,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 50,
+ "mbps": 0.38,
+ "content_p50": 10.4,
+ "content_p95": 136.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.9,
+ "content_p95": 12.8,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.9,
+ "content_p95": 12.7,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 53,
+ "mbps": 0.39,
+ "content_p50": 10.2,
+ "content_p95": 16.6,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 55,
+ "mbps": 0.41,
+ "content_p50": 10.9,
+ "content_p95": 16.1,
+ "bitrate": 1382400,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 12.6,
+ "content_p95": 15.7,
+ "bitrate": 1382400,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.42,
+ "content_p50": 12.8,
+ "content_p95": 15.4,
+ "bitrate": 1382400,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 12.7,
+ "content_p95": 15.3,
+ "bitrate": 2256021,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 13.0,
+ "content_p95": 16.4,
+ "bitrate": 3168263,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 57,
+ "mbps": 0.43,
+ "content_p50": 12.8,
+ "content_p95": 16.7,
+ "bitrate": 4382456,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 45,
+ "mbps": 0.35,
+ "content_p50": 13.0,
+ "content_p95": 15.9,
+ "bitrate": 2435350,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 13.4,
+ "content_p95": 15.4,
+ "bitrate": 2435350,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 17,
+ "mbps": 0.12,
+ "content_p50": 13.7,
+ "content_p95": 16.1,
+ "bitrate": 2435350,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.8,
+ "content_p95": 15.3,
+ "input_p50": 25.7,
+ "input_p95": 34.4,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.542,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 50.978,
+ "target": 3797645,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 9.07,
+ "e": "down to 2764 kbit/s: queue 14 ms, held 3, oldest 0 ms, base 5 ms, sent 307 got 322 wants 452"
+ },
+ {
+ "t": 12.07,
+ "e": "down to 1382 kbit/s: queue 18 ms, held 6, oldest 242 ms, base 5 ms, sent 295 got 268 wants 466"
+ },
+ {
+ "t": 18.17,
+ "e": "down to 2435 kbit/s: queue 20 ms, held 6, oldest 274 ms, base 5 ms, sent 256 got 221 wants 452"
+ }
+ ],
+ "source_fps": 60.0,
+ "cpu": {
+ "mac_agent_pct": 16.9,
+ "frame_viewer_pct": 38.7,
+ "frame_tailscaled_pct": 5.0,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 6.8,
+ "frame_total_pct": 24.9
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 1.99,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test",
+ "route": "network"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 762,
+ "frames_drawn": 762,
+ "frames_shown": 651,
+ "duration_s": 20.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.6,
+ "p95": 0.8,
+ "p99": 11.0,
+ "n": 762
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 16.2,
+ "p99": 17.3,
+ "n": 762
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 8.5,
+ "p99": 14.2,
+ "n": 762
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 762
+ },
+ "network": {
+ "p50": 5.2,
+ "p95": 115.6,
+ "p99": 159.6,
+ "n": 762
+ },
+ "decode": {
+ "p50": 5.5,
+ "p95": 18.4,
+ "p99": 27.7,
+ "n": 762
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.4,
+ "p99": 1.9,
+ "n": 762
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 12.3,
+ "p99": 23.8,
+ "n": 651
+ },
+ "content": {
+ "p50": 22.2,
+ "p95": 141.0,
+ "p99": 175.6,
+ "n": 762
+ },
+ "content_shown": {
+ "p50": 24.6,
+ "p95": 101.5,
+ "p99": 177.7,
+ "n": 651
+ }
+ },
+ "fps": 37.7,
+ "fps_shown": 32.2,
+ "late_pct": 42.26,
+ "stall_max": 332.6,
+ "stalls_over_100ms": 18,
+ "mbps": 4.94,
+ "keyframes": 4,
+ "size": "1920x1290",
+ "captured": 1097,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 55,
+ "mbps": 9.56,
+ "content_p50": 19.6,
+ "content_p95": 26.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 56,
+ "mbps": 8.8,
+ "content_p50": 18.9,
+ "content_p95": 25.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 52,
+ "mbps": 8.96,
+ "content_p50": 18.6,
+ "content_p95": 34.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.35,
+ "content_p50": 18.6,
+ "content_p95": 24.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.8,
+ "content_p50": 19.6,
+ "content_p95": 28.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 49,
+ "mbps": 10.39,
+ "content_p50": 31.3,
+ "content_p95": 135.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 48,
+ "mbps": 8.29,
+ "content_p50": 53.6,
+ "content_p95": 175.6,
+ "bitrate": 8168400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 33,
+ "mbps": 2.07,
+ "content_p50": 109.8,
+ "content_p95": 172.8,
+ "bitrate": 2613888,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 35,
+ "mbps": 1.17,
+ "content_p50": 111.0,
+ "content_p95": 177.0,
+ "bitrate": 1094296,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 22,
+ "mbps": 0.44,
+ "content_p50": 79.5,
+ "content_p95": 169.5,
+ "bitrate": 875436,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 31,
+ "mbps": 0.56,
+ "content_p50": 20.5,
+ "content_p95": 35.1,
+ "bitrate": 741574,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 21,
+ "mbps": 0.57,
+ "content_p50": 27.1,
+ "content_p95": 42.5,
+ "bitrate": 1002304,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 30,
+ "mbps": 0.78,
+ "content_p50": 22.2,
+ "content_p95": 29.3,
+ "bitrate": 801843,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 30,
+ "mbps": 1.01,
+ "content_p50": 24.2,
+ "content_p95": 87.7,
+ "bitrate": 1232751,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 30,
+ "mbps": 1.51,
+ "content_p50": 21.1,
+ "content_p95": 38.3,
+ "bitrate": 1806290,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 30,
+ "mbps": 2.34,
+ "content_p50": 21.7,
+ "content_p95": 41.9,
+ "bitrate": 2876638,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 30,
+ "mbps": 3.57,
+ "content_p50": 25.4,
+ "content_p95": 51.7,
+ "bitrate": 3994304,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 30,
+ "mbps": 4.67,
+ "content_p50": 31.1,
+ "content_p95": 54.3,
+ "bitrate": 5481917,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 30,
+ "mbps": 6.77,
+ "content_p50": 30.3,
+ "content_p95": 48.0,
+ "bitrate": 8258121,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 30,
+ "mbps": 7.43,
+ "content_p50": 35.4,
+ "content_p95": 43.0,
+ "bitrate": 11157058,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 6,
+ "mbps": 1.63,
+ "content_p50": 33.6,
+ "content_p95": 41.1,
+ "bitrate": 12322763,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 22.2,
+ "content_p95": 141.0,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "bad",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.792,
+ "ceiling": 14860800,
+ "fps": 45,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 61.471,
+ "target": 14860800,
+ "tier": 1
+ },
+ "events": [
+ {
+ "t": 6.13,
+ "e": "down to 11888 kbit/s: queue 56 ms, held 0, oldest 19 ms, base 6 ms, sent 13722 got 14074 wants 14249"
+ },
+ {
+ "t": 6.73,
+ "e": "down to 8168 kbit/s: queue 72 ms, held 5, oldest 0 ms, base 6 ms, sent 8768 got 9076 wants 9996"
+ },
+ {
+ "t": 7.03,
+ "e": "down to 4084 kbit/s: queue 25 ms, held 5, oldest 221 ms, base 6 ms, sent 5652 got 4017 wants 8949"
+ },
+ {
+ "t": 7.33,
+ "e": "down to 3267 kbit/s: queue 364 ms, held 1, oldest 28 ms, base 6 ms, sent 3634 got 4790 wants 12946"
+ },
+ {
+ "t": 7.73,
+ "e": "down to 2613 kbit/s: queue 90 ms, held 1, oldest 127 ms, base 6 ms, sent 3380 got 4111 wants 4188"
+ },
+ {
+ "t": 8.13,
+ "e": "down to 1367 kbit/s: queue 87 ms, held 3, oldest 8 ms, base 6 ms, sent 1478 got 1519 wants 2178"
+ },
+ {
+ "t": 8.53,
+ "e": "down to 1094 kbit/s: queue 122 ms, held 0, oldest 111 ms, base 6 ms, sent 1010 got 1247 wants 1347"
+ },
+ {
+ "t": 8.73,
+ "e": "tier 4"
+ },
+ {
+ "t": 9.13,
+ "e": "down to 875 kbit/s: queue 147 ms, held 2, oldest 63 ms, base 5 ms, sent 1121 got 1101 wants 1529"
+ },
+ {
+ "t": 9.73,
+ "e": "down to 875 kbit/s: queue 50 ms, held 1, oldest 1 ms, base 5 ms, sent 494 got 458 wants 529"
+ },
+ {
+ "t": 10.03,
+ "e": "down to 628 kbit/s: queue 72 ms, held 1, oldest 0 ms, base 5 ms, sent 663 got 698 wants 765"
+ },
+ {
+ "t": 12.13,
+ "e": "down to 801 kbit/s: queue 42 ms, held 0, oldest 0 ms, base 5 ms, sent 918 got 918 wants 918"
+ },
+ {
+ "t": 16.43,
+ "e": "tier 3"
+ },
+ {
+ "t": 18.63,
+ "e": "tier 2"
+ },
+ {
+ "t": 20.73,
+ "e": "tier 1"
+ }
+ ],
+ "source_fps": 54.3,
+ "cpu": {
+ "mac_agent_pct": 6.8,
+ "frame_viewer_pct": 45.0,
+ "frame_tailscaled_pct": 7.2,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 5.6,
+ "frame_total_pct": 26.9
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.3,
+ "panel": "valve.steam.desktopgame.2001614738",
+ "src": "separate:8635",
+ "route": "network"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 242,
+ "frames_drawn": 242,
+ "frames_shown": 242,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -5.7,
+ "p95": 1.4,
+ "p99": 6.9,
+ "n": 242
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 242
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 8.3,
+ "p99": 16.3,
+ "n": 242
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 242
+ },
+ "network": {
+ "p50": 3.3,
+ "p95": 8.8,
+ "p99": 18.8,
+ "n": 242
+ },
+ "decode": {
+ "p50": 2.1,
+ "p95": 4.5,
+ "p99": 6.6,
+ "n": 242
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.4,
+ "p99": 2.0,
+ "n": 242
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 5.1,
+ "p99": 6.8,
+ "n": 242
+ },
+ "content": {
+ "p50": 13.5,
+ "p95": 24.3,
+ "p99": 31.1,
+ "n": 242
+ },
+ "content_shown": {
+ "p50": 15.0,
+ "p95": 25.1,
+ "p99": 32.2,
+ "n": 242
+ }
+ },
+ "fps": 11.5,
+ "fps_shown": 11.5,
+ "late_pct": 37.45,
+ "stall_max": 6722.9,
+ "stalls_over_100ms": 50,
+ "mbps": 0.07,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 242,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 56.8,
+ "p95": 106.3,
+ "p99": 120.5,
+ "n": 62
+ },
+ "input_shown_ms": {
+ "p50": 57.3,
+ "p95": 107.1,
+ "p99": 121.5,
+ "n": 62
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 6.7,
+ "p95": 36.8,
+ "p99": 38.6,
+ "n": 62
+ },
+ "mac": {
+ "p50": 30.7,
+ "p95": 66.7,
+ "p99": 93.7,
+ "n": 62
+ },
+ "back": {
+ "p50": 13.8,
+ "p95": 25.5,
+ "p99": 29.5,
+ "n": 62
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 62
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 31,
+ "mbps": 0.26,
+ "content_p50": 14.1,
+ "content_p95": 17.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 22,
+ "mbps": 0.09,
+ "content_p50": 12.6,
+ "content_p95": 16.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 14,
+ "mbps": 0.06,
+ "content_p50": 13.4,
+ "content_p95": 16.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.2,
+ "content_p95": 16.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 8,
+ "mbps": 0.03,
+ "content_p50": 13.8,
+ "content_p95": 17.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 12,
+ "mbps": 0.08,
+ "content_p50": 13.6,
+ "content_p95": 16.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 10,
+ "mbps": 0.05,
+ "content_p50": 12.9,
+ "content_p95": 17.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 12,
+ "mbps": 0.06,
+ "content_p50": 13.9,
+ "content_p95": 19.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 32,
+ "mbps": 0.21,
+ "content_p50": 13.5,
+ "content_p95": 17.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 51,
+ "mbps": 0.24,
+ "content_p50": 12.9,
+ "content_p95": 26.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 19,
+ "mbps": 0.12,
+ "content_p50": 15.5,
+ "content_p95": 29.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 9,
+ "mbps": 0.05,
+ "content_p50": 13.0,
+ "content_p95": 14.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 8,
+ "mbps": 0.06,
+ "content_p50": 15.1,
+ "content_p95": 18.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 6,
+ "mbps": 0.12,
+ "content_p50": 15.6,
+ "content_p95": 20.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 16.9,
+ "content_p95": 28.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 13.5,
+ "content_p95": 24.3,
+ "input_p50": 56.8,
+ "input_p95": 106.3,
+ "grades": {
+ "input_replies": "acceptable",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "acceptable",
+ "input_p95": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.958,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 11.5,
+ "cpu": {
+ "mac_agent_pct": 2.2,
+ "frame_viewer_pct": 11.7,
+ "frame_tailscaled_pct": 1.9,
+ "frame_sshd_pct": 0.0,
+ "frame_gamescope_pct": 3.5,
+ "frame_total_pct": 17.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.35,
+ "panel": "valve.steam.desktopgame.2001525486",
+ "src": "separate:8785",
+ "route": "network"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-9e4dcdd-final.json b/bench/results/2026-09-28-9e4dcdd-final.json
new file mode 100644
index 0000000..f0f657f
--- /dev/null
+++ b/bench/results/2026-09-28-9e4dcdd-final.json
@@ -0,0 +1,1173 @@
+{
+ "label": "final",
+ "date": "2026-09-28T21:02:06",
+ "commit": "9e4dcdd",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 1216,
+ "frames_drawn": 1216,
+ "frames_shown": 640,
+ "duration_s": 20.3,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1216
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1216
+ },
+ "encode": {
+ "p50": 4.5,
+ "p95": 5.2,
+ "p99": 5.6,
+ "n": 1216
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 1216
+ },
+ "network": {
+ "p50": 4.3,
+ "p95": 8.9,
+ "p99": 11.3,
+ "n": 1216
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.8,
+ "p99": 3.6,
+ "n": 1216
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.8,
+ "p99": 2.0,
+ "n": 1216
+ },
+ "present": {
+ "p50": 4.6,
+ "p95": 14.9,
+ "p99": 19.7,
+ "n": 640
+ },
+ "content": {
+ "p50": 10.5,
+ "p95": 16.7,
+ "p99": 18.9,
+ "n": 1216
+ },
+ "content_shown": {
+ "p50": 15.1,
+ "p95": 26.4,
+ "p99": 32.6,
+ "n": 640
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 31.6,
+ "late_pct": 0.66,
+ "stall_max": 66.0,
+ "stalls_over_100ms": 0,
+ "mbps": 0.47,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1216,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 29.6,
+ "p95": 36.3,
+ "p99": 53.4,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 32.5,
+ "p95": 46.1,
+ "p99": 65.7,
+ "n": 21
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 10.5,
+ "p95": 20.3,
+ "p99": 24.9,
+ "n": 40
+ },
+ "mac": {
+ "p50": 6.5,
+ "p95": 16.4,
+ "p99": 19.2,
+ "n": 40
+ },
+ "back": {
+ "p50": 11.9,
+ "p95": 16.4,
+ "p99": 17.1,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "asked": 40,
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.5,
+ "content_p95": 10.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.2,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.4,
+ "content_p95": 10.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.7,
+ "content_p95": 24.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.4,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 9.5,
+ "content_p95": 11.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.5,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.0,
+ "content_p95": 10.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.5,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.9,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.3,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.3,
+ "content_p95": 14.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 13.9,
+ "content_p95": 16.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 14.2,
+ "content_p95": 16.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 16.6,
+ "content_p95": 18.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.55,
+ "content_p50": 14.7,
+ "content_p95": 17.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.8,
+ "content_p95": 18.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 14.3,
+ "content_p95": 17.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 13.8,
+ "content_p95": 16.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 16,
+ "mbps": 0.13,
+ "content_p50": 15.0,
+ "content_p95": 16.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.5,
+ "content_p95": 16.7,
+ "input_p50": 29.6,
+ "input_p95": 36.3,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.625,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 49.79,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 59.9,
+ "cpu": {
+ "mac_agent_pct": 17.1,
+ "frame_viewer_pct": 38.5,
+ "frame_tailscaled_pct": 4.4,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 12.3,
+ "frame_total_pct": 52.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 101 Hz",
+ "show_s": 1.85,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 1130,
+ "frames_drawn": 1130,
+ "frames_shown": 800,
+ "duration_s": 20.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.4,
+ "p95": 3.0,
+ "p99": 4.6,
+ "n": 1130
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1130
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 10.5,
+ "p99": 18.9,
+ "n": 1130
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 1130
+ },
+ "network": {
+ "p50": 5.5,
+ "p95": 8.6,
+ "p99": 18.9,
+ "n": 1130
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 9.9,
+ "p99": 13.8,
+ "n": 1130
+ },
+ "draw": {
+ "p50": 0.8,
+ "p95": 1.5,
+ "p99": 2.2,
+ "n": 1130
+ },
+ "present": {
+ "p50": 4.8,
+ "p95": 15.8,
+ "p99": 21.3,
+ "n": 800
+ },
+ "content": {
+ "p50": 19.8,
+ "p95": 27.4,
+ "p99": 46.3,
+ "n": 1130
+ },
+ "content_shown": {
+ "p50": 25.3,
+ "p95": 37.8,
+ "p99": 50.1,
+ "n": 800
+ }
+ },
+ "fps": 55.9,
+ "fps_shown": 39.6,
+ "late_pct": 4.78,
+ "stall_max": 222.5,
+ "stalls_over_100ms": 2,
+ "mbps": 9.15,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 1151,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "asked": 0,
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 44,
+ "mbps": 7.88,
+ "content_p50": 19.9,
+ "content_p95": 48.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 46,
+ "mbps": 5.9,
+ "content_p50": 18.1,
+ "content_p95": 104.3,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 58,
+ "mbps": 8.45,
+ "content_p50": 19.6,
+ "content_p95": 31.6,
+ "bitrate": 10055362,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 58,
+ "mbps": 9.54,
+ "content_p50": 19.0,
+ "content_p95": 24.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.33,
+ "content_p50": 19.2,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 55,
+ "mbps": 10.11,
+ "content_p50": 19.3,
+ "content_p95": 26.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 57,
+ "mbps": 11.68,
+ "content_p50": 21.0,
+ "content_p95": 46.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.76,
+ "content_p50": 20.6,
+ "content_p95": 29.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.21,
+ "content_p50": 18.9,
+ "content_p95": 25.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.4,
+ "content_p50": 19.5,
+ "content_p95": 26.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 8.51,
+ "content_p50": 19.4,
+ "content_p95": 24.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 9.46,
+ "content_p50": 19.3,
+ "content_p95": 24.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 57,
+ "mbps": 8.96,
+ "content_p50": 20.2,
+ "content_p95": 25.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 57,
+ "mbps": 8.85,
+ "content_p50": 20.4,
+ "content_p95": 26.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.16,
+ "content_p50": 19.6,
+ "content_p95": 26.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 57,
+ "mbps": 8.81,
+ "content_p50": 19.3,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 58,
+ "mbps": 11.7,
+ "content_p50": 21.1,
+ "content_p95": 37.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 57,
+ "mbps": 9.27,
+ "content_p50": 20.2,
+ "content_p95": 24.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 57,
+ "mbps": 8.77,
+ "content_p50": 20.0,
+ "content_p95": 27.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 57,
+ "mbps": 9.16,
+ "content_p50": 20.2,
+ "content_p95": 26.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 11,
+ "mbps": 1.8,
+ "content_p50": 19.9,
+ "content_p95": 23.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.8,
+ "content_p95": 27.4,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.833,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 44.915,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 1.03,
+ "e": "down to 7430 kbit/s: queue 149 ms, held 5, oldest 79 ms, base 6 ms, sent 5371 got 5599 wants 11155"
+ }
+ ],
+ "source_fps": 57.0,
+ "cpu": {
+ "mac_agent_pct": 9.0,
+ "frame_viewer_pct": 75.3,
+ "frame_tailscaled_pct": 9.9,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 10.6,
+ "frame_total_pct": 43.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 31 Hz",
+ "show_s": 1.31,
+ "panel": "valve.steam.desktopgame.2001906569",
+ "src": "separate:7610"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 96,
+ "frames_drawn": 96,
+ "frames_shown": 96,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.3,
+ "p95": 5.5,
+ "p99": 6.2,
+ "n": 96
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 96
+ },
+ "encode": {
+ "p50": 6.9,
+ "p95": 10.7,
+ "p99": 15.1,
+ "n": 96
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 96
+ },
+ "network": {
+ "p50": 4.1,
+ "p95": 6.3,
+ "p99": 8.5,
+ "n": 96
+ },
+ "decode": {
+ "p50": 2.2,
+ "p95": 5.0,
+ "p99": 6.3,
+ "n": 96
+ },
+ "draw": {
+ "p50": 0.8,
+ "p95": 1.4,
+ "p99": 1.9,
+ "n": 96
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 1.2,
+ "p99": 4.5,
+ "n": 96
+ },
+ "content": {
+ "p50": 15.0,
+ "p95": 21.4,
+ "p99": 30.7,
+ "n": 96
+ },
+ "content_shown": {
+ "p50": 15.8,
+ "p95": 22.2,
+ "p99": 33.0,
+ "n": 96
+ }
+ },
+ "fps": 4.6,
+ "fps_shown": 4.6,
+ "late_pct": 82.47,
+ "stall_max": 1002.0,
+ "stalls_over_100ms": 74,
+ "mbps": 0.01,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 96,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 43.0,
+ "p95": 60.5,
+ "p99": 80.0,
+ "n": 66
+ },
+ "input_shown_ms": {
+ "p50": 43.8,
+ "p95": 61.5,
+ "p99": 80.7,
+ "n": 66
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 4.3,
+ "p95": 6.9,
+ "p99": 32.6,
+ "n": 66
+ },
+ "mac": {
+ "p50": 23.5,
+ "p95": 34.8,
+ "p99": 39.6,
+ "n": 66
+ },
+ "back": {
+ "p50": 15.0,
+ "p95": 20.7,
+ "p99": 21.9,
+ "n": 66
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 66
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 17,
+ "mbps": 0.04,
+ "content_p50": 13.7,
+ "content_p95": 19.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 9,
+ "mbps": 0.04,
+ "content_p50": 15.5,
+ "content_p95": 41.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 7,
+ "mbps": 0.02,
+ "content_p50": 14.3,
+ "content_p95": 18.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.3,
+ "content_p95": 21.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.4,
+ "content_p95": 16.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.7,
+ "content_p95": 20.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 17.8,
+ "content_p95": 20.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.5,
+ "content_p95": 17.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.6,
+ "content_p95": 16.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.5,
+ "content_p95": 29.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.8,
+ "content_p95": 18.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.7,
+ "content_p95": 18.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 13.6,
+ "content_p95": 19.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 17.8,
+ "content_p95": 17.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 19.8,
+ "content_p95": 20.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 15.7,
+ "content_p95": 15.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 13.6,
+ "content_p95": 19.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 15.3,
+ "content_p95": 30.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 17.4,
+ "content_p95": 19.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 2,
+ "mbps": 0.0,
+ "content_p50": 11.6,
+ "content_p95": 15.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.8,
+ "content_p95": 14.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 15.0,
+ "content_p95": 21.4,
+ "input_p50": 43.0,
+ "input_p95": 60.5,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.75,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 4.5,
+ "cpu": {
+ "mac_agent_pct": 1.4,
+ "frame_viewer_pct": 5.3,
+ "frame_tailscaled_pct": 1.5,
+ "frame_sshd_pct": 0.1,
+ "frame_gamescope_pct": 5.9,
+ "frame_total_pct": 28.4
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.3,
+ "panel": "valve.steam.desktopgame.2001175832",
+ "src": "separate:7747"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean.json b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean.json
new file mode 100644
index 0000000..0b1f835
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean.json
@@ -0,0 +1,4211 @@
+{
+ "label": "ab-adapt-clean",
+ "date": "2026-09-28T17:50:22",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": ":35.258683 [Info] - 0 - entering standby",
+ "scenarios": [],
+ "arms": [
+ "off:FRAME_MAC_VIEW_ADAPT=0",
+ "on:"
+ ],
+ "runs": {
+ "off": [
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 944,
+ "frames_drawn": 944,
+ "frames_shown": 648,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 944
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 944
+ },
+ "encode": {
+ "p50": 4.3,
+ "p95": 5.0,
+ "p99": 5.2,
+ "n": 944
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 944
+ },
+ "network": {
+ "p50": 5.6,
+ "p95": 9.6,
+ "p99": 12.9,
+ "n": 944
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 1.7,
+ "p99": 4.2,
+ "n": 944
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.5,
+ "p99": 0.9,
+ "n": 944
+ },
+ "present": {
+ "p50": 4.5,
+ "p95": 15.8,
+ "p99": 18.4,
+ "n": 648
+ },
+ "content": {
+ "p50": 11.5,
+ "p95": 15.8,
+ "p99": 19.3,
+ "n": 944
+ },
+ "content_shown": {
+ "p50": 17.0,
+ "p95": 28.4,
+ "p99": 31.5,
+ "n": 648
+ }
+ },
+ "fps": 60.1,
+ "fps_shown": 41.2,
+ "late_pct": 0.95,
+ "stall_max": 68.8,
+ "stalls_over_100ms": 0,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1034,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 28.8,
+ "p95": 38.1,
+ "p99": 45.1,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 30.2,
+ "p95": 45.6,
+ "p99": 50.1,
+ "n": 19
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 7.1,
+ "p95": 21.5,
+ "p99": 23.6,
+ "n": 30
+ },
+ "mac": {
+ "p50": 4.2,
+ "p95": 15.1,
+ "p99": 15.8,
+ "n": 30
+ },
+ "back": {
+ "p50": 12.2,
+ "p95": 16.3,
+ "p99": 16.9,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 10.9,
+ "content_p95": 16.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.2,
+ "content_p95": 15.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 10.2,
+ "content_p95": 13.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 10.3,
+ "content_p95": 15.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 10.4,
+ "content_p95": 14.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.7,
+ "content_p95": 14.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 10.7,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.1,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.4,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.6,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 13.8,
+ "content_p95": 16.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 14.3,
+ "content_p95": 18.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 13.7,
+ "content_p95": 15.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.1,
+ "content_p95": 17.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.1,
+ "content_p95": 18.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 44,
+ "mbps": 0.32,
+ "content_p50": 12.1,
+ "content_p95": 13.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 11.5,
+ "content_p95": 15.8,
+ "input_p50": 28.8,
+ "input_p95": 38.1,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.833,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.9,
+ "cpu": {
+ "mac_agent_pct": 16.1,
+ "frame_viewer_pct": 66.4,
+ "frame_tailscaled_pct": 5.8,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 28.1,
+ "frame_total_pct": 31.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 1.52,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 742,
+ "frames_drawn": 742,
+ "frames_shown": 603,
+ "duration_s": 15.8,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.8,
+ "p95": 12.1,
+ "p99": 22.3,
+ "n": 742
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 742
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.0,
+ "p99": 16.4,
+ "n": 742
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 742
+ },
+ "network": {
+ "p50": 8.1,
+ "p95": 24.9,
+ "p99": 61.3,
+ "n": 742
+ },
+ "decode": {
+ "p50": 6.0,
+ "p95": 12.4,
+ "p99": 26.2,
+ "n": 742
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.2,
+ "p99": 1.9,
+ "n": 742
+ },
+ "present": {
+ "p50": 2.1,
+ "p95": 19.6,
+ "p99": 24.7,
+ "n": 603
+ },
+ "content": {
+ "p50": 19.5,
+ "p95": 44.4,
+ "p99": 118.6,
+ "n": 742
+ },
+ "content_shown": {
+ "p50": 25.0,
+ "p95": 51.3,
+ "p99": 125.2,
+ "n": 603
+ }
+ },
+ "fps": 47.1,
+ "fps_shown": 38.3,
+ "late_pct": 20.92,
+ "stall_max": 128.0,
+ "stalls_over_100ms": 2,
+ "mbps": 9.15,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 815,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 40,
+ "mbps": 9.15,
+ "content_p50": 28.7,
+ "content_p95": 139.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 36,
+ "mbps": 7.35,
+ "content_p50": 23.6,
+ "content_p95": 37.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 44,
+ "mbps": 9.92,
+ "content_p50": 21.9,
+ "content_p95": 39.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 53,
+ "mbps": 9.98,
+ "content_p50": 20.4,
+ "content_p95": 37.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 45,
+ "mbps": 9.58,
+ "content_p50": 20.2,
+ "content_p95": 41.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 53,
+ "mbps": 10.27,
+ "content_p50": 19.2,
+ "content_p95": 37.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 48,
+ "mbps": 11.11,
+ "content_p50": 29.9,
+ "content_p95": 107.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 44,
+ "mbps": 8.39,
+ "content_p50": 24.1,
+ "content_p95": 34.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 49,
+ "mbps": 8.68,
+ "content_p50": 14.5,
+ "content_p95": 39.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 43,
+ "mbps": 8.08,
+ "content_p50": 16.6,
+ "content_p95": 36.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 53,
+ "mbps": 8.85,
+ "content_p50": 15.1,
+ "content_p95": 25.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 50,
+ "mbps": 8.82,
+ "content_p50": 20.1,
+ "content_p95": 35.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 54,
+ "mbps": 8.42,
+ "content_p50": 18.9,
+ "content_p95": 26.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 52,
+ "mbps": 9.27,
+ "content_p50": 12.8,
+ "content_p95": 28.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 47,
+ "mbps": 9.89,
+ "content_p50": 21.5,
+ "content_p95": 42.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 31,
+ "mbps": 6.44,
+ "content_p50": 21.6,
+ "content_p95": 39.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.5,
+ "content_p95": 44.4,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 7.541,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 51.6,
+ "cpu": {
+ "mac_agent_pct": 7.1,
+ "frame_viewer_pct": 80.9,
+ "frame_tailscaled_pct": 7.9,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 25.3,
+ "frame_total_pct": 31.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 1.68,
+ "panel": "valve.steam.desktopgame.2001300498",
+ "src": "separate:13051"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 944,
+ "frames_drawn": 944,
+ "frames_shown": 608,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 944
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 944
+ },
+ "encode": {
+ "p50": 4.0,
+ "p95": 4.7,
+ "p99": 5.2,
+ "n": 944
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 944
+ },
+ "network": {
+ "p50": 4.8,
+ "p95": 40.4,
+ "p99": 74.2,
+ "n": 944
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 5.4,
+ "p99": 10.8,
+ "n": 944
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.5,
+ "p99": 0.9,
+ "n": 944
+ },
+ "present": {
+ "p50": 4.9,
+ "p95": 17.7,
+ "p99": 22.2,
+ "n": 608
+ },
+ "content": {
+ "p50": 10.5,
+ "p95": 52.3,
+ "p99": 82.1,
+ "n": 944
+ },
+ "content_shown": {
+ "p50": 16.0,
+ "p95": 33.4,
+ "p99": 82.8,
+ "n": 608
+ }
+ },
+ "fps": 60.1,
+ "fps_shown": 38.7,
+ "late_pct": 3.61,
+ "stall_max": 120.1,
+ "stalls_over_100ms": 2,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1034,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 26.7,
+ "p95": 89.3,
+ "p99": 106.5,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 29.4,
+ "p95": 44.4,
+ "p99": 106.9,
+ "n": 18
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 9.2,
+ "p95": 38.3,
+ "p99": 70.5,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.2,
+ "p95": 14.1,
+ "p99": 18.3,
+ "n": 30
+ },
+ "back": {
+ "p50": 10.4,
+ "p95": 16.2,
+ "p99": 86.2,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 10.3,
+ "content_p95": 14.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 10.4,
+ "content_p95": 13.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.8,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 9.6,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 9.6,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 61,
+ "mbps": 0.57,
+ "content_p50": 9.9,
+ "content_p95": 34.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 9.7,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 10.5,
+ "content_p95": 40.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 11.8,
+ "content_p95": 82.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.4,
+ "content_p95": 68.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 61,
+ "mbps": 0.46,
+ "content_p50": 12.4,
+ "content_p95": 71.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 59,
+ "mbps": 0.44,
+ "content_p50": 11.8,
+ "content_p95": 70.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 11.8,
+ "content_p95": 70.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 43,
+ "mbps": 0.32,
+ "content_p50": 12.1,
+ "content_p95": 69.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.5,
+ "content_p95": 52.3,
+ "input_p50": 26.7,
+ "input_p95": 89.3,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "local",
+ "input_p95": "acceptable",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.458,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 5,
+ "scale": 1,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.9,
+ "cpu": {
+ "mac_agent_pct": 14.0,
+ "frame_viewer_pct": 66.0,
+ "frame_tailscaled_pct": 5.1,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 28.5,
+ "frame_total_pct": 32.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 135 Hz",
+ "show_s": 1.73,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 823,
+ "frames_drawn": 823,
+ "frames_shown": 523,
+ "duration_s": 15.6,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.4,
+ "p95": 5.1,
+ "p99": 12.6,
+ "n": 823
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 823
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 7.8,
+ "p99": 10.5,
+ "n": 823
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 823
+ },
+ "network": {
+ "p50": 16.2,
+ "p95": 90.9,
+ "p99": 145.2,
+ "n": 823
+ },
+ "decode": {
+ "p50": 8.7,
+ "p95": 25.2,
+ "p99": 35.5,
+ "n": 823
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 0.9,
+ "p99": 1.7,
+ "n": 823
+ },
+ "present": {
+ "p50": 1.9,
+ "p95": 20.9,
+ "p99": 26.5,
+ "n": 523
+ },
+ "content": {
+ "p50": 33.3,
+ "p95": 108.6,
+ "p99": 167.3,
+ "n": 823
+ },
+ "content_shown": {
+ "p50": 35.0,
+ "p95": 106.8,
+ "p99": 141.4,
+ "n": 523
+ }
+ },
+ "fps": 52.6,
+ "fps_shown": 33.4,
+ "late_pct": 14.84,
+ "stall_max": 232.0,
+ "stalls_over_100ms": 32,
+ "mbps": 9.35,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 895,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 52,
+ "mbps": 8.9,
+ "content_p50": 30.2,
+ "content_p95": 85.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 53,
+ "mbps": 9.82,
+ "content_p50": 32.6,
+ "content_p95": 106.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 53,
+ "mbps": 9.45,
+ "content_p50": 31.9,
+ "content_p95": 96.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 56,
+ "mbps": 9.42,
+ "content_p50": 37.6,
+ "content_p95": 99.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.26,
+ "content_p50": 38.2,
+ "content_p95": 97.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 11.43,
+ "content_p50": 56.3,
+ "content_p95": 144.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 50,
+ "mbps": 8.55,
+ "content_p50": 27.0,
+ "content_p95": 104.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 52,
+ "mbps": 9.63,
+ "content_p50": 37.1,
+ "content_p95": 99.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 46,
+ "mbps": 8.79,
+ "content_p50": 31.2,
+ "content_p95": 115.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 55,
+ "mbps": 9.43,
+ "content_p50": 29.4,
+ "content_p95": 97.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 50,
+ "mbps": 9.29,
+ "content_p50": 29.8,
+ "content_p95": 93.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 55,
+ "mbps": 9.78,
+ "content_p50": 27.0,
+ "content_p95": 98.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 52,
+ "mbps": 9.19,
+ "content_p50": 53.0,
+ "content_p95": 116.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 55,
+ "mbps": 9.33,
+ "content_p50": 45.5,
+ "content_p95": 102.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 45,
+ "mbps": 7.78,
+ "content_p50": 28.6,
+ "content_p95": 102.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 35,
+ "mbps": 6.17,
+ "content_p50": 62.5,
+ "content_p95": 211.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 33.3,
+ "content_p95": 108.6,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "acceptable",
+ "content_p95": "bad",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 7.791,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 57.4,
+ "cpu": {
+ "mac_agent_pct": 7.6,
+ "frame_viewer_pct": 83.9,
+ "frame_tailscaled_pct": 7.8,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 26.7,
+ "frame_total_pct": 32.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 135 Hz",
+ "show_s": 1.66,
+ "panel": "valve.steam.desktopgame.2001667691",
+ "src": "separate:13436"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 943,
+ "frames_drawn": 943,
+ "frames_shown": 631,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 943
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 943
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 943
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 943
+ },
+ "network": {
+ "p50": 6.4,
+ "p95": 23.1,
+ "p99": 64.7,
+ "n": 943
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.8,
+ "p99": 6.3,
+ "n": 943
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.5,
+ "p99": 0.9,
+ "n": 943
+ },
+ "present": {
+ "p50": 5.2,
+ "p95": 15.7,
+ "p99": 21.4,
+ "n": 631
+ },
+ "content": {
+ "p50": 12.2,
+ "p95": 29.7,
+ "p99": 72.2,
+ "n": 943
+ },
+ "content_shown": {
+ "p50": 18.4,
+ "p95": 32.7,
+ "p99": 75.5,
+ "n": 631
+ }
+ },
+ "fps": 60.1,
+ "fps_shown": 40.2,
+ "late_pct": 5.73,
+ "stall_max": 123.4,
+ "stalls_over_100ms": 1,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1034,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 31.4,
+ "p95": 78.1,
+ "p99": 187.4,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 38.8,
+ "p95": 56.9,
+ "p99": 62.9,
+ "n": 20
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 11.4,
+ "p95": 40.8,
+ "p99": 170.5,
+ "n": 30
+ },
+ "mac": {
+ "p50": 7.0,
+ "p95": 15.0,
+ "p99": 16.1,
+ "n": 30
+ },
+ "back": {
+ "p50": 11.6,
+ "p95": 18.8,
+ "p99": 29.4,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 9.3,
+ "content_p95": 53.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.3,
+ "content_p95": 51.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 22.3,
+ "content_p95": 86.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.51,
+ "content_p50": 16.9,
+ "content_p95": 65.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 11.6,
+ "content_p95": 37.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 12.3,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 12.6,
+ "content_p95": 16.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.9,
+ "content_p95": 15.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 11.8,
+ "content_p95": 17.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.8,
+ "content_p95": 19.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.1,
+ "content_p95": 15.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.6,
+ "content_p95": 18.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.5,
+ "content_p95": 17.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 13.0,
+ "content_p95": 18.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 14.0,
+ "content_p95": 22.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 43,
+ "mbps": 0.3,
+ "content_p50": 13.6,
+ "content_p95": 22.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 12.2,
+ "content_p95": 29.7,
+ "input_p50": 31.4,
+ "input_p95": 78.1,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "acceptable",
+ "fps": "local",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.125,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.9,
+ "cpu": {
+ "mac_agent_pct": 15.5,
+ "frame_viewer_pct": 67.2,
+ "frame_tailscaled_pct": 22.5,
+ "frame_sshd_pct": 2.6,
+ "frame_gamescope_pct": 28.6,
+ "frame_total_pct": 38.9
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 132 Hz",
+ "show_s": 1.52,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 624,
+ "frames_drawn": 622,
+ "frames_shown": 522,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -2.0,
+ "p95": 13.8,
+ "p99": 34.2,
+ "n": 624
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.2,
+ "n": 624
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 8.1,
+ "p99": 13.7,
+ "n": 624
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 624
+ },
+ "network": {
+ "p50": 7.5,
+ "p95": 29.1,
+ "p99": 83.6,
+ "n": 624
+ },
+ "decode": {
+ "p50": 7.6,
+ "p95": 83.1,
+ "p99": 242.3,
+ "n": 622
+ },
+ "draw": {
+ "p50": 0.8,
+ "p95": 2.2,
+ "p99": 5.1,
+ "n": 622
+ },
+ "present": {
+ "p50": 1.5,
+ "p95": 19.9,
+ "p99": 25.1,
+ "n": 522
+ },
+ "content": {
+ "p50": 25.3,
+ "p95": 110.6,
+ "p99": 326.3,
+ "n": 622
+ },
+ "content_shown": {
+ "p50": 30.3,
+ "p95": 91.4,
+ "p99": 143.8,
+ "n": 522
+ }
+ },
+ "fps": 39.5,
+ "fps_shown": 33.2,
+ "late_pct": 33.66,
+ "stall_max": 362.0,
+ "stalls_over_100ms": 5,
+ "mbps": 8.59,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 678,
+ "viewer_never_drawn": 2,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 42,
+ "mbps": 9.07,
+ "content_p50": 24.3,
+ "content_p95": 39.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 42,
+ "mbps": 9.11,
+ "content_p50": 27.3,
+ "content_p95": 76.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 43,
+ "mbps": 8.81,
+ "content_p50": 25.6,
+ "content_p95": 96.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 44,
+ "mbps": 8.11,
+ "content_p50": 46.5,
+ "content_p95": 89.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 37,
+ "mbps": 8.52,
+ "content_p50": 19.4,
+ "content_p95": 49.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 43,
+ "mbps": 9.51,
+ "content_p50": 28.1,
+ "content_p95": 92.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 35,
+ "mbps": 10.84,
+ "content_p50": 24.8,
+ "content_p95": 61.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 25,
+ "mbps": 6.13,
+ "content_p50": 21.8,
+ "content_p95": 62.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 52,
+ "mbps": 9.53,
+ "content_p50": 17.0,
+ "content_p95": 29.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 37,
+ "mbps": 8.18,
+ "content_p50": 35.9,
+ "content_p95": 78.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 21,
+ "mbps": 5.48,
+ "content_p50": 44.5,
+ "content_p95": 350.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 34,
+ "mbps": 7.4,
+ "content_p50": 27.0,
+ "content_p95": 54.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 54,
+ "mbps": 8.87,
+ "content_p50": 15.4,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 43,
+ "mbps": 10.59,
+ "content_p50": 131.1,
+ "content_p95": 322.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 43,
+ "mbps": 9.22,
+ "content_p50": 23.9,
+ "content_p95": 42.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 27,
+ "mbps": 5.81,
+ "content_p50": 27.6,
+ "content_p95": 53.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 25.3,
+ "content_p95": 110.6,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "acceptable",
+ "content_p95": "bad",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 7.583,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 43.2,
+ "cpu": {
+ "mac_agent_pct": 5.8,
+ "frame_viewer_pct": 86.7,
+ "frame_tailscaled_pct": 9.9,
+ "frame_sshd_pct": 1.0,
+ "frame_gamescope_pct": 25.0,
+ "frame_total_pct": 77.7
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 1.54,
+ "panel": "valve.steam.desktopgame.2001796574",
+ "src": "separate:13568"
+ }
+ ]
+ ],
+ "on": [
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 843,
+ "frames_drawn": 843,
+ "frames_shown": 580,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 843
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 12.6,
+ "n": 843
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 843
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 843
+ },
+ "network": {
+ "p50": 4.8,
+ "p95": 66.0,
+ "p99": 82.6,
+ "n": 843
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 4.1,
+ "p99": 6.3,
+ "n": 843
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.6,
+ "p99": 1.0,
+ "n": 843
+ },
+ "present": {
+ "p50": 4.0,
+ "p95": 17.6,
+ "p99": 23.5,
+ "n": 580
+ },
+ "content": {
+ "p50": 10.9,
+ "p95": 75.0,
+ "p99": 91.6,
+ "n": 843
+ },
+ "content_shown": {
+ "p50": 16.5,
+ "p95": 81.2,
+ "p99": 93.9,
+ "n": 580
+ }
+ },
+ "fps": 53.6,
+ "fps_shown": 36.9,
+ "late_pct": 6.06,
+ "stall_max": 127.2,
+ "stalls_over_100ms": 2,
+ "mbps": 0.43,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1037,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 27.2,
+ "p95": 95.5,
+ "p99": 107.8,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 32.1,
+ "p95": 95.9,
+ "p99": 108.5,
+ "n": 19
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 8.0,
+ "p95": 44.4,
+ "p99": 44.8,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.8,
+ "p95": 13.7,
+ "p99": 17.9,
+ "n": 30
+ },
+ "back": {
+ "p50": 11.2,
+ "p95": 84.3,
+ "p99": 85.8,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.1,
+ "content_p95": 13.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.1,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.6,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.0,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 9.9,
+ "content_p95": 35.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 54,
+ "mbps": 0.43,
+ "content_p50": 9.8,
+ "content_p95": 51.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 52,
+ "mbps": 0.52,
+ "content_p50": 10.6,
+ "content_p95": 68.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 50,
+ "mbps": 0.39,
+ "content_p50": 10.6,
+ "content_p95": 73.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 47,
+ "mbps": 0.39,
+ "content_p50": 11.2,
+ "content_p95": 80.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 52,
+ "mbps": 0.41,
+ "content_p50": 11.2,
+ "content_p95": 79.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 52,
+ "mbps": 0.4,
+ "content_p50": 10.5,
+ "content_p95": 75.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 51,
+ "mbps": 0.4,
+ "content_p50": 13.4,
+ "content_p95": 75.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 50,
+ "mbps": 0.4,
+ "content_p50": 14.7,
+ "content_p95": 77.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 45,
+ "mbps": 0.37,
+ "content_p50": 15.3,
+ "content_p95": 102.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 52,
+ "mbps": 0.4,
+ "content_p50": 15.6,
+ "content_p95": 80.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 39,
+ "mbps": 0.3,
+ "content_p50": 14.7,
+ "content_p95": 80.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.9,
+ "content_p95": 75.0,
+ "input_p50": 27.2,
+ "input_p95": 95.5,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "local",
+ "input_p95": "acceptable",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.875,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 66.1,
+ "cpu": {
+ "mac_agent_pct": 14.9,
+ "frame_viewer_pct": 58.7,
+ "frame_tailscaled_pct": 4.9,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 25.8,
+ "frame_total_pct": 28.7
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.32,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 756,
+ "frames_drawn": 756,
+ "frames_shown": 606,
+ "duration_s": 15.6,
+ "stages_ms": {
+ "capture": {
+ "p50": -7.9,
+ "p95": 7.1,
+ "p99": 14.9,
+ "n": 756
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 7.8,
+ "n": 756
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 9.0,
+ "p99": 12.8,
+ "n": 756
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 756
+ },
+ "network": {
+ "p50": 7.1,
+ "p95": 27.0,
+ "p99": 45.8,
+ "n": 756
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 11.9,
+ "p99": 20.9,
+ "n": 756
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 0.8,
+ "p99": 1.6,
+ "n": 756
+ },
+ "present": {
+ "p50": 3.3,
+ "p95": 20.5,
+ "p99": 25.9,
+ "n": 606
+ },
+ "content": {
+ "p50": 16.6,
+ "p95": 45.7,
+ "p99": 64.8,
+ "n": 756
+ },
+ "content_shown": {
+ "p50": 22.8,
+ "p95": 50.9,
+ "p99": 68.4,
+ "n": 606
+ }
+ },
+ "fps": 48.4,
+ "fps_shown": 38.8,
+ "late_pct": 21.06,
+ "stall_max": 88.2,
+ "stalls_over_100ms": 0,
+ "mbps": 9.16,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 841,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 50,
+ "mbps": 8.74,
+ "content_p50": 14.0,
+ "content_p95": 30.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 45,
+ "mbps": 8.64,
+ "content_p50": 22.9,
+ "content_p95": 49.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 47,
+ "mbps": 8.91,
+ "content_p50": 20.8,
+ "content_p95": 45.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 46,
+ "mbps": 9.41,
+ "content_p50": 18.6,
+ "content_p95": 53.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 49,
+ "mbps": 8.61,
+ "content_p50": 13.0,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 53,
+ "mbps": 11.17,
+ "content_p50": 11.7,
+ "content_p95": 51.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 51,
+ "mbps": 9.1,
+ "content_p50": 12.4,
+ "content_p95": 27.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 48,
+ "mbps": 9.45,
+ "content_p50": 19.5,
+ "content_p95": 56.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 55,
+ "mbps": 9.21,
+ "content_p50": 11.9,
+ "content_p95": 30.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 50,
+ "mbps": 8.55,
+ "content_p50": 23.1,
+ "content_p95": 63.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 49,
+ "mbps": 8.94,
+ "content_p50": 19.5,
+ "content_p95": 36.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 54,
+ "mbps": 9.51,
+ "content_p50": 16.0,
+ "content_p95": 31.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 41,
+ "mbps": 8.36,
+ "content_p50": 21.5,
+ "content_p95": 45.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 37,
+ "mbps": 8.55,
+ "content_p50": 22.4,
+ "content_p95": 51.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 48,
+ "mbps": 10.2,
+ "content_p50": 19.6,
+ "content_p95": 34.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 33,
+ "mbps": 5.73,
+ "content_p50": 10.7,
+ "content_p95": 54.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 16.6,
+ "content_p95": 45.7,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 7.041,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 0.19,
+ "e": "tier 0"
+ }
+ ],
+ "source_fps": 53.9,
+ "cpu": {
+ "mac_agent_pct": 7.7,
+ "frame_viewer_pct": 83.2,
+ "frame_tailscaled_pct": 9.0,
+ "frame_sshd_pct": 1.0,
+ "frame_gamescope_pct": 25.4,
+ "frame_total_pct": 32.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 1.54,
+ "panel": "valve.steam.desktopgame.2001527998",
+ "src": "separate:13175"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 932,
+ "frames_drawn": 932,
+ "frames_shown": 637,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 932
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.2,
+ "n": 932
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.9,
+ "p99": 5.2,
+ "n": 932
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 932
+ },
+ "network": {
+ "p50": 4.6,
+ "p95": 9.1,
+ "p99": 12.6,
+ "n": 932
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 1.6,
+ "p99": 4.1,
+ "n": 932
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.5,
+ "p99": 0.9,
+ "n": 932
+ },
+ "present": {
+ "p50": 5.0,
+ "p95": 15.8,
+ "p99": 19.3,
+ "n": 637
+ },
+ "content": {
+ "p50": 10.5,
+ "p95": 15.4,
+ "p99": 19.0,
+ "n": 932
+ },
+ "content_shown": {
+ "p50": 16.1,
+ "p95": 28.2,
+ "p99": 33.7,
+ "n": 637
+ }
+ },
+ "fps": 59.4,
+ "fps_shown": 40.6,
+ "late_pct": 0.75,
+ "stall_max": 126.0,
+ "stalls_over_100ms": 1,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1031,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 24.6,
+ "p95": 39.8,
+ "p99": 45.9,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 34.5,
+ "p95": 41.8,
+ "p99": 58.5,
+ "n": 24
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.1,
+ "p95": 19.2,
+ "p99": 21.0,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.2,
+ "p95": 14.8,
+ "p99": 16.1,
+ "n": 30
+ },
+ "back": {
+ "p50": 10.7,
+ "p95": 14.8,
+ "p99": 14.9,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.2,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 9.5,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 53,
+ "mbps": 0.45,
+ "content_p50": 10.0,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.8,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 0.47,
+ "content_p50": 10.7,
+ "content_p95": 14.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 10.3,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.8,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.9,
+ "content_p95": 12.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 10.1,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 10.2,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 10.6,
+ "content_p95": 14.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 14.3,
+ "content_p95": 17.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 14.1,
+ "content_p95": 16.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 13.8,
+ "content_p95": 15.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 42,
+ "mbps": 0.32,
+ "content_p50": 13.8,
+ "content_p95": 18.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.5,
+ "content_p95": 15.4,
+ "input_p50": 24.6,
+ "input_p95": 39.8,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.459,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.7,
+ "cpu": {
+ "mac_agent_pct": 15.9,
+ "frame_viewer_pct": 66.0,
+ "frame_tailscaled_pct": 5.4,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 28.8,
+ "frame_total_pct": 32.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 1.49,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 604,
+ "frames_drawn": 604,
+ "frames_shown": 496,
+ "duration_s": 15.8,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.6,
+ "p95": 11.2,
+ "p99": 19.8,
+ "n": 604
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 15.4,
+ "p99": 23.5,
+ "n": 604
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 9.3,
+ "p99": 19.2,
+ "n": 604
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.7,
+ "n": 604
+ },
+ "network": {
+ "p50": 7.9,
+ "p95": 76.6,
+ "p99": 94.2,
+ "n": 604
+ },
+ "decode": {
+ "p50": 6.3,
+ "p95": 19.1,
+ "p99": 25.5,
+ "n": 604
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.0,
+ "p99": 1.6,
+ "n": 604
+ },
+ "present": {
+ "p50": 0.9,
+ "p95": 19.0,
+ "p99": 22.0,
+ "n": 496
+ },
+ "content": {
+ "p50": 23.4,
+ "p95": 100.3,
+ "p99": 145.3,
+ "n": 604
+ },
+ "content_shown": {
+ "p50": 28.9,
+ "p95": 104.7,
+ "p99": 158.2,
+ "n": 496
+ }
+ },
+ "fps": 38.2,
+ "fps_shown": 31.3,
+ "late_pct": 25.87,
+ "stall_max": 180.7,
+ "stalls_over_100ms": 22,
+ "mbps": 6.88,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 843,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 28,
+ "mbps": 6.12,
+ "content_p50": 30.5,
+ "content_p95": 161.7,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 24,
+ "mbps": 4.2,
+ "content_p50": 36.9,
+ "content_p95": 120.7,
+ "bitrate": 8223440,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 41,
+ "mbps": 8.28,
+ "content_p50": 20.3,
+ "content_p95": 62.3,
+ "bitrate": 11110898,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 42,
+ "mbps": 9.01,
+ "content_p50": 22.2,
+ "content_p95": 91.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 47,
+ "mbps": 8.61,
+ "content_p50": 15.8,
+ "content_p95": 81.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 43,
+ "mbps": 8.02,
+ "content_p50": 17.4,
+ "content_p95": 56.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 40,
+ "mbps": 7.97,
+ "content_p50": 29.0,
+ "content_p95": 82.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 36,
+ "mbps": 7.83,
+ "content_p50": 33.3,
+ "content_p95": 100.5,
+ "bitrate": 6992996,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 40,
+ "mbps": 5.6,
+ "content_p50": 21.7,
+ "content_p95": 97.9,
+ "bitrate": 7742295,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 43,
+ "mbps": 7.07,
+ "content_p50": 19.5,
+ "content_p95": 90.7,
+ "bitrate": 10470493,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 44,
+ "mbps": 7.09,
+ "content_p50": 19.9,
+ "content_p95": 97.5,
+ "bitrate": 7246454,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 35,
+ "mbps": 5.19,
+ "content_p50": 22.4,
+ "content_p95": 166.6,
+ "bitrate": 5307220,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 39,
+ "mbps": 4.5,
+ "content_p50": 22.9,
+ "content_p95": 84.3,
+ "bitrate": 5887942,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 36,
+ "mbps": 5.81,
+ "content_p50": 28.4,
+ "content_p95": 94.8,
+ "bitrate": 8002349,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 36,
+ "mbps": 7.68,
+ "content_p50": 27.4,
+ "content_p95": 107.5,
+ "bitrate": 11948287,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 30,
+ "mbps": 5.87,
+ "content_p50": 33.0,
+ "content_p95": 104.9,
+ "bitrate": 14562426,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 23.4,
+ "content_p95": 100.3,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "bad",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.959,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "target": 7430400,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 0.87,
+ "e": "down to 7430 kbit/s: queue 191 ms, held 3, oldest 218 ms, base 7 ms, sent 4207 got 3329"
+ },
+ {
+ "t": 7.54,
+ "e": "down to 8741 kbit/s: queue 52 ms, held 3, oldest 35 ms, base 7 ms, sent 10505 got 9712"
+ },
+ {
+ "t": 7.84,
+ "e": "down to 6992 kbit/s: queue 17 ms, held 3, oldest 3 ms, base 7 ms, sent 8895 got 9452"
+ },
+ {
+ "t": 10.54,
+ "e": "down to 7246 kbit/s: queue 19 ms, held 3, oldest 80 ms, base 7 ms, sent 8009 got 8051"
+ },
+ {
+ "t": 11.74,
+ "e": "down to 5307 kbit/s: queue 26 ms, held 3, oldest 9 ms, base 6 ms, sent 5808 got 5896"
+ },
+ {
+ "t": 12.34,
+ "e": "tier 1"
+ },
+ {
+ "t": 16.14,
+ "e": "tier 0"
+ },
+ {
+ "t": 17.34,
+ "e": "down to 7430 kbit/s: queue 94 ms, held 2, oldest 94 ms, base 6 ms, sent 7608 got 6892"
+ }
+ ],
+ "source_fps": 53.4,
+ "cpu": {
+ "mac_agent_pct": 6.5,
+ "frame_viewer_pct": 69.3,
+ "frame_tailscaled_pct": 6.4,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 23.2,
+ "frame_total_pct": 27.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.35,
+ "panel": "valve.steam.desktopgame.2001652781",
+ "src": "separate:13306"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 482,
+ "frames_drawn": 482,
+ "frames_shown": 479,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 482
+ },
+ "queue": {
+ "p50": 8.8,
+ "p95": 16.5,
+ "p99": 18.6,
+ "n": 482
+ },
+ "encode": {
+ "p50": 3.7,
+ "p95": 4.5,
+ "p99": 5.0,
+ "n": 482
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 482
+ },
+ "network": {
+ "p50": 6.0,
+ "p95": 36.8,
+ "p99": 84.9,
+ "n": 482
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.7,
+ "p99": 4.8,
+ "n": 482
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 0.9,
+ "n": 482
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 10.8,
+ "p99": 13.0,
+ "n": 479
+ },
+ "content": {
+ "p50": 22.9,
+ "p95": 54.1,
+ "p99": 104.8,
+ "n": 482
+ },
+ "content_shown": {
+ "p50": 24.9,
+ "p95": 54.9,
+ "p99": 77.6,
+ "n": 479
+ }
+ },
+ "fps": 30.6,
+ "fps_shown": 30.4,
+ "late_pct": 68.81,
+ "stall_max": 218.0,
+ "stalls_over_100ms": 10,
+ "mbps": 0.19,
+ "keyframes": 2,
+ "size": "960x540",
+ "captured": 1037,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 53.9,
+ "p95": 117.8,
+ "p99": 222.5,
+ "n": 29
+ },
+ "input_shown_ms": {
+ "p50": 54.4,
+ "p95": 123.9,
+ "p99": 223.5,
+ "n": 29
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 17.7,
+ "p95": 77.5,
+ "p99": 127.7,
+ "n": 29
+ },
+ "mac": {
+ "p50": 8.2,
+ "p95": 30.4,
+ "p99": 63.3,
+ "n": 29
+ },
+ "back": {
+ "p50": 19.5,
+ "p95": 38.3,
+ "p99": 38.6,
+ "n": 29
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 29
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 55,
+ "mbps": 0.46,
+ "content_p50": 9.9,
+ "content_p95": 20.9,
+ "bitrate": 1545640,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 33,
+ "mbps": 0.28,
+ "content_p50": 27.7,
+ "content_p95": 40.2,
+ "bitrate": 772820,
+ "tier": 3,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 32,
+ "mbps": 0.28,
+ "content_p50": 22.9,
+ "content_p95": 29.0,
+ "bitrate": 772820,
+ "tier": 3,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 25,
+ "mbps": 0.23,
+ "content_p50": 26.5,
+ "content_p95": 106.6,
+ "bitrate": 386410,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 21,
+ "mbps": 0.14,
+ "content_p50": 37.0,
+ "content_p95": 54.1,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 24,
+ "mbps": 0.1,
+ "content_p50": 32.6,
+ "content_p95": 96.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 29.6,
+ "content_p95": 46.7,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 30,
+ "mbps": 0.12,
+ "content_p50": 25.7,
+ "content_p95": 80.1,
+ "bitrate": 380000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 32,
+ "mbps": 0.2,
+ "content_p50": 29.0,
+ "content_p95": 56.4,
+ "bitrate": 671280,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 26,
+ "mbps": 0.13,
+ "content_p50": 31.8,
+ "content_p95": 47.8,
+ "bitrate": 458624,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 30,
+ "mbps": 0.14,
+ "content_p50": 36.1,
+ "content_p95": 64.2,
+ "bitrate": 458624,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 29,
+ "mbps": 0.14,
+ "content_p50": 28.1,
+ "content_p95": 56.7,
+ "bitrate": 458624,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 26,
+ "mbps": 0.11,
+ "content_p50": 18.7,
+ "content_p95": 27.8,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 32,
+ "mbps": 0.24,
+ "content_p50": 20.1,
+ "content_p95": 24.8,
+ "bitrate": 468000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 32,
+ "mbps": 0.18,
+ "content_p50": 19.4,
+ "content_p95": 32.8,
+ "bitrate": 468000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 24,
+ "mbps": 0.12,
+ "content_p50": 21.9,
+ "content_p95": 35.2,
+ "bitrate": 671280,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 22.9,
+ "content_p95": 54.1,
+ "input_p50": 53.9,
+ "input_p95": 117.8,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "acceptable",
+ "input_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.417,
+ "ceiling": 5529600,
+ "fps": 30,
+ "inFlight": 2,
+ "scale": 0.5,
+ "target": 1386355,
+ "tier": 4
+ },
+ "events": [
+ {
+ "t": 0.87,
+ "e": "down to 1545 kbit/s: queue 18 ms, held 3, oldest 11 ms, base 5 ms, sent 424 got 420"
+ },
+ {
+ "t": 1.27,
+ "e": "down to 772 kbit/s: queue 40 ms, held 3, oldest 55 ms, base 5 ms, sent 374 got 368"
+ },
+ {
+ "t": 1.48,
+ "e": "tier 3"
+ },
+ {
+ "t": 3.37,
+ "e": "down to 386 kbit/s: queue 119 ms, held 5, oldest 20 ms, base 5 ms, sent 210 got 208"
+ },
+ {
+ "t": 3.88,
+ "e": "tier 4"
+ },
+ {
+ "t": 4.07,
+ "e": "down to 300 kbit/s: queue 55 ms, held 0, oldest 0 ms, base 5 ms, sent 254 got 254"
+ },
+ {
+ "t": 4.97,
+ "e": "down to 300 kbit/s: queue 113 ms, held 1, oldest 91 ms, base 5 ms, sent 154 got 161"
+ },
+ {
+ "t": 9.78,
+ "e": "down to 458 kbit/s: queue 206 ms, held 6, oldest 0 ms, base 6 ms, sent 109 got 127"
+ },
+ {
+ "t": 12.17,
+ "e": "down to 300 kbit/s: queue 4 ms, held 6, oldest 215 ms, base 6 ms, sent 116 got 103"
+ }
+ ],
+ "source_fps": 66.1,
+ "cpu": {
+ "mac_agent_pct": 10.9,
+ "frame_viewer_pct": 42.3,
+ "frame_tailscaled_pct": 3.8,
+ "frame_sshd_pct": 0.4,
+ "frame_gamescope_pct": 15.4,
+ "frame_total_pct": 23.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 70 Hz",
+ "show_s": 1.46,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 523,
+ "frames_drawn": 523,
+ "frames_shown": 515,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.7,
+ "p95": 10.3,
+ "p99": 17.5,
+ "n": 523
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 18.2,
+ "p99": 33.6,
+ "n": 523
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 8.4,
+ "p99": 17.2,
+ "n": 523
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 523
+ },
+ "network": {
+ "p50": 7.7,
+ "p95": 34.2,
+ "p99": 205.8,
+ "n": 523
+ },
+ "decode": {
+ "p50": 6.2,
+ "p95": 15.1,
+ "p99": 23.5,
+ "n": 523
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 0.8,
+ "p99": 1.7,
+ "n": 523
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 11.4,
+ "p99": 14.3,
+ "n": 515
+ },
+ "content": {
+ "p50": 23.9,
+ "p95": 65.6,
+ "p99": 214.3,
+ "n": 523
+ },
+ "content_shown": {
+ "p50": 26.4,
+ "p95": 66.0,
+ "p99": 215.2,
+ "n": 515
+ }
+ },
+ "fps": 33.3,
+ "fps_shown": 32.7,
+ "late_pct": 56.13,
+ "stall_max": 321.6,
+ "stalls_over_100ms": 10,
+ "mbps": 6.29,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 789,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 29,
+ "mbps": 6.01,
+ "content_p50": 17.9,
+ "content_p95": 139.0,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 26,
+ "mbps": 2.1,
+ "content_p50": 33.3,
+ "content_p95": 86.5,
+ "bitrate": 2202984,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 30,
+ "mbps": 2.11,
+ "content_p50": 23.4,
+ "content_p95": 40.3,
+ "bitrate": 2770610,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 30,
+ "mbps": 3.24,
+ "content_p50": 19.3,
+ "content_p95": 47.2,
+ "bitrate": 3853181,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 26,
+ "mbps": 3.85,
+ "content_p50": 20.9,
+ "content_p95": 120.6,
+ "bitrate": 5294082,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 26,
+ "mbps": 2.95,
+ "content_p50": 23.3,
+ "content_p95": 46.7,
+ "bitrate": 3187180,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 29,
+ "mbps": 3.51,
+ "content_p50": 27.5,
+ "content_p95": 48.7,
+ "bitrate": 4898398,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 28,
+ "mbps": 5.29,
+ "content_p50": 28.2,
+ "content_p95": 48.8,
+ "bitrate": 6685266,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 29,
+ "mbps": 7.09,
+ "content_p50": 23.8,
+ "content_p95": 50.0,
+ "bitrate": 9063588,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 35,
+ "mbps": 9.72,
+ "content_p50": 31.0,
+ "content_p95": 59.7,
+ "bitrate": 13502047,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 42,
+ "mbps": 12.62,
+ "content_p50": 23.9,
+ "content_p95": 34.4,
+ "bitrate": 14860800,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 47,
+ "mbps": 11.31,
+ "content_p50": 20.5,
+ "content_p95": 41.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 35,
+ "mbps": 8.1,
+ "content_p50": 20.7,
+ "content_p95": 120.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 35,
+ "mbps": 6.11,
+ "content_p50": 21.1,
+ "content_p95": 36.8,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 42,
+ "mbps": 8.02,
+ "content_p50": 24.8,
+ "content_p95": 38.7,
+ "bitrate": 10055362,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 34,
+ "mbps": 6.95,
+ "content_p50": 19.2,
+ "content_p95": 309.3,
+ "bitrate": 12271987,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 23.9,
+ "content_p95": 65.6,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 7.209,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "target": 7502051,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 0.78,
+ "e": "down to 7430 kbit/s: queue 95 ms, held 3, oldest 0 ms, base 7 ms, sent 4459 got 5191"
+ },
+ {
+ "t": 1.08,
+ "e": "down to 3715 kbit/s: queue 130 ms, held 2, oldest 127 ms, base 7 ms, sent 2434 got 2754"
+ },
+ {
+ "t": 1.48,
+ "e": "down to 2202 kbit/s: queue 44 ms, held 1, oldest 37 ms, base 7 ms, sent 1917 got 2447"
+ },
+ {
+ "t": 1.68,
+ "e": "tier 3"
+ },
+ {
+ "t": 4.98,
+ "e": "down to 3187 kbit/s: queue 102 ms, held 0, oldest 31 ms, base 7 ms, sent 3802 got 3541"
+ },
+ {
+ "t": 5.18,
+ "e": "tier 2"
+ },
+ {
+ "t": 9.38,
+ "e": "tier 1"
+ },
+ {
+ "t": 11.48,
+ "e": "tier 0"
+ },
+ {
+ "t": 13.28,
+ "e": "down to 7430 kbit/s: queue 257 ms, held 5, oldest 240 ms, base 6 ms, sent 4758 got 6534"
+ },
+ {
+ "t": 15.99,
+ "e": "down to 6774 kbit/s: queue 1 ms, held 6, oldest 284 ms, base 7 ms, sent 5665 got 4867"
+ }
+ ],
+ "source_fps": 50.3,
+ "cpu": {
+ "mac_agent_pct": 5.8,
+ "frame_viewer_pct": 64.7,
+ "frame_tailscaled_pct": 5.9,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 16.2,
+ "frame_total_pct": 26.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.37,
+ "panel": "valve.steam.desktopgame.2001605080",
+ "src": "separate:13687"
+ }
+ ]
+ ]
+ },
+ "table": {
+ "test": {
+ "content_p50": {
+ "off": [
+ 11.5,
+ 10.5,
+ 12.2
+ ],
+ "on": [
+ 10.9,
+ 10.5,
+ 22.9
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 15.8,
+ 52.3,
+ 29.7
+ ],
+ "on": [
+ 75.0,
+ 15.4,
+ 54.1
+ ]
+ },
+ "input_p50": {
+ "off": [
+ 28.8,
+ 26.7,
+ 31.4
+ ],
+ "on": [
+ 27.2,
+ 24.6,
+ 53.9
+ ]
+ },
+ "input_p95": {
+ "off": [
+ 38.1,
+ 89.3,
+ 78.1
+ ],
+ "on": [
+ 95.5,
+ 39.8,
+ 117.8
+ ]
+ },
+ "fps": {
+ "off": [
+ 60.1,
+ 60.1,
+ 60.1
+ ],
+ "on": [
+ 53.6,
+ 59.4,
+ 30.6
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 0.95,
+ 3.61,
+ 5.73
+ ],
+ "on": [
+ 6.06,
+ 0.75,
+ 68.81
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 68.8,
+ 120.1,
+ 123.4
+ ],
+ "on": [
+ 127.2,
+ 126.0,
+ 218.0
+ ]
+ },
+ "mbps": {
+ "off": [
+ 0.48,
+ 0.47,
+ 0.48
+ ],
+ "on": [
+ 0.43,
+ 0.48,
+ 0.19
+ ]
+ }
+ },
+ "scroll": {
+ "content_p50": {
+ "off": [
+ 19.5,
+ 33.3,
+ 25.3
+ ],
+ "on": [
+ 16.6,
+ 23.4,
+ 23.9
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 44.4,
+ 108.6,
+ 110.6
+ ],
+ "on": [
+ 45.7,
+ 100.3,
+ 65.6
+ ]
+ },
+ "input_p50": {
+ "off": [],
+ "on": []
+ },
+ "input_p95": {
+ "off": [],
+ "on": []
+ },
+ "fps": {
+ "off": [
+ 47.1,
+ 52.6,
+ 39.5
+ ],
+ "on": [
+ 48.4,
+ 38.2,
+ 33.3
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 20.92,
+ 14.84,
+ 33.66
+ ],
+ "on": [
+ 21.06,
+ 25.87,
+ 56.13
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 128.0,
+ 232.0,
+ 362.0
+ ],
+ "on": [
+ 88.2,
+ 180.7,
+ 321.6
+ ]
+ },
+ "mbps": {
+ "off": [
+ 9.15,
+ 9.35,
+ 8.59
+ ],
+ "on": [
+ 9.16,
+ 6.88,
+ 6.29
+ ]
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean2.json b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean2.json
new file mode 100644
index 0000000..d88f64a
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-clean2.json
@@ -0,0 +1,4150 @@
+{
+ "label": "ab-adapt-clean2",
+ "date": "2026-09-28T20:18:23",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 15.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "8:23.135290 [Info] - 0 - leaving standby",
+ "scenarios": [],
+ "arms": [
+ "off:FRAME_MAC_VIEW_ADAPT=0",
+ "on:"
+ ],
+ "runs": {
+ "off": [
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 925,
+ "frames_drawn": 925,
+ "frames_shown": 649,
+ "duration_s": 15.4,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 925
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 925
+ },
+ "encode": {
+ "p50": 3.6,
+ "p95": 4.5,
+ "p99": 4.8,
+ "n": 925
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 925
+ },
+ "network": {
+ "p50": 4.0,
+ "p95": 10.6,
+ "p99": 57.5,
+ "n": 925
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.9,
+ "p99": 6.5,
+ "n": 925
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.8,
+ "p99": 1.0,
+ "n": 925
+ },
+ "present": {
+ "p50": 4.2,
+ "p95": 14.9,
+ "p99": 19.7,
+ "n": 649
+ },
+ "content": {
+ "p50": 9.5,
+ "p95": 16.2,
+ "p99": 68.6,
+ "n": 925
+ },
+ "content_shown": {
+ "p50": 14.8,
+ "p95": 26.6,
+ "p99": 33.5,
+ "n": 649
+ }
+ },
+ "fps": 59.9,
+ "fps_shown": 42.1,
+ "late_pct": 2.05,
+ "stall_max": 150.2,
+ "stalls_over_100ms": 2,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1047,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 28.3,
+ "p95": 38.3,
+ "p99": 41.2,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 34.8,
+ "p95": 43.5,
+ "p99": 47.2,
+ "n": 21
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 11.3,
+ "p95": 19.6,
+ "p99": 23.2,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.5,
+ "p95": 14.1,
+ "p99": 14.8,
+ "n": 30
+ },
+ "back": {
+ "p50": 9.9,
+ "p95": 13.8,
+ "p99": 15.2,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.0,
+ "content_p95": 12.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.8,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.0,
+ "content_p95": 15.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 9.2,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 61,
+ "mbps": 0.46,
+ "content_p50": 9.1,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 9.0,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 59,
+ "mbps": 0.48,
+ "content_p50": 9.5,
+ "content_p95": 20.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 9.2,
+ "content_p95": 20.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.4,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.8,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.8,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 59,
+ "mbps": 0.46,
+ "content_p50": 13.6,
+ "content_p95": 67.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 13.1,
+ "content_p95": 102.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.1,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 25,
+ "mbps": 0.19,
+ "content_p50": 9.2,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.5,
+ "content_p95": 16.2,
+ "input_p50": 28.3,
+ "input_p95": 38.3,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.375,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 68.0,
+ "cpu": {
+ "mac_agent_pct": 11.5,
+ "frame_viewer_pct": 39.7,
+ "frame_tailscaled_pct": 4.8,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 6.2,
+ "frame_total_pct": 23.9
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 69 Hz",
+ "show_s": 2.03,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 835,
+ "frames_drawn": 835,
+ "frames_shown": 628,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.6,
+ "p95": 1.6,
+ "p99": 8.2,
+ "n": 835
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 835
+ },
+ "encode": {
+ "p50": 6.4,
+ "p95": 8.6,
+ "p99": 14.2,
+ "n": 835
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 835
+ },
+ "network": {
+ "p50": 6.1,
+ "p95": 12.0,
+ "p99": 24.3,
+ "n": 835
+ },
+ "decode": {
+ "p50": 5.8,
+ "p95": 10.9,
+ "p99": 14.7,
+ "n": 835
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.4,
+ "p99": 1.8,
+ "n": 835
+ },
+ "present": {
+ "p50": 4.2,
+ "p95": 17.9,
+ "p99": 22.4,
+ "n": 628
+ },
+ "content": {
+ "p50": 20.0,
+ "p95": 32.3,
+ "p99": 45.5,
+ "n": 835
+ },
+ "content_shown": {
+ "p50": 25.2,
+ "p95": 42.2,
+ "p99": 53.7,
+ "n": 628
+ }
+ },
+ "fps": 54.9,
+ "fps_shown": 41.3,
+ "late_pct": 6.59,
+ "stall_max": 84.3,
+ "stalls_over_100ms": 0,
+ "mbps": 9.44,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 946,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 57,
+ "mbps": 10.21,
+ "content_p50": 19.2,
+ "content_p95": 24.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 55,
+ "mbps": 9.18,
+ "content_p50": 18.6,
+ "content_p95": 45.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 8.93,
+ "content_p50": 19.1,
+ "content_p95": 24.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.42,
+ "content_p50": 19.5,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 56,
+ "mbps": 9.88,
+ "content_p50": 19.9,
+ "content_p95": 25.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 52,
+ "mbps": 9.25,
+ "content_p50": 19.7,
+ "content_p95": 27.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 53,
+ "mbps": 12.28,
+ "content_p50": 20.6,
+ "content_p95": 38.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 53,
+ "mbps": 9.14,
+ "content_p50": 19.2,
+ "content_p95": 24.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 55,
+ "mbps": 9.31,
+ "content_p50": 20.4,
+ "content_p95": 28.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 55,
+ "mbps": 9.22,
+ "content_p50": 21.8,
+ "content_p95": 34.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 56,
+ "mbps": 8.46,
+ "content_p50": 22.7,
+ "content_p95": 41.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 56,
+ "mbps": 9.18,
+ "content_p50": 20.0,
+ "content_p95": 23.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 54,
+ "mbps": 9.08,
+ "content_p50": 21.4,
+ "content_p95": 27.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 55,
+ "mbps": 8.78,
+ "content_p50": 19.6,
+ "content_p95": 28.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 55,
+ "mbps": 9.45,
+ "content_p50": 18.8,
+ "content_p95": 26.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 9,
+ "mbps": 1.68,
+ "content_p50": 25.8,
+ "content_p95": 44.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 20.0,
+ "content_p95": 32.3,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 6.875,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 62.2,
+ "cpu": {
+ "mac_agent_pct": 7.4,
+ "frame_viewer_pct": 70.3,
+ "frame_tailscaled_pct": 7.7,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 6.7,
+ "frame_total_pct": 28.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001924380",
+ "src": "separate:5316"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 912,
+ "frames_drawn": 912,
+ "frames_shown": 618,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 912
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.2,
+ "n": 912
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.9,
+ "p99": 5.2,
+ "n": 912
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 912
+ },
+ "network": {
+ "p50": 3.8,
+ "p95": 8.0,
+ "p99": 11.1,
+ "n": 912
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.3,
+ "p99": 4.2,
+ "n": 912
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 1.0,
+ "n": 912
+ },
+ "present": {
+ "p50": 5.1,
+ "p95": 16.1,
+ "p99": 17.8,
+ "n": 618
+ },
+ "content": {
+ "p50": 9.7,
+ "p95": 14.3,
+ "p99": 17.6,
+ "n": 912
+ },
+ "content_shown": {
+ "p50": 15.3,
+ "p95": 27.8,
+ "p99": 29.4,
+ "n": 618
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 40.7,
+ "late_pct": 0.33,
+ "stall_max": 120.5,
+ "stalls_over_100ms": 1,
+ "mbps": 0.48,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1032,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 22.6,
+ "p95": 32.7,
+ "p99": 46.2,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 28.6,
+ "p95": 39.0,
+ "p99": 47.0,
+ "n": 23
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 7.3,
+ "p95": 18.0,
+ "p99": 22.1,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.2,
+ "p95": 14.0,
+ "p99": 17.2,
+ "n": 30
+ },
+ "back": {
+ "p50": 9.9,
+ "p95": 13.6,
+ "p99": 14.3,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 8.9,
+ "content_p95": 71.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 8.7,
+ "content_p95": 10.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 8.7,
+ "content_p95": 10.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.1,
+ "content_p95": 10.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.7,
+ "content_p95": 10.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 8.7,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 8.9,
+ "content_p95": 11.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 8.7,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.2,
+ "content_p95": 13.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.6,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.6,
+ "content_p95": 14.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 12.3,
+ "content_p95": 15.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 12.8,
+ "content_p95": 15.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 12.7,
+ "content_p95": 14.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.9,
+ "content_p95": 17.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.09,
+ "content_p50": 12.6,
+ "content_p95": 14.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.7,
+ "content_p95": 14.3,
+ "input_p50": 22.6,
+ "input_p95": 32.7,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.334,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 40,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 67.9,
+ "cpu": {
+ "mac_agent_pct": 16.1,
+ "frame_viewer_pct": 46.3,
+ "frame_tailscaled_pct": 5.1,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 8.9,
+ "frame_total_pct": 28.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 140 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 864,
+ "frames_drawn": 864,
+ "frames_shown": 626,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.6,
+ "p95": 0.4,
+ "p99": 0.8,
+ "n": 864
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 864
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 8.5,
+ "p99": 11.1,
+ "n": 864
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 864
+ },
+ "network": {
+ "p50": 6.0,
+ "p95": 9.2,
+ "p99": 14.7,
+ "n": 864
+ },
+ "decode": {
+ "p50": 5.7,
+ "p95": 9.9,
+ "p99": 12.9,
+ "n": 864
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.3,
+ "p99": 1.9,
+ "n": 864
+ },
+ "present": {
+ "p50": 4.3,
+ "p95": 16.8,
+ "p99": 21.2,
+ "n": 626
+ },
+ "content": {
+ "p50": 19.5,
+ "p95": 25.2,
+ "p99": 32.2,
+ "n": 864
+ },
+ "content_shown": {
+ "p50": 24.4,
+ "p95": 36.6,
+ "p99": 41.8,
+ "n": 626
+ }
+ },
+ "fps": 57.0,
+ "fps_shown": 41.3,
+ "late_pct": 3.36,
+ "stall_max": 105.4,
+ "stalls_over_100ms": 1,
+ "mbps": 9.44,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 978,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 56,
+ "mbps": 9.38,
+ "content_p50": 19.1,
+ "content_p95": 24.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 56,
+ "mbps": 9.47,
+ "content_p50": 19.2,
+ "content_p95": 24.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 56,
+ "mbps": 8.94,
+ "content_p50": 19.3,
+ "content_p95": 25.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.51,
+ "content_p50": 19.7,
+ "content_p95": 26.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.65,
+ "content_p50": 18.6,
+ "content_p95": 22.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 9.0,
+ "content_p50": 18.7,
+ "content_p95": 24.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 55,
+ "mbps": 11.82,
+ "content_p50": 20.2,
+ "content_p95": 33.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 9.24,
+ "content_p50": 18.9,
+ "content_p95": 21.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.37,
+ "content_p50": 19.3,
+ "content_p95": 25.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.2,
+ "content_p50": 20.5,
+ "content_p95": 27.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 58,
+ "mbps": 9.12,
+ "content_p50": 20.1,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 57,
+ "mbps": 9.29,
+ "content_p50": 19.4,
+ "content_p95": 25.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 57,
+ "mbps": 9.29,
+ "content_p50": 19.9,
+ "content_p95": 23.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 58,
+ "mbps": 8.8,
+ "content_p50": 19.8,
+ "content_p95": 23.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 58,
+ "mbps": 9.34,
+ "content_p50": 20.3,
+ "content_p95": 28.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 10,
+ "mbps": 1.68,
+ "content_p50": 19.6,
+ "content_p95": 22.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.5,
+ "content_p95": 25.2,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 6.75,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 40,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 64.3,
+ "cpu": {
+ "mac_agent_pct": 8.6,
+ "frame_viewer_pct": 73.0,
+ "frame_tailscaled_pct": 8.0,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 7.8,
+ "frame_total_pct": 30.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 1.26,
+ "panel": "valve.steam.desktopgame.2001204762",
+ "src": "separate:5722"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 912,
+ "frames_drawn": 912,
+ "frames_shown": 618,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 912
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 912
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 912
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 912
+ },
+ "network": {
+ "p50": 4.6,
+ "p95": 8.9,
+ "p99": 13.7,
+ "n": 912
+ },
+ "decode": {
+ "p50": 1.2,
+ "p95": 2.2,
+ "p99": 3.9,
+ "n": 912
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 1.0,
+ "n": 912
+ },
+ "present": {
+ "p50": 5.4,
+ "p95": 17.0,
+ "p99": 19.3,
+ "n": 618
+ },
+ "content": {
+ "p50": 10.6,
+ "p95": 15.0,
+ "p99": 19.9,
+ "n": 912
+ },
+ "content_shown": {
+ "p50": 16.0,
+ "p95": 27.7,
+ "p99": 30.8,
+ "n": 618
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 40.6,
+ "late_pct": 0.99,
+ "stall_max": 37.7,
+ "stalls_over_100ms": 0,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1034,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 30.9,
+ "p95": 103.7,
+ "p99": 196.7,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 36.3,
+ "p95": 106.8,
+ "p99": 200.8,
+ "n": 19
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 13.9,
+ "p95": 74.2,
+ "p99": 183.2,
+ "n": 30
+ },
+ "mac": {
+ "p50": 5.5,
+ "p95": 16.4,
+ "p99": 18.7,
+ "n": 30
+ },
+ "back": {
+ "p50": 11.2,
+ "p95": 15.5,
+ "p99": 20.2,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 9.4,
+ "content_p95": 13.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.5,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.4,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.6,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.1,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 10.5,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.3,
+ "content_p95": 13.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.3,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.1,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.9,
+ "content_p95": 14.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.4,
+ "content_p95": 15.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 11.5,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 13.3,
+ "content_p95": 17.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.1,
+ "content_p95": 16.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.5,
+ "content_p95": 16.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.09,
+ "content_p50": 12.9,
+ "content_p95": 15.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.6,
+ "content_p95": 15.0,
+ "input_p50": 30.9,
+ "input_p95": 103.7,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "acceptable",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.625,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 40,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 68.0,
+ "cpu": {
+ "mac_agent_pct": 15.7,
+ "frame_viewer_pct": 52.1,
+ "frame_tailscaled_pct": 8.9,
+ "frame_sshd_pct": 1.3,
+ "frame_gamescope_pct": 9.1,
+ "frame_total_pct": 23.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.26,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 870,
+ "frames_drawn": 870,
+ "frames_shown": 627,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.4,
+ "p95": 0.4,
+ "p99": 0.8,
+ "n": 870
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 870
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 8.5,
+ "p99": 13.9,
+ "n": 870
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 870
+ },
+ "network": {
+ "p50": 6.3,
+ "p95": 27.2,
+ "p99": 133.0,
+ "n": 870
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 15.5,
+ "p99": 21.7,
+ "n": 870
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.5,
+ "p99": 1.9,
+ "n": 870
+ },
+ "present": {
+ "p50": 3.6,
+ "p95": 16.4,
+ "p99": 21.1,
+ "n": 627
+ },
+ "content": {
+ "p50": 20.5,
+ "p95": 45.9,
+ "p99": 170.0,
+ "n": 870
+ },
+ "content_shown": {
+ "p50": 27.1,
+ "p95": 55.8,
+ "p99": 200.4,
+ "n": 627
+ }
+ },
+ "fps": 57.3,
+ "fps_shown": 41.3,
+ "late_pct": 4.6,
+ "stall_max": 321.6,
+ "stalls_over_100ms": 2,
+ "mbps": 9.4,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 986,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 56,
+ "mbps": 9.16,
+ "content_p50": 27.2,
+ "content_p95": 49.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 55,
+ "mbps": 9.46,
+ "content_p50": 23.6,
+ "content_p95": 237.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 58,
+ "mbps": 8.98,
+ "content_p50": 21.5,
+ "content_p95": 26.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 58,
+ "mbps": 9.81,
+ "content_p50": 22.4,
+ "content_p95": 55.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.66,
+ "content_p50": 21.1,
+ "content_p95": 31.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 58,
+ "mbps": 8.89,
+ "content_p50": 19.9,
+ "content_p95": 24.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 11.75,
+ "content_p50": 20.5,
+ "content_p95": 74.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 9.0,
+ "content_p50": 20.1,
+ "content_p95": 27.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 57,
+ "mbps": 9.35,
+ "content_p50": 20.5,
+ "content_p95": 24.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.17,
+ "content_p50": 19.8,
+ "content_p95": 24.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 8.89,
+ "content_p50": 19.3,
+ "content_p95": 25.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 9.09,
+ "content_p50": 18.9,
+ "content_p95": 22.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 58,
+ "mbps": 9.74,
+ "content_p50": 19.7,
+ "content_p95": 31.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 57,
+ "mbps": 8.62,
+ "content_p50": 18.5,
+ "content_p95": 21.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 58,
+ "mbps": 9.36,
+ "content_p50": 25.9,
+ "content_p95": 37.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 11,
+ "mbps": 1.84,
+ "content_p50": 21.5,
+ "content_p95": 123.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 20.5,
+ "content_p95": 45.9,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "acceptable",
+ "late_pct": "acceptable",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 7.125,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 64.9,
+ "cpu": {
+ "mac_agent_pct": 8.8,
+ "frame_viewer_pct": 84.1,
+ "frame_tailscaled_pct": 13.1,
+ "frame_sshd_pct": -0.9,
+ "frame_gamescope_pct": 9.9,
+ "frame_total_pct": 35.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 143 Hz",
+ "show_s": 1.44,
+ "panel": "valve.steam.desktopgame.2001042553",
+ "src": "separate:5856"
+ }
+ ]
+ ],
+ "on": [
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 942,
+ "frames_drawn": 942,
+ "frames_shown": 641,
+ "duration_s": 15.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 942
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 942
+ },
+ "encode": {
+ "p50": 3.7,
+ "p95": 4.5,
+ "p99": 5.5,
+ "n": 942
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 942
+ },
+ "network": {
+ "p50": 4.0,
+ "p95": 7.1,
+ "p99": 14.6,
+ "n": 942
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.3,
+ "p99": 3.7,
+ "n": 942
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 1.0,
+ "n": 942
+ },
+ "present": {
+ "p50": 4.9,
+ "p95": 17.5,
+ "p99": 19.9,
+ "n": 641
+ },
+ "content": {
+ "p50": 9.5,
+ "p95": 13.2,
+ "p99": 23.3,
+ "n": 942
+ },
+ "content_shown": {
+ "p50": 15.0,
+ "p95": 27.3,
+ "p99": 29.9,
+ "n": 641
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 40.9,
+ "late_pct": 1.17,
+ "stall_max": 75.6,
+ "stalls_over_100ms": 0,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1064,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 27.2,
+ "p95": 51.1,
+ "p99": 60.4,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 33.7,
+ "p95": 53.0,
+ "p99": 72.9,
+ "n": 20
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 11.8,
+ "p95": 39.5,
+ "p99": 41.9,
+ "n": 30
+ },
+ "mac": {
+ "p50": 4.6,
+ "p95": 14.7,
+ "p99": 19.7,
+ "n": 30
+ },
+ "back": {
+ "p50": 9.5,
+ "p95": 14.0,
+ "p99": 15.3,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.2,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.9,
+ "content_p95": 24.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 8.7,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.6,
+ "content_p95": 11.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.0,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 8.6,
+ "content_p95": 10.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 8.5,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.6,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 8.0,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.1,
+ "content_p95": 14.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.2,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.8,
+ "content_p95": 14.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.6,
+ "content_p95": 12.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.4,
+ "content_p95": 15.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 11.0,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 42,
+ "mbps": 0.31,
+ "content_p50": 11.2,
+ "content_p95": 14.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.5,
+ "content_p95": 13.2,
+ "input_p50": 27.2,
+ "input_p95": 51.1,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.541,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 52.292,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 67.8,
+ "cpu": {
+ "mac_agent_pct": 12.1,
+ "frame_viewer_pct": 45.2,
+ "frame_tailscaled_pct": 5.1,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 7.9,
+ "frame_total_pct": 27.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 1.3,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 857,
+ "frames_drawn": 857,
+ "frames_shown": 618,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -2.9,
+ "p95": 4.5,
+ "p99": 6.3,
+ "n": 857
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 857
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 9.0,
+ "p99": 15.0,
+ "n": 857
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 857
+ },
+ "network": {
+ "p50": 5.2,
+ "p95": 12.8,
+ "p99": 25.4,
+ "n": 857
+ },
+ "decode": {
+ "p50": 5.8,
+ "p95": 10.4,
+ "p99": 13.5,
+ "n": 857
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.4,
+ "p99": 1.8,
+ "n": 857
+ },
+ "present": {
+ "p50": 4.7,
+ "p95": 16.8,
+ "p99": 20.3,
+ "n": 618
+ },
+ "content": {
+ "p50": 19.5,
+ "p95": 31.7,
+ "p99": 45.5,
+ "n": 857
+ },
+ "content_shown": {
+ "p50": 25.7,
+ "p95": 38.5,
+ "p99": 47.8,
+ "n": 618
+ }
+ },
+ "fps": 56.4,
+ "fps_shown": 40.7,
+ "late_pct": 5.6,
+ "stall_max": 120.5,
+ "stalls_over_100ms": 1,
+ "mbps": 9.4,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 971,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 53,
+ "mbps": 9.14,
+ "content_p50": 20.2,
+ "content_p95": 40.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 58,
+ "mbps": 9.36,
+ "content_p50": 20.2,
+ "content_p95": 43.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 54,
+ "mbps": 9.42,
+ "content_p50": 18.9,
+ "content_p95": 28.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.46,
+ "content_p50": 18.1,
+ "content_p95": 25.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 56,
+ "mbps": 8.81,
+ "content_p50": 18.9,
+ "content_p95": 25.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 11.11,
+ "content_p50": 18.6,
+ "content_p95": 34.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 57,
+ "mbps": 9.96,
+ "content_p50": 18.1,
+ "content_p95": 24.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.79,
+ "content_p50": 18.4,
+ "content_p95": 27.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.1,
+ "content_p50": 20.3,
+ "content_p95": 27.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 56,
+ "mbps": 9.65,
+ "content_p50": 19.3,
+ "content_p95": 28.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 56,
+ "mbps": 9.15,
+ "content_p50": 20.4,
+ "content_p95": 34.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 57,
+ "mbps": 9.46,
+ "content_p50": 20.4,
+ "content_p95": 31.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 57,
+ "mbps": 9.53,
+ "content_p50": 20.4,
+ "content_p95": 31.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 57,
+ "mbps": 8.94,
+ "content_p50": 20.2,
+ "content_p95": 25.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.2,
+ "content_p50": 19.4,
+ "content_p95": 27.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 10,
+ "mbps": 1.69,
+ "content_p50": 18.1,
+ "content_p95": 23.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.5,
+ "content_p95": 31.7,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.666,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 47.293,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 63.9,
+ "cpu": {
+ "mac_agent_pct": 8.5,
+ "frame_viewer_pct": 72.5,
+ "frame_tailscaled_pct": 8.3,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 7.1,
+ "frame_total_pct": 30.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001321860",
+ "src": "separate:5452"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 740,
+ "frames_drawn": 740,
+ "frames_shown": 520,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 740
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 13.2,
+ "p99": 19.4,
+ "n": 740
+ },
+ "encode": {
+ "p50": 4.1,
+ "p95": 4.8,
+ "p99": 5.2,
+ "n": 740
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 740
+ },
+ "network": {
+ "p50": 3.6,
+ "p95": 125.3,
+ "p99": 165.0,
+ "n": 740
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 6.1,
+ "p99": 10.2,
+ "n": 740
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.6,
+ "p99": 1.1,
+ "n": 740
+ },
+ "present": {
+ "p50": 0.9,
+ "p95": 15.9,
+ "p99": 20.1,
+ "n": 520
+ },
+ "content": {
+ "p50": 9.5,
+ "p95": 137.4,
+ "p99": 173.0,
+ "n": 740
+ },
+ "content_shown": {
+ "p50": 14.9,
+ "p95": 66.7,
+ "p99": 174.2,
+ "n": 520
+ }
+ },
+ "fps": 48.7,
+ "fps_shown": 34.2,
+ "late_pct": 16.35,
+ "stall_max": 267.4,
+ "stalls_over_100ms": 16,
+ "mbps": 0.36,
+ "keyframes": 2,
+ "size": "960x540",
+ "captured": 1032,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 32.1,
+ "p95": 63.7,
+ "p99": 188.2,
+ "n": 29
+ },
+ "input_shown_ms": {
+ "p50": 36.2,
+ "p95": 64.1,
+ "p99": 188.9,
+ "n": 20
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 10.5,
+ "p95": 31.9,
+ "p99": 47.7,
+ "n": 29
+ },
+ "mac": {
+ "p50": 5.4,
+ "p95": 19.2,
+ "p99": 30.2,
+ "n": 29
+ },
+ "back": {
+ "p50": 9.7,
+ "p95": 29.3,
+ "p99": 168.5,
+ "n": 29
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 29
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.6,
+ "content_p95": 10.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.8,
+ "content_p95": 11.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.6,
+ "content_p95": 10.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.3,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 8.6,
+ "content_p95": 10.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.56,
+ "content_p50": 9.1,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 9.1,
+ "content_p95": 25.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.2,
+ "content_p95": 57.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 52,
+ "mbps": 0.4,
+ "content_p50": 22.0,
+ "content_p95": 159.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 47,
+ "mbps": 0.35,
+ "content_p50": 90.3,
+ "content_p95": 164.7,
+ "bitrate": 2764800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 45,
+ "mbps": 0.34,
+ "content_p50": 93.8,
+ "content_p95": 166.0,
+ "bitrate": 345600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 21,
+ "mbps": 0.18,
+ "content_p50": 81.0,
+ "content_p95": 177.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 27,
+ "mbps": 0.13,
+ "content_p50": 29.0,
+ "content_p95": 176.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 31,
+ "mbps": 0.11,
+ "content_p50": 21.7,
+ "content_p95": 29.4,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 31,
+ "mbps": 0.11,
+ "content_p50": 19.6,
+ "content_p95": 29.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 7,
+ "mbps": 0.02,
+ "content_p50": 21.9,
+ "content_p95": 29.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.5,
+ "content_p95": 137.4,
+ "input_p50": 32.1,
+ "input_p95": 63.7,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "bad",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.625,
+ "ceiling": 5529600,
+ "fps": 30,
+ "inFlight": 3,
+ "scale": 0.5,
+ "slack": 86.958,
+ "target": 917248,
+ "tier": 4
+ },
+ "events": [
+ {
+ "t": 9.22,
+ "e": "down to 2764 kbit/s: queue 25 ms, held 4, oldest 0 ms, base 5 ms, sent 354 got 379"
+ },
+ {
+ "t": 10.02,
+ "e": "down to 1382 kbit/s: queue 99 ms, held 5, oldest 0 ms, base 5 ms, sent 346 got 346"
+ },
+ {
+ "t": 10.32,
+ "e": "down to 691 kbit/s: queue 109 ms, held 4, oldest 15 ms, base 5 ms, sent 316 got 309"
+ },
+ {
+ "t": 10.82,
+ "e": "down to 345 kbit/s: queue 105 ms, held 5, oldest 177 ms, base 5 ms, sent 304 got 243"
+ },
+ {
+ "t": 11.22,
+ "e": "down to 300 kbit/s: queue 118 ms, held 0, oldest 42 ms, base 5 ms, sent 302 got 332"
+ },
+ {
+ "t": 11.42,
+ "e": "tier 4"
+ },
+ {
+ "t": 11.62,
+ "e": "down to 300 kbit/s: queue 140 ms, held 3, oldest 0 ms, base 5 ms, sent 291 got 291"
+ },
+ {
+ "t": 12.22,
+ "e": "down to 300 kbit/s: queue 61 ms, held 2, oldest 1 ms, base 5 ms, sent 152 got 137"
+ },
+ {
+ "t": 12.52,
+ "e": "down to 300 kbit/s: queue 102 ms, held 0, oldest 4 ms, base 5 ms, sent 156 got 146"
+ }
+ ],
+ "source_fps": 67.9,
+ "cpu": {
+ "mac_agent_pct": 13.4,
+ "frame_viewer_pct": 35.6,
+ "frame_tailscaled_pct": 4.1,
+ "frame_sshd_pct": 0.5,
+ "frame_gamescope_pct": 7.0,
+ "frame_total_pct": 24.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 138 Hz",
+ "show_s": 1.29,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 852,
+ "frames_drawn": 852,
+ "frames_shown": 607,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.5,
+ "p95": 0.5,
+ "p99": 6.7,
+ "n": 852
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.5,
+ "n": 852
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 9.3,
+ "p99": 16.5,
+ "n": 852
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 852
+ },
+ "network": {
+ "p50": 5.9,
+ "p95": 15.8,
+ "p99": 58.8,
+ "n": 852
+ },
+ "decode": {
+ "p50": 5.8,
+ "p95": 11.6,
+ "p99": 23.2,
+ "n": 852
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.3,
+ "p99": 1.8,
+ "n": 852
+ },
+ "present": {
+ "p50": 4.2,
+ "p95": 16.4,
+ "p99": 20.1,
+ "n": 607
+ },
+ "content": {
+ "p50": 19.6,
+ "p95": 37.9,
+ "p99": 106.2,
+ "n": 852
+ },
+ "content_shown": {
+ "p50": 25.7,
+ "p95": 45.1,
+ "p99": 110.7,
+ "n": 607
+ }
+ },
+ "fps": 56.2,
+ "fps_shown": 40.0,
+ "late_pct": 5.52,
+ "stall_max": 147.0,
+ "stalls_over_100ms": 3,
+ "mbps": 9.36,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 982,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 44,
+ "mbps": 8.61,
+ "content_p50": 29.3,
+ "content_p95": 126.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 55,
+ "mbps": 9.63,
+ "content_p50": 19.5,
+ "content_p95": 29.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 9.2,
+ "content_p50": 20.4,
+ "content_p95": 71.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 53,
+ "mbps": 9.04,
+ "content_p50": 19.5,
+ "content_p95": 36.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 9.08,
+ "content_p50": 18.9,
+ "content_p95": 22.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 9.22,
+ "content_p50": 19.3,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 57,
+ "mbps": 11.78,
+ "content_p50": 20.5,
+ "content_p95": 37.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.73,
+ "content_p50": 19.2,
+ "content_p95": 24.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.29,
+ "content_p50": 19.1,
+ "content_p95": 23.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 58,
+ "mbps": 9.5,
+ "content_p50": 18.9,
+ "content_p95": 24.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 58,
+ "mbps": 8.58,
+ "content_p50": 19.2,
+ "content_p95": 23.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 57,
+ "mbps": 9.44,
+ "content_p50": 20.2,
+ "content_p95": 28.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 58,
+ "mbps": 9.7,
+ "content_p50": 20.1,
+ "content_p95": 37.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 58,
+ "mbps": 9.15,
+ "content_p50": 20.8,
+ "content_p95": 41.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 58,
+ "mbps": 9.29,
+ "content_p50": 19.7,
+ "content_p95": 23.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 9,
+ "mbps": 1.64,
+ "content_p50": 20.9,
+ "content_p95": 38.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.6,
+ "content_p95": 37.9,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.625,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 48.604,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 64.6,
+ "cpu": {
+ "mac_agent_pct": 8.3,
+ "frame_viewer_pct": 71.7,
+ "frame_tailscaled_pct": 8.0,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 7.2,
+ "frame_total_pct": 29.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001299121",
+ "src": "separate:5587"
+ }
+ ],
+ [
+ {
+ "scenario": "test",
+ "frames_sent": 908,
+ "frames_drawn": 908,
+ "frames_shown": 382,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 908
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.2,
+ "n": 908
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 908
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 908
+ },
+ "network": {
+ "p50": 4.2,
+ "p95": 7.5,
+ "p99": 10.1,
+ "n": 908
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 4.0,
+ "p99": 4.6,
+ "n": 908
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 1.1,
+ "p99": 1.3,
+ "n": 908
+ },
+ "present": {
+ "p50": 6.2,
+ "p95": 17.0,
+ "p99": 18.3,
+ "n": 382
+ },
+ "content": {
+ "p50": 10.5,
+ "p95": 14.2,
+ "p99": 16.6,
+ "n": 908
+ },
+ "content_shown": {
+ "p50": 17.1,
+ "p95": 27.2,
+ "p99": 29.4,
+ "n": 382
+ }
+ },
+ "fps": 59.7,
+ "fps_shown": 25.1,
+ "late_pct": 0.33,
+ "stall_max": 122.8,
+ "stalls_over_100ms": 1,
+ "mbps": 0.47,
+ "keyframes": 1,
+ "size": "1280x720",
+ "captured": 1033,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 25.2,
+ "p95": 33.5,
+ "p99": 34.1,
+ "n": 30
+ },
+ "input_shown_ms": {
+ "p50": 32.4,
+ "p95": 43.4,
+ "p99": 43.4,
+ "n": 9
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.9,
+ "p95": 17.6,
+ "p99": 18.3,
+ "n": 30
+ },
+ "mac": {
+ "p50": 6.2,
+ "p95": 14.5,
+ "p99": 20.8,
+ "n": 30
+ },
+ "back": {
+ "p50": 10.7,
+ "p95": 13.5,
+ "p99": 14.8,
+ "n": 30
+ }
+ },
+ "inputs": {
+ "sent": 30,
+ "seen": 30
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.7,
+ "content_p95": 15.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 12.1,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 11.0,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.9,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.3,
+ "content_p95": 13.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.56,
+ "content_p50": 10.3,
+ "content_p95": 13.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.3,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 56,
+ "mbps": 0.44,
+ "content_p50": 8.9,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.4,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.6,
+ "content_p95": 11.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.1,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.1,
+ "content_p95": 13.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.3,
+ "content_p95": 14.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 12.3,
+ "content_p95": 14.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.4,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 12,
+ "mbps": 0.09,
+ "content_p50": 12.5,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.5,
+ "content_p95": 14.2,
+ "input_p50": 25.2,
+ "input_p95": 33.5,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.167,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 49.102,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 68.0,
+ "cpu": {
+ "mac_agent_pct": 15.5,
+ "frame_viewer_pct": 51.6,
+ "frame_tailscaled_pct": 5.2,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 9.5,
+ "frame_total_pct": 33.4
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 135 Hz",
+ "show_s": 1.37,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 843,
+ "frames_drawn": 843,
+ "frames_shown": 414,
+ "duration_s": 15.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.7,
+ "p95": 0.5,
+ "p99": 1.3,
+ "n": 843
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 843
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 9.2,
+ "p99": 17.1,
+ "n": 843
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 843
+ },
+ "network": {
+ "p50": 6.4,
+ "p95": 14.7,
+ "p99": 68.7,
+ "n": 843
+ },
+ "decode": {
+ "p50": 6.0,
+ "p95": 11.7,
+ "p99": 16.1,
+ "n": 843
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.4,
+ "p99": 1.7,
+ "n": 843
+ },
+ "present": {
+ "p50": 4.0,
+ "p95": 18.1,
+ "p99": 25.9,
+ "n": 414
+ },
+ "content": {
+ "p50": 20.5,
+ "p95": 33.6,
+ "p99": 103.0,
+ "n": 843
+ },
+ "content_shown": {
+ "p50": 26.1,
+ "p95": 41.0,
+ "p99": 122.7,
+ "n": 414
+ }
+ },
+ "fps": 55.6,
+ "fps_shown": 27.3,
+ "late_pct": 6.29,
+ "stall_max": 153.6,
+ "stalls_over_100ms": 3,
+ "mbps": 9.25,
+ "keyframes": 1,
+ "size": "1920x1290",
+ "captured": 970,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 53,
+ "mbps": 9.13,
+ "content_p50": 22.1,
+ "content_p95": 110.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 53,
+ "mbps": 9.46,
+ "content_p50": 20.7,
+ "content_p95": 27.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 56,
+ "mbps": 9.19,
+ "content_p50": 20.2,
+ "content_p95": 26.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.36,
+ "content_p50": 20.1,
+ "content_p95": 27.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 9.66,
+ "content_p50": 19.9,
+ "content_p95": 24.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 8.68,
+ "content_p50": 20.5,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 46,
+ "mbps": 10.48,
+ "content_p50": 25.4,
+ "content_p95": 126.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 56,
+ "mbps": 9.16,
+ "content_p50": 20.1,
+ "content_p95": 35.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 57,
+ "mbps": 8.76,
+ "content_p50": 21.1,
+ "content_p95": 28.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.29,
+ "content_p50": 20.5,
+ "content_p95": 24.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 8.87,
+ "content_p50": 20.4,
+ "content_p95": 40.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 9.17,
+ "content_p50": 20.3,
+ "content_p95": 23.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 56,
+ "mbps": 9.05,
+ "content_p50": 19.5,
+ "content_p95": 23.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 56,
+ "mbps": 8.85,
+ "content_p50": 20.8,
+ "content_p95": 31.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 9.48,
+ "content_p50": 20.6,
+ "content_p95": 34.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 10,
+ "mbps": 1.65,
+ "content_p50": 24.8,
+ "content_p95": 39.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 20.5,
+ "content_p95": 33.6,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 7.167,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 48.229,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 63.8,
+ "cpu": {
+ "mac_agent_pct": 8.5,
+ "frame_viewer_pct": 78.1,
+ "frame_tailscaled_pct": 8.2,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 9.0,
+ "frame_total_pct": 29.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.26,
+ "panel": "valve.steam.desktopgame.2001733678",
+ "src": "separate:5994"
+ }
+ ]
+ ]
+ },
+ "table": {
+ "test": {
+ "content_p50": {
+ "off": [
+ 9.5,
+ 9.7,
+ 10.6
+ ],
+ "on": [
+ 9.5,
+ 9.5,
+ 10.5
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 16.2,
+ 14.3,
+ 15.0
+ ],
+ "on": [
+ 13.2,
+ 137.4,
+ 14.2
+ ]
+ },
+ "input_p50": {
+ "off": [
+ 28.3,
+ 22.6,
+ 30.9
+ ],
+ "on": [
+ 27.2,
+ 32.1,
+ 25.2
+ ]
+ },
+ "input_p95": {
+ "off": [
+ 38.3,
+ 32.7,
+ 103.7
+ ],
+ "on": [
+ 51.1,
+ 63.7,
+ 33.5
+ ]
+ },
+ "fps": {
+ "off": [
+ 59.9,
+ 60.0,
+ 60.0
+ ],
+ "on": [
+ 60.0,
+ 48.7,
+ 59.7
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 2.05,
+ 0.33,
+ 0.99
+ ],
+ "on": [
+ 1.17,
+ 16.35,
+ 0.33
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 150.2,
+ 120.5,
+ 37.7
+ ],
+ "on": [
+ 75.6,
+ 267.4,
+ 122.8
+ ]
+ },
+ "mbps": {
+ "off": [
+ 0.47,
+ 0.48,
+ 0.47
+ ],
+ "on": [
+ 0.47,
+ 0.36,
+ 0.47
+ ]
+ }
+ },
+ "scroll": {
+ "content_p50": {
+ "off": [
+ 20.0,
+ 19.5,
+ 20.5
+ ],
+ "on": [
+ 19.5,
+ 19.6,
+ 20.5
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 32.3,
+ 25.2,
+ 45.9
+ ],
+ "on": [
+ 31.7,
+ 37.9,
+ 33.6
+ ]
+ },
+ "input_p50": {
+ "off": [],
+ "on": []
+ },
+ "input_p95": {
+ "off": [],
+ "on": []
+ },
+ "fps": {
+ "off": [
+ 54.9,
+ 57.0,
+ 57.3
+ ],
+ "on": [
+ 56.4,
+ 56.2,
+ 55.6
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 6.59,
+ 3.36,
+ 4.6
+ ],
+ "on": [
+ 5.6,
+ 5.52,
+ 6.29
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 84.3,
+ 105.4,
+ 321.6
+ ],
+ "on": [
+ 120.5,
+ 147.0,
+ 153.6
+ ]
+ },
+ "mbps": {
+ "off": [
+ 9.44,
+ 9.44,
+ 9.4
+ ],
+ "on": [
+ 9.4,
+ 9.36,
+ 9.25
+ ]
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-step3.json b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-step3.json
new file mode 100644
index 0000000..332db76
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-ab-adapt-step3.json
@@ -0,0 +1,3743 @@
+{
+ "label": "ab-adapt-step3",
+ "date": "2026-09-28T20:24:10",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "50@0,3@8,50@16",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 24.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "8:23.135290 [Info] - 0 - leaving standby",
+ "scenarios": [],
+ "arms": [
+ "off:FRAME_MAC_VIEW_ADAPT=0",
+ "on:"
+ ],
+ "runs": {
+ "off": [
+ [
+ {
+ "scenario": "scroll",
+ "frames_sent": 1179,
+ "frames_drawn": 1110,
+ "frames_shown": 576,
+ "duration_s": 24.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.7,
+ "p95": 0.5,
+ "p99": 4.3,
+ "n": 1179
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.3,
+ "n": 1179
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 8.7,
+ "p99": 13.8,
+ "n": 1179
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 1690.5,
+ "p99": 2066.6,
+ "n": 1179
+ },
+ "network": {
+ "p50": 13.6,
+ "p95": 4872.0,
+ "p99": 5491.7,
+ "n": 1179
+ },
+ "decode": {
+ "p50": 6.0,
+ "p95": 46.0,
+ "p99": 75.8,
+ "n": 1110
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.3,
+ "p99": 2.0,
+ "n": 1110
+ },
+ "present": {
+ "p50": 5.1,
+ "p95": 34.3,
+ "p99": 44.3,
+ "n": 576
+ },
+ "content": {
+ "p50": 27.5,
+ "p95": 4986.4,
+ "p99": 5514.6,
+ "n": 1110
+ },
+ "content_shown": {
+ "p50": 36.0,
+ "p95": 4759.3,
+ "p99": 5454.2,
+ "n": 576
+ }
+ },
+ "fps": 45.8,
+ "fps_shown": 23.8,
+ "late_pct": 19.19,
+ "stall_max": 232.1,
+ "stalls_over_100ms": 5,
+ "mbps": 8.62,
+ "keyframes": 3,
+ "size": "1920x1290",
+ "captured": 1500,
+ "viewer_never_drawn": 69,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 55,
+ "mbps": 9.44,
+ "content_p50": 22.8,
+ "content_p95": 36.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 57,
+ "mbps": 9.75,
+ "content_p50": 25.3,
+ "content_p95": 139.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 58,
+ "mbps": 9.8,
+ "content_p50": 24.2,
+ "content_p95": 110.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 58,
+ "mbps": 9.52,
+ "content_p50": 22.5,
+ "content_p95": 37.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 56,
+ "mbps": 8.91,
+ "content_p50": 23.4,
+ "content_p95": 30.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 53,
+ "mbps": 8.91,
+ "content_p50": 23.4,
+ "content_p95": 77.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 12.0,
+ "content_p50": 22.9,
+ "content_p95": 54.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 58,
+ "mbps": 8.82,
+ "content_p50": 21.8,
+ "content_p95": 28.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 58,
+ "mbps": 9.48,
+ "content_p50": 1079.9,
+ "content_p95": 2016.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 57,
+ "mbps": 9.19,
+ "content_p50": 3208.0,
+ "content_p95": 4125.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 58,
+ "mbps": 8.91,
+ "content_p50": 5139.1,
+ "content_p95": 5585.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 29,
+ "mbps": 6.33,
+ "content_p50": 5245.6,
+ "content_p95": 5430.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 10,
+ "mbps": 2.92,
+ "content_p50": 4133.7,
+ "content_p95": 4182.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 9,
+ "mbps": 4.39,
+ "content_p50": 3552.8,
+ "content_p95": 3589.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 25,
+ "mbps": 5.77,
+ "content_p50": 2820.8,
+ "content_p95": 2926.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 10,
+ "mbps": 2.52,
+ "content_p50": 1747.5,
+ "content_p95": 1791.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 13,
+ "mbps": 10.51,
+ "content_p50": 1139.0,
+ "content_p95": 1179.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 31,
+ "mbps": 13.74,
+ "content_p50": 164.3,
+ "content_p95": 295.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 57,
+ "mbps": 9.05,
+ "content_p50": 24.4,
+ "content_p95": 30.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 57,
+ "mbps": 9.45,
+ "content_p50": 29.5,
+ "content_p95": 43.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 58,
+ "mbps": 9.16,
+ "content_p50": 27.3,
+ "content_p95": 63.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 58,
+ "mbps": 9.75,
+ "content_p50": 26.9,
+ "content_p95": 40.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 57,
+ "mbps": 9.45,
+ "content_p50": 28.5,
+ "content_p95": 34.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 58,
+ "mbps": 9.21,
+ "content_p50": 25.9,
+ "content_p95": 30.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 12,
+ "mbps": 1.9,
+ "content_p50": 25.9,
+ "content_p95": 28.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 63.8,
+ "worst_p95": 5585.2,
+ "settled_s": 10.0
+ }
+ ],
+ "content_p50": 27.5,
+ "content_p95": 4986.4,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "acceptable",
+ "content_p95": "bad",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable",
+ "adapt@8s": "bad"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 10.625,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 62.0,
+ "cpu": {
+ "mac_agent_pct": 7.7,
+ "frame_viewer_pct": 62.6,
+ "frame_tailscaled_pct": 20.6,
+ "frame_sshd_pct": 2.4,
+ "frame_gamescope_pct": 9.7,
+ "frame_total_pct": 44.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.28,
+ "panel": "valve.steam.desktopgame.2001525774",
+ "src": "separate:6129"
+ },
+ {
+ "scenario": "test",
+ "frames_sent": 1464,
+ "frames_drawn": 1464,
+ "frames_shown": 1198,
+ "duration_s": 24.4,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1464
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1464
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.9,
+ "p99": 5.2,
+ "n": 1464
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 1464
+ },
+ "network": {
+ "p50": 6.8,
+ "p95": 102.3,
+ "p99": 162.7,
+ "n": 1464
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 10.7,
+ "p99": 16.6,
+ "n": 1464
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 1464
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 12.6,
+ "p99": 15.1,
+ "n": 1198
+ },
+ "content": {
+ "p50": 12.8,
+ "p95": 116.8,
+ "p99": 168.7,
+ "n": 1464
+ },
+ "content_shown": {
+ "p50": 14.7,
+ "p95": 30.1,
+ "p99": 173.8,
+ "n": 1198
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 49.1,
+ "late_pct": 3.69,
+ "stall_max": 185.4,
+ "stalls_over_100ms": 16,
+ "mbps": 0.49,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1584,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 34.4,
+ "p95": 86.0,
+ "p99": 207.9,
+ "n": 48
+ },
+ "input_shown_ms": {
+ "p50": 34.9,
+ "p95": 86.7,
+ "p99": 208.5,
+ "n": 46
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 13.9,
+ "p95": 29.5,
+ "p99": 55.9,
+ "n": 48
+ },
+ "mac": {
+ "p50": 6.5,
+ "p95": 16.2,
+ "p99": 18.2,
+ "n": 48
+ },
+ "back": {
+ "p50": 12.8,
+ "p95": 48.6,
+ "p99": 178.9,
+ "n": 48
+ }
+ },
+ "inputs": {
+ "sent": 48,
+ "seen": 48
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.52,
+ "content_p50": 11.7,
+ "content_p95": 15.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 11.8,
+ "content_p95": 16.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 11.2,
+ "content_p95": 16.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.3,
+ "content_p95": 14.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 11.1,
+ "content_p95": 15.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 10.7,
+ "content_p95": 13.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 11.6,
+ "content_p95": 15.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 11.9,
+ "content_p95": 14.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 15.7,
+ "content_p95": 19.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 15.3,
+ "content_p95": 19.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 16.1,
+ "content_p95": 55.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 23.0,
+ "content_p95": 71.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 72.3,
+ "content_p95": 174.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.5,
+ "content_p50": 79.0,
+ "content_p95": 173.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 48.3,
+ "content_p95": 163.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 68.1,
+ "content_p95": 168.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 13.8,
+ "content_p95": 125.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 13.7,
+ "content_p95": 19.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.6,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 12.1,
+ "content_p95": 16.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 12.1,
+ "content_p95": 21.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.0,
+ "content_p95": 14.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 12.5,
+ "content_p95": 15.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.8,
+ "content_p95": 13.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 24,
+ "mbps": 0.17,
+ "content_p50": 10.2,
+ "content_p95": 14.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 40.0,
+ "worst_p95": 174.5,
+ "settled_s": 9.0
+ }
+ ],
+ "content_p50": 12.8,
+ "content_p95": 116.8,
+ "input_p50": 34.4,
+ "input_p95": 86.0,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "bad",
+ "input_p50": "local",
+ "input_p95": "acceptable",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable",
+ "adapt@8s": "bad"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 6.042,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 40,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 64.9,
+ "cpu": {
+ "mac_agent_pct": 14.9,
+ "frame_viewer_pct": 43.7,
+ "frame_tailscaled_pct": 21.3,
+ "frame_sshd_pct": -1.7,
+ "frame_gamescope_pct": 9.8,
+ "frame_total_pct": 42.4
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
+ "show_s": 1.42,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ }
+ ],
+ [
+ {
+ "scenario": "scroll",
+ "frames_sent": 1168,
+ "frames_drawn": 1081,
+ "frames_shown": 565,
+ "duration_s": 24.3,
+ "stages_ms": {
+ "capture": {
+ "p50": -7.8,
+ "p95": 0.4,
+ "p99": 3.8,
+ "n": 1168
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1168
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.3,
+ "p99": 15.8,
+ "n": 1168
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 1496.6,
+ "p99": 2773.0,
+ "n": 1168
+ },
+ "network": {
+ "p50": 9.8,
+ "p95": 4895.9,
+ "p99": 5418.1,
+ "n": 1168
+ },
+ "decode": {
+ "p50": 5.9,
+ "p95": 17.7,
+ "p99": 65.2,
+ "n": 1081
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.3,
+ "p99": 1.8,
+ "n": 1081
+ },
+ "present": {
+ "p50": 5.3,
+ "p95": 30.8,
+ "p99": 41.9,
+ "n": 565
+ },
+ "content": {
+ "p50": 23.8,
+ "p95": 4355.1,
+ "p99": 5463.9,
+ "n": 1081
+ },
+ "content_shown": {
+ "p50": 32.9,
+ "p95": 4606.2,
+ "p99": 5408.3,
+ "n": 565
+ }
+ },
+ "fps": 44.5,
+ "fps_shown": 23.3,
+ "late_pct": 17.67,
+ "stall_max": 398.2,
+ "stalls_over_100ms": 2,
+ "mbps": 8.39,
+ "keyframes": 3,
+ "size": "1920x1290",
+ "captured": 1509,
+ "viewer_never_drawn": 87,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 55,
+ "mbps": 9.32,
+ "content_p50": 23.8,
+ "content_p95": 73.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 58,
+ "mbps": 9.52,
+ "content_p50": 22.8,
+ "content_p95": 28.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 58,
+ "mbps": 9.4,
+ "content_p50": 22.9,
+ "content_p95": 26.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 57,
+ "mbps": 9.62,
+ "content_p50": 23.6,
+ "content_p95": 33.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 8.86,
+ "content_p50": 22.4,
+ "content_p95": 25.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 57,
+ "mbps": 9.67,
+ "content_p50": 23.2,
+ "content_p95": 29.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 12.04,
+ "content_p50": 23.7,
+ "content_p95": 46.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 58,
+ "mbps": 8.85,
+ "content_p50": 22.5,
+ "content_p95": 27.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 57,
+ "mbps": 9.27,
+ "content_p50": 979.8,
+ "content_p95": 1991.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 58,
+ "mbps": 8.89,
+ "content_p50": 3131.5,
+ "content_p95": 3947.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 58,
+ "mbps": 9.25,
+ "content_p50": 5057.4,
+ "content_p95": 5532.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 5,
+ "mbps": 6.6,
+ "content_p50": 5369.2,
+ "content_p95": 5392.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 0,
+ "mbps": 3.01,
+ "content_p50": null,
+ "content_p95": null,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 0,
+ "mbps": 5.04,
+ "content_p50": null,
+ "content_p95": null,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 0,
+ "mbps": 1.64,
+ "content_p50": null,
+ "content_p95": null,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 0,
+ "mbps": 2.19,
+ "content_p50": null,
+ "content_p95": null,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 27,
+ "mbps": 13.34,
+ "content_p50": 332.4,
+ "content_p95": 451.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 57,
+ "mbps": 9.49,
+ "content_p50": 24.1,
+ "content_p95": 32.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 58,
+ "mbps": 9.06,
+ "content_p50": 23.0,
+ "content_p95": 28.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 58,
+ "mbps": 9.39,
+ "content_p50": 23.3,
+ "content_p95": 26.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 57,
+ "mbps": 8.89,
+ "content_p50": 23.4,
+ "content_p95": 32.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 58,
+ "mbps": 9.87,
+ "content_p50": 23.7,
+ "content_p95": 28.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 57,
+ "mbps": 9.49,
+ "content_p50": 22.2,
+ "content_p95": 29.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 57,
+ "mbps": 8.75,
+ "content_p50": 22.3,
+ "content_p95": 27.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 15,
+ "mbps": 2.32,
+ "content_p50": 22.0,
+ "content_p95": 25.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 42.7,
+ "worst_p95": 5532.6,
+ "settled_s": 9.0
+ }
+ ],
+ "content_p50": 23.8,
+ "content_p95": 4355.1,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "bad",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad",
+ "adapt@8s": "bad"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 10.292,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 62.1,
+ "cpu": {
+ "mac_agent_pct": 7.4,
+ "frame_viewer_pct": 65.4,
+ "frame_tailscaled_pct": 8.1,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 11.5,
+ "frame_total_pct": 38.7
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.29,
+ "panel": "valve.steam.desktopgame.2001170343",
+ "src": "separate:6551"
+ },
+ {
+ "scenario": "test",
+ "frames_sent": 1459,
+ "frames_drawn": 1459,
+ "frames_shown": 572,
+ "duration_s": 24.3,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1459
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1459
+ },
+ "encode": {
+ "p50": 4.1,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 1459
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 1459
+ },
+ "network": {
+ "p50": 4.6,
+ "p95": 9.0,
+ "p99": 17.9,
+ "n": 1459
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.6,
+ "p99": 4.2,
+ "n": 1459
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 1459
+ },
+ "present": {
+ "p50": 5.1,
+ "p95": 15.6,
+ "p99": 18.8,
+ "n": 572
+ },
+ "content": {
+ "p50": 10.7,
+ "p95": 15.6,
+ "p99": 24.6,
+ "n": 1459
+ },
+ "content_shown": {
+ "p50": 16.1,
+ "p95": 27.1,
+ "p99": 31.8,
+ "n": 572
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 23.5,
+ "late_pct": 1.92,
+ "stall_max": 157.3,
+ "stalls_over_100ms": 1,
+ "mbps": 0.46,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1580,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 29.6,
+ "p95": 50.1,
+ "p99": 163.0,
+ "n": 48
+ },
+ "input_shown_ms": {
+ "p50": 36.5,
+ "p95": 52.4,
+ "p99": 163.6,
+ "n": 21
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 12.0,
+ "p95": 21.1,
+ "p99": 34.4,
+ "n": 48
+ },
+ "mac": {
+ "p50": 5.5,
+ "p95": 15.1,
+ "p99": 16.3,
+ "n": 48
+ },
+ "back": {
+ "p50": 11.0,
+ "p95": 15.7,
+ "p99": 150.9,
+ "n": 48
+ }
+ },
+ "inputs": {
+ "sent": 48,
+ "seen": 48
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.3,
+ "content_p95": 14.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.8,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.1,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.0,
+ "content_p95": 12.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.6,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.9,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.56,
+ "content_p50": 10.1,
+ "content_p95": 13.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.8,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.8,
+ "content_p95": 16.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.4,
+ "content_p95": 14.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.8,
+ "content_p95": 14.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.7,
+ "content_p95": 15.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.9,
+ "content_p95": 17.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 13.3,
+ "content_p95": 16.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 13.4,
+ "content_p95": 107.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 13.4,
+ "content_p95": 18.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.55,
+ "content_p50": 9.5,
+ "content_p95": 13.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.43,
+ "content_p50": 9.3,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.8,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.5,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.7,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 9.3,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.5,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 19,
+ "mbps": 0.13,
+ "content_p50": 10.2,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 40.0,
+ "worst_p95": 107.3,
+ "settled_s": 0.0
+ }
+ ],
+ "content_p50": 10.7,
+ "content_p95": 15.6,
+ "input_p50": 29.6,
+ "input_p95": 50.1,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable",
+ "adapt@8s": "local"
+ },
+ "controller": {
+ "adapt": false,
+ "baseRtt": 5.916,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 40,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.0,
+ "cpu": {
+ "mac_agent_pct": 14.7,
+ "frame_viewer_pct": 54.2,
+ "frame_tailscaled_pct": 6.0,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 14.5,
+ "frame_total_pct": 39.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 70 Hz",
+ "show_s": 1.37,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ }
+ ]
+ ],
+ "on": [
+ [
+ {
+ "scenario": "scroll",
+ "frames_sent": 1012,
+ "frames_drawn": 1012,
+ "frames_shown": 975,
+ "duration_s": 24.2,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.2,
+ "p95": 3.0,
+ "p99": 4.5,
+ "n": 1012
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 15.9,
+ "p99": 17.4,
+ "n": 1012
+ },
+ "encode": {
+ "p50": 6.4,
+ "p95": 7.5,
+ "p99": 8.5,
+ "n": 1012
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.4,
+ "n": 1012
+ },
+ "network": {
+ "p50": 9.9,
+ "p95": 51.0,
+ "p99": 167.2,
+ "n": 1012
+ },
+ "decode": {
+ "p50": 5.2,
+ "p95": 8.7,
+ "p99": 13.2,
+ "n": 1012
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.4,
+ "p99": 1.7,
+ "n": 1012
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 11.6,
+ "p99": 14.3,
+ "n": 975
+ },
+ "content": {
+ "p50": 26.6,
+ "p95": 69.2,
+ "p99": 189.9,
+ "n": 1012
+ },
+ "content_shown": {
+ "p50": 31.6,
+ "p95": 71.1,
+ "p99": 190.4,
+ "n": 975
+ }
+ },
+ "fps": 41.8,
+ "fps_shown": 40.3,
+ "late_pct": 31.82,
+ "stall_max": 245.3,
+ "stalls_over_100ms": 5,
+ "mbps": 6.13,
+ "keyframes": 3,
+ "size": "1920x1290",
+ "captured": 1507,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 56,
+ "mbps": 9.54,
+ "content_p50": 21.9,
+ "content_p95": 42.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 58,
+ "mbps": 9.38,
+ "content_p50": 21.4,
+ "content_p95": 26.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 9.41,
+ "content_p50": 22.3,
+ "content_p95": 27.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 58,
+ "mbps": 9.67,
+ "content_p50": 23.0,
+ "content_p95": 25.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 57,
+ "mbps": 8.99,
+ "content_p50": 22.9,
+ "content_p95": 28.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 58,
+ "mbps": 9.25,
+ "content_p50": 23.3,
+ "content_p95": 32.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 57,
+ "mbps": 11.74,
+ "content_p50": 22.2,
+ "content_p95": 46.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 58,
+ "mbps": 8.89,
+ "content_p50": 21.1,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 25,
+ "mbps": 3.02,
+ "content_p50": 75.9,
+ "content_p95": 218.9,
+ "bitrate": 3715200,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 50,
+ "mbps": 3.15,
+ "content_p50": 56.5,
+ "content_p95": 84.0,
+ "bitrate": 2112376,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 24,
+ "mbps": 0.94,
+ "content_p50": 37.9,
+ "content_p95": 54.8,
+ "bitrate": 1689900,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 30,
+ "mbps": 1.93,
+ "content_p50": 48.0,
+ "content_p95": 57.0,
+ "bitrate": 2414756,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 30,
+ "mbps": 2.74,
+ "content_p50": 58.5,
+ "content_p95": 92.4,
+ "bitrate": 2611641,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 30,
+ "mbps": 2.12,
+ "content_p50": 45.3,
+ "content_p95": 79.0,
+ "bitrate": 2089312,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 30,
+ "mbps": 2.24,
+ "content_p50": 52.0,
+ "content_p95": 71.4,
+ "bitrate": 2633067,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 30,
+ "mbps": 2.47,
+ "content_p50": 53.7,
+ "content_p95": 93.6,
+ "bitrate": 2357098,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 30,
+ "mbps": 2.61,
+ "content_p50": 24.7,
+ "content_p95": 39.8,
+ "bitrate": 2957087,
+ "tier": 3,
+ "w": 1440,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 30,
+ "mbps": 3.74,
+ "content_p50": 29.5,
+ "content_p95": 38.0,
+ "bitrate": 4561519,
+ "tier": 3,
+ "w": 1440,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 30,
+ "mbps": 5.32,
+ "content_p50": 29.2,
+ "content_p95": 41.6,
+ "bitrate": 6236880,
+ "tier": 3,
+ "w": 1440,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 30,
+ "mbps": 6.62,
+ "content_p50": 35.0,
+ "content_p95": 68.5,
+ "bitrate": 8466786,
+ "tier": 2,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 30,
+ "mbps": 7.48,
+ "content_p50": 36.1,
+ "content_p95": 41.1,
+ "bitrate": 12628270,
+ "tier": 2,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 42,
+ "mbps": 7.71,
+ "content_p50": 31.7,
+ "content_p95": 39.0,
+ "bitrate": 14860800,
+ "tier": 1,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 46,
+ "mbps": 8.79,
+ "content_p50": 33.6,
+ "content_p95": 40.6,
+ "bitrate": 14860800,
+ "tier": 1,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 53,
+ "mbps": 8.78,
+ "content_p50": 28.4,
+ "content_p95": 42.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 13,
+ "mbps": 1.89,
+ "content_p50": 24.7,
+ "content_p95": 28.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 45.2,
+ "worst_p95": 218.9,
+ "settled_s": 8.0
+ }
+ ],
+ "content_p50": 26.6,
+ "content_p95": 69.2,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "acceptable",
+ "content_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "acceptable",
+ "adapt@8s": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.333,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 51.355,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 8.31,
+ "e": "down to 7430 kbit/s: queue 100 ms, held 6, oldest 197 ms, base 10 ms, sent 5528 got 5268 wants 8906"
+ },
+ {
+ "t": 8.71,
+ "e": "down to 3715 kbit/s: queue 209 ms, held 4, oldest 115 ms, base 10 ms, sent 2861 got 2421 wants 10373"
+ },
+ {
+ "t": 9.41,
+ "e": "down to 2640 kbit/s: queue 48 ms, held 0, oldest 59 ms, base 10 ms, sent 3222 got 2933 wants 3166"
+ },
+ {
+ "t": 9.71,
+ "e": "down to 2112 kbit/s: queue 48 ms, held 0, oldest 31 ms, base 10 ms, sent 2907 got 3032 wants 2906"
+ },
+ {
+ "t": 9.81,
+ "e": "tier 3"
+ },
+ {
+ "t": 10.21,
+ "e": "down to 1689 kbit/s: queue 186 ms, held 4, oldest 0 ms, base 10 ms, sent 2091 got 2288 wants 3137"
+ },
+ {
+ "t": 12.91,
+ "e": "down to 2611 kbit/s: queue 44 ms, held 0, oldest 27 ms, base 10 ms, sent 3243 got 2901 wants 3243"
+ },
+ {
+ "t": 13.31,
+ "e": "down to 2089 kbit/s: queue 41 ms, held 0, oldest 60 ms, base 10 ms, sent 2775 got 2764 wants 2775"
+ },
+ {
+ "t": 15.31,
+ "e": "down to 2357 kbit/s: queue 44 ms, held 0, oldest 28 ms, base 10 ms, sent 2539 got 2729 wants 2539"
+ },
+ {
+ "t": 19.11,
+ "e": "tier 2"
+ },
+ {
+ "t": 21.31,
+ "e": "tier 1"
+ },
+ {
+ "t": 23.51,
+ "e": "tier 0"
+ }
+ ],
+ "source_fps": 62.3,
+ "cpu": {
+ "mac_agent_pct": 7.3,
+ "frame_viewer_pct": 57.5,
+ "frame_tailscaled_pct": 7.4,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 10.6,
+ "frame_total_pct": 47.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001853011",
+ "src": "separate:6269"
+ },
+ {
+ "scenario": "test",
+ "frames_sent": 1457,
+ "frames_drawn": 1457,
+ "frames_shown": 578,
+ "duration_s": 24.3,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1457
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1457
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.9,
+ "p99": 5.2,
+ "n": 1457
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 1457
+ },
+ "network": {
+ "p50": 5.0,
+ "p95": 10.1,
+ "p99": 13.0,
+ "n": 1457
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.2,
+ "p99": 3.6,
+ "n": 1457
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 1.1,
+ "n": 1457
+ },
+ "present": {
+ "p50": 5.6,
+ "p95": 16.1,
+ "p99": 18.0,
+ "n": 578
+ },
+ "content": {
+ "p50": 11.1,
+ "p95": 16.2,
+ "p99": 19.2,
+ "n": 1457
+ },
+ "content_shown": {
+ "p50": 18.9,
+ "p95": 27.0,
+ "p99": 31.0,
+ "n": 578
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 23.8,
+ "late_pct": 0.82,
+ "stall_max": 32.9,
+ "stalls_over_100ms": 0,
+ "mbps": 0.47,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1578,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 30.0,
+ "p95": 47.7,
+ "p99": 76.0,
+ "n": 48
+ },
+ "input_shown_ms": {
+ "p50": 33.7,
+ "p95": 47.5,
+ "p99": 81.8,
+ "n": 17
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 10.2,
+ "p95": 27.4,
+ "p99": 49.5,
+ "n": 48
+ },
+ "mac": {
+ "p50": 5.3,
+ "p95": 15.1,
+ "p99": 16.3,
+ "n": 48
+ },
+ "back": {
+ "p50": 12.5,
+ "p95": 18.7,
+ "p99": 19.2,
+ "n": 48
+ }
+ },
+ "inputs": {
+ "sent": 48,
+ "seen": 48
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.0,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.5,
+ "content_p95": 15.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.1,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.6,
+ "content_p95": 13.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.7,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.8,
+ "content_p95": 12.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.56,
+ "content_p50": 10.8,
+ "content_p95": 13.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.9,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 13.4,
+ "content_p95": 15.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 13.8,
+ "content_p95": 16.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.1,
+ "content_p95": 16.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 13.8,
+ "content_p95": 22.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.3,
+ "content_p95": 18.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.2,
+ "content_p95": 18.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 14.1,
+ "content_p95": 18.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 14.2,
+ "content_p95": 17.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 9.9,
+ "content_p95": 18.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.9,
+ "content_p95": 11.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.0,
+ "content_p95": 12.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.0,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.8,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.9,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.9,
+ "content_p95": 12.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 17,
+ "mbps": 0.11,
+ "content_p50": 10.9,
+ "content_p95": 12.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 40.0,
+ "worst_p95": 22.7,
+ "settled_s": 0.0
+ }
+ ],
+ "content_p50": 11.1,
+ "content_p95": 16.2,
+ "input_p50": 30.0,
+ "input_p95": 47.7,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "local",
+ "adapt@8s": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.083,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 47.104,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 64.9,
+ "cpu": {
+ "mac_agent_pct": 15.6,
+ "frame_viewer_pct": 49.6,
+ "frame_tailscaled_pct": 5.8,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 11.3,
+ "frame_total_pct": 37.4
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 70 Hz",
+ "show_s": 1.29,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ }
+ ],
+ [
+ {
+ "scenario": "scroll",
+ "frames_sent": 1044,
+ "frames_drawn": 1044,
+ "frames_shown": 786,
+ "duration_s": 24.3,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.6,
+ "p95": 0.4,
+ "p99": 1.8,
+ "n": 1044
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 14.9,
+ "p99": 17.0,
+ "n": 1044
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 8.8,
+ "p99": 17.0,
+ "n": 1044
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.5,
+ "n": 1044
+ },
+ "network": {
+ "p50": 10.1,
+ "p95": 52.0,
+ "p99": 225.2,
+ "n": 1044
+ },
+ "decode": {
+ "p50": 5.4,
+ "p95": 9.5,
+ "p99": 17.6,
+ "n": 1044
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.3,
+ "p99": 1.7,
+ "n": 1044
+ },
+ "present": {
+ "p50": 0.7,
+ "p95": 14.4,
+ "p99": 22.3,
+ "n": 786
+ },
+ "content": {
+ "p50": 25.5,
+ "p95": 74.6,
+ "p99": 236.2,
+ "n": 1044
+ },
+ "content_shown": {
+ "p50": 33.0,
+ "p95": 74.9,
+ "p99": 234.6,
+ "n": 786
+ }
+ },
+ "fps": 43.0,
+ "fps_shown": 32.4,
+ "late_pct": 27.59,
+ "stall_max": 430.7,
+ "stalls_over_100ms": 7,
+ "mbps": 6.22,
+ "keyframes": 3,
+ "size": "1920x1290",
+ "captured": 1499,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 54,
+ "mbps": 9.98,
+ "content_p50": 23.9,
+ "content_p95": 33.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 57,
+ "mbps": 9.24,
+ "content_p50": 23.4,
+ "content_p95": 32.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 57,
+ "mbps": 9.09,
+ "content_p50": 23.0,
+ "content_p95": 32.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 54,
+ "mbps": 9.77,
+ "content_p50": 24.1,
+ "content_p95": 36.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 58,
+ "mbps": 9.09,
+ "content_p50": 23.2,
+ "content_p95": 27.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 52,
+ "mbps": 8.72,
+ "content_p50": 24.5,
+ "content_p95": 63.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 58,
+ "mbps": 11.69,
+ "content_p50": 23.1,
+ "content_p95": 50.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 57,
+ "mbps": 8.99,
+ "content_p50": 22.4,
+ "content_p95": 32.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 20,
+ "mbps": 3.23,
+ "content_p50": 205.5,
+ "content_p95": 270.8,
+ "bitrate": 7430400,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 34,
+ "mbps": 1.66,
+ "content_p50": 18.4,
+ "content_p95": 428.1,
+ "bitrate": 1765116,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 57,
+ "mbps": 1.27,
+ "content_p50": 23.3,
+ "content_p95": 101.0,
+ "bitrate": 2240789,
+ "tier": 0,
+ "w": 1920,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 35,
+ "mbps": 1.73,
+ "content_p50": 31.8,
+ "content_p95": 148.1,
+ "bitrate": 1405577,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 30,
+ "mbps": 1.26,
+ "content_p50": 38.8,
+ "content_p95": 64.3,
+ "bitrate": 1596134,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 31,
+ "mbps": 1.84,
+ "content_p50": 43.3,
+ "content_p95": 59.6,
+ "bitrate": 2289953,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 30,
+ "mbps": 2.61,
+ "content_p50": 59.7,
+ "content_p95": 85.4,
+ "bitrate": 2373537,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 30,
+ "mbps": 2.39,
+ "content_p50": 53.7,
+ "content_p95": 87.6,
+ "bitrate": 2660890,
+ "tier": 3,
+ "w": 1440,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 30,
+ "mbps": 3.04,
+ "content_p50": 25.4,
+ "content_p95": 37.6,
+ "bitrate": 3707143,
+ "tier": 3,
+ "w": 1440,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 30,
+ "mbps": 4.45,
+ "content_p50": 31.8,
+ "content_p95": 41.7,
+ "bitrate": 5099706,
+ "tier": 3,
+ "w": 1440,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 28,
+ "mbps": 5.76,
+ "content_p50": 34.2,
+ "content_p95": 51.9,
+ "bitrate": 7698527,
+ "tier": 2,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 30,
+ "mbps": 7.38,
+ "content_p50": 37.7,
+ "content_p95": 45.2,
+ "bitrate": 10412237,
+ "tier": 2,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 41,
+ "mbps": 8.63,
+ "content_p50": 34.5,
+ "content_p95": 64.1,
+ "bitrate": 14024186,
+ "tier": 1,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 46,
+ "mbps": 7.87,
+ "content_p50": 35.1,
+ "content_p95": 45.4,
+ "bitrate": 14860800,
+ "tier": 1,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 53,
+ "mbps": 9.21,
+ "content_p50": 24.4,
+ "content_p95": 37.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 57,
+ "mbps": 9.68,
+ "content_p50": 23.2,
+ "content_p95": 31.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 15,
+ "mbps": 2.34,
+ "content_p50": 25.1,
+ "content_p95": 34.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 62.6,
+ "worst_p95": 428.1,
+ "settled_s": 8.0
+ }
+ ],
+ "content_p50": 25.5,
+ "content_p95": 74.6,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "acceptable",
+ "content_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad",
+ "adapt@8s": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 7.875,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 4,
+ "scale": 1,
+ "slack": 48.916,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 8.32,
+ "e": "down to 7430 kbit/s: queue 98 ms, held 6, oldest 191 ms, base 9 ms, sent 6068 got 6112 wants 9608"
+ },
+ {
+ "t": 8.92,
+ "e": "down to 3715 kbit/s: queue 197 ms, held 5, oldest 239 ms, base 9 ms, sent 2451 got 2882 wants 8733"
+ },
+ {
+ "t": 9.22,
+ "e": "down to 2206 kbit/s: queue 237 ms, held 4, oldest 161 ms, base 9 ms, sent 3261 got 2451 wants 10510"
+ },
+ {
+ "t": 9.52,
+ "e": "down to 1765 kbit/s: queue 392 ms, held 6, oldest 456 ms, base 9 ms, sent 2553 got 2705 wants 10396"
+ },
+ {
+ "t": 11.22,
+ "e": "down to 1756 kbit/s: queue 48 ms, held 0, oldest 3 ms, base 8 ms, sent 1996 got 1952 wants 2107"
+ },
+ {
+ "t": 11.52,
+ "e": "tier 3"
+ },
+ {
+ "t": 11.92,
+ "e": "down to 1405 kbit/s: queue 158 ms, held 2, oldest 0 ms, base 8 ms, sent 1907 got 1907 wants 2384"
+ },
+ {
+ "t": 14.92,
+ "e": "down to 2373 kbit/s: queue 47 ms, held 0, oldest 58 ms, base 8 ms, sent 2973 got 2637 wants 2972"
+ },
+ {
+ "t": 18.22,
+ "e": "tier 2"
+ },
+ {
+ "t": 20.32,
+ "e": "tier 1"
+ },
+ {
+ "t": 22.42,
+ "e": "tier 0"
+ }
+ ],
+ "source_fps": 61.7,
+ "cpu": {
+ "mac_agent_pct": 7.1,
+ "frame_viewer_pct": 59.9,
+ "frame_tailscaled_pct": 7.4,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 9.8,
+ "frame_total_pct": 37.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 70 Hz",
+ "show_s": 1.32,
+ "panel": "valve.steam.desktopgame.2001220345",
+ "src": "separate:6403"
+ },
+ {
+ "scenario": "test",
+ "frames_sent": 1455,
+ "frames_drawn": 1455,
+ "frames_shown": 569,
+ "duration_s": 24.3,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1455
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1455
+ },
+ "encode": {
+ "p50": 4.0,
+ "p95": 4.7,
+ "p99": 5.0,
+ "n": 1455
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 1455
+ },
+ "network": {
+ "p50": 5.7,
+ "p95": 9.8,
+ "p99": 13.3,
+ "n": 1455
+ },
+ "decode": {
+ "p50": 1.1,
+ "p95": 2.6,
+ "p99": 3.9,
+ "n": 1455
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 1455
+ },
+ "present": {
+ "p50": 5.7,
+ "p95": 17.2,
+ "p99": 19.5,
+ "n": 569
+ },
+ "content": {
+ "p50": 11.5,
+ "p95": 15.9,
+ "p99": 19.4,
+ "n": 1455
+ },
+ "content_shown": {
+ "p50": 17.6,
+ "p95": 29.4,
+ "p99": 31.2,
+ "n": 569
+ }
+ },
+ "fps": 59.8,
+ "fps_shown": 23.4,
+ "late_pct": 1.1,
+ "stall_max": 115.1,
+ "stalls_over_100ms": 1,
+ "mbps": 0.47,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1580,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 29.1,
+ "p95": 50.1,
+ "p99": 55.5,
+ "n": 48
+ },
+ "input_shown_ms": {
+ "p50": 44.2,
+ "p95": 51.9,
+ "p99": 55.9,
+ "n": 15
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 10.1,
+ "p95": 23.2,
+ "p99": 27.8,
+ "n": 48
+ },
+ "mac": {
+ "p50": 6.0,
+ "p95": 17.1,
+ "p99": 20.1,
+ "n": 48
+ },
+ "back": {
+ "p50": 11.6,
+ "p95": 17.2,
+ "p99": 18.3,
+ "n": 48
+ }
+ },
+ "inputs": {
+ "sent": 48,
+ "seen": 48
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.9,
+ "content_p95": 13.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 11.0,
+ "content_p95": 13.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 11.0,
+ "content_p95": 14.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.9,
+ "content_p95": 15.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 4,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 11.4,
+ "content_p95": 18.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 5,
+ "fps": 59,
+ "mbps": 0.47,
+ "content_p50": 10.8,
+ "content_p95": 13.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 6,
+ "fps": 61,
+ "mbps": 0.58,
+ "content_p50": 11.3,
+ "content_p95": 25.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 7,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 11.0,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 8,
+ "fps": 61,
+ "mbps": 0.48,
+ "content_p50": 13.9,
+ "content_p95": 17.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 9,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 13.8,
+ "content_p95": 18.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 10,
+ "fps": 61,
+ "mbps": 0.45,
+ "content_p50": 13.9,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 11,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 11.8,
+ "content_p95": 15.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 12,
+ "fps": 61,
+ "mbps": 0.46,
+ "content_p50": 13.4,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 13,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 13.4,
+ "content_p95": 16.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 14,
+ "fps": 57,
+ "mbps": 0.43,
+ "content_p50": 13.2,
+ "content_p95": 18.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 15,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 13.4,
+ "content_p95": 16.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 3.0
+ },
+ {
+ "t": 16,
+ "fps": 61,
+ "mbps": 0.56,
+ "content_p50": 10.5,
+ "content_p95": 15.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 17,
+ "fps": 59,
+ "mbps": 0.43,
+ "content_p50": 10.8,
+ "content_p95": 14.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 18,
+ "fps": 61,
+ "mbps": 0.46,
+ "content_p50": 10.7,
+ "content_p95": 13.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 19,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 10.7,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 20,
+ "fps": 61,
+ "mbps": 0.46,
+ "content_p50": 10.5,
+ "content_p95": 15.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 21,
+ "fps": 59,
+ "mbps": 0.44,
+ "content_p50": 10.7,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 22,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.8,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 23,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.6,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ },
+ {
+ "t": 24,
+ "fps": 19,
+ "mbps": 0.13,
+ "content_p50": 10.6,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": 50.0
+ }
+ ],
+ "adapt": [
+ {
+ "at": 8.0,
+ "to_mbit": 3.0,
+ "p95_bound": 40.0,
+ "worst_p95": 18.3,
+ "settled_s": 0.0
+ }
+ ],
+ "content_p50": 11.5,
+ "content_p95": 15.9,
+ "input_p50": 29.1,
+ "input_p95": 50.1,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable",
+ "adapt@8s": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6,
+ "ceiling": 5529600,
+ "fps": 60,
+ "inFlight": 2,
+ "scale": 1,
+ "slack": 49.792,
+ "target": 5529600,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 65.0,
+ "cpu": {
+ "mac_agent_pct": 14.2,
+ "frame_viewer_pct": 52.2,
+ "frame_tailscaled_pct": 5.8,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 12.2,
+ "frame_total_pct": 38.8
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
+ "show_s": 1.38,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ }
+ ]
+ ]
+ },
+ "table": {
+ "scroll": {
+ "content_p50": {
+ "off": [
+ 27.5,
+ 23.8
+ ],
+ "on": [
+ 26.6,
+ 25.5
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 4986.4,
+ 4355.1
+ ],
+ "on": [
+ 69.2,
+ 74.6
+ ]
+ },
+ "input_p50": {
+ "off": [],
+ "on": []
+ },
+ "input_p95": {
+ "off": [],
+ "on": []
+ },
+ "fps": {
+ "off": [
+ 45.8,
+ 44.5
+ ],
+ "on": [
+ 41.8,
+ 43.0
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 19.19,
+ 17.67
+ ],
+ "on": [
+ 31.82,
+ 27.59
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 232.1,
+ 398.2
+ ],
+ "on": [
+ 245.3,
+ 430.7
+ ]
+ },
+ "mbps": {
+ "off": [
+ 8.62,
+ 8.39
+ ],
+ "on": [
+ 6.13,
+ 6.22
+ ]
+ }
+ },
+ "test": {
+ "content_p50": {
+ "off": [
+ 12.8,
+ 10.7
+ ],
+ "on": [
+ 11.1,
+ 11.5
+ ]
+ },
+ "content_p95": {
+ "off": [
+ 116.8,
+ 15.6
+ ],
+ "on": [
+ 16.2,
+ 15.9
+ ]
+ },
+ "input_p50": {
+ "off": [
+ 34.4,
+ 29.6
+ ],
+ "on": [
+ 30.0,
+ 29.1
+ ]
+ },
+ "input_p95": {
+ "off": [
+ 86.0,
+ 50.1
+ ],
+ "on": [
+ 47.7,
+ 50.1
+ ]
+ },
+ "fps": {
+ "off": [
+ 60.0,
+ 60.0
+ ],
+ "on": [
+ 60.0,
+ 59.8
+ ]
+ },
+ "late_pct": {
+ "off": [
+ 3.69,
+ 1.92
+ ],
+ "on": [
+ 0.82,
+ 1.1
+ ]
+ },
+ "stall_max": {
+ "off": [
+ 185.4,
+ 157.3
+ ],
+ "on": [
+ 32.9,
+ 115.1
+ ]
+ },
+ "mbps": {
+ "off": [
+ 0.49,
+ 0.46
+ ],
+ "on": [
+ 0.47,
+ 0.47
+ ]
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-ab-keepwarm.json b/bench/results/2026-09-28-a3c6e5c-dirty-ab-keepwarm.json
new file mode 100644
index 0000000..21c9e37
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-ab-keepwarm.json
@@ -0,0 +1,2172 @@
+{
+ "label": "ab-keepwarm",
+ "date": "2026-09-28T20:28:57",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": "8:23.135290 [Info] - 0 - leaving standby",
+ "scenarios": [],
+ "arms": [
+ "cold:FRAME_MAC_VIEW_WARM=0",
+ "warm:FRAME_MAC_VIEW_WARM=40"
+ ],
+ "runs": {
+ "cold": [
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 145,
+ "frames_drawn": 145,
+ "frames_shown": 142,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -2.5,
+ "p95": 2.7,
+ "p99": 19.4,
+ "n": 145
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.3,
+ "n": 145
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 9.6,
+ "p99": 18.2,
+ "n": 145
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 1.0,
+ "n": 145
+ },
+ "network": {
+ "p50": 3.7,
+ "p95": 7.5,
+ "p99": 22.8,
+ "n": 145
+ },
+ "decode": {
+ "p50": 2.1,
+ "p95": 4.2,
+ "p99": 5.1,
+ "n": 145
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.3,
+ "p99": 2.0,
+ "n": 145
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 9.3,
+ "p99": 12.6,
+ "n": 142
+ },
+ "content": {
+ "p50": 13.9,
+ "p95": 25.6,
+ "p99": 38.0,
+ "n": 145
+ },
+ "content_shown": {
+ "p50": 14.9,
+ "p95": 26.0,
+ "p99": 36.9,
+ "n": 142
+ }
+ },
+ "fps": 6.9,
+ "fps_shown": 6.7,
+ "late_pct": 51.37,
+ "stall_max": 1007.5,
+ "stalls_over_100ms": 66,
+ "mbps": 0.03,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 145,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 44.5,
+ "p95": 68.2,
+ "p99": 72.7,
+ "n": 66
+ },
+ "input_shown_ms": {
+ "p50": 44.9,
+ "p95": 68.9,
+ "p99": 73.1,
+ "n": 66
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 4.2,
+ "p95": 17.4,
+ "p99": 41.0,
+ "n": 66
+ },
+ "mac": {
+ "p50": 22.8,
+ "p95": 36.8,
+ "p99": 42.9,
+ "n": 66
+ },
+ "back": {
+ "p50": 14.5,
+ "p95": 20.7,
+ "p99": 25.4,
+ "n": 66
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 66
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 37,
+ "mbps": 0.26,
+ "content_p50": 14.9,
+ "content_p95": 36.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 42,
+ "mbps": 0.15,
+ "content_p50": 12.7,
+ "content_p95": 15.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 15.1,
+ "content_p95": 20.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.6,
+ "content_p95": 14.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.5,
+ "content_p95": 18.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.0,
+ "content_p95": 20.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.5,
+ "content_p95": 15.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.5,
+ "content_p95": 18.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.4,
+ "content_p95": 16.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.3,
+ "content_p95": 16.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.9,
+ "content_p95": 16.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.0,
+ "content_p95": 18.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 13.2,
+ "content_p95": 17.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 12.9,
+ "content_p95": 13.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 14.2,
+ "content_p95": 25.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 13.9,
+ "content_p95": 13.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 15.8,
+ "content_p95": 15.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 13.6,
+ "content_p95": 25.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 15.7,
+ "content_p95": 15.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 17.6,
+ "content_p95": 17.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 16.0,
+ "content_p95": 16.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 13.9,
+ "content_p95": 25.6,
+ "input_p50": 44.5,
+ "input_p95": 68.2,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.75,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 6.9,
+ "cpu": {
+ "mac_agent_pct": 1.7,
+ "frame_viewer_pct": 10.2,
+ "frame_tailscaled_pct": 1.6,
+ "frame_sshd_pct": 0.2,
+ "frame_gamescope_pct": 5.4,
+ "frame_total_pct": 32.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.57,
+ "panel": "valve.steam.desktopgame.2001615880",
+ "src": "separate:6692"
+ }
+ ],
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 190,
+ "frames_drawn": 190,
+ "frames_shown": 190,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.7,
+ "p95": 4.9,
+ "p99": 7.4,
+ "n": 190
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 190
+ },
+ "encode": {
+ "p50": 6.4,
+ "p95": 9.0,
+ "p99": 12.6,
+ "n": 190
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 190
+ },
+ "network": {
+ "p50": 2.8,
+ "p95": 5.1,
+ "p99": 7.8,
+ "n": 190
+ },
+ "decode": {
+ "p50": 2.0,
+ "p95": 4.0,
+ "p99": 5.9,
+ "n": 190
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.5,
+ "p99": 2.0,
+ "n": 190
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 8.5,
+ "p99": 13.4,
+ "n": 190
+ },
+ "content": {
+ "p50": 12.8,
+ "p95": 19.4,
+ "p99": 21.3,
+ "n": 190
+ },
+ "content_shown": {
+ "p50": 13.8,
+ "p95": 22.9,
+ "p99": 25.0,
+ "n": 190
+ }
+ },
+ "fps": 9.0,
+ "fps_shown": 9.0,
+ "late_pct": 41.88,
+ "stall_max": 2314.7,
+ "stalls_over_100ms": 67,
+ "mbps": 0.03,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 190,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 35.9,
+ "p95": 91.9,
+ "p99": 180.6,
+ "n": 65
+ },
+ "input_shown_ms": {
+ "p50": 36.3,
+ "p95": 92.4,
+ "p99": 181.0,
+ "n": 65
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.3,
+ "p95": 19.9,
+ "p99": 57.7,
+ "n": 65
+ },
+ "mac": {
+ "p50": 17.6,
+ "p95": 38.0,
+ "p99": 89.2,
+ "n": 65
+ },
+ "back": {
+ "p50": 13.2,
+ "p95": 20.9,
+ "p99": 21.3,
+ "n": 65
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 65
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 14,
+ "mbps": 0.03,
+ "content_p50": 12.3,
+ "content_p95": 17.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 12.0,
+ "content_p95": 20.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.5,
+ "content_p95": 19.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 12.5,
+ "content_p95": 18.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 11,
+ "mbps": 0.03,
+ "content_p50": 13.2,
+ "content_p95": 19.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 12,
+ "mbps": 0.03,
+ "content_p50": 14.7,
+ "content_p95": 18.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 12.6,
+ "content_p95": 14.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 13,
+ "mbps": 0.03,
+ "content_p50": 13.5,
+ "content_p95": 22.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 19,
+ "mbps": 0.05,
+ "content_p50": 12.4,
+ "content_p95": 16.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.0,
+ "content_p95": 14.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 12.9,
+ "content_p95": 13.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 10.9,
+ "content_p95": 21.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 14.2,
+ "content_p95": 14.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 13.7,
+ "content_p95": 13.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 12.7,
+ "content_p95": 15.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 42,
+ "mbps": 0.14,
+ "content_p50": 12.4,
+ "content_p95": 16.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 25,
+ "mbps": 0.1,
+ "content_p50": 12.2,
+ "content_p95": 14.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 7,
+ "mbps": 0.11,
+ "content_p50": 15.2,
+ "content_p95": 20.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 14.2,
+ "content_p95": 14.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 12.8,
+ "content_p95": 19.4,
+ "input_p50": 35.9,
+ "input_p95": 91.9,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.709,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 9.0,
+ "cpu": {
+ "mac_agent_pct": 2.0,
+ "frame_viewer_pct": 12.8,
+ "frame_tailscaled_pct": 2.0,
+ "frame_sshd_pct": 0.2,
+ "frame_gamescope_pct": 6.0,
+ "frame_total_pct": 26.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.29,
+ "panel": "valve.steam.desktopgame.2001147903",
+ "src": "separate:7108"
+ }
+ ],
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 83,
+ "frames_drawn": 83,
+ "frames_shown": 83,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.2,
+ "p95": 19.9,
+ "p99": 50.0,
+ "n": 83
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 21.0,
+ "n": 83
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 8.9,
+ "p99": 19.9,
+ "n": 83
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.3,
+ "p99": 9.8,
+ "n": 83
+ },
+ "network": {
+ "p50": 3.4,
+ "p95": 8.8,
+ "p99": 43.6,
+ "n": 83
+ },
+ "decode": {
+ "p50": 2.6,
+ "p95": 8.7,
+ "p99": 11.1,
+ "n": 83
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.1,
+ "p99": 1.7,
+ "n": 83
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 5.7,
+ "p99": 11.5,
+ "n": 83
+ },
+ "content": {
+ "p50": 14.6,
+ "p95": 60.4,
+ "p99": 124.2,
+ "n": 83
+ },
+ "content_shown": {
+ "p50": 15.9,
+ "p95": 61.1,
+ "p99": 124.8,
+ "n": 83
+ }
+ },
+ "fps": 3.9,
+ "fps_shown": 3.9,
+ "late_pct": 61.45,
+ "stall_max": 9230.5,
+ "stalls_over_100ms": 39,
+ "mbps": 0.15,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 114,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 44.3,
+ "p95": 236.4,
+ "p99": 1853.7,
+ "n": 39
+ },
+ "input_shown_ms": {
+ "p50": 45.2,
+ "p95": 236.9,
+ "p99": 1854.2,
+ "n": 39
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.3,
+ "p95": 18.0,
+ "p99": 45.1,
+ "n": 39
+ },
+ "mac": {
+ "p50": 21.2,
+ "p95": 150.9,
+ "p99": 1809.2,
+ "n": 39
+ },
+ "back": {
+ "p50": 13.9,
+ "p95": 23.3,
+ "p99": 179.5,
+ "n": 39
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 39
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 7,
+ "mbps": 0.04,
+ "content_p50": 16.2,
+ "content_p95": 179.5,
+ "bitrate": 469728,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 10,
+ "mbps": 0.04,
+ "content_p50": 16.5,
+ "content_p95": 30.9,
+ "bitrate": 469728,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.8,
+ "content_p95": 14.6,
+ "bitrate": 469728,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 13.5,
+ "content_p95": 18.5,
+ "bitrate": 673370,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 7,
+ "mbps": 0.02,
+ "content_p50": 12.7,
+ "content_p95": 13.4,
+ "bitrate": 1217929,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 15,
+ "mbps": 0.06,
+ "content_p50": 13.3,
+ "content_p95": 19.4,
+ "bitrate": 1786562,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 13.9,
+ "content_p95": 20.1,
+ "bitrate": 2543412,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.11,
+ "content_p50": 14.2,
+ "content_p95": 23.2,
+ "bitrate": 3182528,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 13.5,
+ "content_p95": 13.5,
+ "bitrate": 4891587,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 5,
+ "mbps": 0.97,
+ "content_p50": 41.2,
+ "content_p95": 89.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 5,
+ "mbps": 0.65,
+ "content_p50": 36.5,
+ "content_p95": 44.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 5,
+ "mbps": 0.72,
+ "content_p50": 26.1,
+ "content_p95": 40.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 21,
+ "fps": 4,
+ "mbps": 0.5,
+ "content_p50": 17.6,
+ "content_p95": 24.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 14.6,
+ "content_p95": 60.4,
+ "input_p50": 44.3,
+ "input_p95": 236.4,
+ "grades": {
+ "input_replies": "bad",
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "local",
+ "input_p95": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 6.375,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 44.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 0.39,
+ "e": "down to 469 kbit/s: queue 165 ms, held 4, oldest 213 ms, base 5 ms, sent 44 got 28 wants 234"
+ }
+ ],
+ "source_fps": 5.4,
+ "cpu": {
+ "mac_agent_pct": 1.5,
+ "frame_viewer_pct": 8.1,
+ "frame_tailscaled_pct": 1.8,
+ "frame_sshd_pct": 0.1,
+ "frame_gamescope_pct": 5.5,
+ "frame_total_pct": 25.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001547827",
+ "src": "separate:7247"
+ }
+ ]
+ ],
+ "warm": [
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 114,
+ "frames_drawn": 114,
+ "frames_shown": 112,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.2,
+ "p95": 5.8,
+ "p99": 10.8,
+ "n": 114
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 114
+ },
+ "encode": {
+ "p50": 6.6,
+ "p95": 12.2,
+ "p99": 14.3,
+ "n": 114
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.4,
+ "p99": 2.9,
+ "n": 114
+ },
+ "network": {
+ "p50": 3.9,
+ "p95": 12.5,
+ "p99": 44.8,
+ "n": 114
+ },
+ "decode": {
+ "p50": 2.2,
+ "p95": 5.4,
+ "p99": 9.2,
+ "n": 114
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.5,
+ "p99": 1.9,
+ "n": 114
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 0.9,
+ "p99": 1.2,
+ "n": 112
+ },
+ "content": {
+ "p50": 14.5,
+ "p95": 29.9,
+ "p99": 65.7,
+ "n": 114
+ },
+ "content_shown": {
+ "p50": 15.1,
+ "p95": 28.9,
+ "p99": 52.5,
+ "n": 112
+ }
+ },
+ "fps": 5.4,
+ "fps_shown": 5.3,
+ "late_pct": 72.17,
+ "stall_max": 1001.0,
+ "stalls_over_100ms": 67,
+ "mbps": 0.03,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 162,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 46.6,
+ "p95": 71.3,
+ "p99": 84.0,
+ "n": 66
+ },
+ "input_shown_ms": {
+ "p50": 47.1,
+ "p95": 71.8,
+ "p99": 84.7,
+ "n": 66
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 4.3,
+ "p95": 9.7,
+ "p99": 34.2,
+ "n": 66
+ },
+ "mac": {
+ "p50": 25.8,
+ "p95": 42.8,
+ "p99": 44.3,
+ "n": 66
+ },
+ "back": {
+ "p50": 15.2,
+ "p95": 29.3,
+ "p99": 29.9,
+ "n": 66
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 66
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 29,
+ "mbps": 0.26,
+ "content_p50": 14.3,
+ "content_p95": 21.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 7,
+ "mbps": 0.03,
+ "content_p50": 13.4,
+ "content_p95": 15.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.9,
+ "content_p95": 18.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 9,
+ "mbps": 0.03,
+ "content_p50": 16.1,
+ "content_p95": 20.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 14.4,
+ "content_p95": 19.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 10,
+ "mbps": 0.04,
+ "content_p50": 13.8,
+ "content_p95": 29.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 14.2,
+ "content_p95": 28.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 4,
+ "mbps": 0.02,
+ "content_p50": 16.8,
+ "content_p95": 28.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 6,
+ "mbps": 0.03,
+ "content_p50": 13.8,
+ "content_p95": 16.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.5,
+ "content_p95": 20.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 9,
+ "mbps": 0.05,
+ "content_p50": 29.9,
+ "content_p95": 95.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 7,
+ "mbps": 0.02,
+ "content_p50": 13.8,
+ "content_p95": 30.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 13.3,
+ "content_p95": 17.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 16.6,
+ "content_p95": 16.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 15.2,
+ "content_p95": 17.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.2,
+ "content_p95": 14.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 19.1,
+ "content_p95": 19.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 14.2,
+ "content_p95": 14.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 13.9,
+ "content_p95": 13.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.4,
+ "content_p95": 14.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 15.1,
+ "content_p95": 15.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 14.5,
+ "content_p95": 29.9,
+ "input_p50": 46.6,
+ "input_p95": 71.3,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "acceptable"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.541,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 1,
+ "scale": 1,
+ "slack": 48.292,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 7.7,
+ "cpu": {
+ "mac_agent_pct": 2.0,
+ "frame_viewer_pct": 10.9,
+ "frame_tailscaled_pct": 2.8,
+ "frame_sshd_pct": 0.4,
+ "frame_gamescope_pct": 5.4,
+ "frame_total_pct": 36.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 70 Hz",
+ "show_s": 1.28,
+ "panel": "valve.steam.desktopgame.2001621121",
+ "src": "separate:6829"
+ }
+ ],
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 220,
+ "frames_drawn": 220,
+ "frames_shown": 217,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -5.5,
+ "p95": 3.8,
+ "p99": 14.3,
+ "n": 220
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.3,
+ "n": 220
+ },
+ "encode": {
+ "p50": 6.5,
+ "p95": 9.0,
+ "p99": 18.8,
+ "n": 220
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 1.7,
+ "n": 220
+ },
+ "network": {
+ "p50": 3.1,
+ "p95": 8.9,
+ "p99": 46.7,
+ "n": 220
+ },
+ "decode": {
+ "p50": 2.0,
+ "p95": 4.1,
+ "p99": 7.6,
+ "n": 220
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.4,
+ "p99": 1.8,
+ "n": 220
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 10.4,
+ "p99": 13.9,
+ "n": 217
+ },
+ "content": {
+ "p50": 13.0,
+ "p95": 25.6,
+ "p99": 122.5,
+ "n": 220
+ },
+ "content_shown": {
+ "p50": 14.1,
+ "p95": 27.2,
+ "p99": 122.9,
+ "n": 217
+ }
+ },
+ "fps": 10.4,
+ "fps_shown": 10.3,
+ "late_pct": 38.46,
+ "stall_max": 4500.7,
+ "stalls_over_100ms": 64,
+ "mbps": 0.05,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 220,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 51.4,
+ "p95": 63.8,
+ "p99": 80.7,
+ "n": 64
+ },
+ "input_shown_ms": {
+ "p50": 51.8,
+ "p95": 64.3,
+ "p99": 81.3,
+ "n": 64
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 4.1,
+ "p95": 17.8,
+ "p99": 29.2,
+ "n": 64
+ },
+ "mac": {
+ "p50": 31.3,
+ "p95": 35.3,
+ "p99": 39.4,
+ "n": 64
+ },
+ "back": {
+ "p50": 13.6,
+ "p95": 19.9,
+ "p99": 26.1,
+ "n": 64
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 64
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 28,
+ "mbps": 0.27,
+ "content_p50": 16.7,
+ "content_p95": 61.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 8,
+ "mbps": 0.03,
+ "content_p50": 13.7,
+ "content_p95": 19.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.3,
+ "content_p95": 14.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.2,
+ "content_p95": 13.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 12.9,
+ "content_p95": 14.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.9,
+ "content_p95": 24.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.6,
+ "content_p95": 17.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 13.1,
+ "content_p95": 15.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 13.8,
+ "content_p95": 15.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 14.5,
+ "content_p95": 16.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 12.6,
+ "content_p95": 19.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 48,
+ "mbps": 0.15,
+ "content_p50": 12.8,
+ "content_p95": 16.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 47,
+ "mbps": 0.15,
+ "content_p50": 12.6,
+ "content_p95": 16.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 30,
+ "mbps": 0.09,
+ "content_p50": 12.8,
+ "content_p95": 15.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 11.6,
+ "content_p95": 13.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 6,
+ "mbps": 0.11,
+ "content_p50": 42.9,
+ "content_p95": 143.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 12.8,
+ "content_p95": 13.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 13.0,
+ "content_p95": 25.6,
+ "input_p50": 51.4,
+ "input_p95": 63.8,
+ "grades": {
+ "input_replies": "local",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "acceptable",
+ "input_p95": "local"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 5.792,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14860800,
+ "tier": 0
+ },
+ "events": [],
+ "source_fps": 10.4,
+ "cpu": {
+ "mac_agent_pct": 2.5,
+ "frame_viewer_pct": 14.7,
+ "frame_tailscaled_pct": 2.9,
+ "frame_sshd_pct": 0.4,
+ "frame_gamescope_pct": 6.1,
+ "frame_total_pct": 33.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 71 Hz",
+ "show_s": 1.27,
+ "panel": "valve.steam.desktopgame.2001606834",
+ "src": "separate:6969"
+ }
+ ],
+ [
+ {
+ "scenario": "type",
+ "frames_sent": 128,
+ "frames_drawn": 128,
+ "frames_shown": 126,
+ "duration_s": 21.1,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.5,
+ "p95": 1.4,
+ "p99": 26.9,
+ "n": 128
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.7,
+ "n": 128
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.9,
+ "p99": 23.0,
+ "n": 128
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.3,
+ "p99": 2.8,
+ "n": 128
+ },
+ "network": {
+ "p50": 3.3,
+ "p95": 5.1,
+ "p99": 205.9,
+ "n": 128
+ },
+ "decode": {
+ "p50": 2.1,
+ "p95": 4.2,
+ "p99": 6.4,
+ "n": 128
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.7,
+ "p99": 1.9,
+ "n": 128
+ },
+ "present": {
+ "p50": 0.6,
+ "p95": 9.2,
+ "p99": 13.7,
+ "n": 126
+ },
+ "content": {
+ "p50": 13.4,
+ "p95": 21.6,
+ "p99": 264.8,
+ "n": 128
+ },
+ "content_shown": {
+ "p50": 14.5,
+ "p95": 26.1,
+ "p99": 265.4,
+ "n": 126
+ }
+ },
+ "fps": 6.1,
+ "fps_shown": 6.0,
+ "late_pct": 17.83,
+ "stall_max": 9246.3,
+ "stalls_over_100ms": 17,
+ "mbps": 0.02,
+ "keyframes": 0,
+ "size": "1920x1290",
+ "captured": 129,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 42.5,
+ "p95": 194.0,
+ "p99": 404.5,
+ "n": 25
+ },
+ "input_shown_ms": {
+ "p50": 43.2,
+ "p95": 194.6,
+ "p99": 405.1,
+ "n": 25
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 9.4,
+ "p95": 26.2,
+ "p99": 35.0,
+ "n": 25
+ },
+ "mac": {
+ "p50": 23.3,
+ "p95": 135.9,
+ "p99": 176.2,
+ "n": 25
+ },
+ "back": {
+ "p50": 13.8,
+ "p95": 21.6,
+ "p99": 264.8,
+ "n": 25
+ }
+ },
+ "inputs": {
+ "asked": 65,
+ "sent": 66,
+ "seen": 25
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 24,
+ "mbps": 0.06,
+ "content_p50": 13.0,
+ "content_p95": 14.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 58,
+ "mbps": 0.19,
+ "content_p50": 13.6,
+ "content_p95": 18.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 26,
+ "mbps": 0.08,
+ "content_p50": 12.9,
+ "content_p95": 15.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 6,
+ "mbps": 0.08,
+ "content_p50": 13.5,
+ "content_p95": 17.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 4,
+ "mbps": 0.05,
+ "content_p50": 18.8,
+ "content_p95": 25.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 13.3,
+ "content_p95": 13.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 4,
+ "mbps": 0.03,
+ "content_p50": 264.8,
+ "content_p95": 354.5,
+ "bitrate": 300000,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 13.8,
+ "content_p95": 15.1,
+ "bitrate": 300000,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 13.4,
+ "content_p95": 21.6,
+ "input_p50": 42.5,
+ "input_p95": 194.0,
+ "grades": {
+ "input_replies": "bad",
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "bad"
+ },
+ "controller": {
+ "adapt": true,
+ "baseRtt": 0,
+ "ceiling": 14860800,
+ "fps": 60,
+ "inFlight": 0,
+ "scale": 1,
+ "slack": 41.666,
+ "target": 14855423,
+ "tier": 0
+ },
+ "events": [
+ {
+ "t": 10.93,
+ "e": "down to 300 kbit/s: queue 0 ms, held 0, oldest 201 ms, base 5 ms, sent 61 got 0 wants 51"
+ }
+ ],
+ "source_fps": 6.1,
+ "cpu": {
+ "mac_agent_pct": 2.1,
+ "frame_viewer_pct": 11.1,
+ "frame_tailscaled_pct": 2.6,
+ "frame_sshd_pct": 0.4,
+ "frame_gamescope_pct": 6.0,
+ "frame_total_pct": 25.6
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 72 Hz",
+ "show_s": 1.32,
+ "panel": "valve.steam.desktopgame.2001763888",
+ "src": "separate:7401"
+ }
+ ]
+ ]
+ },
+ "table": {
+ "type": {
+ "content_p50": {
+ "cold": [
+ 13.9,
+ 12.8,
+ 14.6
+ ],
+ "warm": [
+ 14.5,
+ 13.0,
+ 13.4
+ ]
+ },
+ "content_p95": {
+ "cold": [
+ 25.6,
+ 19.4,
+ 60.4
+ ],
+ "warm": [
+ 29.9,
+ 25.6,
+ 21.6
+ ]
+ },
+ "input_p50": {
+ "cold": [
+ 44.5,
+ 35.9,
+ 44.3
+ ],
+ "warm": [
+ 46.6,
+ 51.4,
+ 42.5
+ ]
+ },
+ "input_p95": {
+ "cold": [
+ 68.2,
+ 91.9,
+ 236.4
+ ],
+ "warm": [
+ 71.3,
+ 63.8,
+ 194.0
+ ]
+ },
+ "fps": {
+ "cold": [
+ 6.9,
+ 9.0,
+ 3.9
+ ],
+ "warm": [
+ 5.4,
+ 10.4,
+ 6.1
+ ]
+ },
+ "late_pct": {
+ "cold": [
+ 51.37,
+ 41.88,
+ 61.45
+ ],
+ "warm": [
+ 72.17,
+ 38.46,
+ 17.83
+ ]
+ },
+ "stall_max": {
+ "cold": [
+ 1007.5,
+ 2314.7,
+ 9230.5
+ ],
+ "warm": [
+ 1001.0,
+ 4500.7,
+ 9246.3
+ ]
+ },
+ "mbps": {
+ "cold": [
+ 0.03,
+ 0.03,
+ 0.15
+ ],
+ "warm": [
+ 0.03,
+ 0.05,
+ 0.02
+ ]
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean.json b/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean.json
new file mode 100644
index 0000000..7f449e1
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean.json
@@ -0,0 +1,984 @@
+{
+ "label": "adapt-clean",
+ "date": "2026-09-28T17:13:59",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": ":39.639829 [Info] - 0 - entering standby",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 653,
+ "frames_drawn": 653,
+ "frames_shown": 649,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 653
+ },
+ "queue": {
+ "p50": 11.2,
+ "p95": 16.1,
+ "p99": 17.1,
+ "n": 653
+ },
+ "encode": {
+ "p50": 3.5,
+ "p95": 4.2,
+ "p99": 4.4,
+ "n": 653
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 653
+ },
+ "network": {
+ "p50": 2.9,
+ "p95": 7.0,
+ "p99": 12.0,
+ "n": 653
+ },
+ "decode": {
+ "p50": 1.0,
+ "p95": 2.4,
+ "p99": 2.8,
+ "n": 653
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.6,
+ "p99": 0.9,
+ "n": 653
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 0.9,
+ "p99": 5.9,
+ "n": 649
+ },
+ "content": {
+ "p50": 19.8,
+ "p95": 26.2,
+ "p99": 30.1,
+ "n": 653
+ },
+ "content_shown": {
+ "p50": 20.3,
+ "p95": 26.9,
+ "p99": 31.1,
+ "n": 649
+ }
+ },
+ "fps": 31.5,
+ "fps_shown": 31.3,
+ "late_pct": 98.01,
+ "stall_max": 144.0,
+ "stalls_over_100ms": 2,
+ "mbps": 0.13,
+ "keyframes": 2,
+ "size": "960x540",
+ "captured": 1336,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 43.5,
+ "p95": 61.8,
+ "p99": 63.4,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 44.3,
+ "p95": 62.3,
+ "p99": 63.9,
+ "n": 40
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 11.1,
+ "p95": 29.2,
+ "p99": 36.1,
+ "n": 40
+ },
+ "mac": {
+ "p50": 10.4,
+ "p95": 28.2,
+ "p99": 32.4,
+ "n": 40
+ },
+ "back": {
+ "p50": 17.5,
+ "p95": 27.9,
+ "p99": 28.8,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 31,
+ "mbps": 0.13,
+ "content_p50": 21.5,
+ "content_p95": 27.1,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 22.4,
+ "content_p95": 26.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 18.9,
+ "content_p95": 24.8,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 18.2,
+ "content_p95": 24.6,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 21.0,
+ "content_p95": 25.6,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 32,
+ "mbps": 0.12,
+ "content_p50": 19.5,
+ "content_p95": 24.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 22.2,
+ "content_p95": 25.6,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 19.9,
+ "content_p95": 24.0,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 31,
+ "mbps": 0.21,
+ "content_p50": 22.7,
+ "content_p95": 26.9,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 20.2,
+ "content_p95": 26.1,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 32,
+ "mbps": 0.12,
+ "content_p50": 18.2,
+ "content_p95": 23.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 19.8,
+ "content_p95": 24.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 19.2,
+ "content_p95": 24.8,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 32,
+ "mbps": 0.12,
+ "content_p50": 21.0,
+ "content_p95": 24.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 30,
+ "mbps": 0.13,
+ "content_p50": 20.4,
+ "content_p95": 96.7,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 18.2,
+ "content_p95": 24.4,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 31,
+ "mbps": 0.12,
+ "content_p50": 18.4,
+ "content_p95": 25.1,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 32,
+ "mbps": 0.13,
+ "content_p50": 17.2,
+ "content_p95": 23.5,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 32,
+ "mbps": 0.19,
+ "content_p50": 19.4,
+ "content_p95": 25.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 31,
+ "mbps": 0.14,
+ "content_p50": 21.1,
+ "content_p95": 84.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 24,
+ "mbps": 0.08,
+ "content_p50": 20.8,
+ "content_p95": 25.1,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.8,
+ "content_p95": 26.2,
+ "input_p50": 43.5,
+ "input_p95": 61.8,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "source_fps": 64.5,
+ "cpu": {
+ "mac_agent_pct": 11.3,
+ "frame_viewer_pct": 35.9,
+ "frame_tailscaled_pct": 3.5,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 7.5,
+ "frame_total_pct": 16.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 4.68,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 937,
+ "frames_drawn": 937,
+ "frames_shown": 720,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -3.9,
+ "p95": 0.9,
+ "p99": 8.2,
+ "n": 937
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 16.3,
+ "p99": 20.1,
+ "n": 937
+ },
+ "encode": {
+ "p50": 6.4,
+ "p95": 7.2,
+ "p99": 9.3,
+ "n": 937
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 937
+ },
+ "network": {
+ "p50": 7.3,
+ "p95": 13.0,
+ "p99": 22.8,
+ "n": 937
+ },
+ "decode": {
+ "p50": 6.2,
+ "p95": 12.7,
+ "p99": 18.6,
+ "n": 937
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.1,
+ "p99": 1.9,
+ "n": 937
+ },
+ "present": {
+ "p50": 2.9,
+ "p95": 18.5,
+ "p99": 24.5,
+ "n": 720
+ },
+ "content": {
+ "p50": 19.9,
+ "p95": 37.2,
+ "p99": 50.2,
+ "n": 937
+ },
+ "content_shown": {
+ "p50": 27.0,
+ "p95": 45.7,
+ "p99": 55.0,
+ "n": 720
+ }
+ },
+ "fps": 45.3,
+ "fps_shown": 34.8,
+ "late_pct": 28.63,
+ "stall_max": 99.3,
+ "stalls_over_100ms": 0,
+ "mbps": 7.59,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 1215,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 30,
+ "mbps": 4.01,
+ "content_p50": 31.9,
+ "content_p95": 52.3,
+ "bitrate": 4867140,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 30,
+ "mbps": 4.12,
+ "content_p50": 29.9,
+ "content_p95": 45.3,
+ "bitrate": 3807054,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 30,
+ "mbps": 3.26,
+ "content_p50": 31.8,
+ "content_p95": 52.5,
+ "bitrate": 3235995,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 30,
+ "mbps": 3.72,
+ "content_p50": 24.5,
+ "content_p95": 37.7,
+ "bitrate": 4238740,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 30,
+ "mbps": 5.09,
+ "content_p50": 25.2,
+ "content_p95": 39.6,
+ "bitrate": 5992063,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 31,
+ "mbps": 7.29,
+ "content_p50": 29.1,
+ "content_p95": 53.5,
+ "bitrate": 5526067,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 35,
+ "mbps": 4.58,
+ "content_p50": 25.3,
+ "content_p95": 32.4,
+ "bitrate": 6018152,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 46,
+ "mbps": 7.12,
+ "content_p50": 25.1,
+ "content_p95": 34.3,
+ "bitrate": 8412933,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 46,
+ "mbps": 8.25,
+ "content_p50": 27.1,
+ "content_p95": 40.3,
+ "bitrate": 10760191,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 47,
+ "mbps": 9.3,
+ "content_p50": 27.8,
+ "content_p95": 41.2,
+ "bitrate": 13717060,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 55,
+ "mbps": 8.98,
+ "content_p50": 16.5,
+ "content_p95": 21.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 51,
+ "mbps": 9.32,
+ "content_p50": 18.6,
+ "content_p95": 35.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 56,
+ "mbps": 9.53,
+ "content_p50": 15.4,
+ "content_p95": 26.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 55,
+ "mbps": 8.46,
+ "content_p50": 18.6,
+ "content_p95": 23.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 54,
+ "mbps": 9.82,
+ "content_p50": 15.9,
+ "content_p95": 28.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 54,
+ "mbps": 11.11,
+ "content_p50": 15.5,
+ "content_p95": 26.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 54,
+ "mbps": 9.79,
+ "content_p50": 17.4,
+ "content_p95": 25.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 57,
+ "mbps": 9.3,
+ "content_p50": 16.3,
+ "content_p95": 23.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 55,
+ "mbps": 8.3,
+ "content_p50": 19.5,
+ "content_p95": 31.0,
+ "bitrate": 7984791,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 51,
+ "mbps": 8.56,
+ "content_p50": 16.4,
+ "content_p95": 29.1,
+ "bitrate": 10220855,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 40,
+ "mbps": 7.13,
+ "content_p50": 18.3,
+ "content_p95": 23.9,
+ "bitrate": 12025604,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 19.9,
+ "content_p95": 37.2,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "local"
+ },
+ "source_fps": 58.7,
+ "cpu": {
+ "mac_agent_pct": 6.6,
+ "frame_viewer_pct": 72.4,
+ "frame_tailscaled_pct": 8.1,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 10.0,
+ "frame_total_pct": 24.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 6.43,
+ "panel": "valve.steam.desktopgame.2001414593",
+ "src": "separate:9384"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 25,
+ "frames_drawn": 25,
+ "frames_shown": 25,
+ "duration_s": 14.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -0.3,
+ "p95": 0.9,
+ "p99": 1.1,
+ "n": 25
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 25
+ },
+ "encode": {
+ "p50": 5.0,
+ "p95": 33.4,
+ "p99": 33.6,
+ "n": 25
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.1,
+ "p99": 0.2,
+ "n": 25
+ },
+ "network": {
+ "p50": 6.1,
+ "p95": 14.9,
+ "p99": 24.4,
+ "n": 25
+ },
+ "decode": {
+ "p50": 3.0,
+ "p95": 10.5,
+ "p99": 11.4,
+ "n": 25
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.0,
+ "p99": 1.1,
+ "n": 25
+ },
+ "present": {
+ "p50": 0.8,
+ "p95": 1.4,
+ "p99": 1.7,
+ "n": 25
+ },
+ "content": {
+ "p50": 12.6,
+ "p95": 41.0,
+ "p99": 46.3,
+ "n": 25
+ },
+ "content_shown": {
+ "p50": 13.9,
+ "p95": 41.8,
+ "p99": 46.6,
+ "n": 25
+ }
+ },
+ "fps": 1.7,
+ "fps_shown": 1.7,
+ "late_pct": 100.0,
+ "stall_max": 3099.2,
+ "stalls_over_100ms": 21,
+ "mbps": 0.04,
+ "keyframes": 4,
+ "size": "960x646",
+ "captured": 79,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 41.3,
+ "p95": 60.3,
+ "p99": 68.6,
+ "n": 18
+ },
+ "input_shown_ms": {
+ "p50": 43.0,
+ "p95": 61.1,
+ "p99": 69.6,
+ "n": 18
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.3,
+ "p95": 11.6,
+ "p99": 14.2,
+ "n": 18
+ },
+ "mac": {
+ "p50": 23.1,
+ "p95": 35.0,
+ "p99": 42.6,
+ "n": 18
+ },
+ "back": {
+ "p50": 11.8,
+ "p95": 19.2,
+ "p99": 22.7,
+ "n": 18
+ }
+ },
+ "inputs": {
+ "sent": 66,
+ "seen": 18
+ },
+ "timeline": [
+ {
+ "t": 3,
+ "fps": 1,
+ "mbps": 0.01,
+ "content_p50": 11.8,
+ "content_p95": 11.8,
+ "bitrate": 1224075,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 1,
+ "mbps": 0.02,
+ "content_p50": 14.7,
+ "content_p95": 14.7,
+ "bitrate": 2091896,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 4,
+ "mbps": 0.1,
+ "content_p50": 18.6,
+ "content_p95": 37.4,
+ "bitrate": 3071304,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.13,
+ "content_p50": 4.2,
+ "content_p95": 19.2,
+ "bitrate": 4031277,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 6,
+ "mbps": 0.04,
+ "content_p50": 12.0,
+ "content_p95": 46.3,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 1,
+ "mbps": 0.01,
+ "content_p50": 12.2,
+ "content_p95": 12.2,
+ "bitrate": 300000,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 1,
+ "mbps": 0.02,
+ "content_p50": -1.2,
+ "content_p95": -1.2,
+ "bitrate": 842857,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 1,
+ "mbps": 0.02,
+ "content_p50": 14.4,
+ "content_p95": 14.4,
+ "bitrate": 2091896,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 12.6,
+ "content_p95": 41.0,
+ "input_p50": 41.3,
+ "input_p95": 60.3,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "local",
+ "input_p95": "local"
+ },
+ "source_fps": 5.4,
+ "cpu": {
+ "mac_agent_pct": 1.2,
+ "frame_viewer_pct": 5.0,
+ "frame_tailscaled_pct": 1.5,
+ "frame_sshd_pct": 0.2,
+ "frame_gamescope_pct": 4.7,
+ "frame_total_pct": 8.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 140 Hz",
+ "show_s": 5.12,
+ "panel": "valve.steam.desktopgame.2001910179",
+ "src": "separate:9510"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean2.json b/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean2.json
new file mode 100644
index 0000000..79fd46c
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-adapt-clean2.json
@@ -0,0 +1,1116 @@
+{
+ "label": "adapt-clean2",
+ "date": "2026-09-28T17:16:27",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": ":39.639829 [Info] - 0 - entering standby",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 1229,
+ "frames_drawn": 1229,
+ "frames_shown": 814,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1229
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1229
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.0,
+ "n": 1229
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 1229
+ },
+ "network": {
+ "p50": 6.1,
+ "p95": 10.9,
+ "p99": 28.8,
+ "n": 1229
+ },
+ "decode": {
+ "p50": 1.2,
+ "p95": 3.4,
+ "p99": 4.4,
+ "n": 1229
+ },
+ "draw": {
+ "p50": 0.3,
+ "p95": 0.7,
+ "p99": 1.1,
+ "n": 1229
+ },
+ "present": {
+ "p50": 5.5,
+ "p95": 16.5,
+ "p99": 19.2,
+ "n": 814
+ },
+ "content": {
+ "p50": 12.2,
+ "p95": 17.6,
+ "p99": 34.3,
+ "n": 1229
+ },
+ "content_shown": {
+ "p50": 18.0,
+ "p95": 29.6,
+ "p99": 38.7,
+ "n": 814
+ }
+ },
+ "fps": 59.3,
+ "fps_shown": 39.3,
+ "late_pct": 1.55,
+ "stall_max": 168.0,
+ "stalls_over_100ms": 2,
+ "mbps": 0.47,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1337,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 27.8,
+ "p95": 41.1,
+ "p99": 46.2,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 32.3,
+ "p95": 47.0,
+ "p99": 53.4,
+ "n": 25
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 8.0,
+ "p95": 19.4,
+ "p99": 31.7,
+ "n": 40
+ },
+ "mac": {
+ "p50": 5.3,
+ "p95": 14.2,
+ "p99": 18.3,
+ "n": 40
+ },
+ "back": {
+ "p50": 12.7,
+ "p95": 16.6,
+ "p99": 17.7,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.2,
+ "content_p95": 13.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.0,
+ "content_p95": 13.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.6,
+ "content_p95": 17.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.9,
+ "content_p95": 15.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 11.0,
+ "content_p95": 17.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.6,
+ "content_p95": 13.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.59,
+ "content_p50": 10.4,
+ "content_p95": 15.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.3,
+ "content_p95": 15.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 11.8,
+ "content_p95": 15.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 11.9,
+ "content_p95": 14.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 12.3,
+ "content_p95": 23.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 0.44,
+ "content_p50": 12.5,
+ "content_p95": 17.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 14.2,
+ "content_p95": 17.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 58,
+ "mbps": 0.44,
+ "content_p50": 14.2,
+ "content_p95": 27.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 14.3,
+ "content_p95": 17.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 13.3,
+ "content_p95": 17.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.57,
+ "content_p50": 11.6,
+ "content_p95": 14.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.3,
+ "content_p95": 18.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.44,
+ "content_p50": 12.4,
+ "content_p95": 14.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 13.7,
+ "content_p95": 16.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 33,
+ "mbps": 0.24,
+ "content_p50": 13.3,
+ "content_p95": 135.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 12.2,
+ "content_p95": 17.6,
+ "input_p50": 27.8,
+ "input_p95": 41.1,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "acceptable",
+ "stall_max": "acceptable"
+ },
+ "source_fps": 64.6,
+ "cpu": {
+ "mac_agent_pct": 15.4,
+ "frame_viewer_pct": 57.6,
+ "frame_tailscaled_pct": 5.8,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 11.1,
+ "frame_total_pct": 22.2
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 5.83,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 734,
+ "frames_drawn": 734,
+ "frames_shown": 707,
+ "duration_s": 20.6,
+ "stages_ms": {
+ "capture": {
+ "p50": -4.8,
+ "p95": 6.5,
+ "p99": 13.1,
+ "n": 734
+ },
+ "queue": {
+ "p50": 8.4,
+ "p95": 19.8,
+ "p99": 28.9,
+ "n": 734
+ },
+ "encode": {
+ "p50": 6.3,
+ "p95": 7.1,
+ "p99": 10.1,
+ "n": 734
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 734
+ },
+ "network": {
+ "p50": 7.4,
+ "p95": 20.9,
+ "p99": 48.9,
+ "n": 734
+ },
+ "decode": {
+ "p50": 5.4,
+ "p95": 13.5,
+ "p99": 17.2,
+ "n": 734
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 1.2,
+ "p99": 1.8,
+ "n": 734
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 13.6,
+ "p99": 21.3,
+ "n": 707
+ },
+ "content": {
+ "p50": 24.8,
+ "p95": 48.1,
+ "p99": 69.4,
+ "n": 734
+ },
+ "content_shown": {
+ "p50": 26.9,
+ "p95": 50.5,
+ "p99": 68.5,
+ "n": 707
+ }
+ },
+ "fps": 35.6,
+ "fps_shown": 34.3,
+ "late_pct": 58.12,
+ "stall_max": 168.7,
+ "stalls_over_100ms": 2,
+ "mbps": 4.09,
+ "keyframes": 3,
+ "size": "960x646",
+ "captured": 1122,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 45,
+ "mbps": 7.92,
+ "content_p50": 23.2,
+ "content_p95": 38.8,
+ "bitrate": 8869129,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 46,
+ "mbps": 8.52,
+ "content_p50": 31.8,
+ "content_p95": 45.2,
+ "bitrate": 11334867,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 45,
+ "mbps": 8.17,
+ "content_p50": 29.2,
+ "content_p95": 65.7,
+ "bitrate": 6587726,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 45,
+ "mbps": 6.56,
+ "content_p50": 26.5,
+ "content_p95": 43.5,
+ "bitrate": 7164744,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 46,
+ "mbps": 6.64,
+ "content_p50": 21.5,
+ "content_p95": 30.9,
+ "bitrate": 6256828,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 44,
+ "mbps": 7.24,
+ "content_p50": 22.0,
+ "content_p95": 47.5,
+ "bitrate": 6291668,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 44,
+ "mbps": 5.19,
+ "content_p50": 28.2,
+ "content_p95": 50.0,
+ "bitrate": 5347917,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 44,
+ "mbps": 4.54,
+ "content_p50": 23.7,
+ "content_p95": 43.0,
+ "bitrate": 3863869,
+ "tier": 1,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 35,
+ "mbps": 3.83,
+ "content_p50": 28.2,
+ "content_p95": 41.2,
+ "bitrate": 4222978,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 29,
+ "mbps": 4.65,
+ "content_p50": 30.0,
+ "content_p95": 53.8,
+ "bitrate": 5482055,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 28,
+ "mbps": 4.05,
+ "content_p50": 26.4,
+ "content_p95": 121.7,
+ "bitrate": 3981384,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 30,
+ "mbps": 3.12,
+ "content_p50": 24.5,
+ "content_p95": 42.4,
+ "bitrate": 2876549,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 31,
+ "mbps": 2.72,
+ "content_p50": 22.6,
+ "content_p95": 36.5,
+ "bitrate": 2445066,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 29,
+ "mbps": 2.4,
+ "content_p50": 26.4,
+ "content_p95": 46.1,
+ "bitrate": 2690671,
+ "tier": 2,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 26,
+ "mbps": 2.21,
+ "content_p50": 24.8,
+ "content_p95": 55.1,
+ "bitrate": 2168791,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 31,
+ "mbps": 1.65,
+ "content_p50": 24.1,
+ "content_p95": 33.2,
+ "bitrate": 1566951,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 30,
+ "mbps": 1.43,
+ "content_p50": 19.8,
+ "content_p95": 38.0,
+ "bitrate": 1317132,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 31,
+ "mbps": 1.33,
+ "content_p50": 23.2,
+ "content_p95": 41.1,
+ "bitrate": 1190325,
+ "tier": 3,
+ "w": 1440,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 25,
+ "mbps": 1.11,
+ "content_p50": 23.6,
+ "content_p95": 99.7,
+ "bitrate": 860009,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 29,
+ "mbps": 0.64,
+ "content_p50": 22.1,
+ "content_p95": 41.5,
+ "bitrate": 731007,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 21,
+ "mbps": 0.47,
+ "content_p50": 26.1,
+ "content_p95": 31.1,
+ "bitrate": 619308,
+ "tier": 4,
+ "w": 960,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 24.8,
+ "content_p95": 48.1,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "source_fps": 54.5,
+ "cpu": {
+ "mac_agent_pct": 5.6,
+ "frame_viewer_pct": 57.2,
+ "frame_tailscaled_pct": 5.3,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 8.9,
+ "frame_total_pct": 21.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 6.59,
+ "panel": "valve.steam.desktopgame.2001551415",
+ "src": "separate:9635"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 75,
+ "frames_drawn": 75,
+ "frames_shown": 75,
+ "duration_s": 20.0,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.3,
+ "p95": 4.2,
+ "p99": 5.1,
+ "n": 75
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 75
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 9.4,
+ "p99": 11.7,
+ "n": 75
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 75
+ },
+ "network": {
+ "p50": 4.0,
+ "p95": 12.4,
+ "p99": 26.6,
+ "n": 75
+ },
+ "decode": {
+ "p50": 2.3,
+ "p95": 7.2,
+ "p99": 14.4,
+ "n": 75
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.5,
+ "p99": 1.6,
+ "n": 75
+ },
+ "present": {
+ "p50": 0.5,
+ "p95": 1.1,
+ "p99": 1.2,
+ "n": 75
+ },
+ "content": {
+ "p50": 14.4,
+ "p95": 25.1,
+ "p99": 37.7,
+ "n": 75
+ },
+ "content_shown": {
+ "p50": 14.9,
+ "p95": 25.6,
+ "p99": 38.9,
+ "n": 75
+ }
+ },
+ "fps": 3.8,
+ "fps_shown": 3.8,
+ "late_pct": 97.3,
+ "stall_max": 1109.7,
+ "stalls_over_100ms": 70,
+ "mbps": 0.03,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 75,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 45.2,
+ "p95": 77.2,
+ "p99": 83.1,
+ "n": 66
+ },
+ "input_shown_ms": {
+ "p50": 45.9,
+ "p95": 77.9,
+ "p99": 84.3,
+ "n": 66
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.8,
+ "p95": 16.5,
+ "p99": 29.9,
+ "n": 66
+ },
+ "mac": {
+ "p50": 24.4,
+ "p95": 41.2,
+ "p99": 41.6,
+ "n": 66
+ },
+ "back": {
+ "p50": 14.5,
+ "p95": 25.1,
+ "p99": 31.6,
+ "n": 66
+ }
+ },
+ "inputs": {
+ "sent": 66,
+ "seen": 66
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 2.0,
+ "content_p95": 4.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.7,
+ "content_p95": 37.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 17.1,
+ "content_p95": 18.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.7,
+ "content_p95": 26.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.9,
+ "content_p95": 19.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 17.8,
+ "content_p95": 21.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.22,
+ "content_p50": 18.7,
+ "content_p95": 31.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 18.1,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 12.5,
+ "content_p95": 13.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 6.4,
+ "content_p95": 16.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 14.4,
+ "content_p95": 18.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 11.9,
+ "content_p95": 14.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 12.9,
+ "content_p95": 22.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 13.5,
+ "content_p95": 13.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 2.2,
+ "content_p95": 2.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 110.2,
+ "content_p95": 110.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.4,
+ "content_p95": 14.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 2,
+ "mbps": 0.25,
+ "content_p50": -0.0,
+ "content_p95": 17.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.1,
+ "content_p95": 14.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 17.5,
+ "content_p95": 17.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 14.4,
+ "content_p95": 25.1,
+ "input_p50": 45.2,
+ "input_p95": 77.2,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "acceptable"
+ },
+ "source_fps": 3.8,
+ "cpu": {
+ "mac_agent_pct": 1.2,
+ "frame_viewer_pct": 7.1,
+ "frame_tailscaled_pct": 2.2,
+ "frame_sshd_pct": 0.3,
+ "frame_gamescope_pct": 4.7,
+ "frame_total_pct": 15.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 6.64,
+ "panel": "valve.steam.desktopgame.2001684656",
+ "src": "separate:9763"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json b/bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json
new file mode 100644
index 0000000..d12e58c
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json
@@ -0,0 +1,1124 @@
+{
+ "label": "baseline-fixed",
+ "date": "2026-09-28T17:02:53",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": ":51.824645 [Info] - 0 - entering standby",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 1246,
+ "frames_drawn": 1223,
+ "frames_shown": 804,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1246
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1246
+ },
+ "encode": {
+ "p50": 4.0,
+ "p95": 4.7,
+ "p99": 4.9,
+ "n": 1246
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 1246
+ },
+ "network": {
+ "p50": 4.0,
+ "p95": 6.8,
+ "p99": 221.1,
+ "n": 1246
+ },
+ "decode": {
+ "p50": 1.3,
+ "p95": 2.5,
+ "p99": 4.2,
+ "n": 1223
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.5,
+ "p99": 0.9,
+ "n": 1223
+ },
+ "present": {
+ "p50": 6.2,
+ "p95": 15.6,
+ "p99": 16.9,
+ "n": 804
+ },
+ "content": {
+ "p50": 9.9,
+ "p95": 12.9,
+ "p99": 16.4,
+ "n": 1223
+ },
+ "content_shown": {
+ "p50": 16.1,
+ "p95": 25.5,
+ "p99": 27.9,
+ "n": 804
+ }
+ },
+ "fps": 59.0,
+ "fps_shown": 38.8,
+ "late_pct": 0.33,
+ "stall_max": 433.3,
+ "stalls_over_100ms": 1,
+ "mbps": 0.48,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1598,
+ "viewer_never_drawn": 23,
+ "input_ms": {
+ "p50": 28.8,
+ "p95": 39.0,
+ "p99": 45.2,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 33.7,
+ "p95": 55.8,
+ "p99": 58.6,
+ "n": 29
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 9.8,
+ "p95": 21.7,
+ "p99": 22.5,
+ "n": 40
+ },
+ "mac": {
+ "p50": 4.5,
+ "p95": 15.7,
+ "p99": 18.4,
+ "n": 40
+ },
+ "back": {
+ "p50": 10.6,
+ "p95": 13.2,
+ "p99": 15.7,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 59,
+ "mbps": 0.49,
+ "content_p50": 9.3,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.6,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.2,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.8,
+ "content_p95": 11.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 38,
+ "mbps": 0.58,
+ "content_p50": 9.3,
+ "content_p95": 395.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.3,
+ "content_p95": 11.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.4,
+ "content_p95": 12.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.5,
+ "content_p95": 10.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 8.9,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 8.8,
+ "content_p95": 12.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 11.5,
+ "content_p95": 14.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 11.2,
+ "content_p95": 14.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 61,
+ "mbps": 0.47,
+ "content_p50": 10.7,
+ "content_p95": 12.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 59,
+ "mbps": 0.44,
+ "content_p50": 10.3,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 61,
+ "mbps": 0.57,
+ "content_p50": 10.1,
+ "content_p95": 14.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 59,
+ "mbps": 0.45,
+ "content_p50": 10.2,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.1,
+ "content_p95": 11.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.0,
+ "content_p95": 11.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 9.9,
+ "content_p95": 11.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.5,
+ "content_p95": 13.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 46,
+ "mbps": 0.34,
+ "content_p50": 9.2,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 9.9,
+ "content_p95": 12.9,
+ "input_p50": 28.8,
+ "input_p95": 39.0,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "bad"
+ },
+ "cpu": {
+ "mac_agent_pct": 14.3,
+ "frame_viewer_pct": 49.7,
+ "frame_tailscaled_pct": 5.7,
+ "frame_sshd_pct": 0.7,
+ "frame_gamescope_pct": 6.7,
+ "frame_total_pct": 16.5
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 142 Hz",
+ "show_s": 3.62,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 1047,
+ "frames_drawn": 1042,
+ "frames_shown": 798,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -5.5,
+ "p95": 5.8,
+ "p99": 14.2,
+ "n": 1047
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 1047
+ },
+ "encode": {
+ "p50": 6.7,
+ "p95": 9.0,
+ "p99": 14.1,
+ "n": 1047
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 1047
+ },
+ "network": {
+ "p50": 4.9,
+ "p95": 9.3,
+ "p99": 47.3,
+ "n": 1047
+ },
+ "decode": {
+ "p50": 6.4,
+ "p95": 15.2,
+ "p99": 22.6,
+ "n": 1042
+ },
+ "draw": {
+ "p50": 0.6,
+ "p95": 0.8,
+ "p99": 1.6,
+ "n": 1042
+ },
+ "present": {
+ "p50": 4.7,
+ "p95": 19.6,
+ "p99": 24.9,
+ "n": 798
+ },
+ "content": {
+ "p50": 14.7,
+ "p95": 32.2,
+ "p99": 73.2,
+ "n": 1042
+ },
+ "content_shown": {
+ "p50": 21.1,
+ "p95": 41.7,
+ "p99": 66.3,
+ "n": 798
+ }
+ },
+ "fps": 50.4,
+ "fps_shown": 38.6,
+ "late_pct": 16.04,
+ "stall_max": 152.4,
+ "stalls_over_100ms": 3,
+ "mbps": 9.37,
+ "keyframes": 3,
+ "size": "1920x1290",
+ "captured": 1312,
+ "viewer_never_drawn": 5,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 51,
+ "mbps": 8.99,
+ "content_p50": 14.9,
+ "content_p95": 32.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 55,
+ "mbps": 10.09,
+ "content_p50": 17.4,
+ "content_p95": 26.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 51,
+ "mbps": 8.51,
+ "content_p50": 15.1,
+ "content_p95": 25.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 44,
+ "mbps": 7.9,
+ "content_p50": 16.7,
+ "content_p95": 73.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 46,
+ "mbps": 11.34,
+ "content_p50": 17.4,
+ "content_p95": 120.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 45,
+ "mbps": 8.55,
+ "content_p50": 15.1,
+ "content_p95": 25.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 51,
+ "mbps": 9.61,
+ "content_p50": 17.4,
+ "content_p95": 24.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 54,
+ "mbps": 9.52,
+ "content_p50": 16.9,
+ "content_p95": 27.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 50,
+ "mbps": 8.51,
+ "content_p50": 15.8,
+ "content_p95": 33.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 54,
+ "mbps": 9.22,
+ "content_p50": 12.4,
+ "content_p95": 27.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 51,
+ "mbps": 9.96,
+ "content_p50": 9.9,
+ "content_p95": 28.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 51,
+ "mbps": 9.27,
+ "content_p50": 14.1,
+ "content_p95": 22.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 52,
+ "mbps": 9.58,
+ "content_p50": 14.4,
+ "content_p95": 25.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 54,
+ "mbps": 8.61,
+ "content_p50": 7.8,
+ "content_p95": 22.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 43,
+ "mbps": 10.54,
+ "content_p50": 19.0,
+ "content_p95": 37.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 49,
+ "mbps": 8.51,
+ "content_p50": 16.1,
+ "content_p95": 27.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 51,
+ "mbps": 11.23,
+ "content_p50": 11.1,
+ "content_p95": 110.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 52,
+ "mbps": 8.99,
+ "content_p50": 13.9,
+ "content_p95": 26.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 52,
+ "mbps": 9.05,
+ "content_p50": 11.6,
+ "content_p95": 26.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 49,
+ "mbps": 9.3,
+ "content_p50": 12.4,
+ "content_p95": 28.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 37,
+ "mbps": 6.58,
+ "content_p50": 15.7,
+ "content_p95": 31.0,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 14.7,
+ "content_p95": 32.2,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "fps": "acceptable",
+ "late_pct": "bad",
+ "stall_max": "acceptable"
+ },
+ "cpu": {
+ "mac_agent_pct": 7.6,
+ "frame_viewer_pct": 73.9,
+ "frame_tailscaled_pct": 8.0,
+ "frame_sshd_pct": 0.9,
+ "frame_gamescope_pct": 6.5,
+ "frame_total_pct": 19.4
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 138 Hz",
+ "show_s": 3.38,
+ "panel": "valve.steam.desktopgame.2001739297",
+ "src": "separate:8529"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 77,
+ "frames_drawn": 77,
+ "frames_shown": 77,
+ "duration_s": 20.0,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.2,
+ "p95": 4.7,
+ "p99": 4.8,
+ "n": 77
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 77
+ },
+ "encode": {
+ "p50": 6.8,
+ "p95": 11.7,
+ "p99": 14.7,
+ "n": 77
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 77
+ },
+ "network": {
+ "p50": 4.9,
+ "p95": 8.7,
+ "p99": 75.8,
+ "n": 77
+ },
+ "decode": {
+ "p50": 4.2,
+ "p95": 9.9,
+ "p99": 14.5,
+ "n": 77
+ },
+ "draw": {
+ "p50": 1.0,
+ "p95": 1.6,
+ "p99": 2.1,
+ "n": 77
+ },
+ "present": {
+ "p50": 0.9,
+ "p95": 1.3,
+ "p99": 1.7,
+ "n": 77
+ },
+ "content": {
+ "p50": 16.7,
+ "p95": 34.2,
+ "p99": 88.1,
+ "n": 77
+ },
+ "content_shown": {
+ "p50": 17.9,
+ "p95": 34.6,
+ "p99": 88.9,
+ "n": 77
+ }
+ },
+ "fps": 3.8,
+ "fps_shown": 3.8,
+ "late_pct": 94.74,
+ "stall_max": 1156.0,
+ "stalls_over_100ms": 71,
+ "mbps": 0.03,
+ "keyframes": 2,
+ "size": "1920x1290",
+ "captured": 151,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 51.2,
+ "p95": 73.2,
+ "p99": 79.9,
+ "n": 66
+ },
+ "input_shown_ms": {
+ "p50": 52.1,
+ "p95": 73.9,
+ "p99": 80.5,
+ "n": 66
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 5.6,
+ "p95": 13.2,
+ "p99": 17.5,
+ "n": 66
+ },
+ "mac": {
+ "p50": 34.1,
+ "p95": 43.6,
+ "p99": 48.7,
+ "n": 66
+ },
+ "back": {
+ "p50": 16.4,
+ "p95": 26.5,
+ "p99": 26.8,
+ "n": 66
+ }
+ },
+ "inputs": {
+ "sent": 66,
+ "seen": 66
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 7.7,
+ "content_p95": 15.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 7.4,
+ "content_p95": 16.7,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 16.2,
+ "content_p95": 21.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 6,
+ "mbps": 0.02,
+ "content_p50": 19.1,
+ "content_p95": 26.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 18.9,
+ "content_p95": 20.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.21,
+ "content_p50": 13.7,
+ "content_p95": 43.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 20.2,
+ "content_p95": 24.5,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 16.4,
+ "content_p95": 16.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 15.2,
+ "content_p95": 24.6,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 7.7,
+ "content_p95": 17.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 5,
+ "mbps": 0.02,
+ "content_p50": 6.0,
+ "content_p95": 26.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 7,
+ "mbps": 0.02,
+ "content_p50": 19.2,
+ "content_p95": 46.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 21.7,
+ "content_p95": 22.3,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 13.4,
+ "content_p95": 13.4,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 3,
+ "mbps": 0.01,
+ "content_p50": 19.0,
+ "content_p95": 20.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 88.1,
+ "content_p95": 88.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 1,
+ "mbps": 0.25,
+ "content_p50": 26.8,
+ "content_p95": 26.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 2,
+ "mbps": 0.01,
+ "content_p50": 19.1,
+ "content_p95": 19.2,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 175.1,
+ "content_p95": 175.1,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 16.9,
+ "content_p95": 16.9,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 14.8,
+ "content_p95": 14.8,
+ "bitrate": 14860800,
+ "tier": 0,
+ "w": 1920,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 16.7,
+ "content_p95": 34.2,
+ "input_p50": 51.2,
+ "input_p95": 73.2,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "acceptable",
+ "input_p95": "acceptable"
+ },
+ "cpu": {
+ "mac_agent_pct": 1.1,
+ "frame_viewer_pct": 8.0,
+ "frame_tailscaled_pct": 1.8,
+ "frame_sshd_pct": 0.5,
+ "frame_gamescope_pct": 3.7,
+ "frame_total_pct": 9.1
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 143 Hz",
+ "show_s": 3.79,
+ "panel": "valve.steam.desktopgame.2001132791",
+ "src": "separate:8652"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/bench/results/2026-09-28-a3c6e5c-dirty-baseline.json b/bench/results/2026-09-28-a3c6e5c-dirty-baseline.json
new file mode 100644
index 0000000..25b9f92
--- /dev/null
+++ b/bench/results/2026-09-28-a3c6e5c-dirty-baseline.json
@@ -0,0 +1,1124 @@
+{
+ "label": "baseline",
+ "date": "2026-09-28T16:53:43",
+ "commit": "a3c6e5c+dirty",
+ "config": {
+ "quality": "balanced",
+ "mode": "separate",
+ "net": "none",
+ "delay_ms": 0,
+ "buffer_ms": 250,
+ "host": "frame",
+ "ssh_opts": [],
+ "encoder": "",
+ "duration_s": 20.0,
+ "browser_flags": []
+ },
+ "frame_build": "20260925.6191901",
+ "mac": "26.5.2",
+ "headset": ":51.824645 [Info] - 0 - entering standby",
+ "scenarios": [
+ {
+ "scenario": "test",
+ "frames_sent": 1248,
+ "frames_drawn": 1245,
+ "frames_shown": 809,
+ "duration_s": 20.8,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.0,
+ "n": 1248
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.1,
+ "p99": 0.1,
+ "n": 1248
+ },
+ "encode": {
+ "p50": 4.2,
+ "p95": 4.8,
+ "p99": 5.1,
+ "n": 1248
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 1248
+ },
+ "network": {
+ "p50": 4.2,
+ "p95": 6.9,
+ "p99": 13.9,
+ "n": 1248
+ },
+ "decode": {
+ "p50": 1.3,
+ "p95": 2.5,
+ "p99": 4.6,
+ "n": 1245
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.6,
+ "p99": 1.0,
+ "n": 1245
+ },
+ "present": {
+ "p50": 6.9,
+ "p95": 15.4,
+ "p99": 19.5,
+ "n": 809
+ },
+ "content": {
+ "p50": 10.3,
+ "p95": 13.5,
+ "p99": 19.6,
+ "n": 1245
+ },
+ "content_shown": {
+ "p50": 17.4,
+ "p95": 25.6,
+ "p99": 30.7,
+ "n": 809
+ }
+ },
+ "fps": 60.0,
+ "fps_shown": 39.0,
+ "late_pct": 0.96,
+ "stall_max": 110.3,
+ "stalls_over_100ms": 1,
+ "mbps": 0.47,
+ "keyframes": 2,
+ "size": "1280x720",
+ "captured": 1588,
+ "viewer_never_drawn": 3,
+ "input_ms": {
+ "p50": 28.0,
+ "p95": 34.4,
+ "p99": 37.6,
+ "n": 40
+ },
+ "input_shown_ms": {
+ "p50": 35.1,
+ "p95": 49.5,
+ "p99": 50.5,
+ "n": 27
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 11.5,
+ "p95": 19.7,
+ "p99": 22.1,
+ "n": 40
+ },
+ "mac": {
+ "p50": 4.7,
+ "p95": 12.3,
+ "p99": 16.1,
+ "n": 40
+ },
+ "back": {
+ "p50": 11.2,
+ "p95": 12.9,
+ "p99": 16.5,
+ "n": 40
+ }
+ },
+ "inputs": {
+ "sent": 40,
+ "seen": 40
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 60,
+ "mbps": 0.49,
+ "content_p50": 10.2,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.4,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.5,
+ "content_p95": 12.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 60,
+ "mbps": 0.48,
+ "content_p50": 10.7,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.6,
+ "content_p95": 13.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 60,
+ "mbps": 0.58,
+ "content_p50": 10.6,
+ "content_p95": 12.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.8,
+ "content_p95": 13.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.5,
+ "content_p95": 13.0,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 10.5,
+ "content_p95": 12.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.3,
+ "content_p95": 13.6,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 10.8,
+ "content_p95": 14.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 58,
+ "mbps": 0.56,
+ "content_p50": 10.4,
+ "content_p95": 61.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 10.8,
+ "content_p95": 12.7,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 12.4,
+ "content_p95": 16.1,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.8,
+ "content_p95": 12.2,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 60,
+ "mbps": 0.47,
+ "content_p50": 9.7,
+ "content_p95": 12.3,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 60,
+ "mbps": 0.45,
+ "content_p50": 9.7,
+ "content_p95": 12.5,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 10.9,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.7,
+ "content_p95": 11.4,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 60,
+ "mbps": 0.46,
+ "content_p50": 9.9,
+ "content_p95": 18.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 47,
+ "mbps": 0.36,
+ "content_p50": 9.9,
+ "content_p95": 11.8,
+ "bitrate": 5529600,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 10.3,
+ "content_p95": 13.5,
+ "input_p50": 28.0,
+ "input_p95": 34.4,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "local",
+ "input_p50": "local",
+ "input_p95": "local",
+ "fps": "local",
+ "late_pct": "local",
+ "stall_max": "acceptable"
+ },
+ "cpu": {
+ "mac_agent_pct": 15.4,
+ "frame_viewer_pct": 51.1,
+ "frame_tailscaled_pct": 5.3,
+ "frame_sshd_pct": 0.8,
+ "frame_gamescope_pct": 5.7,
+ "frame_total_pct": 15.3
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 140 Hz",
+ "show_s": 1.87,
+ "panel": "valve.steam.desktopgame.2001639889",
+ "src": "test"
+ },
+ {
+ "scenario": "scroll",
+ "frames_sent": 690,
+ "frames_drawn": 683,
+ "frames_shown": 645,
+ "duration_s": 20.7,
+ "stages_ms": {
+ "capture": {
+ "p50": -1.2,
+ "p95": 16.9,
+ "p99": 24.5,
+ "n": 690
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 690
+ },
+ "encode": {
+ "p50": 4.1,
+ "p95": 7.0,
+ "p99": 13.2,
+ "n": 690
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.3,
+ "n": 690
+ },
+ "network": {
+ "p50": 6.2,
+ "p95": 14.6,
+ "p99": 119.2,
+ "n": 690
+ },
+ "decode": {
+ "p50": 3.7,
+ "p95": 9.4,
+ "p99": 23.3,
+ "n": 683
+ },
+ "draw": {
+ "p50": 0.4,
+ "p95": 0.8,
+ "p99": 1.2,
+ "n": 683
+ },
+ "present": {
+ "p50": 0.8,
+ "p95": 18.6,
+ "p99": 23.6,
+ "n": 645
+ },
+ "content": {
+ "p50": 15.0,
+ "p95": 40.3,
+ "p99": 112.7,
+ "n": 683
+ },
+ "content_shown": {
+ "p50": 20.2,
+ "p95": 42.7,
+ "p99": 106.9,
+ "n": 645
+ }
+ },
+ "fps": 33.0,
+ "fps_shown": 31.1,
+ "late_pct": 42.82,
+ "stall_max": 492.6,
+ "stalls_over_100ms": 6,
+ "mbps": 3.11,
+ "keyframes": 3,
+ "size": "1280x860",
+ "captured": 905,
+ "viewer_never_drawn": 7,
+ "input_ms": {
+ "n": 0
+ },
+ "input_shown_ms": {
+ "n": 0
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "n": 0
+ },
+ "mac": {
+ "n": 0
+ },
+ "back": {
+ "n": 0
+ }
+ },
+ "inputs": {
+ "sent": 0,
+ "seen": 0
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 36,
+ "mbps": 3.42,
+ "content_p50": 17.7,
+ "content_p95": 34.3,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 35,
+ "mbps": 2.8,
+ "content_p50": 13.6,
+ "content_p95": 27.7,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 31,
+ "mbps": 2.15,
+ "content_p50": 12.1,
+ "content_p95": 34.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 37,
+ "mbps": 2.29,
+ "content_p50": 14.6,
+ "content_p95": 28.5,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 35,
+ "mbps": 2.11,
+ "content_p50": 16.1,
+ "content_p95": 29.3,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 33,
+ "mbps": 2.05,
+ "content_p50": 10.9,
+ "content_p95": 27.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 33,
+ "mbps": 5.71,
+ "content_p50": 17.1,
+ "content_p95": 41.7,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 31,
+ "mbps": 3.3,
+ "content_p50": 12.1,
+ "content_p95": 31.3,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 24,
+ "mbps": 4.37,
+ "content_p50": 26.6,
+ "content_p95": 236.8,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 36,
+ "mbps": 3.19,
+ "content_p50": 11.6,
+ "content_p95": 26.5,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 36,
+ "mbps": 3.53,
+ "content_p50": 15.2,
+ "content_p95": 29.6,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 29,
+ "mbps": 3.56,
+ "content_p50": 11.5,
+ "content_p95": 36.8,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 29,
+ "mbps": 1.99,
+ "content_p50": 13.2,
+ "content_p95": 41.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 31,
+ "mbps": 3.4,
+ "content_p50": 23.9,
+ "content_p95": 44.5,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 32,
+ "mbps": 2.85,
+ "content_p50": 14.3,
+ "content_p95": 37.6,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 31,
+ "mbps": 3.39,
+ "content_p50": 19.8,
+ "content_p95": 35.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 22,
+ "mbps": 2.74,
+ "content_p50": 22.5,
+ "content_p95": 112.7,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 44,
+ "mbps": 3.2,
+ "content_p50": 14.0,
+ "content_p95": 31.0,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 35,
+ "mbps": 4.12,
+ "content_p50": 16.8,
+ "content_p95": 32.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 36,
+ "mbps": 2.63,
+ "content_p50": 13.9,
+ "content_p95": 25.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 27,
+ "mbps": 1.71,
+ "content_p50": 17.2,
+ "content_p95": 40.3,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 15.0,
+ "content_p95": 40.3,
+ "input_p50": null,
+ "input_p95": null,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "fps": "bad",
+ "late_pct": "bad",
+ "stall_max": "bad"
+ },
+ "cpu": {
+ "mac_agent_pct": 5.1,
+ "frame_viewer_pct": 44.2,
+ "frame_tailscaled_pct": 4.8,
+ "frame_sshd_pct": 0.6,
+ "frame_gamescope_pct": 4.2,
+ "frame_total_pct": 13.7
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 139 Hz",
+ "show_s": 1.82,
+ "panel": "valve.steam.desktopgame.2001631563",
+ "src": "separate:6889"
+ },
+ {
+ "scenario": "type",
+ "frames_sent": 76,
+ "frames_drawn": 76,
+ "frames_shown": 76,
+ "duration_s": 20.2,
+ "stages_ms": {
+ "capture": {
+ "p50": 0.5,
+ "p95": 7.2,
+ "p99": 10.1,
+ "n": 76
+ },
+ "queue": {
+ "p50": 0.0,
+ "p95": 0.0,
+ "p99": 0.1,
+ "n": 76
+ },
+ "encode": {
+ "p50": 4.4,
+ "p95": 9.7,
+ "p99": 16.7,
+ "n": 76
+ },
+ "socket": {
+ "p50": 0.1,
+ "p95": 0.2,
+ "p99": 0.2,
+ "n": 76
+ },
+ "network": {
+ "p50": 5.2,
+ "p95": 43.2,
+ "p99": 263.5,
+ "n": 76
+ },
+ "decode": {
+ "p50": 2.1,
+ "p95": 4.8,
+ "p99": 13.4,
+ "n": 76
+ },
+ "draw": {
+ "p50": 0.7,
+ "p95": 1.0,
+ "p99": 1.1,
+ "n": 76
+ },
+ "present": {
+ "p50": 0.9,
+ "p95": 1.5,
+ "p99": 1.8,
+ "n": 76
+ },
+ "content": {
+ "p50": 12.9,
+ "p95": 54.2,
+ "p99": 264.6,
+ "n": 76
+ },
+ "content_shown": {
+ "p50": 14.1,
+ "p95": 55.3,
+ "p99": 265.9,
+ "n": 76
+ }
+ },
+ "fps": 3.8,
+ "fps_shown": 3.8,
+ "late_pct": 96.0,
+ "stall_max": 1327.9,
+ "stalls_over_100ms": 68,
+ "mbps": 0.02,
+ "keyframes": 2,
+ "size": "1280x860",
+ "captured": 125,
+ "viewer_never_drawn": 0,
+ "input_ms": {
+ "p50": 50.1,
+ "p95": 110.6,
+ "p99": 165.7,
+ "n": 65
+ },
+ "input_shown_ms": {
+ "p50": 50.8,
+ "p95": 111.5,
+ "p99": 166.2,
+ "n": 65
+ },
+ "input_parts_ms": {
+ "uplink": {
+ "p50": 7.6,
+ "p95": 18.3,
+ "p99": 23.2,
+ "n": 65
+ },
+ "mac": {
+ "p50": 30.6,
+ "p95": 72.4,
+ "p99": 119.2,
+ "n": 65
+ },
+ "back": {
+ "p50": 12.3,
+ "p95": 28.1,
+ "p99": 33.9,
+ "n": 65
+ }
+ },
+ "inputs": {
+ "sent": 66,
+ "seen": 65
+ },
+ "timeline": [
+ {
+ "t": 0,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 12.3,
+ "content_p95": 28.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 1,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 16.8,
+ "content_p95": 33.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 2,
+ "fps": 6,
+ "mbps": 0.01,
+ "content_p50": 9.0,
+ "content_p95": 22.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 3,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 11.5,
+ "content_p95": 20.6,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 4,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 9.2,
+ "content_p95": 13.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 5,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 3.0,
+ "content_p95": 15.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 6,
+ "fps": 5,
+ "mbps": 0.14,
+ "content_p50": 14.0,
+ "content_p95": 21.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 7,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 9.5,
+ "content_p95": 16.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 8,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 13.8,
+ "content_p95": 16.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 9,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 14.0,
+ "content_p95": 28.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 10,
+ "fps": 5,
+ "mbps": 0.01,
+ "content_p50": 12.8,
+ "content_p95": 31.7,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 11,
+ "fps": 6,
+ "mbps": 0.01,
+ "content_p50": 12.3,
+ "content_p95": 14.4,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 12,
+ "fps": 4,
+ "mbps": 0.01,
+ "content_p50": 15.2,
+ "content_p95": 17.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 13,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 18.9,
+ "content_p95": 18.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 14,
+ "fps": 2,
+ "mbps": 0.0,
+ "content_p50": 203.5,
+ "content_p95": 215.0,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 15,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 16.1,
+ "content_p95": 16.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 16,
+ "fps": 2,
+ "mbps": 0.16,
+ "content_p50": 15.5,
+ "content_p95": 35.1,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 17,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 264.6,
+ "content_p95": 264.6,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 18,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 12.9,
+ "content_p95": 12.9,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 19,
+ "fps": 2,
+ "mbps": 0.0,
+ "content_p50": 11.7,
+ "content_p95": 54.2,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ },
+ {
+ "t": 20,
+ "fps": 1,
+ "mbps": 0.0,
+ "content_p50": 339.6,
+ "content_p95": 339.6,
+ "bitrate": 6604800,
+ "tier": 0,
+ "w": 1280,
+ "net": null
+ }
+ ],
+ "adapt": [],
+ "content_p50": 12.9,
+ "content_p95": 54.2,
+ "input_p50": 50.1,
+ "input_p95": 110.6,
+ "grades": {
+ "content_p50": "local",
+ "content_p95": "acceptable",
+ "input_p50": "acceptable",
+ "input_p95": "acceptable"
+ },
+ "cpu": {
+ "mac_agent_pct": 1.2,
+ "frame_viewer_pct": 6.5,
+ "frame_tailscaled_pct": 1.6,
+ "frame_sshd_pct": 0.3,
+ "frame_gamescope_pct": 2.4,
+ "frame_total_pct": 8.0
+ },
+ "viewer": "h264 software; ANGLE (Mesa, zink Vulkan 1.4(Turnip Adreno (TM) 750 (MESA_TURNIP)), OpenGL 4.6); raf 141 Hz",
+ "show_s": 1.43,
+ "panel": "valve.steam.desktopgame.2001427053",
+ "src": "separate:7011"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/compat-db/README.md b/compat-db/README.md
index 5c81211..d851728 100644
--- a/compat-db/README.md
+++ b/compat-db/README.md
@@ -1,9 +1,32 @@
# compat-db: Frame Control's compatibility database
A private [Lakebed](https://docs.lakebed.dev/) capsule holding compatibility
-reports for Android apps on the Steam Frame. For now only the maintainer's
-copy of Frame Control has the key to read or write it. Everyone else's reports
-stay on their own Mac (see `shared()` in `ui/frame_compat_db.py`).
+reports for Android apps on the Steam Frame. Only the maintainer's copy of
+Frame Control has the key to read or write it (see `shared()` in
+`ui/frame_compat_db.py`). Everyone else's reports stay on their computer
+unless they turn on **Share compatibility results**. Then the reports also go
+to PostHog as `compat_report` events, and the maintainer syncs them in (below).
+
+## Community reports
+
+```sh
+python3 ui/frame_compat_db.py sync --dry-run # what would be added
+python3 ui/frame_compat_db.py sync # add them
+```
+
+`sync` reads `compat_report` events through PostHog's query API and adds
+them with `via` set to `community`, `community-probe` or `community-install`.
+It skips invalid reports and anything over 30 per reporter per day. Each run
+re-reads the last 30 days, because an offline copy sends its reports late,
+with the time they were made. `posthog-sync.json`, next to the outbox,
+remembers which reports it has handled and each reporter's daily count, so
+nothing is added twice and the cap holds across runs.
+
+It needs:
+
+- the PostHog project id: `"project"` in `ui/telemetry.json`
+- a personal API key with `query:read`: `POSTHOG_PERSONAL_API_KEY`, or in the
+ Keychain (service `frame-control-posthog`, account `personal-api-key`)
- Live: `https://frame-compat.lakebed.app` (deploy `dep_dDmcsosVSiFirpW6`,
claimed, so it doesn't expire). The browser page only says it's private.
diff --git a/docs/agents.md b/docs/agents.md
new file mode 100644
index 0000000..76ee9c1
--- /dev/null
+++ b/docs/agents.md
@@ -0,0 +1,222 @@
+# Frame Control for AI agents
+
+**Documented interface:** Frame Control's own stdlib Python MCP adapter wraps
+its loopback HTTP API. No API key, hosted service, model SDK or third-party
+helper app is needed. The assistant is our HTML/Python implementation hosted
+in the platform Chromium browser. Its optional LLM endpoint is user configuration.
+Installing other apps is an optional management action, never a prerequisite.
+
+## Connect an MCP client
+
+The default MCP command starts a private HTTP backend on a free loopback port,
+with a fresh local access key. It stops that backend when the MCP client closes
+stdin or sends SIGTERM. It uses its own SSH control socket, so closing it does
+not close the desktop app's connection. No manually started server is needed.
+
+Add this stdio server to your MCP client (use absolute paths):
+
+```json
+{
+ "mcpServers": {
+ "frame-control": {
+ "command": "python3",
+ "args": ["/absolute/path/frame-control/ui/frame_mcp.py"]
+ }
+ }
+}
+```
+
+For Codex, the equivalent registration is:
+
+```sh
+codex mcp add frame-control -- python3 /absolute/path/frame-control/ui/frame_mcp.py
+```
+
+New agent sessions load the entry. An already running session may need its MCP
+connections reloaded; registration does not retroactively add tools to its
+initial tool inventory. Keep the checkout at that path while it is registered.
+Use `codex mcp remove frame-control` to remove only this registration.
+
+To reuse a running server instead, pass `--url http://127.0.0.1:47810`.
+The desktop app uses a random port; use that port with `--url`, or run the
+checkout server above. If the HTTP server uses `FRAME_UI_KEY`, pass the same
+value in the MCP process environment. This is local access control, not an LLM
+API key. The adapter only accepts loopback HTTP servers, refuses redirects and
+ignores environment proxies. Stdout contains newline-delimited JSON-RPC only.
+It supports MCP initialization, ping, tool listing and tool calls; no sampling,
+resources, prompts or streaming transport.
+
+| Tool | Arguments | Effect |
+|---|---|---|
+| `computer_state` | none | Read-only gamescope window IDs/focus and bounded AT-SPI tree; reports incomplete observations |
+| `status` | none | Battery, services, installed games and Flatpaks |
+| `screenshot` | `view`: `headset` (default) or `desktop` | Returns PNG image content to the MCP client |
+| `job` | `id` | Background install status; poll until `done`, inspect `error` |
+| `launch` | `appid` | Launch an installed Steam app |
+| `install` / `uninstall` | `id` | Install from Flathub / remove a user Flatpak |
+| `send_text` | `text` | Frame desktop clipboard; desktop must be open |
+| `send_file` | `path` | File on the HTTP server computer, up to 16 MiB, copied to Frame `~/Downloads` |
+| `panel` | `id` | Launch an installed Flatpak as a panel using the existing launcher |
+| `power` | `action`: `suspend`, `reboot`, `poweroff` | Open a terminal for the user to enter the sudo password |
+| `keep_awake` | `action`: `on`, `off`, `status` | Optional keep-awake script interface |
+
+Only install free software with its developer's consent. There is no purchase,
+entitlement bypass or arbitrary shell tool. `install` returns a background job
+ID; it does not claim the installation has finished. APK and sideloaded title
+installs remain in the main UI for now.
+
+### Approval is a separate human action
+
+Every mutation first returns an `approvalUrl`, exact action and `confirmation`
+token. Ask the user to open that URL and choose **Approve this action** or
+**Reject**. Then repeat the same tool and arguments with the token in
+`confirmation`. The server refuses execution before approval, changed arguments,
+expired tokens and reuse. A file approval binds the content hash as well as the
+path. Approvals last five minutes and disappear when the HTTP server restarts.
+A failed execution also consumes the approval; review a fresh request to retry.
+The panel does not execute an action merely because it was approved.
+
+MCP has no approval tool. This is protection against accidental model tool
+calls, not a sandbox against a client with independent shell/HTTP access to your
+computer. Grant the MCP client only the access you intend. Status, captures and computer-state observations
+are returned directly to that client, which may forward them to its configured
+model. The assistant's separate opt-in does not govern an external MCP client.
+
+Power still requires the existing password prompt in a local terminal. MCP
+never receives passwords. Power via `FRAME_LOCAL=1` is unsupported: use the main
+UI. The panel launcher and keep-awake adapter require zsh on the computer.
+
+[PR #16](https://github.com/saphid/frame-control/pull/16) owns
+`scripts/keep-awake.sh on|off|status`. This branch does not copy or change it.
+Until that script is present, the tool reports it unavailable. Keep-awake is
+never automatic: `on` changes the shared idle timers; explicitly approve `off`
+to restore them after work. It is not a per-agent lease; coordinate with other
+users. No changes are made to the analytics/update interfaces in
+[PR #17](https://github.com/saphid/frame-control/pull/17). Prompts, keys, model
+replies, screenshots and approval payloads are not sent to analytics.
+
+## Assistant panel
+
+Open **Tools → Open assistant**, or `http://127.0.0.1:47810/assistant`.
+To put the same page in the headset, with the HTTP server still running:
+
+```sh
+python3 scripts/assistant-on-frame.py --port 47810
+```
+
+This starts an SSH reverse forward bound to Frame loopback (port 47812 by
+default), then a dedicated Chromium profile tagged as a SteamVR panel. Keep the
+command running. Ctrl-C closes this browser profile and the tunnel; it leaves
+other Chromium windows and the existing HTTP server alone. A failed cleanup
+prints the temporary profile path so it can be removed when the Frame returns.
+Use `--frame-port` if the default is busy. Chromium must already be available as
+`org.chromium.Chromium`; the launcher never installs anything automatically.
+Place the panel with SteamVR's normal docking controls.
+
+Enter your full **chat-completions endpoint**, model name and optional key.
+An OpenAI-compatible local server works without a key; no OpenAI account is
+required. HTTP is allowed only on loopback; other endpoints require HTTPS.
+Loopback refers to the computer running the HTTP server, even in the headset.
+Endpoints with embedded credentials, query strings or redirects are refused.
+
+Check the message consent box and press **Send message**. Screenshot context is
+a separate unchecked box and sends one fresh capture with that request. Both
+boxes reset after sending, and changing endpoint/model revokes consent. Nothing
+is sent when opening the page or entering configuration. There is no model
+list fetch, saved history, automatic screenshot capture or assistant telemetry.
+Each send is independent: previous messages and replies are not included.
+
+Configuration, credentials and chat remain in page memory; close/reload the page
+or choose **Clear everything** to clear them. A request already sent cannot be
+recalled. Only the chosen endpoint gets the request; proxy environment variables
+and redirects are disabled. Its privacy and retention policy still applies.
+Replies are plain text and cannot call tools or operate the Frame. A model must
+support image inputs to accept screenshot context.
+
+## Evidence and limits
+
+**Verified 2026-09-28, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** loopback HTTP
+status through an SSH reverse tunnel; platform Chromium created a separate
+SteamVR panel (confirmed in `GAMESCOPE_FOCUSABLE_APPS`); headset capture returned
+a PNG. These checks preceded the UI implementation. No power or global settings
+were changed.
+
+**Inferred:** visual comfort and controller keyboard usability while wearing
+the headset; panel creation in gamescope alone does not establish these.
+Windows/Linux launcher support, live third-party model endpoints, installs,
+uninstalls, power and keep-awake changes are not covered by that feasibility
+check. See the PR for the final unit and end-to-end results.
+
+**Verified end to end on the same Frame/build (2026-09-28):** a stdio MCP client
+initialized, read status, retrieved a headset PNG, and transferred a test file
+only after approval through the Chromium page. Remote file bytes matched;
+reusing the confirmation was rejected. The actual headset Chromium page sent
+text and then separately opted-in image context to a local test endpoint and
+displayed its replies. Without consent there were zero endpoint requests.
+The test endpoint returned canned replies: model inference and a live external
+provider remain **unverified**. The launcher’s Ctrl-C cleanup was checked;
+profiles, SSH tunnels and the test file were removed. No installs, removals,
+launches of user games, power operations or keep-awake changes were performed.
+
+**Verified locally:** unit coverage includes the stdio subprocess, approval
+binding/expiry/replay/concurrency, file-change rejection, and a real local HTTP
+endpoint for opt-in, text/image payloads and redirect refusal. Fake-Frame
+regressions are in `tests/e2e/test_agents.py`; local Docker execution was blocked
+because the Docker daemon was unavailable. The ARM64 fake-Frame CI job passed
+on this branch (run 36421345682).
+
+**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
+browser profile and SSH tunnel and remove the profile and panel log.
+
+**Verified end to end on the same Frame/build (2026-09-29), with mutations:**
+a stdio MCP client started `ui/frame_mcp.py` in its default mode (private
+backend, no API key, no prestarted server) and a human approved or rejected
+each change in the approval page in a real Chrome window:
+
+| Tool | Result on the Frame |
+|---|---|
+| `send_file` | Approved; the file arrived in `~/Downloads` with identical contents |
+| `install` | Approved; `io.github.fizzyizzy05.binary` job finished in about 35 s |
+| `panel` | Approved; gamescope listed a new panel window, and `computer_state` reported the same window ID and PID. The app rendered in that window (below) |
+| `launch` | Approved; Keep Talking and Nobody Explodes (341800) started under Proton and `computer_state` reported it as the focused app |
+| `uninstall` | Approved; app and locale removed |
+| `power` | Rejected in the page. Unapproved retries, the same token used for `uninstall`, and a retry after rejection were all refused. Nothing was powered off |
+| `send_text` | Approved, then refused because the Plasma desktop was not open (documented requirement) |
+| `keep_awake` | `status` reports the script unavailable until PR #16 lands |
+
+A separate Claude Code CLI session, with only this server configured, read
+status, `computer_state` and a headset capture, and requested an install. It
+received an approval URL and did not execute anything.
+
+The assistant opened as a Frame panel through `scripts/assistant-on-frame.py`.
+Against a loopback stub model, a send without consent made zero requests. With
+consent it made exactly one, carrying the text and a fresh Frame screenshot.
+Consent unticked itself after sending. SIGTERM removed the panel, profile, log
+and tunnel.
+
+**Not verified while unworn:** every headset capture was a uniform dark frame,
+so SteamVR's rendered view of panels and the game could not be checked; window
+captures (`xwd`) were used instead. MCP can launch a game or panel but has no
+tool to stop one: the tester stopped them over SSH. Removing an app leaves any
+runtime it pulled in; Flatpak may also remove related extensions when that
+runtime is removed by hand.
+
+
+
+
+
+
+
+## Computer-use coverage
+
+MCP is the tool transport, not a limit on what an agent can do. A screenshot,
+accessibility snapshot, click or keystroke can all be MCP tools when we have a
+reliable underlying implementation. See [the investigation](computer-use.md)
+for the verified boundaries. `computer_state` adds observation, not an input
+channel: it cannot click an approval button or send keyboard/mouse events.
+
+**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** the command saved
+by `codex mcp add` launched without a prestarted server, negotiated MCP, listed
+12 tools, read live Frame status and returned X11 window state plus AT-SPI
+observations. It exited 0 at EOF. Steam's accessibility tree had inaccessible
+children, reported as `incomplete: true`; this is not a complete actionable UI.
diff --git a/docs/announcements.md b/docs/announcements.md
new file mode 100644
index 0000000..a62c0ab
--- /dev/null
+++ b/docs/announcements.md
@@ -0,0 +1,144 @@
+# Announcing changes
+
+How we tell people about Frame Control features and fixes as they merge. The
+same few sentences feed the X post, the release notes and the website, so they
+are written once, in the pull request, while the change is fresh.
+
+## What gets announced
+
+| Kind | Announce? | Example |
+|---|---|---|
+| **New** — something you can now do | Yes, its own post | Stream Mac windows into the Frame as panels |
+| **Better** — something existing got noticeably easier, faster or wider | Yes, its own post or a roundup | APKs install without the Android SDK |
+| **Fixed** — something broken that users hit | Yes if people reported it or it blocked a flow; otherwise the next roundup | Mac mirror showed a zoomed-in corner |
+| **Release** — a tagged build | Always, one post linking the release | Frame Control 0.3.1 |
+| Tests, refactors, CI, docs-only, website polish | No | Fake Frame tests, screenshot crop |
+
+If a change isn't worth a sentence to someone who owns a Frame, it isn't
+announced.
+
+## The voice
+
+Write it the way the README and release notes already read.
+
+- **Lead with what the person can now do**, in their words: "Install older
+ versions of an app when the newest won't run on the Frame", not "Add APK
+ version fallback resolver".
+- **Plain and specific.** Name the thing, give the number: "about 30 fps",
+ "4,500 apps", "up to 8 older versions". No "blazing", "game-changing",
+ "excited to announce", "huge", or exclamation marks.
+- **Say where it works.** Platforms and what it was tested on, briefly:
+ "Tested on a real Frame from macOS 27." Don't claim what wasn't tested.
+- **Say the catch.** If it needs a setup step, an unsigned build, or only works
+ on one OS, say so in the same post.
+- **Sentence case**, full sentences, British spelling to match the docs.
+ Contractions are fine.
+- **No emoji in the text.** One image, GIF or short clip carries the tone
+ instead. The only symbol is the kind label below.
+- **Unofficial, always.** Never imply Valve made or endorses it. Say "Steam
+ Frame" for the headset and "Frame Control" for the app.
+- **Credit people.** If a user reported the bug or suggested the feature and is
+ happy to be named, thank them by handle.
+
+## The formats
+
+Every announceable PR ends with an `## Announcement` section holding these.
+The reviewer checks it like code.
+
+### 1. The post (X, and any other social account)
+
+```
+:
+
+
+
+
+```
+
+- `` is `New`, `Better` or `Fixed`.
+- 280 characters maximum including the link (X counts any link as 23).
+- One link: the release if it has shipped, otherwise the PR.
+- One visual when the change is visible: a screenshot from the app, a GIF, or a
+ short clip from the headset. Alt text describes what it shows.
+- No hashtags, except `#SteamFrame` on releases and on posts about something
+ new, because people search for it.
+
+### 2. The release-note line
+
+One bullet under **New in x.y.z**, same as the current release notes: the
+first half of the post's first sentence, no kind label, no link.
+
+### 3. Release post
+
+```
+Frame Control :
+
+. Also: .
+
+Windows, macOS and Linux:
+#SteamFrame
+```
+
+The release title on GitHub uses the same `Frame Control : `
+line, as 0.3.0 and 0.3.1 already do.
+
+### Roundups
+
+Small fixes that don't earn their own post wait for a roundup, posted with the
+next release or when three or more have piled up:
+
+```
+Fixed in Frame Control this week:
+-
+-
+-
+
+
+```
+
+## Examples from what has already merged
+
+**#11, older APK versions**
+
+```
+New: when an Android app is too new for the Frame, Frame Control now offers
+older versions that will install.
+
+It checks F-Droid, its archive and IzzyOnDroid, and verifies each download
+before it goes on the headset.
+
+https://github.com/saphid/steam-frame/pull/11
+```
+
+**#8, Mac mirror fixes**
+
+```
+Fixed: mirroring your Mac into the Steam Frame now fits the whole desktop in
+the panel, asks for the right password, and shows the cursor.
+
+Tested end to end on a real Frame from macOS 27.
+
+https://github.com/saphid/steam-frame/pull/8
+```
+
+**v0.3.1**
+
+```
+Frame Control 0.3.1: install APKs without the Android SDK
+
+Frame Control now reads APK files itself, so there's nothing extra to install.
+Also: Linux and Windows game sideloading, and one-click install links.
+
+Windows, macOS and Linux: https://github.com/saphid/steam-frame/releases/tag/v0.3.1
+#SteamFrame
+```
+
+## Posting
+
+Nothing is posted without a person approving it. The flow is:
+
+1. The PR carries its `## Announcement` section.
+2. On merge, the post is drafted from that section (manually for now).
+3. Alex approves or edits it, then it's posted from the project account.
+4. Replies and questions that turn out to be bugs become GitHub issues labelled
+ `feedback`, same as the website form.
diff --git a/docs/apk-repos.md b/docs/apk-repos.md
new file mode 100644
index 0000000..25a86dd
--- /dev/null
+++ b/docs/apk-repos.md
@@ -0,0 +1,144 @@
+# APK repositories
+
+Frame Control supports **F-Droid-format repositories**, including F-Droid,
+F-Droid archive, IzzyOnDroid and user-provided HTTPS repositories. Repository
+indexes are authenticated before their apps appear. Search lists builds with
+Android API ≤30 and arm64-v8a or no native libraries, using the same streaming
+reducer as the existing catalogue. This does not guarantee an app works in Lepton.
+
+## Formats considered
+
+| Format | Users and purpose | Support in this source |
+|---|---|---|
+| F-Droid v2 | F-Droid, IzzyOnDroid, self-hosted fdroidserver repositories; consumed by F-Droid clients including Droid-ify and Neo Store | Preferred: signed `entry.jar` authenticates `entry.json`; its SHA-256 authenticates `index-v2.json`, which supplies APK SHA-256 hashes |
+| F-Droid v1 | Older F-Droid servers and clients | Fallback: verify `index-v1.jar`, then read its signed `index-v1.json` |
+| Obtainium configurations / exports | Obtainium users share app URLs plus source-specific filters and update settings; exports can contain a list of app configuration objects | Not imported here: configurations describe how to find releases, not one signed repository index |
+| SideQuest listings / custom feeds | SideQuest's own app discovery and installation service | No interoperable signed custom-repository specification was established from the public project documentation examined; SideQuest needs its own adapter |
+| GitHub release lists | Developers publish APK assets on release pages; community lists link to projects | Not a repository standard: asset naming, build selection and publisher verification vary; handled separately from this F-Droid source |
+| Minimal JSON list | A private list could contain package, title, APK URL and SHA-256 | Deliberately not introduced: unsigned hashes downloaded alongside files do not authenticate their publisher; another bespoke signing/update protocol would duplicate F-Droid |
+
+Research references (checked 2026-09-28):
+
+- [F-Droid APIs](https://f-droid.org/docs/All_our_APIs/) and
+ [repository setup](https://f-droid.org/docs/Setup_an_F-Droid_App_Repo/).
+- [F-Droid signing keys](https://f-droid.org/docs/Release_Channels_and_Signing_Keys/)
+ and [IzzyOnDroid's repository page and fingerprint](https://apt.izzysoft.de/fdroid/).
+- [Droid-ify](https://github.com/Droid-ify/client) and
+ [Neo Store](https://github.com/NeoApplications/Neo-Store).
+- [Obtainium](https://github.com/ImranR98/Obtainium), its
+ [configuration/deep-link format](https://wiki.obtainium.imranr.dev/deep_links/),
+ and [community app configurations](https://apps.obtainium.imranr.dev/).
+- [SideQuest's public client](https://github.com/SideQuestVR/SideQuest).
+ The absence of a specification in these materials is not proof that no
+ historical or private custom-feed format exists.
+
+## Add a repository in Frame Control
+
+From the Frame Control checkout, use its source-management CLI:
+
+```sh
+python3 ui/apk_sources/fdroid.py add 'https://example.org/fdroid/repo?fingerprint=YOUR_64_HEX_CERTIFICATE_FINGERPRINT' --name 'My apps'
+python3 ui/apk_sources/fdroid.py list
+python3 ui/apk_sources/fdroid.py search SOURCE_ID 'music'
+python3 ui/apk_sources/fdroid.py download SOURCE_ID org.example.app
+python3 ui/apk_sources/fdroid.py remove SOURCE_ID
+```
+
+Replace `SOURCE_ID` with the `id` printed by `add` or `list`. `--fingerprint`
+can also supply the pin. `fdroidrepos://example.org/fdroid/repo?fingerprint=…`
+links are accepted and converted to HTTPS. Conflicting fingerprints are refused.
+A URL must identify the repository directory, not its website or an index file.
+
+Adding fetches and validates the complete index **before saving** the source.
+Without a fingerprint, Frame Control verifies the JAR signature and remembers
+its signer: trust on first use (TOFU). This establishes continuity with the
+first server response, not independent publisher identity. Obtain the published
+fingerprint through a trusted channel when possible; the store's Add a source
+form shows the pinned one ("Trusted on first use: …") so you can compare it.
+Re-adding an existing URL preserves its pin; changing it requires deliberately
+removing and re-adding it.
+
+The API for the search/server integration is in `ui/apk_sources/fdroid.py`:
+`add_repo(url, fingerprint=None, name=None)`, `remove_repo(source_id)`,
+`set_enabled(source_id, enabled)`, and `user_repos()`. The module also exposes
+`sources`, `search`, `details`, and `download` from the shared source contract.
+This change supplies the CLI and API; the integrated source-management UI is
+separate work. Built-in sources can be disabled but cannot be removed.
+
+Settings and pins live in `frame_host.data_dir('apk-repos.json')`
+(`~/Library/Application Support/Frame Control/apk-repos.json` on macOS).
+Authenticated reduced indexes and APKs live under
+`frame_host.cache_dir('apk-sources')`; indexes refresh after 24 hours. An
+expired index is still served (marked stale in the store) while it refreshes in
+the background; a failed refresh is retried after 10 minutes.
+Only the running Frame Control app prunes cached APKs (at start and after store
+downloads); the command-line tools never do.
+The existing catalogue's unverified index cache is never treated as authenticated.
+
+Rollback protection: each repository's newest accepted signed index timestamp
+is kept in `apk-repo-state.json` next to the settings, and an older index is
+refused. Once a repository has served a v2 `entry.jar`, a missing `entry.jar`
+is an error rather than a reason to fall back to `index-v1.jar`. `entry.jar`
+must be signed with SHA-2 (SHA-1 is still accepted for legacy `index-v1.jar`).
+Removing a repository clears its state.
+
+## Publish your own repository
+
+Only publish free APKs you own or have the developer's permission to distribute.
+Do not publish paid app mirrors or bypass store licences. Check distribution
+terms before adding someone else's repository; this module does not infer legal
+permission from a signature or automatically audit a repository's terms.
+
+Install a current [fdroidserver](https://f-droid.org/docs/Installing_the_Server_and_Repo_Tools/)
+and its documented Android/Java dependencies on the publishing machine, then:
+
+```sh
+mkdir my-fdroid
+cd my-fdroid
+fdroid init
+# Set repo_url in config.yml to https://example.org/fdroid/repo
+# Also set repo_name and repo_description; keep the generated signing key safe.
+cp /path/to/your-free-app.apk repo/
+fdroid update --create-metadata
+# Review the generated metadata (name, summary, licence, source and website).
+fdroid update
+```
+
+Serve the generated **repo directory** at that HTTPS URL, including APKs,
+icons, `entry.jar`, `index-v2.json` and `index-v1.jar`. Do not publish the
+private signing keystore or configuration passwords. Configure fdroidserver's
+`serverwebroot` and run `fdroid deploy` for managed publication, or copy the
+public directory with your existing deployment tool. Publish the SHA-256
+repository certificate fingerprint displayed by fdroidserver in a link such as
+`https://example.org/fdroid/repo?fingerprint=…`.
+
+Keep the repository signing key backed up: changing it breaks existing pins.
+For updates, add the new APK, edit metadata as needed, run `fdroid update` and
+publish again. Test the published URL with Frame Control's `add`, `search` and
+`download` commands. The above publisher setup is documented from fdroidserver;
+it was not executed as part of this implementation.
+
+## Verification and limits
+
+The stdlib verifier supports one RSA PKCS#1 v1.5 JAR/CMS signer with a key of
+2048–8192 bits; SHA-256/384/512 and legacy SHA-1 digest encodings are
+recognized. It checks the signer certificate pin, the signature over `.SF`,
+the whole-manifest digest, and the manifest's digest of the JSON member.
+ECDSA, DSA, RSA-PSS, multiple signers and section-only `.SF` manifests are
+rejected. Certificates are pinned identities, not validated as Web PKI chains.
+HTTPS certificates are separately checked by Python's normal TLS validation.
+
+v1 fallback occurs only when `entry.jar` returns HTTP 404 or 410. Signature,
+fingerprint, index hash, TLS and server errors never trigger an unsigned
+fallback. APKs are cached by SHA-256 and checked again before reuse. Here,
+`verified: true` means the bytes match the signed repository's APK hash; it
+is not an independent APK publisher-signature or runtime compatibility verdict.
+There is no repository timestamp rollback/expiry policy or automated signing-key
+rotation yet. An old correctly signed index can still validate.
+
+Offline fixtures exercise v2, v1, TOFU, pin changes, disabled sources, cache
+reuse, URL rejection and corruption of every signature/hash layer. On the Mac,
+the real IzzyOnDroid repository was added with its published pin, searched for
+Tiny Music Player, and its 16,520-byte APK downloaded with SHA-256
+`d7bcb24d101b04beb3394b695b24be4e2c3d6ed702f1d0e06bc4dd707f64d86a`.
+No headset connection or installation was performed.
diff --git a/docs/apk-sources.md b/docs/apk-sources.md
new file mode 100644
index 0000000..7caca71
--- /dev/null
+++ b/docs/apk-sources.md
@@ -0,0 +1,103 @@
+# Developer-consented APK sources
+
+Surveyed 2026-09-28. Free access is not proof of redistribution permission or
+Frame compatibility. These adapters fetch only public publisher releases or
+link to publisher pages. They do not acquire store entitlements, defeat access
+checks, install anything, or rehost APKs. See [VR compatibility](vr-apks.md).
+
+| Source | Developer consent and automated-access position | API/feed; VR coverage | Decision |
+|---|---|---|---|
+| [itch.io](https://itch.io/docs/legal/terms) | Publishers warrant distribution rights (§4). Users may access content through the service; this is not blanket scraping permission. Main robots excludes `/game/download/`; author subdomains exclude `/*/download/`. No challenge bypass. | Public free Android RSS for `openxr` and `oculus-quest`; substantial indie VR. Server API is mostly authenticated publisher/account functionality, not a general anonymous store-download API. | Implement RSS search, artwork and page links; `downloadable: False`. The supplied free-download script follows keyed download pages excluded by robots, so it is not shipped. |
+| [GitHub releases](https://docs.github.com/en/rest/releases/releases) | Maintainers publish assets; curated repositories below establish provenance. Public hosting or an open-source topic alone does not establish rights to every uploaded binary. Use supported REST API under [API terms](https://docs.github.com/en/site-policy/github-terms/github-terms-of-service#h-api-terms), not HTML crawling. | Releases API includes APK assets and sometimes SHA-256. Topic search finds OpenXR/Quest projects. 60 unauthenticated requests/hour; authenticated user limits are generally 5,000/hour, with separate search/secondary limits. | Implement curated downloads and explicit topic discovery. Unreviewed topic results are page-only. |
+| [Uptodown](https://www.uptodown.com/aboutus) | Developer distribution program exists, but that does not prove publisher authorization for every catalog item. [Privacy policy](https://www.uptodown.com/aboutus/privacy) explicitly describes protection against automated access. General automation permission was not established. | Broad Android catalog, limited VR focus; no supported public consumer-download API established in this survey. | Page links only; no downloader. Do not infer consent from an unchanged APK signature. |
+| [APKPure](https://apkpure.com/terms) | Third-party APK catalog; individual publisher consent and automation rights were not established. Terms request returned HTTP 403; no bypass attempted. | Broad Android coverage, incidental VR; internal endpoints are not permission to automate. | Exclude automatic indexing/downloading; user may open site. |
+| [APKMirror](https://www.apkmirror.com/faq/) | Publisher-signed files and a free-app policy are not a blanket developer-consent or automation grant. FAQ request returned HTTP 403, so current terms could not be confirmed. | General Android/version archive; APK bundles often need another installer; little VR focus. No supported consumer-download API established. | Page links only, no scraping or bundle conversion. |
+| [Aptoide](https://en.aptoide.com/company/legal) | Terms define an app supplier as developer, owner or authorized distributor; user stores still require per-item provenance. API availability alone does not settle third-party access rights. | API ecosystem and general Android catalog; weak VR focus. | Defer until a publisher-owned store and its API terms can be approved. No blanket community-store downloader. |
+| [Amazon Appstore](https://developer.amazon.com/docs/app-submission/understanding-submission.html) | Official developer submissions; store account, device and license rules apply. Publisher submission APIs do not authorize public binary extraction. | Fire-device distribution; Android-device Appstore support ended in 2025; little Quest relevance. | Official product links only; no account or entitlement extraction. |
+| [PICO / ByteDance store](https://developer.picoxr.com/document/distribute) | Official publisher channel with store/device entitlements. No public unauthenticated binary-download grant established; documentation request encountered a redirect error. | Strong standalone VR; PICO builds may depend on PICO services/extensions. | Store links only. A developer's independently published GitHub/itch build can qualify separately. |
+| [Meta Horizon Store / former App Lab](https://www.meta.com/experiences/) | Official developer submissions. A free store entitlement is still an entitlement; no license bypass or authenticated store extraction. App Lab was folded into the main store in 2024. | Strongest Quest coverage; no supported anonymous APK-download API established. | Store links only; independently distributed free builds use their publisher source. |
+| [Khronos samples](https://github.com/KhronosGroup/OpenXR-SDK-Source) | Official upstream, Apache-2.0 sample; developer-published release APKs. GitHub API terms apply. | `hello_xr` Vulkan/OpenGL ES APKs; excellent OpenXR diagnostics. | Included in GitHub curated list, Vulkan variant selected. |
+| [Meta OpenXR samples](https://github.com/meta-quest/Meta-OpenXR-SDK) | Official upstream; check each sample's license. Source availability does not imply a published APK, and some samples require Meta extensions/services. | Source/build examples, inconsistent ready-made APK releases. | Link to upstream; add specific free APKs only after release/provenance review. |
+| [Godot XR demos](https://github.com/GodotVR/godot-xr-tools) | Official project source and publisher demo pages; licenses and dependencies vary by demo. | OpenXR examples on GitHub/itch. Older Godot builds can fail on Lepton's missing clipboard service. | Covered by source discovery; no compatibility promise from an OpenXR tag. |
+
+The table distinguishes observed restrictions from unknown permission. An
+unverified policy is a reason to defer automation, not a claim that a site is
+unlawful. Only the two implemented source kinds are registered by their own
+`sources()` functions; the other rows are recommendations, not new UI entries.
+
+## Adapters
+
+`ui/apk_sources/github.py` uses `github_curated.json`: Khronos `hello_xr`,
+[Open Brush](https://github.com/icosa-foundation/open-brush), and
+[SuperTux 3D](https://github.com/SgtBilko76/SuperTux-3D). These have official
+OpenXR project/release evidence, not a blanket claim of headset compatibility.
+Open Brush's compatibility evidence is recorded in [vr-apks.md](vr-apks.md).
+Open Brush and SuperTux publish the selected builds as prereleases; curated
+opt-ins preserve that label in version records. Exact APK filename patterns
+avoid downloading desktop archives or alternate non-Quest builds.
+[OpenSaberPlus](https://github.com/arpruss/OpenSaberPlus) was examined but not
+curated: GitHub reports its license as `NOASSERTION`, and current OpenXR APK
+provenance was not established in this pass.
+
+Default GitHub search is offline against this small list. Queries
+`topic:openxr`, `topic:oculus-quest`, and `topic:quest` explicitly call repository
+search. Results outside the curated list stay page-only, even if a repository
+claims an open-source license. This prevents an arbitrary tagged mirror from
+becoming a trusted downloader. Extend the curated JSON after provenance review.
+
+Set optional `FRAME_GITHUB_TOKEN` in the process environment for a higher API
+quota. Tokens are sent only to `api.github.com`, never written to the cache,
+never sent to asset hosts, and removed on redirects. The adapter does not
+read `gh` credentials automatically. Metadata is cached for one hour under
+`frame_host.cache_dir('apk-sources', 'publisher')`. A cold details request
+fetches at most ten releases. Rate-limit errors are surfaced without retry
+loops. Asset IDs and release tags are not Android version codes: metadata
+leaves the latter unknown and rejects a requested `version_code` rather than
+silently fetching a different build.
+
+`itch.py` exposes separate OpenXR and Quest feed sources, so one feed's failure
+does not suppress the other at the aggregator level. Queries filter the current
+feed window locally: this is not an exhaustive historical itch search. Only
+explicit zero-price Android entries are returned. Covers are exposed in
+`images`; absent screenshots, APK version, ABI and minimum SDK stay unknown.
+Curated GitHub entries include publisher artwork and plain-language summaries.
+Repository image URLs are pinned to inspected commits. Open Brush screenshots
+come from its README-linked Steam listing; SuperTux uses the upstream gameplay
+preview embedded in the port's README (not a headset capture). The hello_xr
+sample has a launcher icon and GitHub social banner; no published screenshot
+was found in the inspected repository/README, so its screenshot list is empty.
+Uncurated topic results use the owner's avatar and GitHub's repository social
+preview. These are repository placeholders, not app screenshots. Itch's recorded
+RSS includes only covers, so screenshot lists remain empty without page scraping. VR is
+based on curated evidence or a VR-specific feed/topic, not a compatibility claim.
+
+Downloads stream to unique temporary files, require an APK manifest entry,
+restrict HTTPS origins and redirects, and enforce a 2 GiB ceiling. `verified`
+means the downloaded SHA-256 matches GitHub's published digest. Without such a
+digest, the computed SHA-256 is returned with `verified: False`; neither value
+claims publisher-signature validation. Installation must inspect the APK as
+usual. OBBs, split APKs, paid assets and external release-body download links
+are unsupported.
+
+## Evidence and limits
+
+On this Mac, Python 3.9 downloaded the real Khronos Vulkan 1.1.63 APK through
+the GitHub adapter, matched its published SHA-256
+`f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34`, and
+`python3 ui/frame_android.py info ` exited 0: package
+`org.khronos.openxr.hello_xr.vulkan`, version code 1063, minimum API 24,
+arm64-v8a present, OpenXR detected. No Frame connection or installation occurred.
+
+The itch OpenXR RSS was fetched successfully and recorded as a fixture.
+Subsequent live adapter search encountered HTTP 429; it is not claimed as a
+successful live end-to-end search. Fixture search finds Off Nominal and parses
+nine Android entries from the ten-item feed (one has only an HTML platform).
+A real itch download and APK inspection were deliberately not performed:
+robots restrictions take precedence over that requested proof. No current
+policy text is claimed verified where the table records failed access.
+
+Tests use recorded, reduced API/RSS fixtures with network access blocked in
+the new test class. They cover selection, prereleases, unknown topic results,
+paid/non-Android exclusion, URL restrictions, redirect credential removal,
+caching, rate limits, checksum mismatch, non-APK rejection and partial-file
+cleanup. See `.claude/NOTES-more-sources.md` for commands and local evidence.
diff --git a/docs/apks.md b/docs/apks.md
index 8bca60a..4a841d4 100644
--- a/docs/apks.md
+++ b/docs/apks.md
@@ -5,6 +5,12 @@ in **Lepton**, Valve's Waydroid-based container. Lepton is built for games,
not general Android use
([GamingOnLinux](https://www.gamingonlinux.com/2026/09/lepton-from-valve-to-run-android-games-on-linux-is-now-open-source/)).
+**VR streaming clients:** WiVRn 26.9 and ALVR 20.14.1 install, but both fail
+OpenXR instance creation on the checked Frame because its Android runtime lacks
+`XR_KHR_convert_timespec_time` (**verified** 2026-09-28, SteamOS 0.4.1,
+BUILD_ID 20260925.6191901). They are not Frame Control dependencies. See
+[the feasibility results and options](linux-vr-streaming.md).
+
## Install from the Mac: one app, one Lepton instance (verified 2026-09-25)
Use Frame Control's **Android apps** section (search, Install, Test, Report), drop
@@ -325,3 +331,11 @@ because gamescope scales Lepton's surface to fit the same panel. Also unverified
whether the settings survive the app or its Lepton instance relaunching.
Lepton Development rebuilds its Android data on exit, so there they probably
don't.
+
+## Expansion files and save backups
+
+SideQuest-inspired CLI helpers install local OBB files into an already-running
+app instance and back up/restore a stopped instance's private app data. See
+[SideQuest features and limits](sidequest.md) for commands, archive scope and
+verification status. These paths have offline coverage; real Frame storage and
+permissions remain unverified. They do not change APK install or launch behavior.
diff --git a/docs/computer-use.md b/docs/computer-use.md
new file mode 100644
index 0000000..afaf88f
--- /dev/null
+++ b/docs/computer-use.md
@@ -0,0 +1,67 @@
+# Computer use through Frame Control MCP
+
+The MCP transport can carry semantic actions or visual computer-use actions.
+The limits are the Frame's underlying interfaces, permissions and whether an
+action can be targeted and verified. A stereoscopic headset screenshot alone
+is not a reliable coordinate system for clicking a particular app window.
+
+## What exists, and the right route
+
+| Surface | Evidence and route | Remaining work or boundary |
+|---|---|---|
+| Frame management | **Verified:** existing SSH/HTTP operations for status, capture and file transfer work through MCP. Typed install/launch/power tools wrap the existing API. | Extend typed operations before adding generic mouse automation. Preserve explicit approval for consequential changes. |
+| App/window observation | **Verified 2026-09-29:** `computer_state` reads gamescope X11 window/app/process triples, focused app and the installed AT-SPI library. | Bounded to 96 accessible nodes and six levels. Trees may be truncated, stale, hidden or incomplete. Snapshot paths and XIDs are observations, never durable action permissions. |
+| Chromium page content | **Verified previously:** the assistant rendered and could be exercised through CDP in an isolated Frame Chromium profile. | A shipped click/type surface needs exact owned browser/target binding, fresh element references, lifecycle cleanup, consent and post-action readback. Do not expose unrestricted JavaScript or attach to arbitrary existing profiles automatically. |
+| Steam UI | **Verified 2026-09-29:** the AT-SPI service listed the Steam client's Chromium process and frame nodes, but child traversal was incomplete. Existing `frame_steam.py` uses Steam's loopback CDP endpoint for specific operations. | Prefer those narrow Steam interfaces. Presence of AT-SPI does not prove controls are actionable, and generic pointer injection is not proved for VR menus. |
+| Other Linux apps | **Verified 2026-09-29:** Frame ships libX11, libXtst and libatspi; `/dev/uinput` is writable by the current user. | Library presence and access permissions do not prove that a game accepts input. Global virtual input can affect whichever app has focus. Do not ship a blind keyboard/mouse tool on this evidence alone. |
+| Panel focus and layouts | **Documented in [#41](https://github.com/saphid/frame-control/pull/41):** `POST /api/panels` accepts `list`, `focus` and `open`. Focus was verified there. | Reuse that owned interface after integration. Its tested gamescope-owned overlay transform setters return `PermissionDenied`; no reliable saved spatial-layout interface was established. Do not duplicate its implementation here. |
+| Shared keyboard/trackpad | **Documented in [#19](https://github.com/saphid/frame-control/pull/19):** `/api/input` supplies state/start and event submission, implemented with a bundled KDE Connect daemon. | This branch does not import, launch or depend on that daemon. The user's own-implementation rule remains authoritative. A first-party input implementation or permitted bundled-library route needs its own delivery evidence before MCP integration. |
+| Physical/device boundaries | **Documented:** an asleep Frame may be off the network; power authorization can require the user's password; physical pairing and headset fit/comfort require the user. | MCP cannot bypass offline hardware, consent, compositor permissions or physical verification. Keep explicit human handoffs. |
+
+## Reusing the existing computer-use work
+
+**Documented:** the installed `cua-driver` skill has the right control pattern:
+observe an exact window, use a semantic target if available, fall back to pixels
+from that same snapshot, then read back the result. Its browser route requires
+an exact process/window/target binding and session-scoped element references.
+Those are useful design rules for Frame tools.
+
+**Verified locally 2026-09-29:** `cua-driver describe get_window_state` describes
+host-local process/window IDs and macOS AX inspection. It does not establish an
+SSH Frame target. The installed skill's advertised Linux companion file is
+missing. A native ARM64 Frame backend, its dependencies and remote transport
+have not been verified. We therefore do not claim that the existing Mac driver
+can control the Frame by passing it a Frame PID or screenshot, and we do not
+make the feature depend on installing that application.
+
+Frame Control's `computer_state` is our own Python implementation over installed
+platform libraries. It sends the probe over SSH stdin, writes no helper to disk,
+and exits after one observation. Missing displays/libraries return explicit
+errors; a 15-second process deadline prevents a stalled accessibility call from
+leaving a probe behind. Window names and accessibility text are untrusted app
+content, never instructions to an agent.
+
+**Recommended next implementation:** an isolated Chromium session with typed
+snapshot/click/type/scroll tools and exact fresh target binding, then individually
+verified native app actions. Use the headset capture to judge appearance, not to
+invent a screen-to-window coordinate transform. Direct tool calls must retain
+approval rules; a generic computer-use tool must not become a route around the
+MCP approval panel, install confirmation or power confirmation.
+
+## Isolated browser input proof
+
+**Verified 2026-09-29, SteamOS 0.4.1, BUILD_ID 20260925.6191901:** a temporary
+Frame Chromium profile loaded a local test page through an SSH reverse tunnel.
+CDP `Input.insertText` entered the test string in its own input. A CDP
+`Input.dispatchMouseEvent` press/release on its own button copied that string
+to the page's result; DOM readback matched exactly. The browser profile,
+loopback forwards and panel log were removed afterward. No user app was typed
+into, no global settings were changed and no third-party helper app was used.
+
+AT-SPI did **not** expose the test page's controls in that same probe, even with
+Chromium's renderer-accessibility flag. It returned the partial Steam-client
+tree instead. The reason remains **unverified**; this is an evidence gap, not
+proof that Frame accessibility cannot work. For a first implementation,
+Chromium's proven page-specific CDP route is stronger than assuming complete
+AT-SPI coverage. This proof does not ship unrestricted click/type tools or
+establish input delivery to SteamVR's menus.
diff --git a/docs/devices.md b/docs/devices.md
new file mode 100644
index 0000000..1ed3200
--- /dev/null
+++ b/docs/devices.md
@@ -0,0 +1,180 @@
+# Headsets, addresses and the connection
+
+Frame Control can manage more than one Steam Frame, and each headset can be
+reached at more than one address: a LAN IP at home, another at the office, its
+mDNS name (`frame.local`), its Tailscale IP or MagicDNS name. The **Devices**
+tab (key 5) lists them, and the connection pill in the header shows what the
+app is doing to reach the one in use, step by step, as it happens.
+
+The code is in three modules, all stdlib-only Python on your computer:
+
+| Module | What it does |
+|---|---|
+| `ui/frame_devices.py` | The registry: headsets, their addresses, networks; importing and updating `~/.ssh/config`; pinned host keys |
+| `ui/frame_network.py` | Which network this computer is on, and Tailscale's state |
+| `ui/frame_link.py` | The connector: finds the headset, keeps the SSH connection, publishes each stage; the Devices API |
+
+## Headsets
+
+Each headset keeps its own SSH alias, as Set Up Connection has always written
+it: the first is `frame`, the next `frame-2`, and so on. Terminal's
+`ssh frame-2` and the helper scripts (`FRAME_ALIAS=frame-2 scripts/push.sh …`)
+work for each one.
+
+- **Nothing to migrate by hand.** On first start, the app imports every
+ `# >>> steam-frame (ALIAS) >>>` block in `~/.ssh/config` as a headset, with
+ the block's HostName as its first address. It also copies the host key your
+ `known_hosts` already trusts for that address into the headset's own
+ known_hosts file, `~/.ssh/frame-control-hosts/`, so nobody is asked to trust it again.
+- **Add a headset** runs Set Up Connection (`scripts/connect.sh` on macOS,
+ `ui/frame_connect.py --alias NAME` elsewhere) in a terminal with a new alias.
+ When it writes its block, the app picks the headset up by itself. If Set Up
+ Connection runs again and finds a headset somewhere new, that address is added
+ at the top of its list.
+- **Use this headset** (or the switcher in the header, or the app's
+ **Frame → Headset** menu) moves the whole app to another headset; every panel
+ reloads from it. From that moment no command goes to the previous headset, even
+ if the new one never answers. It waits while an install is running, since an
+ install reads the SSH settings step by step.
+- **Remove** forgets a headset. Its `~/.ssh/config` block stays unless you tick
+ the box; either way it isn't imported again unless Set Up Connection changes it.
+- A plain `FRAME_ALIAS` that Set Up Connection never configured still works: the
+ app shows it as not set up and lets ssh's own config decide where it goes.
+
+## Addresses
+
+Each address has a kind (LAN, mDNS, Tailscale or Other, guessed from the address
+and changeable), an optional label, the networks it has worked on, and when it
+last worked with its round-trip time.
+
+When connecting, the app **tries all addresses at once** (TCP to the SSH port)
+and ranks them:
+
+1. addresses that worked on the network this computer is on now;
+2. mDNS names;
+3. Tailscale addresses, if Tailscale is running here;
+4. addresses not tried on this network yet;
+5. addresses that only ever worked on other networks;
+6. Tailscale addresses while Tailscale is off.
+
+Your order on the Devices tab breaks ties. The best-ranked address that answers
+wins; one that answers first waits up to 0.35 s for a better-ranked one that is
+still trying. If SSH to the winner fails in a way another address could fix
+(a different device answered there, or the link dropped), the next one that
+answered is tried. Every success records the network on that address, so next
+time on that network it's tried first.
+
+**Test now** probes every address and tries SSH on each one that answers, without
+disturbing the connection in use: "SSH works", "answered as a different
+headset", "refused this computer's key", or why it didn't answer. **Find on
+Tailscale** lists your tailnet's devices (likely headsets first, from `tailscale
+status --json`, including the Mac app's own CLI) with buttons to add their
+MagicDNS name or IP. **Find on this network** asks mDNS for SteamOS devkit
+services and checks `ALIAS.local` and `frame.local`.
+
+## Networks
+
+A network is told apart by its default gateway: the router's IP address plus its
+hardware (MAC) address, read with `route`/`arp` (macOS), `ip route`/`ip neigh`
+(Linux) or `route print`/`arp -a` (Windows). That works on wired networks, and
+on macOS 14 and later, which hides the Wi-Fi name from apps without Location
+permission. Where the system does share the Wi-Fi name, it's shown, and you can
+name any network yourself ("Home Wi-Fi") on the Devices tab.
+
+The app rereads the gateway every 5 seconds and Tailscale's state every
+30 seconds. Changing networks reconnects.
+
+## The connection, stage by stage
+
+The connector runs in the server (`frame_link.Link`) and moves through:
+
+1. **Checking this computer's network**: gateway, Wi-Fi, this computer's IP, Tailscale.
+2. **Finding the headset**: each address resolving, trying, answered in N ms,
+ no answer, refused, or can't be found.
+3. **Opening SSH** to the address that answered.
+4. **Checking the headset's identity**: the host key must match the one pinned
+ for this headset.
+5. **Logging in** as the headset's user.
+6. **Connected** via network N, address A, round trip T; or **failed** at a stage
+ with the reason in plain words and a countdown to the next try (5, 10, 20,
+ then every 30 seconds). Retry now skips the wait.
+
+Stages 3 to 5 come from following `ssh -v` as it runs. On macOS and Linux the
+connection is an SSH ControlMaster that every command shares; when it dies (the
+headset slept or left the network) the connector notices and starts again. On
+Windows, where OpenSSH can't share a connection, the same handshake runs once
+and each command then connects on its own; a command that can't reach the
+headset makes the connector start again.
+
+Once connected, every `ssh`, `scp` and `rsync` the app runs gets
+`-o HostName= -o HostKeyAlias=frame-control-
+-o UserKnownHostsFile=~/.ssh/frame-control-hosts/ -o HashKnownHosts=no -o User=… -o Port=…`. The
+alias's block in `~/.ssh/config` is also updated to the last address that
+worked (and to the user and port you set), so Terminal's `ssh frame` and the
+scripts follow. Edits to `~/.ssh/config` take a lock file
+(`~/.ssh/config.frame-control.lock`) that Set Up Connection takes too, and never
+write over a change someone else made since the app last read the file.
+
+**Host keys are pinned per headset, not per address.** Your own `known_hosts`
+is keyed by address, so a different device answering at a remembered IP (a DHCP
+lease that moved) would look like a new host there. The app keeps one known_hosts
+file per headset instead, so saving or forgetting one headset's key never touches
+another's: a different device answering at one of its
+addresses is refused, and the pill says so. A headset's first connection trusts
+the key it shows, as Set Up Connection does. After reinstalling SteamOS the
+headset has a new key; **Forget identity** on the Devices tab lets the next
+connection save the new one.
+
+## One server at a time
+
+Only one Frame Control server runs per user (a lock file, `server.lock`, in the app's
+data folder). Two would each connect, reconnect and edit the headsets on their own, and
+one could move the other's install to a different headset. A second one, say
+`scripts/frame-ui.sh` while the app is open, exits with "Frame Control is already
+running". `FRAME_CONTROL_DATA_DIR` gives a separate one, with its own headsets.
+
+## API
+
+All under the usual `/api/` guards (loopback `Host`, `X-Frame-UI` header).
+
+| Request | Returns |
+|---|---|
+| `GET /api/connection` | The connection state: `phase` (connecting, connected, failed), `device`, `network`, `stages`, `probes`, `via`, `error`, `retry_at`, `tests`, `version` |
+| `GET /api/connection/events` | The same as server-sent events, one each time it changes (the page reads it with `fetch`, since `EventSource` can't send the header) |
+| `GET /api/devices` | Headsets, the current network, known networks, the next free alias |
+| `GET /api/devices/tailscale?id=` | Tailscale peers, likely headsets first |
+| `GET /api/devices/mdns?id=` | Headsets found on this network |
+| `POST /api/devices` | `{"action": ...}`: `use`, `update` (name, user, port), `remove`, `address-add`, `address-update`, `address-remove`, `address-move`, `test`, `forget-identity`, `name-network`, `setup` (alias, optional host), `retry` |
+
+Every host, alias and user is checked against strict patterns before it's
+stored, because they end up in ssh arguments and `~/.ssh/config`; nothing goes
+through a shell.
+
+## The registry file
+
+`devices.json` in the app's data folder (`~/Library/Application Support/Frame
+Control` on macOS, `%APPDATA%\Frame Control` on Windows,
+`~/.local/share/frame-control` on Linux). It's plain JSON so the iPhone app can
+share the format later (it still connects to one host; see
+[iphone.md](iphone.md)):
+
+```json
+{"version": 1, "active": "f67f8b7e",
+ "devices": [{"id": "f67f8b7e", "name": "Steam Frame", "alias": "frame", "user": "steamos", "port": 22,
+ "identity_files": ["~/.ssh/id_ed25519_frame"],
+ "addresses": [{"host": "frame.local", "kind": "mdns", "label": "",
+ "networks": ["n-e0998baa61"], "last_ok": 1790593550.4, "last_rtt_ms": 0.9}]}],
+ "networks": {"n-e0998baa61": {"name": "Home Wi-Fi", "ssid": null, "gateway": "192.168.1.1",
+ "gateway_mac": "b4:fb:e4:b5:67:55", "wifi": true, "last_seen": 1790593550.0}}}
+```
+
+A network id is `n-` and the first 10 hex digits of SHA-1 of `gateway|mac`.
+
+## Tests
+
+`tests/test_devices.py`, `tests/test_network.py` and `tests/test_link.py` run
+with the other unit tests. They use a stand-in `ssh` (`tests/fakessh/ssh`) that
+prints what `ssh -v` prints and plays a ControlMaster, real sockets on this
+computer for the addresses, and temporary folders for `~/.ssh`
+(`FRAME_CONTROL_SSH_DIR`) and the app data (`FRAME_CONTROL_DATA_DIR`), so they
+never touch yours.
diff --git a/docs/evidence/linux-vr/2026-09-28.txt b/docs/evidence/linux-vr/2026-09-28.txt
new file mode 100644
index 0000000..ab71697
--- /dev/null
+++ b/docs/evidence/linux-vr/2026-09-28.txt
@@ -0,0 +1,47 @@
+Frame client feasibility, 2026-09-28
+SteamOS VERSION_ID=0.4.1 BUILD_ID=20260925.6191901; uname -m=aarch64
+SteamVR: vrserver log reports 2.18.1; process 2256 remained alive across checks.
+Base: dcf9689f6459e576d35fc507eb702ca6b2bf4dad (main).
+Unmodified upstream release APKs; installed with ui/frame_android.py install APK --vr.
+No Linux host attached. No pairing, streamed video, input, audio or worn-headset checks.
+Selected logcat lines only; timestamps in Android logs are UTC.
+
+WiVRn-release.apk
+https://github.com/WiVRn/WiVRn/releases/tag/v26.9
+sha256=1df6649ec77224fcc821af0ab4897222bdf3d7eb6ce6ad636461336724111331
+E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+I/WiVRn ( 1140): [2026-09-28 12:07:58.987] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
+E/OpenXR-Loader( 1140): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+I/WiVRn ( 1140): [2026-09-28 12:07:59.034] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
+E/WiVRn ( 1140): [2026-09-28 12:07:59.035] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
+
+alvr_client_android.apk
+https://github.com/alvr-org/ALVR/releases/tag/v20.14.1
+sha256=be68feeb02665e3d69f1cdbcabf38ea4d15c42868a7ec6b5e698dbefee4e4e36
+E/OpenXR-Loader( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+I/RustStdoutStderr( 1139): Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+E/[ALVR NATIVE-RUST]( 1139): ALVR panicked: What happened:
+E/[ALVR NATIVE-RUST]( 1139): panicked at alvr/client_openxr/src/lib.rs:220:10:
+E/[ALVR NATIVE-RUST]( 1139): called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
+
+Cleanup verified: both app directories, compatdata directories and Steam shortcuts absent.
+Both test containers stopped and removed. No Steam/SteamVR restart or global setting changes.
+
+Valve release notes fetched from ISteamNews/GetNewsForApp/v2 (appid=250820).
+
+SteamVR Beta Updated - 2.18.1
+https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787
+Added tethered Quest support over USB (must be used with Steam Link Beta)
+
+Introducing SteamVR 2.17
+https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486
+Adds initial support for USB streaming. Note: Requires new Steam Client Beta.
+Fix crash using Steam Link on Linux when games submit invalid textures.
+Improve streaming recovery when using Steam Link on Linux.
+
+SteamVR Beta Updated - 2.17.8
+https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598
+Fix crash using Steam Link on Linux when games submit invalid textures.
+Improve streaming recovery when using Steam Link on Linux.
+Adds initial support for USB streaming.
+USB streaming can be used without WiFi by opting into the Steam Client Beta.
diff --git a/docs/evidence/linux-vr/2026-09-29-alvr.png b/docs/evidence/linux-vr/2026-09-29-alvr.png
new file mode 100644
index 0000000..70d67b3
Binary files /dev/null and b/docs/evidence/linux-vr/2026-09-29-alvr.png differ
diff --git a/docs/evidence/linux-vr/2026-09-29-wivrn.png b/docs/evidence/linux-vr/2026-09-29-wivrn.png
new file mode 100644
index 0000000..70d67b3
Binary files /dev/null and b/docs/evidence/linux-vr/2026-09-29-wivrn.png differ
diff --git a/docs/evidence/linux-vr/2026-09-29.txt b/docs/evidence/linux-vr/2026-09-29.txt
new file mode 100644
index 0000000..e1e87ec
--- /dev/null
+++ b/docs/evidence/linux-vr/2026-09-29.txt
@@ -0,0 +1,29 @@
+Locked real-Frame repeat, 2026-09-29
+SteamOS 0.4.1, BUILD_ID 20260925.6191901; aarch64.
+mkdir /tmp/frame-test.lock succeeded before installs/launches; rmdir issued after cleanup.
+Preflight battery 44%, charging; before WiVRn 46%, before ALVR 47%, cleanup 47%.
+Original Steam PID 49823 and vrserver PID 49571 present after cleanup.
+Same unmodified upstream release APKs and SHA-256s as 2026-09-28.txt.
+Installer functions loaded from a613735 before checkout was fast-forwarded to current main.
+No compatibility layer injected. Per-app immersive Lepton instances, Steam shortcut launches.
+Headset unworn. No Linux gaming host. No pairing or streaming session reached.
+
+WIVRN: selected journal lines (local +1000 prefix, Android timestamps UTC).
+Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.202 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.210 1153 1181 I WiVRn : [2026-09-29 01:03:15.210] [WiVRn] [info] Failed to create OpenXR instance version 1.1.58: XR_ERROR_EXTENSION_NOT_PRESENT
+Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.248 1153 1181 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.256 1153 1181 I WiVRn : [2026-09-29 01:03:15.256] [WiVRn] [info] Failed to create OpenXR instance version 1.0.58: XR_ERROR_EXTENSION_NOT_PRESENT
+Sep 29 11:03:15 frame lepton-steamlaunch-2817846116[1967]: 09-29 01:03:15.257 1153 1181 E WiVRn : [2026-09-29 01:03:15.257] [WiVRn] [error] Error during initialization: Failed to create OpenXR instance: XR_ERROR_EXTENSION_NOT_PRESENT
+Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
+
+ALVR: selected journal lines (local +1000 prefix, Android timestamps UTC).
+Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1167 E OpenXR-Loader: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.553 1139 1165 I RustStdoutStderr: Error [GENERAL | xrCreateInstance | OpenXR-Loader] : LoaderInstance::CreateInstance, no support found for requested extension: XR_KHR_convert_timespec_time
+Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: panicked at alvr/client_openxr/src/lib.rs:220:10:
+Sep 29 11:03:36 frame lepton-steamlaunch-2831623938[1967]: 09-29 01:03:35.611 1139 1167 E [ALVR NATIVE-RUST]: called `Result::unwrap()` on an `Err` value: ERROR_EXTENSION_NOT_PRESENT
+Screenshot API exit 0; 1920x1080 uniformly dark image; no client scene visible.
+
+Cleanup: both test app directories, compatdata, shadercache, containers and shortcuts absent.
+Capture output directory removed. No global settings changed; Steam/SteamVR not stopped.
+The shared lock was subsequently acquired by another thread (new directory timestamp 11:03:49 +1000).
+Native clients and Valve host streaming not exercised: no native build or Linux gaming host available.
diff --git a/docs/evidence/vr-utilities/device.json b/docs/evidence/vr-utilities/device.json
new file mode 100644
index 0000000..a604007
--- /dev/null
+++ b/docs/evidence/vr-utilities/device.json
@@ -0,0 +1,53 @@
+{
+ "date": "2026-09-28",
+ "os": {
+ "version": "0.4.1",
+ "build": "20260925.6191901",
+ "variant": "vr"
+ },
+ "performance": {
+ "compositorFps": 72.0,
+ "frameMs": 13.89,
+ "appFps": null,
+ "gpuMs": 3.07,
+ "compositorCpuMs": 0.61,
+ "cpuPercent": 40.6,
+ "gpuMHz": 903.0
+ },
+ "batteryPercent": 16,
+ "maxTempC": 73.5,
+ "ownership": [
+ {
+ "id": 1009850,
+ "owned": false,
+ "installed": false,
+ "frame": 0
+ },
+ {
+ "id": 1173510,
+ "owned": false,
+ "installed": false,
+ "frame": 0
+ },
+ {
+ "id": 1068820,
+ "owned": false,
+ "installed": false,
+ "frame": 0
+ },
+ {
+ "id": 908520,
+ "owned": false,
+ "installed": false,
+ "frame": 0
+ },
+ {
+ "id": 1494460,
+ "owned": false,
+ "installed": false,
+ "frame": 0
+ }
+ ],
+ "hudLifecycle": "open, duplicate-open, close passed before control-test pause; overlay probe removal verified",
+ "comfort": "Initial 1cm seated probe restored exactly. Later commits/readback disagreed while Frame in use; Alex paused control tests. No controls shipped."
+}
diff --git a/docs/evidence/vr-utilities/hud-card.png b/docs/evidence/vr-utilities/hud-card.png
new file mode 100644
index 0000000..d12d009
Binary files /dev/null and b/docs/evidence/vr-utilities/hud-card.png differ
diff --git a/docs/evidence/vr-utilities/review.md b/docs/evidence/vr-utilities/review.md
new file mode 100644
index 0000000..ae9b11f
--- /dev/null
+++ b/docs/evidence/vr-utilities/review.md
@@ -0,0 +1,58 @@
+# Independent review attempts — 2026-09-28
+
+Target: the implementation and evidence in
+[3fb541d](https://github.com/saphid/frame-control/commit/3fb541d) and
+[6a63a5a](https://github.com/saphid/frame-control/commit/6a63a5a), supplied as a
+frozen diff before those commits were made. No executable code changed after
+the final review snapshot.
+
+Requested model: **SWE-2 Max**, explicitly selected with `--model swe-2-max`.
+No completed verdict or model self-identification was returned. This is not a
+passed review, and there is no "no actionable findings" claim.
+
+## First attempt
+
+Launcher:
+
+```sh
+devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-prompt.txt
+```
+
+The prompt required read-only review, no delegation, no edits and no Frame
+access. The reviewer inspected surrounding code, then stopped while checking
+the public OpenVR header. The tool runner reported:
+
+> warning: rejected a tool call that requires confirmation. Running in non-interactive mode.
+
+The real launcher exit status was **0**, but no verdict was returned. A zero
+process status here is not evidence that the review completed.
+
+## Tool-free retry
+
+Launcher:
+
+```sh
+devin -p --model swe-2-max --permission-mode auto --respect-workspace-trust false --prompt-file /tmp/frame-vr-review-final-prompt.txt
+```
+
+The self-contained prompt supplied the complete frozen changes, surrounding
+code, standards and locally fetched authoritative OpenVR header excerpts. It
+explicitly prohibited tools, edits, delegation and device access. This avoided
+the first attempt's permission boundary without escalating permissions.
+
+No output or verdict arrived within the fifteen-minute review window. The
+process was sent SIGTERM at 918 seconds; the shell recorded real exit status
+**143**. Findings are unavailable. Review must be completed before considering
+this partial draft ready; playspace feasibility is also still paused.
+
+## Other validation
+
+- 166 Python unit tests passed locally.
+- 8 website tests and UI JavaScript syntax passed locally.
+- Desktop and phone-width attached-preview checks passed using live telemetry;
+ paid optional install buttons were disabled, and unavailable metrics cleared.
+- [Fake-Frame CI](https://github.com/saphid/frame-control/actions/runs/36423548487/job/108931878908)
+ passed, as did Windows/Linux server tests and the main checks job. Docker was
+ unavailable locally. macOS/iOS jobs were still queued at this handoff.
+- Real-device evidence and the paused-control limitation are in
+ [VR utilities](../../vr-utilities.md).
diff --git a/docs/family-comfort.md b/docs/family-comfort.md
new file mode 100644
index 0000000..e2f1afe
--- /dev/null
+++ b/docs/family-comfort.md
@@ -0,0 +1,122 @@
+# Family and comfort
+
+Frame Control's Home tab has a **Family and comfort** card, on desktop and
+on iPhone. No third-party notification or parental-control app is needed.
+This is Frame Control code using Python, Steam and SteamVR already on the Frame.
+
+
+
+## Sessions
+
+Set a limit of 1–240 minutes, optional break and check-in intervals, then
+**Start session**. Break and check-in intervals of 0 turn those reminders off.
+**Cancel session** cancels the timer and monitoring without changing the game.
+Cancel before starting a session with different settings.
+
+The Frame shows a one-minute warning, then opens Steam Home in its dashboard.
+**Games stay running**: save and pause before the limit. Some games pause when
+the dashboard opens; others do not. There is no kill, power-off, Steam restart,
+account restriction or parental lock. The wearer can return to the game.
+
+**Documented implementation:** the timer is a single, opt-in Python worker in
+the Frame user's account. Desktop and iPhone share its state. It keeps going
+when the companion disconnects, closes or is suspended. It exits after
+completion or cancellation (normally within five seconds). Cancellation waits
+for any in-flight SteamVR action to finish within its timeout; it is not a boot
+service. A Frame reboot invalidates the session. Suspend counts toward the
+limit, using Linux's boot-time clock. If a warning was delayed by suspend or a
+SteamVR failure, Home waits until at least a full minute after a successful
+warning. A failed Home transition remains active and retries, with an error
+shown in the companion. A stale worker is reported as unverified enforcement.
+
+## Alerts and breaks
+
+During a session, battery, overheating and check-in alerts go to connected
+companions. Break reminders and session warnings also appear on the headset.
+
+- **Low battery:** 15% or below while discharging. One alert until charging or
+ recovery to 20%, so values around 15% do not produce repeated notifications.
+- **Overheating:** a thermal zone reaches its own kernel-reported hot/critical
+ trip, or the battery reports `Overheat`. Missing sensors mean unknown, not
+ safe. These are status alerts, not medical advice or an extra thermal governor.
+- **Check in:** an alert after the chosen number of active minutes.
+- **Breaks:** a SteamVR reminder and companion notification at the chosen interval.
+
+**Inferred:** SteamVR activity levels 1 and 2 are a useful proxy for use, not
+proof someone is wearing the headset. Inactive readings reset continuous use;
+missing readings add no time. Long gaps count at most 30 seconds. Breaks and
+check-ins are distinct from the elapsed-time session limit.
+
+Click **Enable / test notifications** on each companion. iOS asks for permission;
+macOS, Windows and Linux follow their notification settings. The page also shows
+recent events and errors. Keep Frame Control open and connected for companion
+alerts. **Phone alerts are local, not push notifications:** iOS suspension,
+force-quit or a lost SSH connection prevents live delivery. Old alerts are not
+replayed as a notification burst on reconnect. Headset warnings and the session
+limit continue without the phone. A physical iPhone's background delivery has
+not been verified and is not guaranteed.
+
+## Casting
+
+**Cast headset view** starts the existing headset Live view and requests full
+screen where supported. Show that screen to people in the room, or use the
+computer/phone's own screen mirroring. It creates no new stream transport,
+public URL or LAN server. iPhone uses the inline viewer if full screen is not
+available. The image includes private content visible to the wearer.
+
+## What is installed
+
+The shared authenticated `/api/comfort` endpoint copies three bundled Python
+files to `~/.cache/frame-control/comfort//`. Session state and
+locks live in `~/.local/state/frame-control/comfort/`, with a private directory
+and 0600 state file. There is no network listener or system service. Cancel a
+session before removing these directories. The iPhone's normal server still
+exits on disconnect; the explicitly started comfort worker is the exception.
+
+## Verification
+
+**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`: shipped
+`/opt/steamvr/bin/linuxarm64/vrcmd --notify TEXT` reported success for a custom
+reminder. Steam's CDP `SteamUIStore.Navigate('/library/home')` and
+`SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main')`
+opened Home while the running app ID stayed unchanged. Prior page and dashboard
+visibility were restored. Kernel hot/critical trips and SteamVR activity were
+read from the real device. No temperature or battery fault was induced.
+
+**Verified locally:** deterministic fake-Frame tests cover late warnings,
+failed warnings/Home actions, cancellation, activity gaps, thresholds, duplicate
+suppression, reboot invalidation, shared session state and the exact Home
+JavaScript. `python3 -m unittest discover -s tests` runs them. The iOS Simulator
+build tests notification content and bounds. Physical iPhone delivery and
+wearer-perceived headset notification visibility remain unverified.
+
+**Verified end to end on the same Frame:** a two-minute session with no companion
+connection for 135 seconds emitted its warning, break and check-in, then opened
+Home. The running app ID was unchanged; the test restored the previous page and
+dashboard visibility and confirmed the worker exited. Casting through the Home
+shortcut decoded the existing headset stream at 30 fps.
+
+**Verified on the iOS 26.5 Simulator:** connected to the real Frame, approved the
+notification prompt, and saw the native Frame Control test banner. Seven iOS
+tests passed.
+
+
+
+Desktop and 390-pixel phone layouts had no horizontal overflow.
+On macOS the development Electron app's real notification attempt was denied
+(`UNErrorDomain` 1); the bridge now returns that failure instead of reporting
+success. Successful macOS/Windows/Linux notification display remains unverified.
+
+**Verified on the real Frame:** its naturally discharging 15% battery produced
+one low-battery event during a short session; the test then cancelled the
+session. Overheating alerts use fake sensor samples in tests: the shared
+headset was not deliberately overheated.
+
+**Verified 2026-09-29 on the same Frame:** a fresh one-minute session opened
+Home more than 60 seconds after the successful warning. The test restored the
+previous page and dashboard visibility. Local regression coverage now includes
+slow notification delivery, a total Home-action timeout, failed worker startup,
+unreadable saved state, malformed activity samples and notification UX: 173
+Python tests passed. Desktop and 390-pixel layouts were checked again; system
+notification-denial guidance stayed visible across polls. Initial event history
+did not replay notifications, and only the latest new event was announced.
diff --git a/docs/frame-control.md b/docs/frame-control.md
index 299c57c..3ec3b4c 100644
--- a/docs/frame-control.md
+++ b/docs/frame-control.md
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
## Features
-The window has four tabs: **Home** (headset view, status, screenshots),
+The window has five tabs: **Home** (headset view, status, screenshots),
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
-the catalogue, display settings, reports) and **Tools** (sending files and text,
-Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
-anywhere in the window. When the Frame can't be reached, one banner says why in
-plain words and the app retries every few seconds, filling everything in once it
-answers. Flatpak and Android installs run in the background; the bottom bar
+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
+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
+reached, a banner says why in plain words, what was tried, and counts down to
+the next try, filling everything in once it answers. Flatpak and Android installs run in the background; the bottom bar
counts them while they run.
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
@@ -56,9 +59,11 @@ counts them while they run.
Steam library), then launch, stop, test or remove it. **Report an APK** records
whether any APK worked (F-Droid or not: pick a file, type a package, or use an
installed app). Your reports are saved on your computer and change the verdicts
- you see. They aren't uploaded anywhere: the shared database is maintainer-only
- for now (see [compat-db/README.md](../compat-db/README.md)). Uses the app's bundled
- `adb`, or yours if you have one.
+ you see. With **Share compatibility results** on (Privacy & updates), they also
+ go to the shared database ([privacy.md](privacy.md),
+ [compat-db/README.md](../compat-db/README.md)). A failed install records
+ itself when the APK was the problem, and after an install the app offers a
+ 20-second test. Uses the app's bundled `adb`, or yours if you have one.
- **Android display**: pick a running Lepton instance (by the app in it) and set
its resolution (Native 1920×1080, or Sharp 2560×1440 with density scaled to
match), UI scale (Smaller / Default / Larger, or an exact dpi) and text size
@@ -70,8 +75,17 @@ counts them while they run.
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
+- **Mac in the headset** (macOS): show any Mac window, or a whole screen, as
+ its own panel in the headset. Place it with the SteamVR dashboard, click and
+ scroll with the laser, and type on the Mac. Streams hardware H.264 through
+ an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
+- **Devices**: several headsets, each with several addresses (LAN IPs per
+ network, its `.local` mDNS name, its Tailscale IP or MagicDNS name). The app
+ tries them all at once and learns which worked on which network. Add, edit,
+ reorder and test addresses, find a headset on Tailscale or on this network,
+ name your networks, and switch headsets. See [devices.md](devices.md).
- **One-click tools**: SSH or SFTP in a terminal window, Steam Link, and remote
desktop (Windows App on macOS, Remote Desktop on Windows, Remmina or FreeRDP on
Linux). Sleep, restart and shut down open a terminal window because SteamOS
@@ -86,13 +100,18 @@ app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` a
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
and `adb` from Google's platform-tools, so there's nothing else to install. It
also bundles curl's copy of Mozilla's CA list, because Python on Windows only
-trusts root certificates already in the Windows store.
-`app/build/fetch-deps.js` downloads both, pinned by SHA-256.
+trusts root certificates already in the Windows store. And it bundles KDE
+Connect for the Frame (Valve's arm64 build and five libraries, 3.6 MB,
+[`frame/kdeconnect`](../frame/kdeconnect/NOTICE.md)), which it copies to the
+Frame for the keyboard and trackpad.
+`app/build/fetch-deps.js` downloads all of it, pinned by SHA-256.
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
with a non-local `Host` header, and any `/api/` request without a custom
header, so other websites can't drive it or read captures. Everything reaches
-the headset through the `frame` SSH alias. On macOS and Linux it keeps one
+the headset through its SSH alias (`frame` for the first one), pointed at the
+address that answered with `-o HostName=` (`ui/frame_link.py`, described in
+[devices.md](devices.md)). On macOS and Linux it keeps one
multiplexed SSH connection open, so status and each capture take about 0.3 s.
Windows' OpenSSH can't share a connection, so there each request connects on
its own and the app is a little slower. What differs between the three
@@ -148,5 +167,16 @@ npm run dist:win # Windows: installer and .zip
npm run dist:linux # Linux: AppImage and .deb, x64 and arm64
```
-Pushing a `v*` tag builds all three in GitHub Actions and attaches them to the
-release (`.github/workflows/release.yml`).
+Pushing a `v*` tag builds all three in GitHub Actions and attaches them to a
+draft release (`.github/workflows/release.yml`). Running copies are offered it
+once you publish it: see [releasing.md](releasing.md).
+
+## AI agents and assistant
+
+**Documented:** [the MCP adapter and assistant panel](agents.md) are Frame
+Control implementations. MCP wraps this HTTP API without API keys. Changes
+require a separate user approval; power also retains its password prompt. The
+assistant uses a user-chosen endpoint and sends nothing until the user opts in
+for a message. Screenshot context is separately opt-in. Model replies cannot
+operate the headset. Tools → Open assistant opens the page; the linked guide
+covers putting it in a Chromium panel on the Frame.
diff --git a/docs/how-the-frame-works.md b/docs/how-the-frame-works.md
index 863b527..2d4cbf9 100644
--- a/docs/how-the-frame-works.md
+++ b/docs/how-the-frame-works.md
@@ -38,6 +38,11 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| SteamVR settings live in `~/.config/openvr/config/steamvr.vrsettings`, not under `~/.local/share/Steam/config/`. `dashboard.lastAccessedExternalOverlayKey` names the last panel you used. | Settings tweaks |
| The Steam client's journal (`journalctl --user`) carries SteamVR system UI lines such as `[Overlays] Created: …` and `vroverlay_uid`. It's the quickest way to see panels come and go. | Debugging |
| Present: `rsync`, `flatpak`, `python3`, `git`, `qdbus6`, `xrdp`, `xprop`, `xwininfo`, `xterm`, `konsole`, `dolphin`, `gamescopectl`. Missing: `wl-copy`, `xclip`, `xsel`, `kdeconnect-cli`, `tailscale` (installable in `~`, see below), `krfb`, `wayvnc`. | Script design |
+| **SteamOS updates arrive on their own.** The Frame went from 0.3.0 (build 20260922.6101926) to **0.4.1, build 20260925.6191901**, between 2026-09-27 and 2026-09-28 with no action from us; `~` (keys, user Flatpaks, `~/.local/share`) survived. **Verified 2026-09-28.** | Keep changes in `~` |
+| **Valve's package repository has more than the image.** `pacman -Si` / `pacman -Sp` work as `steamos` without root and list Valve's own builds, such as `kdeconnect` 24.02.2 and `python-evdev` 1.7.0 in `extra`. Unpacking those packages into `~` runs them without touching the read-only root. The repository URLs say not to share them, so never write them down; Valve also publishes each build's source package there (`sources/packages/`), which is how Frame Control got the complete source for the KDE Connect it ships. **Verified 2026-09-28**, SteamOS 0.4.1. | [streaming.md](streaming.md#input-type-and-point-in-the-frame-from-the-mac-or-iphone) |
+| **gamescope has its own input injection.** An EIS socket at `/run/user/1000/gamescope-0-ei` (libei 1.4.1 is on the image) offers "Gamescope Virtual Input": relative and absolute pointer, buttons, scroll, keyboard. It drives the panel that has focus in the headset, on either X display. Focus moves only with the controller's laser (or to a new panel when none has it); `gamescopectl focus_info` prints the focus state to the journal. **Verified 2026-09-29.** | [streaming.md](streaming.md#live-view-and-control-watch-a-panel-and-tap-on-it) |
+| **A panel's own pixels:** `ffmpeg -f x11grab -window_id -i :` captures one window (x11grab of the root is black under gamescope). Panels live on `:0` (Steam's UI, windows tagged by `panel-on-frame.sh`) or `:1` (apps Steam starts). **Verified 2026-09-29.** | Frame Control's Desktop view |
+| **gamescope runs two Xwayland displays.** `:0` holds Steam's VR bar and menus (`valve.steam.gamepadui.*`) and ignores XTest pointer motion; `:1` holds apps such as Chromium and takes it. There's also a libei socket, `/run/user/1000/gamescope-0-ei`. **Verified 2026-09-28**, SteamOS 0.4.1. | Keyboard and trackpad |
| Flathub is a **system** remote. `--user` installs over SSH work and show up in the desktop menu. | `install-apps.sh` |
| `/` is 10 GB and read-only. `/home` is 929 GB. | Where to put things |
| Clipboard: Klipper over the nested D-Bus bus (`qdbus6 org.kde.klipper …`). | `paste-to-frame.sh` |
@@ -53,13 +58,17 @@ Lepton (Android 11, podman container "lepton-dev") ← its own panel, app 305600
| **T3 Code desktop runs natively.** The stock release `T3-Code-0.0.42-arm64.AppImage` in `~/Applications/T3CodeDesktop/` starts with no extra setup: glibc 2.39, `libfuse.so.2`, GTK 3, NSS and libsecret are on the image. `panel-on-frame.sh --name t3code-desktop -- '~/Applications/T3CodeDesktop/T3-Code.AppImage'` gives it its own panel (`valve.steam.desktopgame.2000281357`, `--ozone-platform=x11`). Its bundled server listens on `127.0.0.1:3773` and shows up in onboarding as the `frame` computer, with `passwordStore: gnome-libsecret`. The image has no agent CLI and no `node`. Agents run through the LAN CLIProxyAPI (`llm-proxy.lan:8317`, which resolves on the Frame). Claude Code 2.1.283 comes from `claude.ai/install.sh`, and Codex 0.157.1 from the `codex-aarch64-unknown-linux-musl` release tarball, both into `~/.local/bin`. `with-cliproxy` and a mode-600 `~/.config/cliproxyapi/secrets.env` are copied from the Mac. The wrappers `claude-cliproxy` and `codex-cliproxy` (a `-c model_provider=cliproxy`, `wire_api="responses"`, `env_key="CLIPROXY_API_KEY"`) are set as `providers.claudeAgent.binaryPath` and `providers.codex.binaryPath` in `~/.t3/userdata/settings.json`, and T3 picked that up without a restart. Through the wrappers, `claude auth status` reports `loggedIn: true` (`oauth_token`), and both CLIs answered a prompt with `kimi-k3`. `gamescopectl screenshot` captured another layer (the Lepton T3 app) rather than this panel. `DISPLAY=:0 xwd -id ` piped to `ffmpeg` captures the window itself (1920×1080). **Verified 2026-09-26**, BUILD_ID 20260922.6101926. | Running T3 Code as a host on the Frame |
| Power actions need `sudo`, which asks for the Developer Mode password over SSH. | Frame Control's power buttons |
| **SSH server:** OpenSSH 9.7p1. It offers `publickey,password` (keyboard-interactive is off, PAM on) and also asks `userdbctl ssh-authorized-keys` for keys. OpenSSH ≥ 8.8 rejects SHA-1 `ssh-rsa` signatures by default, so a client whose RSA support is SHA-1 only (the Swift library Citadel, for one) can't log in with the RSA key that devkit pairing installs; use ed25519 (**inferred** from OpenSSH defaults). **Verified 2026-09-27**, BUILD_ID 20260922.6101926. | [iphone.md](iphone.md), `ui/frame_connect.py` |
+| **A Chromium app window on gamescope's `:0` with its own `STEAM_GAME` becomes a panel, even when started over SSH.** `chromium-xr/chrome --ozone-platform=x11 --app=URL --window-size=1280,720` got a 1920×1080 window, and tagging it produced `[Overlays] Created: valve.steam.desktopgame.` in `~/.local/share/Steam/logs/vrwebhelper_systemui.txt`. Chromium XR decoded a 1080p H.264 WebCodecs stream from the Mac at about 60 fps. XTest events sent to `:0` (libXtst through Python ctypes) did not reach the page. The Frame has `libXtst`, `xprop`, `xwininfo`, `curl` and the `C.utf8` locale. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md) |
+| **Streaming video into a panel: what the Frame adds.** Chromium XR decodes H.264 in **software** (1920×1290 at about 9 Mbit/s took 4–6 ms per frame); its GL is ANGLE → zink → Turnip on the Adreno 750, and it logs `GetVSyncParametersIfAvailable() failed`. An idle page's `requestAnimationFrame` runs at about 140 Hz; when the headset is worn or woken, SteamVR picks the 4320×2160 @ 144 Hz mode. **An unworn headset throttles panel apps** whatever they draw: a local canvas page ran at 58 fps for about 6 s, then 28, then about 15 once in standby (vrserver logs `entering standby`, with `power.pauseCompositorOnStandby 1`). `vrcmd --handlewakeup` gave 137–144 fps for about 2 s, then 36. So a panel's frame rate and compositor delay can only be measured while it's worn. `tailscaled` runs with `--tun=userspace-networking` and cost about 8% of a core at 9 Mbit/s (0.5% over the LAN address). Wi-Fi power saving is on (`iw … get power_save`). **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | [mac-in-headset.md](mac-in-headset.md#measuring) |
+| **USB-C networking.** Plugged into a Mac, the Frame is a USB network device: macOS names the port "Steam Frame" (here `en9`, 10.86.200.234/29), and the Frame's `usb0` is 10.86.200.233. Ping is about 0.9 ms, and SSH works with the usual host key (`-o HostName=10.86.200.233 -o HostKeyAlias=`). Steam's Remote Play discovery also broadcasts over it. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. | Frame Control's Mac stream uses it when present ([mac-in-headset.md](mac-in-headset.md)) |
| **Tools on the image:** Python 3.12.3, `ffmpeg`, `openssl`, `curl`, `rsync`, `zip`/`unzip`, `flatpak`, `wpctl`, `podman`. **No `adb`.** `steamos` is uid 1000, in `wheel`, and sudoers has `%wheel ALL=(ALL) ALL`, so `sudo -S` takes the Developer Mode password on stdin. **Verified 2026-09-27.** | Running Frame Control's server on the Frame (`FRAME_LOCAL=1`, [iphone.md](iphone.md)) |
| **Each Lepton instance is a podman container** named `lepton-steamlaunch-`, labelled with its ADB port (`podman ps --format '{{.Names}} {{.Labels.adb_port}}'`). `podman exec /system/bin/sh -c '…'` runs Android's shell inside it with no adb at all (used for `pidof` and `logcat` by the app tester). Running `wm size`/`wm density` that way is untested. **Verified 2026-09-27.** | `ui/frame_android.py`, the iPhone app's display settings |
| **Asleep means off the network.** In standby the Frame stops answering on its LAN address, `frame.local` and Tailscale alike (`Host is down`, `No route to host`, timeouts), and ping fails. It was unreachable for about 2.5 hours until woken. Nothing over SSH can wake it. **Verified 2026-09-27.** | Frame Control's offline banner and retries |
+| **What puts it to sleep is Steam's idle timer**, not logind. The journal shows `steamui_system: Switching to power state: [ k_ESystemPowerState_Sleep ] reason: 'ComputeNextPowerState: active: 3600 < 3600 (k_EACState_Connected)'`, then Steam suspends. SSH work doesn't count as activity. The timers are the client settings `system_idle_suspend_ac_sec` (3600) and `system_idle_suspend_battery_sec` (900); 0 means Never (Settings → Power → Sleep after inactivity). They can be written over DevTools the way the settings page does. logind refuses a `systemd-inhibit --mode=block` sleep lock from an SSH session (`Interactive authentication required`) but accepts one started with `systemd-run --user`. `scripts/keep-awake.sh on|off|status` does both and restores the old timers on `off`. **Verified 2026-09-28**, BUILD_ID 20260925.6191901. Whether Steam's suspend honours the inhibitor on its own is **inferred** (polkit gives `steamos` no `suspend-ignore-inhibit`), not tested. | Keeping the Frame awake for agent work |
| **Battery at full on a charger** can read `Discharging` at about 0 W (for example 99 %, 0.0 W, USB-C PD 18 W). Treat under 0.5 W on a charger as "not charging", not "draining". **Verified 2026-09-27.** | Frame Control's battery card |
| **The OS image is downloadable.** Valve's recovery images for the Frame are at `https://steamdeck-images.steamos.cloud/recovery/`. The root filesystem inside is btrfs, and it runs as an SSH test target on ARM64 Linux without the headset (`tests/frame-container/frame-image.sh`). **Verified 2026-09-27.** | [recovery-and-images.md](recovery-and-images.md) |
| **Boot / recovery menu.** Hold Power ~10 s until the LED goes off, then power on while holding the **AUX button on top of the Power button** (not the volume keys) until a text menu appears. Entries: `Current` (SteamOS-A/B + build), `Previous` (the other A/B slot), `Boot from USB`, `Repair Steam Installation`, `Erase User Data` (factory reset), `ADB mode`, `Battery Ship Mode`. It auto-boots `Current` after a ~15 s countdown. **Volume Up/Down (left side) move, AUX (right side) selects.** For a boot loop, Valve says pick `Previous` (keeps user data); then `Repair Steam Installation`; `Erase User Data` wipes `~` (SSH keys, Tailscale, Flatpaks, T3 setup). Last resort is a full re-image, two ways: (1) USB: write `steamframe-oobe-repair-.img.bz2` to an 8 GB+ USB-C stick (Balena Etcher on the Mac), pick `Boot from USB`, then use "Wipe Device & Install SteamOS" / "Repair SteamOS" (keeps games and personal content) from the recovery desktop; (2) cable/EDL: `steamframe-oobe-repair-qdl-.tar.gz`, run `flash.sh` (Linux) or `flash.cmd` (Windows), then with the Frame off for 10 s hold Power + Vol Up + Vol Down for 10 s and plug it in; it reflashes and reboots. Both images: `https://steamdeck-images.steamos.cloud/recovery/` (build 20260922.5153644, 0.3.0, 3.8 GiB each, no published checksums); local copies in `~/Downloads/steam-frame-recovery/`. File names, checksums and what's inside: [recovery-and-images.md](recovery-and-images.md). Source: Valve's [SteamOS Recovery FAQ](https://help.steampowered.com/en/faqs/view/1B71-EDF2-EB6D-2BB3) and [Installation and Repair FAQ](https://help.steampowered.com/en/faqs/view/65B4-2AA3-5F37-4227), plus a menu photo in [EloiStree/HelloSteamFrame#9](https://github.com/EloiStree/HelloSteamFrame/issues/9). **Inferred** (Valve docs, 2026-09-26); not yet tried on our Frame. | Recovering from a boot loop |
-| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). | Diagnosing a boot loop |
+| **Boot loop cause: the SteamVR health check.** `steamvr.service` runs `/usr/share/deckard/steamvr-health-check`, which appends `frog:glasses:` to `$XDG_RUNTIME_DIR/steamvr-short-session-tracker` on every failed or <10 s SteamVR run. At 3 it runs `steam-health-check --repair-now`, which **deletes all of `~/.local/share/Steam` (games, login, Developer Mode) and `~/.steam`**, keeping only `registry.vdf`. At 4 it also tries `steamos-bootconf set-mode reboot-other` (fails as the user: `bootenv: Permission denied`). SteamVR normally fails 1–2 times per boot while it waits for the Steam client (`SteamAPI_InitEx failed … Steam is probably not running`, then `fatal stalled cross-thread pipe`). Once Steam has been wiped, it has to re-download a ~210 MB client on every boot, so SteamVR keeps failing, Steam keeps getting wiped and the Frame reboots, in a loop. Also, the Steam updater can deadlock at `Installing update...` (main process blocked writing to the `-child-update-ui` process, which is stuck in `drm_syncobj_array_wait_timeout`). Killing only the `-child-update-ui` process lets the install finish (`package/*.installed` appears). **Fix without sudo:** over USB-C ADB (`adb -s frame shell` works as `steamos` while the Frame is looping; SSH is refused once Developer Mode is lost), truncate both `/run/user/1000/steam{,vr}-short-session-tracker` files and `chmod 444` them (the health check then logs `Permission denied` and does nothing; this is tmpfs, so it resets on reboot). Unstick the updater if needed, let Steam finish installing, then hold Power 10 s and start the Frame normally. `systemctl reboot` over ADB needs interactive auth. After the fix, sign in to Steam and turn Developer Mode back on. **Verified 2026-09-26**, BUILD_ID 20260922.6101926, slot B (clean boot: 0 SteamVR failures, SSH and Tailscale back). **Seen again 2026-09-28** on BUILD_ID 20260925.6191901, beta client 1790377368. The Frame rebooted by itself while off the network. At 21:13 the check tried `reboot-other` (`bootenv: Permission denied`) and reset the unpacked Steam install, and the tracker had 15 entries. The tracker fix above, applied over SSH, stopped the resets (the checks then log `Permission denied`), but Steam itself kept exiting about 17 s after each start (34 restarts). Cause not found yet. | Diagnosing a boot loop |
## Debug recipes
diff --git a/docs/img/assistant-panel.png b/docs/img/assistant-panel.png
new file mode 100644
index 0000000..514a1d5
Binary files /dev/null and b/docs/img/assistant-panel.png differ
diff --git a/docs/img/comfort-desktop.png b/docs/img/comfort-desktop.png
new file mode 100644
index 0000000..6625c57
Binary files /dev/null and b/docs/img/comfort-desktop.png differ
diff --git a/docs/img/comfort-notification-ios.png b/docs/img/comfort-notification-ios.png
new file mode 100644
index 0000000..7ed15d0
Binary files /dev/null and b/docs/img/comfort-notification-ios.png differ
diff --git a/docs/img/mcp-approval-install.png b/docs/img/mcp-approval-install.png
new file mode 100644
index 0000000..780add4
Binary files /dev/null and b/docs/img/mcp-approval-install.png differ
diff --git a/docs/img/mcp-panel-binary.png b/docs/img/mcp-panel-binary.png
new file mode 100644
index 0000000..00e7074
Binary files /dev/null and b/docs/img/mcp-panel-binary.png differ
diff --git a/docs/img/media-sbs-proof.png b/docs/img/media-sbs-proof.png
new file mode 100644
index 0000000..d04ad1c
Binary files /dev/null and b/docs/img/media-sbs-proof.png differ
diff --git a/docs/img/media-splat-proof.png b/docs/img/media-splat-proof.png
new file mode 100644
index 0000000..e74a0cf
Binary files /dev/null and b/docs/img/media-splat-proof.png differ
diff --git a/docs/img/media-ui-panel.png b/docs/img/media-ui-panel.png
new file mode 100644
index 0000000..2e2a41b
Binary files /dev/null and b/docs/img/media-ui-panel.png differ
diff --git a/docs/img/panel-switcher.png b/docs/img/panel-switcher.png
new file mode 100644
index 0000000..71237ce
Binary files /dev/null and b/docs/img/panel-switcher.png differ
diff --git a/docs/iphone.md b/docs/iphone.md
index b09920d..debc954 100644
--- a/docs/iphone.md
+++ b/docs/iphone.md
@@ -12,7 +12,8 @@ An iPhone can't run Python or `ssh`, but the Frame can. So the app:
1. connects to the Frame over SSH itself (the [Citadel](https://github.com/orlandos-nl/Citadel)
Swift SSH library), with its own ed25519 key from the Keychain;
2. copies Frame Control's server and helpers (`ios/scripts/make_frame_bundle.py`,
- under 1 MB) to `~/.cache/frame-control/` on the Frame, once per version;
+ 4.6 MB, 3.6 MB of it the KDE Connect the keyboard and trackpad use) to
+ `~/.cache/frame-control/` on the Frame, once per version;
3. starts `ui/server.py` there with `FRAME_LOCAL=1`. It listens only on the
Frame's own 127.0.0.1, and it stops when the phone disconnects (`--exit-on-eof`);
4. tunnels to it through the SSH session and shows the same page as the desktop
@@ -25,7 +26,10 @@ as its transport too), so the desktop and phone share one code path. Android
display settings use `podman exec` into each Lepton container instead of adb,
which the Frame doesn't have.
-Nothing is left running on the Frame after the phone disconnects; the copied
+The app server stops after the phone disconnects. An explicitly started
+[comfort session](family-comfort.md) keeps its timer and headset reminders running
+until the session ends or is cancelled; phone notifications require the app to
+remain connected and running. The copied
files stay in `~/.cache/frame-control` (delete it any time).
## Pairing
@@ -107,3 +111,11 @@ running), a real sleep/restart/shut down on the Frame, and a physical iPhone.
Debug builds have Simulator test hooks (`FRAME_TEST_HOST`, `FRAME_TEST_PAGE`,
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
don't.
+
+## Family and comfort
+
+The shared Home card sets session limits, breaks and check-ins, and offers
+**Cast headset view**. **Enable / test notifications** requests iOS notification
+permission and sends a local test. These are local notifications, not APNs push;
+iOS background suspension can interrupt phone alerts. The headset timer still
+runs. See [the behavior and verification limits](family-comfort.md).
diff --git a/docs/linux-vr-streaming.md b/docs/linux-vr-streaming.md
new file mode 100644
index 0000000..40466e7
--- /dev/null
+++ b/docs/linux-vr-streaming.md
@@ -0,0 +1,222 @@
+# PC VR streaming from Linux
+
+**Recommendation, 2026-09-28:** test Valve's current SteamVR/Steam Link path
+on a Linux gaming PC before building another streamer. Valve now documents
+Linux streaming fixes and USB support. We have no Linux host attached, so
+Linux-to-Frame VR streaming remains **unverified here**.
+
+This is the feasibility and options report for
+[#24](https://github.com/saphid/frame-control/issues/24), not a shipped streaming
+feature. Frame Control's features must use our own implementation or standard
+platform components. WiVRn and ALVR are research comparisons, not dependencies.
+An optional install shortcut is the most we would offer for a third-party app.
+Our own streamer requires Alex's choice before implementation.
+
+## What was checked on the Frame
+
+**Verified** on 2026-09-28: aarch64, SteamOS **0.4.1**, BUILD_ID
+`20260925.6191901`, SteamVR **2.18.1**. Version and build are recorded separately;
+earlier docs associate this build with other SteamOS version labels.
+
+| Client | Installation | Runtime result |
+|---|---|---|
+| WiVRn **26.9**, upstream `WiVRn-release.apk` | API 29, arm64-v8a; installed in its own immersive Lepton instance | OpenXR instance creation fails: missing `XR_KHR_convert_timespec_time`. Both 1.1.58 and 1.0.58 attempts return `XR_ERROR_EXTENSION_NOT_PRESENT` |
+| ALVR **20.14.1**, upstream `alvr_client_android.apk` | API 26, arm64-v8a; installed in its own immersive Lepton instance | Same missing extension. Client panics at `client_openxr/src/lib.rs:220` with `ERROR_EXTENSION_NOT_PRESENT` |
+
+The [evidence excerpt](evidence/linux-vr/2026-09-28.txt) includes APK SHA-256s,
+upstream release links, loader errors and cleanup results. These are failures
+before an OpenXR session, not successful VR clients. WiVRn was launched twice;
+ALVR's container remained up despite its client panic. Container liveness alone
+does not establish VR compatibility.
+
+Both APKs already declare `MAIN` and `LAUNCHER`. They were installed unmodified
+using this branch's existing `python3 ui/frame_android.py install APK --vr`,
+then launched through their Steam shortcuts. Logs came from the instance's
+`podman exec … /system/bin/logcat`; the user journal also retained WiVRn's errors
+after its container exited. No headset was worn and no host was connected.
+
+All test app files, compatdata, shortcuts and containers were removed afterwards.
+SteamVR's original process remained running. No global settings changed.
+
+### Locked repeat, 2026-09-29 (verified)
+
+Acquired `/tmp/frame-test.lock` before installing or launching anything and
+released it after cleanup. Battery was 44% and charging at preflight, 46–47%
+during the launches, and 47% at cleanup. The SteamOS version/build was unchanged.
+
+Reinstalled and launched both original APKs in immersive Lepton instances.
+WiVRn again failed at OpenXR 1.1 and 1.0 with the missing timespec extension;
+ALVR again panicked on `ERROR_EXTENSION_NOT_PRESENT`. This repeats the
+unmodified-client test, not the newer installer's automatic compatibility-layer
+path. Neither reached a session that could be paired or exercised further.
+Fresh [journal excerpts and cleanup evidence](evidence/linux-vr/2026-09-29.txt)
+record the failures.
+
+SteamVR's screenshot API returned a 1920×1080 headset capture after each
+launch. Both are uniformly dark: [WiVRn](evidence/linux-vr/2026-09-29-wivrn.png)
+and [ALVR](evidence/linux-vr/2026-09-29-alvr.png). These images do **not** prove
+rendering or a working client. The headset was unworn; visibility, controllers,
+frame rate and motion-to-photon latency could not be judged. The explicit
+OpenXR errors, rather than the dark captures, establish the client blocker.
+
+Both test installs, app data, shader caches, shortcuts, containers and temporary
+capture files were removed. The original Steam and SteamVR process IDs were
+unchanged. No reboot, power action or global setting change was used. Native
+clients remain untested, and no Linux gaming host was available for Valve's
+streaming path. The recommendation below is unchanged.
+
+### Relation to the VR APK branch
+
+**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
+was read, not edited (branch inspected at
+[`038dcd4`](https://github.com/saphid/frame-control/commit/038dcd48cd75336f6a86c63c7878bfc9c52deec9)).
+Its compatibility layer handles OpenXR version negotiation, some controller
+profiles and refresh-rate requests. It does **not** implement
+`XR_KHR_convert_timespec_time`. Its launcher fix is unnecessary for these APKs.
+This report has **no unmerged code dependency** on that PR, and neither APK was
+tested with its layer injected.
+
+**Documented from upstream source:** WiVRn requests the extension in
+[`application.cpp`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/application.cpp#L1286)
+and uses it to convert `CLOCK_MONOTONIC` into `XrTime` in
+[`instance::now()`](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/client/xr/instance.cpp#L335).
+ALVR also [requests it unconditionally](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_openxr/src/lib.rs#L188).
+Simply deleting the extension request or returning made-up timestamps would
+not prove correct tracking or timing. A real fix needs a valid clock mapping
+and further runtime tests. No such patch was made.
+
+### Native SteamOS aarch64 clients
+
+**Verified:** the Frame has a native OpenXR runtime manifest at
+`~/.config/openxr/1/active_runtime.json`, pointing to SteamVR's
+`bin/linuxarm64/vrclient.so`.
+
+**Documented:** WiVRn's [26.9 README](https://github.com/WiVRn/WiVRn/blob/bbc6e4cc36c355fa6180980abd231673dc15115d/README.md)
+describes its Linux client as debugging-only, without audio or hardware decode.
+ALVR 20.14.1's [non-Android decoder](https://github.com/alvr-org/ALVR/blob/a9f6542fa507a841f40ab4f3fcb531427cd02550/alvr/client_core/src/video_decoder/mod.rs)
+returns no decoded frames. The inspected releases ship Android clients, not a
+ready-to-run native Frame client.
+
+**Inferred:** a native port is possible research, but neither release offers a
+demonstrated native alternative to the blocked APKs. Native builds, native
+extension enumeration, hardware decoding and audio were **not tested**. The
+Android extension failure does not establish that the native runtime lacks it.
+
+## (a) Valve's own path — recommended first
+
+**Documented**, from Valve's release notes rather than launch-window reports:
+
+- [SteamVR 2.17.8 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1842212951314598)
+ says “Fix crash using Steam Link on Linux when games submit invalid textures”
+ and “Improve streaming recovery when using Steam Link on Linux.” It also
+ adds initial USB streaming, with Steam Client Beta required to use USB
+ without Wi-Fi.
+- [SteamVR 2.17 release](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1843481262693486)
+ repeats Linux streaming fixes and initial USB support. USB is no longer
+ solely a claim about an old beta, but version/channel requirements still
+ need checking on the actual host.
+- [SteamVR 2.18.1 beta](https://steamstore-a.akamaihd.net/news/externalpost/steam_community_announcements/1844751498219787)
+ adds USB-tethered **Quest** support with Steam Link Beta. That entry is not
+ proof of a Frame/Linux combination.
+- The [Steam Link page](https://store.steampowered.com/app/353380/Steam_Link/)
+ lists Linux desktop clients, while its Quest VR requirements still say
+ Windows 10 or newer. Desktop Steam Link support is not equivalent to VR host
+ support, and the Quest requirements are not a Frame support matrix.
+
+**Inferred:** Valve has a Linux VR streaming path worth testing. The old blanket
+claim “Linux cannot stream VR” is no longer justified by the evidence. These
+release notes do not establish which Linux GPU/driver/Frame combinations work.
+USB changes the transport; it does not by itself prove host encoder support.
+
+**Not verified:** Linux host discovery, pairing, wireless or USB streaming,
+stereo rendering, controllers, haptics, audio, latency, or a game. Frame-only
+inspection cannot establish any of these. Flat Remote Play and a desktop shown
+on a panel are not substitutes for this test.
+
+Next test, once a Linux gaming PC is available: record distro, GPU/driver,
+Steam client channel/version and SteamVR version; use the Frame's built-in
+Steam connection flow, first wirelessly and then over a data-capable USB cable.
+Launch a free OpenXR sample or developer-consented VR game. Verify stereo,
+head/controller tracking, haptics and audio while worn; retain both ends' logs
+and measure latency and recovery after a link interruption. Restore any test
+channel changes. Do not change the shared headset's channel just for this report.
+
+If that works, Frame Control can provide our own host checks, setup guidance
+and session controls around Valve's existing platform. First establish which
+controls have a usable interface; no stable automated pairing API has been
+verified. **Estimate (inferred):** 2–5 engineer-days for the hardware feasibility
+pass; another 1–2 weeks for a small integration if those interfaces exist.
+
+## (b) Our own streaming — proposal only
+
+This is a new VR transport and device integration, not a desktop capture feature.
+A plausible first target is **one Linux GPU family, one host, one Frame**, using
+SteamVR on both ends. Our host driver would expose a remote HMD/controllers,
+receive poses and inputs, and obtain stereo textures for hardware encoding.
+Our Frame OpenXR app would decode, submit the correct eye views and render poses
+at predicted display times, and return tracking/input. SteamVR/OpenXR, bundled
+codec/transport libraries and platform GPU APIs fit the ownership rule; a
+WiVRn/ALVR/Monado server dependency would not.
+
+**Inferred design risks:** Linux SteamVR texture-sharing/driver interfaces and
+Frame decode-to-GPU interoperability need a spike before committing to this
+architecture. Sending an already-composited desktop mirror loses the stereo,
+pose and timing information we need. Late reprojection, clock conversion,
+backpressure, controller bindings, audio sync and reconnects are substantial
+work. A runtime shim must not assume `XrTime` equals monotonic nanoseconds.
+
+### Reuse from `mac-in-headset`
+
+**Documented from our code**, read-only at
+[`1b90c64`](https://github.com/saphid/frame-control/commit/1b90c64b73bace54c63a3aae154c5d29a9448d72):
+
+- Reuse the ideas for low-latency encoding without B-frames, dropping work
+ before encoding, bounded queues, keyframe recovery, adaptive bitrate,
+ per-frame timing and authenticated session setup.
+- Its VideoToolbox encoder and ScreenCaptureKit capture are macOS-specific.
+ Linux needs a new GPU encoder path (for example VA-API or NVENC via bundled
+ libraries) and VR texture capture, not a port of window capture.
+- Its WebSocket over SSH is useful for a first controlled transport experiment
+ and control messages. Reliable TCP can stall behind lost packets; a VR media
+ path needs measured deadline behaviour, likely datagrams with loss recovery
+ using an ordinary bundled transport library. Do not invent cryptography.
+- Its Chromium/WebCodecs panel viewer is not a VR client. That branch reports
+ software H.264 decoding and occasional long Wi-Fi stalls on the Frame.
+ Its desktop latency measurements are not motion-to-photon measurements or
+ evidence that a 90/120 Hz stereo stream will work.
+
+**Size/effort estimate (inferred, one experienced full-time engineer, hardware
+available):**
+
+| Phase | Deliverable / stop condition | Effort |
+|---|---|---|
+| Feasibility | Linux driver texture access, Frame hardware decode into OpenXR, pose/clock loop; stop if any cannot meet frame deadlines | 2–4 weeks |
+| First end-to-end prototype | One GPU/codec, stereo sample over a controlled LAN, head/controllers, logs and teardown | 4–8 additional weeks |
+| Usable limited beta | Audio/haptics, pairing, recovery, bitrate/loss handling, installer, worn testing and latency work | 6–12 additional weeks |
+| Wider support | Multiple GPU vendors/distros, USB and Wi-Fi variation, long-session stability | 2–4 additional months |
+
+Planning range: **12–24 engineer-weeks for a limited beta**, roughly
+**10–25k lines of our code plus tests/tooling**, excluding bundled libraries.
+This is a low-confidence scope estimate, not a delivery promise; an unsupported
+driver or decode interface could block it entirely. Foveated streaming,
+eye tracking and parity with Valve are excluded. A Linux gaming PC and repeatable
+worn-headset testing are prerequisites. **Do not build this until Alex chooses.**
+
+## (c) Optional “install WiVRn” shortcut only
+
+Allowed as a clearly optional convenience, never a prerequisite for a Frame
+Control feature. **Documented:** WiVRn's server Flatpak ID is
+`io.github.wivrn.wivrn`; its client/server versions must match, and its Flatpak
+includes xrizer/OpenComposite. Those are properties of an independently
+installed third-party stack, not components of our implementation.
+
+**Recommendation:** defer the shortcut while the current client fails before
+session creation. If offered later, label that compatibility result and let
+the user choose the install; do not present “install” as “streaming works.”
+**Estimate (inferred):** 1–2 engineer-days for an optional host-side shortcut
+with package/version detection and honest status, excluding third-party fixes.
+No shortcut, host install, pairing automation or streaming UI was built here.
+
+Choose **(a)** for the next hardware test. Keep **(b)** as a separately approved
+project if Valve's path fails or lacks a required capability. **(c)** does not
+solve the verified client blocker and should not be the product's foundation.
diff --git a/docs/mac-in-headset.md b/docs/mac-in-headset.md
new file mode 100644
index 0000000..ed1b90a
--- /dev/null
+++ b/docs/mac-in-headset.md
@@ -0,0 +1,485 @@
+# Mac in the headset
+
+Frame Control can show any Mac window, or a whole Mac screen, as its own panel
+in the Steam Frame. You place each panel anywhere in the room with the SteamVR
+dashboard. The laser clicks and drags, the thumbstick scrolls, and you type on
+the Mac's own keyboard. Find it under **Tools → Mac in the headset** (macOS
+only).
+
+The confidence labels are the same as in [ssh.md](ssh.md).
+
+## Why this design
+
+First-party options come first, as the repo's rule asks, with the reason
+each one was or wasn't chosen. The full list for every device is in
+[streaming.md](streaming.md#first-party-options-and-why-they-do-or-dont-fit).
+Checked 2026-09-28.
+
+| Goal | First-party option | Chosen? | Why |
+|---|---|---|---|
+| One Mac screen in the headset | **Apple Screen Sharing** (VNC) → Remmina (Remmina 1.4.43 is already installed on this Frame) | Kept as the fallback (`panel-on-frame.sh mac-screen`) | It's the closest to first-party and needs nothing new. But VNC sends compressed tiles rather than video, so moving content is slow: noticeable lag even on a good 5 GHz link (**verified** 2026-09-27, see [streaming.md](streaming.md)), and the Mac's pointer isn't in the picture without a helper. It shows only whole screens |
+| One Mac screen | **Steam Remote Play**, Mac as host (Valve) | For Mac games only; see [Steam's own streaming](#steams-own-streaming) | The Mac's and the Frame's Steam clients already find each other (**verified**). But Remote Play streams a game (the whole desktop only while the game is out of focus, untested from a Mac), never single windows, and a Mac can't host the Frame's VR streaming |
+| One Mac screen | **AirPlay** (Apple) | No | Apple licenses AirPlay receivers only to TV and speaker makers, and nothing official runs on Linux. UxPlay is an unofficial receiver, and it mirrors a whole screen, not single windows |
+| One Mac screen | **Sidecar / Mac Virtual Display** (Apple) | No | These work only with an iPad or Apple Vision Pro |
+| **Each Mac window as its own panel** | None | – | No first-party way does this: Apple's per-app streaming is only for Vision Pro, and Valve's desktop streaming needs a Windows SteamVR host. So Frame Control does it itself |
+| Mac keyboard and trackpad driving the headset | **Bluetooth HID** | No | macOS can't act as a Bluetooth keyboard or mouse. A real Bluetooth keyboard paired with the Frame still works |
+| Mac keyboard and trackpad | **KDE Connect** (KDE) | No | The Frame has no `kdeconnectd` and it isn't on Flathub. Its Mac app has no keyboard or mouse sharing (**inferred**), and on Wayland it can only reach the desktop panel |
+| Mac keyboard and trackpad | **xrdp** (Valve, Developer Mode) | No | It runs a separate Linux session that you view on the Mac. It isn't the headset's view, and it doesn't carry input the other way |
+
+What that leaves is our own stream: nothing to install on the Mac or the
+Frame, and whole screens or single windows. Here the Mac's own keyboard and
+trackpad need no forwarding, because the windows are still on the Mac. The
+laser is the only input that has to be sent back.
+
+Other routes that were compared:
+
+| Option | One screen | Each window | Speed | Verdict |
+|---|---|---|---|---|
+| Sunshine → Moonlight | ✓ | – | Good | Sunshine's macOS support is still experimental ([discussion #777](https://github.com/orgs/LizardByte/discussions/777)), and it captures whole screens only |
+| Virtual Desktop, Immersed | – | – | – | No Frame client as of September 2026 |
+| **Frame Control's own stream** | ✓ | ✓ | Hardware H.264, sending only changed frames | **Built** |
+
+To type into VR surfaces other than these panels (SteamVR's dashboard,
+games), the Frame supports a uinput keyboard and mouse without sudo
+(verified 2026-09-27: `steamos` is in `input`, and `/dev/uinput` is
+`root:input 660`). That's a separate feature, not part of this one.
+
+## Steam's own streaming
+
+The Frame is built around Steam streaming, so this was checked first
+(2026-09-28). It fits Mac games, not Mac windows.
+
+- **VR streaming from the Mac: no.** The Frame streams VR from a PC running
+ SteamVR ("Steam Link" with foveated streaming). SteamVR dropped macOS in
+ 2020, and Valve lists PCs, laptops, Steam Deck and Steam Machine as
+ hosts, never a Mac (**documented**:
+ [UploadVR](https://www.uploadvr.com/steamvr-drops-mac-support/),
+ [Road to VR](https://roadtovr.com/steam-frame-game-certification-specs/)).
+- **Flat Remote Play from the Mac: probably, for Steam games.**
+ - Steam on this Mac has streaming on, and the two Steam clients already
+ see each other. The Frame's `remote_connections.txt` shows it
+ connecting directly to "Alexs-MacBook-Pro-7" at 192.168.1.211:27036,
+ and the Mac's shows the Frame connecting over Wi-Fi and over the USB-C
+ link (**verified** in both clients' logs).
+ - Whether a stream then starts, and how a flat game looks in the headset
+ (reviews describe a theater screen), is **not tested yet**. The Frame's
+ Steam was crash-looping during this session (below).
+ - Mac-hosted Remote Play has a long-standing report of the stream
+ closing as the game loads
+ ([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/),
+ **reported**).
+- **The Mac desktop through Steam: untested; single windows: no.** Valve
+ says Remote Play shows the host's desktop when the game loses focus
+ ([Steam Remote Play FAQ](https://help.steampowered.com/en/faqs/view/0689-74B8-92AC-10F2),
+ **documented**), so a whole Mac screen may be reachable by starting a
+ game, then switching away from it. Nobody has tried that from a Mac
+ host. It would still be one screen in one panel: Remote Play has
+ nothing like one panel per Mac window, so Frame Control's own stream
+ stays the way to see separate windows.
+- **Steam has a "stream desktop" call, and it pairs with a Mac
+ (verified 2026-09-28).** In the Remote Play device list, the Frame's
+ Steam UI calls `SteamClient.RemotePlay.StartDesktopStream()`
+ for a connected device. Called over CDP with the Mac's client ID, it made
+ the Mac's Steam show "Authorize Device" and ask for a 4-digit code shown
+ on the Frame. Once the code was entered, the Mac logged
+ `k_ERemoteDeviceAuthorizationSuccess`. No stream started in that attempt,
+ and a second attempt, now that the device is authorized, is the next
+ test. If it streams the Mac's desktop, that's a whole-screen option built
+ into Steam: one panel, Valve's encoder and transport. It still wouldn't
+ give each window its own panel.
+- **What Steam's work did give us: the USB-C link.** Plugged into the Mac,
+ the Frame appears as a network port called "Steam Frame". Steam's Remote
+ Play discovery uses it, and so does Frame Control's stream now (see
+ "USB-C, when it's plugged in" below).
+
+Next steps, once Steam on the Frame is healthy:
+
+1. Call `StartDesktopStream` again now that the Frame is authorized, and
+ compare its latency and sharpness with Frame Control's stream of the same
+ screen.
+2. Stream the one Mac game installed here (Fortune Mill) from the Frame's
+ library, over Wi-Fi and over USB-C.
+3. Record whether it starts, how it's shown, and its latency. Steam's
+ streaming overlay shows this; our benchmark can't measure it.
+4. While streaming, switch away from the game on the Mac, and see whether
+ the Mac's desktop appears in the headset, and whether its keyboard and
+ pointer work.
+5. If it works, Frame Control's Games page could offer "Stream from the
+ Mac" for Mac-installed games.
+
+## How it works
+
+```
+Mac Frame
+ScreenCaptureKit (one window or display)
+ → VideoToolbox H.264 (hardware, low-latency,
+ no B-frames)
+ → frame-mac-view, 127.0.0.1 ──ssh -R──→ 127.0.0.1:479xx
+ → Chromium app window per stream
+ (WebCodecs decode), on gamescope's
+ X display, tagged STEAM_GAME
+ → its own SteamVR panel
+ ← CGEvent (clicks, drags, wheel, keys) ←──── pointer, wheel and key events
+```
+
+- **The agent** is `mac/bin/frame-mac-view`, built from `mac/frame-mac-view`
+ (Swift, no dependencies; `build.sh`). Frame Control's server starts it on
+ first use and stops it on quit.
+ - It captures with ScreenCaptureKit, which sends frames only when something
+ changes, so idle windows cost nothing.
+ - It encodes in hardware with VideoToolbox's low-latency rate control (plain
+ real-time mode where that's unavailable).
+ - While anyone is watching, it keeps the Mac's display awake. A sleeping
+ display isn't drawn, so there would be nothing to capture.
+- **The link** is an `ssh -R` tunnel on its own connection. It's encrypted and
+ works anywhere `ssh frame` works, Tailscale included, with no firewall
+ changes on the Mac. If the headset sleeps or the network drops, Frame
+ Control reopens the tunnel on the same port, and open viewers reconnect by
+ themselves.
+- **USB-C, when it's plugged in.** Connected to the Mac by cable, the Frame
+ is also a USB network device: macOS lists a network port called "Steam
+ Frame", and the Frame's `usb0` answers in under 1 ms. Frame Control
+ checks for it each time it opens the tunnel and uses it when it's there,
+ with the Frame's usual SSH host key. Otherwise it uses the normal path.
+ `FRAME_MACVIEW_USB=0` turns this off. **Verified** 2026-09-28, in two
+ interleaved pairs of runs:
+
+ | | USB-C | Wi-Fi (Tailscale) |
+ |---|---|---|
+ | test: content p50 / p95 | 6.9–7.3 / 8.4–8.8 ms | 9.8–10.1 / 12.1–12.3 ms |
+ | test: click to drawn p50 | 16.6–16.9 ms | 26.4–27.8 ms |
+ | scroll: content p95 | 23.0–23.5 ms | 31.4–36.9 ms |
+ | scroll: late frames | 3.2–3.3% | 4.7–7.0% |
+
+ (`bench/results/2026-09-28-*-usb1.json`, `-usb2`, `-wifi1`, `-wifi2`.)
+- **Access.**
+ - Frame Control's own key never leaves the Mac.
+ - Each viewer is opened with a **single-use ticket**. It's tied to one
+ window or display and expires after a minute. It's spent as soon as the
+ viewer confirms it has received its reconnect key. Until then, a retry
+ gets the same key, so a connection lost at that moment doesn't strand
+ the viewer. Stop revokes tickets that haven't been used yet.
+ - After that, the viewer holds a reconnect key for that one source, in
+ memory only. **Stop** revokes it.
+ - Remaining risk: a program running as `steamos` on the Frame could read a
+ ticket from Chromium's command line in the first second or so and use it
+ first. That gets it the one source being opened, not the Mac, and the
+ real viewer would then fail to connect. Android apps in Lepton run in
+ their own podman container, so they shouldn't see the Frame's process
+ list (inferred, not checked).
+- **The viewer** is `ui/mac-view.html`, served by the agent. It opens on the
+ Frame as a Chromium app window, preferring Chromium XR (`~/chromium-xr`,
+ built with H.264) over Flathub Chromium.
+ - The page puts `[fcNNNNN]` in its title. The launcher finds the window by
+ that tag and sets `STEAM_GAME` to a stable id per source, which gives it
+ its own panel (see [panels.md](panels.md)). The same Mac window gets the
+ same panel id each time.
+ - It decodes with WebCodecs. If it falls behind, it skips to the next
+ keyframe instead of showing old frames late.
+ - It falls back to JPEG stills (**Compatible** quality) where H.264 isn't
+ available.
+- **Flow control.** The agent never lets frames queue up anywhere on the
+ way. It skips capture frames *before* encoding, so no reference frame goes
+ missing, and it lowers the bitrate, then the frame rate, then the size, to
+ fit the link (see [Adapting to the network](#adapting-to-the-network)).
+- **Input.**
+ - A click on a window's panel brings that Mac window to the front
+ (Accessibility API), then clicks at the same point. Double clicks, right
+ clicks, drags and the wheel work too.
+ - Keys typed into the panel are sent as Mac key codes. Any keys or buttons
+ still held down are released if the viewer loses focus or disconnects, or
+ when the stream stops. Characters the key
+ table doesn't know, such as those from other keyboard layouts, are typed
+ as text.
+ - The Mac's own keyboard and trackpad keep working as normal. Click a panel
+ with the laser, then type on the Mac.
+
+## Permissions (Mac)
+
+- **Screen Recording**, to see windows. Without it, the card asks for it.
+- **Accessibility**, so input from the headset reaches the Mac. Without it the
+ stream still works, and the viewer says clicks won't go through.
+
+Both are granted to Frame Control. After granting, press **Refresh**, which
+restarts the helper so it picks them up. The app is ad-hoc signed, so macOS
+may ask again after an update.
+
+## Quality settings
+
+| Setting | Long side | fps | Codec | Use |
+|---|---|---|---|---|
+| Sharp | 2560 | 60 | H.264, ~0.14 bits/pixel | Text-heavy windows on a strong link |
+| Balanced (default) | 1920 | 60 | H.264, ~0.1 bits/pixel | Most things |
+| Light | 1280 | 30 | H.264 | Weak Wi-Fi or Tailscale off the LAN |
+| Compatible | 1280 | 20 | JPEG | A Frame browser without H.264 |
+
+## Measuring
+
+Every frame carries a sequence number, and the agent records its journey on
+the Mac's clock (`Sources/Stats.swift`):
+
+| Stage | From → to |
+|---|---|
+| capture | the Mac composited it (ScreenCaptureKit's display time) → the agent got it |
+| queue, encode | → encoding started → the encoder finished |
+| network | → the viewer received it |
+| decode, draw | → WebCodecs decoded it → it was drawn on the page's canvas |
+| present | → the page's next animation frame |
+
+- **Clock sync.** The viewer syncs its clock to the Mac's the way NTP does:
+ it pings over the stream's own WebSocket and keeps the sample with the
+ shortest round trip. It then reports, in Mac time, when each frame arrived
+ (right away, so the agent can pace itself) and when it was decoded and
+ drawn (in batches every 250 ms).
+- **Input.** The first frame captured after a click or key carries that
+ event's id. So input latency is the viewer's event → injected on the Mac →
+ the first frame after it → drawn in the headset.
+- **Where to see it.**
+ - `GET /stats` (key required) returns every frame and input record.
+ - `/status` includes a two-second summary, which Frame Control's card
+ shows next to each live stream.
+ - In the headset, add `?stats=1` to the viewer or press
+ Ctrl+Alt+Shift+S for an overlay.
+- **The benchmark.** `scripts/macview-bench.py` runs fixed scenarios on the
+ real Frame, from the Mac, with nobody wearing the headset:
+ - **test** is the moving test pattern.
+ - **scroll** is a Chrome page on its own display scrolling at 240 pt/s,
+ which gives about 9 Mbit/s of real 1920×1290 video.
+ - **type** types into a Chrome text box, first fast and then with pauses.
+
+ It writes `bench/results/--.json`, compares two
+ results, and runs interleaved A/B tests between agent settings (`ab`).
+ Wi-Fi changes from minute to minute, so single runs at different times
+ aren't comparable. Throttled links come from a shaping relay on the Mac,
+ which needs no sudo (`--net 50@0,3@8,50@16` means 50 Mbit/s, then 3 from
+ 8 s, then 50 from 16 s).
+- **What's graded.** "Content" runs from when the Mac composited a frame
+ (or when ScreenCaptureKit delivered it, if that was earlier) to when it was
+ drawn in the viewer. The Frame's compositor adds its own delay after that.
+ That part is reported, but not graded: an unworn Frame throttles panels to
+ about 36 fps after a few seconds, and to 15 fps in standby, whatever they
+ draw. This was **verified** with a local canvas page that ran on the Frame
+ with no network involved (`bench/pages/present.html`). The compositor's
+ share needs a run with the headset worn.
+
+Targets: content p50 ≤ 25 ms (p95 ≤ 40), click to photon p50 ≤ 50 ms
+(p95 ≤ 70), 60 fps with ≤ 1% late frames, no stall over 100 ms, and adapting
+to a new link rate within 1 s.
+
+Baseline on 2026-09-28 (**verified**, home Wi-Fi, Tailscale, Balanced,
+`bench/results/2026-09-28-a3c6e5c-dirty-baseline-fixed.json`; ms p50/p95):
+
+| Scenario | Content | Input to drawn | fps drawn | Notes |
+|---|---|---|---|---|
+| test (1280×720) | 9.9 | 28.8 / 39.0 | 59 | encode 4.0, network 4.0, decode 1.3 |
+| scroll (1920×1290) | 14.7 | – | 50.4 | encode 6.7, decode 6.4 (software), 9.4 Mbit/s, 16% late |
+| type (1920×1290) | 16.7 | 51.2 / 73.2 | – | most of the input time is the Mac app reacting |
+
+After this work, with the controller on (**verified**, same setup,
+`bench/results/2026-09-28-9e4dcdd-final.json`; ms p50/p95). Content is
+now measured from the earlier of display time and delivery, which adds
+about 5 ms to scroll compared with the baseline's way of measuring:
+
+| Scenario | Content | Input to drawn | fps drawn | Grades |
+|---|---|---|---|---|
+| test | 10.5 / 16.7 | 29.6 / 36.3 | 60 | all within target |
+| scroll | 19.8 / 27.4 | – | 55.9 | fps, late frames (4.8%) and worst gap (222 ms) only "acceptable": Wi-Fi stalls (the Mac captured 57 fps in this run; earlier runs got 46–53 from virtual displays) |
+| type | 15.0 / 21.4 | 43.0 / 60.5 | – | all within target |
+
+Of the targets, click to photon is met without the Frame's compositor (the
+headset has to be worn to measure its share), and so is content latency.
+The frame-rate and no-stall targets aren't yet met while scrolling.
+
+**Worn** (**verified** 2026-09-28 22:00, `vrcmd --stats` activity level 1,
+home Wi-Fi over Tailscale, `bench/results/2026-09-28-39afb23-worn.json`;
+ms p50/p95):
+
+| Scenario | Content | Input to drawn | fps drawn | What happened |
+|---|---|---|---|---|
+| test | 10.8 / 15.3 | 25.7 / 34.4 | 58 | as unworn |
+| scroll | 22.2 / 141 | – | 37.7 | Wi-Fi queued 56–364 ms and held frames for 220–270 ms; the controller went to 2.6 Mbit/s and 45 fps |
+| type | 13.5 / 24.3 | 56.8 / 106 | – | slower replies than unworn (43 / 60) |
+
+The Frame's CPU wasn't the limit: about 27% in total, and the viewer took
+45% of one core. The link to a headset on someone's head is much rougher
+than to one lying still. The adaptation keeps latency bounded there, but
+the frame rate drops. The USB-C cable avoids Wi-Fi entirely. Frames
+actually shown were still about 39 fps for the test pattern while worn, so
+the unworn throttling isn't the whole story. The cause is **unknown**.
+
+What was learned (all **verified**, unless marked):
+
+- The biggest costs are encoding (4–7 ms), network (4–6 ms), and decoding
+ on the Frame. Chromium XR on the Frame decodes H.264 in **software**.
+- Wi-Fi alone stalls for 240–580 ms now and then, over Tailscale and over
+ the LAN alike. Over the LAN (`--host 192.168.1.237`) latency was no
+ better, but the Frame used about 8% less CPU, because tailscaled runs in
+ userspace there.
+- With no controller, a link that slows down queues without limit. In one
+ run, frames arrived 1.9 s late, and at worst 9.4 s late.
+- Tried, and no help, so not kept: VideoToolbox options (require hardware,
+ no frame delay, prioritise speed, a hard data-rate cap), Chromium flags
+ (`--disable-gpu-vsync`, `--disable-frame-rate-limit`,
+ `--use-angle=vulkan`), and a 120 Hz virtual display.
+- Inconclusive, so off by default: keeping the Frame's Wi-Fi awake during
+ typing (`FRAME_MAC_VIEW_WARM=40`, a tiny message every 40 ms for 5 s
+ after input). Over three interleaved runs each, input p50 went 44 → 47 ms
+ and p95 92 → 71 ms, and the ranges overlapped widely
+ (`…-ab-keepwarm.json`). In that run, and in the baseline's typing, the
+ harness typed spaces as "+" (a URL-encoding bug, since fixed), so they
+ went through the text path rather than as space keys.
+- Pointer moves now go out on an 8 ms timer, not on the page's next
+ animation frame, which an unworn Frame slows to 15–36 Hz. This is
+ **inferred** to help dragging; the benchmark has no drag scenario yet.
+- Kept: the encoder's timestamps never jump more than two frame intervals.
+ Before this, the first frame after a pause got a quarter of a second's
+ bit budget, and one P-frame reached 204 KB.
+
+## Adapting to the network
+
+`Sources/Controller.swift`, per stream, latency first:
+
+- **The gate.** The viewer acknowledges every frame as it arrives. A new
+ frame is sent only while the oldest unacknowledged one is younger than
+ the path's usual round trip, plus one frame interval, plus room for this
+ link's normal jitter (1.5 times its recent spread, 25–80 ms). So frames
+ never queue in SSH, TCP or the Wi-Fi driver. While the link is stuck, the
+ newest picture waits and goes out as soon as it moves.
+- **The bitrate.** The link counts as congested when, for two checks in a
+ row (100 ms apart), round trips grow by more than 40 ms while the stream
+ uses much of its budget, or the gate holds frames back, or a frame is
+ stuck for 100 ms. Then the bitrate drops to a bit under what actually got
+ through: at least a fifth off, and at most half. Once the link has been
+ clear for a second, it rises by 10% steps, never above the quality
+ setting's bitrate.
+- **The tier.** When the bitrate stays low, and the stream is really
+ limited by the link rather than having little to send, it steps down:
+ 60 → 45 → 30 fps, then 75%, then 50% of the pixels. It goes straight to
+ the tier the bitrate supports after half a second, and steps back up one
+ tier at a time after two seconds with room to spare.
+- `FRAME_MAC_VIEW_ADAPT=0` turns it off, for comparison.
+
+Measured on the real Frame, 2026-09-28 (**verified**; interleaved A/B, off
+versus on, medians of the runs, ms):
+
+| Link | Scenario | Content p95, off → on | fps drawn, off → on | Result file |
+|---|---|---|---|---|
+| Clean Wi-Fi (3 runs each) | scroll | 32.3 → 33.6 | 57 → 56.2 | `…-ab-adapt-clean2.json` |
+| Clean Wi-Fi | test | 15 → 14.2 | 60 → 59.7 | same |
+| 50 → 3 → 50 Mbit/s at 8 s and 16 s (2 runs each) | scroll | **4670 → 72** | 45 → 42 | `…-ab-adapt-step3.json` |
+| 50 → 3 → 50 Mbit/s | test | 66 → 16 | 60 → 60 | same |
+
+- **Clean link.** On a clean link it costs nothing measurable. An earlier
+ version with a fixed gate slack lost 9 fps to Wi-Fi jitter while
+ scrolling (47 → 38 fps), and that's why the slack now follows the link's
+ jitter.
+- **Throttled link.** Without the controller, frames queued for up to 5.6 s
+ and never caught up while the link was slow (p95 1.8–5.6 s, second by
+ second). With it, in the two runs:
+
+ | | Run 1 | Run 2 |
+ |---|---|---|
+ | Worst second's p95 just after the drop | 219 ms | 428 ms |
+ | p95 back under 100 ms for 3 s in a row | after 1 s | after 4 s |
+ | Stepped down to 1440 px at 30 fps | 1.8 s after the drop | 3.5 s after |
+ | p95 per second after that, on the 3 Mbit/s link | 55–94 ms | 60–148 ms |
+
+ Sending one frame takes about 27 ms on that link by itself. After the
+ link recovered, the stream was back at full size and 60 fps in about
+ 7.5 s. It steps up one tier every two seconds, on purpose, so it doesn't
+ bounce. The 1 s adaptation target was met in one run of two.
+- **A hiccup on a small stream.** In one clean run, a Wi-Fi hiccup made an
+ earlier version halve the test pattern's bitrate five times and drop it
+ to half size. That cut couldn't help: the stream only sends
+ 0.47 Mbit/s. Now the controller estimates what a stream wants (captures
+ per second × average frame size). While a stream wants about half its
+ budget or less, no cut takes it below twice what it wants, so it doesn't change
+ tier.
+
+## Checked so far (2026-09-28)
+
+- **Mac (checked by hand, macOS 26.5.2).**
+ - The agent builds, and lists windows and displays.
+ - In a browser, the test pattern decoded at about 60 fps (H.264) and 30 fps
+ (JPEG, about 22 Mbps).
+ - A click in the viewer arrived at the same point in the source.
+ - `pmset -g assertions` showed the display-awake assertion only while a
+ stream was being watched.
+- **Automated** (`tests/test_macview.py`, on CI's macOS runner): the agent
+ builds (a build failure fails the job).
+ - `/ping` and the page are open; everything else needs the key.
+ - Tickets work once and only for their own source, and Stop revokes
+ reconnect keys.
+ - A WebSocket frame claiming 2^63 bytes closes that socket, and the agent
+ keeps running.
+ - A stream sends an SPS-led H.264 keyframe, and sends another when asked.
+ - Stop ends the stream on the Mac even if the viewer ignores it.
+ - The test doesn't decode video, time it, or check where clicks land.
+- **Linux aarch64 (verified in a stand-in, not on the Frame).** In an Arch
+ Linux ARM container with sshd, Xvfb as `:0` and Chromium 153:
+ - The real `show` path worked in 1–2.3 s: tunnel, ticket, launcher,
+ window found and tagged `STEAM_GAME`.
+ - Frame Control's key didn't appear anywhere in the stand-in's process
+ list.
+ - After the tunnel was killed, it came back on the same port within 4 s,
+ and the viewer reconnected by itself.
+ - Chromium decoded the stream in software with the GPU off.
+ - Stop closed the window, and Chromium exited.
+ - This test found and fixed a bug: in a C locale, `xwininfo` can't print a
+ title with non-ASCII characters, so the launcher reads `_NET_WM_NAME`
+ with `xprop`.
+- **On the Frame (verified 2026-09-28, SteamOS build 20260925.6191901,
+ from the Mac, nobody wearing the headset).** Frame Control's **Show** with
+ the test pattern:
+ - The panel was ready in 1.45 s. SteamVR logged `[Overlays] Created:
+ valve.steam.desktopgame.2001639889` (in `vrwebhelper_systemui.txt`).
+ - The window was tagged `STEAM_GAME`, and gamescope sized it to 1920×1080
+ although 1280×720 was asked for.
+ - Chromium XR decoded it live at about 60 fps: the frame counter advanced
+ 62 in 1.04 s.
+ - Over home Wi-Fi, the frames in the viewer window had been drawn on the
+ Mac about 11–17 ms earlier, plus `xwd`'s own time. That's measured
+ against the two clocks, which were 37–39 ms apart (±4 ms, measured over
+ one SSH session). SteamVR's compositor and the display come on top.
+ - Clicks and keys injected with XTest into gamescope's Xwayland didn't
+ reach the page. That's inconclusive, not a failure: XTest on gamescope
+ isn't how real input arrives. The laser should arrive as a left mouse
+ button and the thumbstick as a wheel, because the window has an app id
+ (from gamescope's source; see [panels.md](panels.md)).
+- **Not yet checked:**
+ - Clicking, dragging and scrolling with the laser while wearing the
+ headset.
+ - Real window capture and input on a Mac with both permissions granted.
+ - Keys from SteamVR's on-screen keyboard.
+ - Whether Flathub Chromium has H.264. Chromium XR is used when it's
+ installed, as it is on this Frame.
+ - Latency with real, busy windows at Sharp.
+ - A benchmark run while wearing the headset. Only then does the Frame show
+ panels at full rate, so only then can the compositor's share of the
+ latency, and the frame rate you actually see, be measured.
+
+## Limits
+
+- Only windows on the Mac's current desktop (Space) are listed, and
+ minimised windows can't be captured.
+- A window's panel shows only that window. Its menus and sheets are separate
+ windows on the Mac, so open them from the Mac or use **Whole screen**.
+- Keys go to whichever Mac window is in front. Clicking a panel brings its
+ window to the front first.
+- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
+ with the Frame.
+
+## Switching panels and workspace limits
+
+**Tools → Panel switcher** lists open SteamVR panels, including Mac viewers.
+Use **Show** to request focus or **Open in headset** for Frame Control's own
+switcher panel. It uses SteamVR/gamescope and Chromium, with no third-party
+overlay app. [Device checks and limits](panels.md#frame-controls-panel-switcher)
+include the difference between a panel surviving a scene launch and staying
+visible over it.
+
+Saved spatial layouts are blocked on this build: the public OpenVR transform
+setter denies access to gamescope-owned panels. Reconnecting an existing viewer
+is supported; restoring its room position after a reboot is not. We do not
+save short-lived Mac window IDs or viewer access keys as if they were a durable
+workspace. See [the feasibility evidence](panels.md#saved-spatial-layouts-blocked-on-the-current-panel-route).
diff --git a/docs/mac-window-separation.md b/docs/mac-window-separation.md
new file mode 100644
index 0000000..4c0d169
--- /dev/null
+++ b/docs/mac-window-separation.md
@@ -0,0 +1,200 @@
+# Real separation of Mac windows in the headset
+
+Research and experiments on 2026-09-28 (macOS 26.5.2, Apple M5 Pro), for
+making each streamed Mac window truly independent. Builds on
+[mac-in-headset.md](mac-in-headset.md). Labels: **verified** (tried here),
+**documented**, **source** (read in someone's code) or **reported**.
+
+## What "separate" lacks today
+
+Today each window is captured on its own
+(`SCContentFilter(desktopIndependentWindow:)`), but it still lives on the
+Mac's one desktop:
+
+- **Clicks.** A click has to raise the window first, so it reorders the
+ Mac's windows, steals focus and moves the real cursor.
+- **Child windows.** A window's menus, popovers, sheets and tooltips are
+ separate windows, so they aren't in its panel. Apple documents that the
+ single-window filter leaves them out.
+- **Size.** A panel's size is tied to the window's size on the Mac screen.
+- **Hidden windows.** Minimised windows, and windows on other Spaces, can't
+ be shown live.
+
+## How the Mac draws, and where pixels can be read
+
+Apps draw with Core Animation into IOSurfaces. WindowServer's compositor
+stacks every window onto each display, including virtual ones, and sends
+the result to the screen. Pixels can be read at three points:
+
+| Where | How | Gets | Doesn't get |
+|---|---|---|---|
+| One window's content | ScreenCaptureKit `desktopIndependentWindow` (public, what we use) | Live, zero-copy, even when covered by other windows (**documented**) | Menus, popovers, sheets. It pauses while the window is minimised (**reported**) |
+| One window plus its children | `SCStreamConfiguration.includeChildWindows` (macOS 14.2+, **reported**) | Menus, popovers and sheets attached to the window | Anything the app puts outside the window's bounds |
+| A whole display | ScreenCaptureKit display filter, with apps or windows included or excluded | Everything on that display, cursor included | Only what's on that display |
+| A snapshot of any window | Private `CGSHWCaptureWindowList`, which AltTab uses for minimised windows and other Spaces (**source**: `alt-tab-macos` `PrivateApis.swift`) | Minimised windows and other Spaces | It's one still image, not a live stream |
+| Another app's layer tree | Private `CALayerHost` with a context id | A live, zero-copy picture | It only works when the other app cooperates, so it's no good for arbitrary windows (**reported**) |
+
+`CGWindowListCreateImage` and `CGDisplayStream` are obsolete in the macOS 15
+SDK (**reported** by MacPorts and JUCE). Nothing reads another app's pixels
+without the Screen Recording permission.
+
+## The idea: each window gets its own virtual display
+
+A virtual display (the private `CGVirtualDisplay`, as used by BetterDisplay,
+DeskPad and quest-display) is a compositor target with no physical screen.
+Put one streamed window alone on its own virtual display, sized to its
+panel, and capture the whole display:
+
+- **Nothing can overlap it.** A plain click at that point always lands on
+ that window, so input doesn't need raising or background-event tricks.
+- **Menus, sheets, popovers, tooltips and context menus appear on the same
+ display, so they're in the panel.** With "Displays have separate Spaces"
+ on (it is on this Mac), each display also has its own menu bar, so the
+ app's menu bar can be part of the panel.
+- **The panel's size is the display's size,** in HiDPI. Resizing the panel
+ means changing the display mode and resizing the window to fill it
+ (Accessibility API).
+- **Minimised windows and other Spaces stop being a problem,** because
+ streamed windows live on their own displays.
+- **The cursor goes where the laser points.** That display's panel is the
+ one being used, much as Vision Pro's Mac Virtual Display works. Keys from
+ the Mac's keyboard go to the window last clicked.
+
+### Verified here (no permission needed)
+
+- **Creating one.** It needs no permission or entitlement. 1920×1080 HiDPI
+ gives a 3840×2160-pixel, 60 Hz display, placed next to the built-in
+ screen, and `NSScreen.screensHaveSeparateSpaces` was true.
+- **Many at once.** 16 were created at once with no error.
+- **Placement.** `CGConfigureDisplayOrigin` far away failed with error
+ 1014: macOS keeps displays edge to edge. Disabling one with the private
+ `CGSConfigureDisplayEnabled` from a command-line tool also failed with 1014.
+- **Removal is deferred while the physical screen sleeps.** With the
+ built-in display asleep, virtual displays were not removed when released,
+ or when their process exited, even from their own `.app`. A second display
+ with the same vendor, product and serial couldn't be created. All of them
+ disappeared as soon as the screen woke (`caffeinate -u`). The helper must
+ therefore:
+ - keep a fixed pool of displays and reuse them rather than create new ones;
+ - keep the screen awake while they exist, which it already does while
+ streaming.
+
+### Built: "Give each window its own display"
+
+Frame Control's helper now does this (`mac/frame-mac-view/Sources/Separate.swift`),
+and it's the default in the Tools card. Streams of this kind are named
+`separate:`.
+
+- For each window shown, it creates a HiDPI virtual display. The display is
+ sized to the window plus a menu bar, with a fresh serial each time, so a
+ display left over while the screen slept can't block a new one.
+- The window is moved onto the display and resized to fill it (Accessibility
+ API), and the whole display is captured.
+- On **Stop** the window goes back where it was, and the display is released.
+- Plain per-window capture is still there: untick "Give each window its own
+ display". Separate mode needs Accessibility (to move the window), and
+ without it, Show says so rather than quietly falling back.
+
+Verified 2026-09-28 (macOS 26.5.2, M5 Pro), using a TextEdit test document
+and the benchmark's Chrome windows:
+
+- **Separation works.** The window moved onto its own display and streamed,
+ with its first frames in 3.8 s. The display showed TextEdit's own menu bar.
+- **Child windows come along.** A context menu and the Page Setup sheet
+ opened on the same display and were in the capture.
+- **Input.** Typing through the stream reached the window. The benchmark's
+ typing scenario does this on every run.
+- **Stop.** Stop put the window back at exactly its old size (656×422), and
+ the display was removed.
+- **The helper must run a real Cocoa event loop.** Earlier, it ran only a
+ `RunLoop`. With that, AppKit never learned about new displays:
+ - `NSScreen.screens` never listed them, so placing the window always waited
+ 3 s and then gave up;
+ - the display's HiDPI mode never applied, so windows were captured at 1×
+ (1280×860 instead of 1920×1290 pixels) and text was soft.
+
+ Running `NSApplication` (with no Dock icon) fixed both. The screen now
+ appears within 100 ms, and captures are at 2× (**verified** in
+ `bench/results/*-baseline.json` against `*-baseline-fixed.json`).
+- **Frame rate.** Chrome drew at 60–61 fps on the virtual display, but
+ ScreenCaptureKit delivered only about 46–53 fps from it, whereas the
+ built-in ProMotion screen gave 121 fps (**verified**). Neither a 120 Hz
+ virtual display (`FRAME_MAC_VIEW_VD_HZ=120`) nor a looser
+ `minimumFrameInterval` changed that. Cause unknown.
+- **Windows that keep their own size** (Calculator, 230×408). The window
+ moved and streamed, but stayed small in the display's corner, so the panel
+ was mostly wallpaper. Now only that corner is captured
+ (`SCStreamConfiguration.sourceRect`): the menu bar and the window, at least
+ 480×360 points so menus fit. Clicks map to the cropped area. The first
+ frame arrived in 0.6–1.1 s, and on Stop the window went back and the
+ display was removed. A display's menu bar names the active app, so it
+ shows the window's own menus only once the panel has been clicked.
+- **Several windows at once** (on the Mac, with a local stand-in viewer that
+ acknowledges frames). Three and four Chrome windows, each scrolling on its
+ own display at 1920×1290 pixels, streamed at 53–56 fps and about
+ 9 Mbit/s each. The helper used 20% (three) or 24% (four) of one CPU core.
+ The hardware encoder is shared: its time per frame went from 6.7 ms for
+ one stream to 7–15 ms for three and 11–25 ms for four, so each extra
+ moving window adds latency to the others. Once in four runs, before a fix,
+ one window left its display when several displays appeared at the same
+ moment, and its stream stopped: nothing on that display was changing any
+ more. The helper now checks every second and puts the window back. Three
+ further runs with four windows were clean, and every display was gone
+ afterwards (checked with `CGGetOnlineDisplayList`).
+- **Quitting.** On SIGTERM, or when Frame Control goes away, the helper ends
+ every stream first, so windows go back before their displays disappear.
+ Verified: a 900×600 window was back at 900×600 after SIGTERM. Closing a
+ viewer 0.05–1.5 s into startup left the window at its old size and no
+ extra display.
+- **A consent prompt.** macOS 26 asks whether to let ScreenCaptureKit apps
+ "bypass the system private window picker". The prompt appeared *on the
+ virtual display*, so it would show up inside the headset panel. Allow it
+ once on the Mac.
+
+### Still to check
+
+1. That a context menu opens over the text being clicked, not just somewhere
+ on the display. This needs a retest after the consent prompt above is
+ allowed.
+2. Stage Manager, which is reported to undo Accessibility resizes.
+3. Where the Dock and the cursor go on a virtual display.
+4. Closing the lid with virtual displays attached (clamshell).
+5. Many moving windows at once in the headset: whether to lower the bitrate
+ or frame rate of panels you aren't using, so the one you are using keeps
+ the encoder to itself.
+6. All of it in the headset, with the laser.
+
+## Input without disturbing the Mac (a finer option)
+
+For windows that stay on the Mac's own screen, input can go to a window
+behind others without raising it:
+
+- **Background click.** `CGEventPostToPid` with the CGEvent fields 91 and
+ 92, which say which window a click is for (`kCGMouseEventWindowUnderMousePointer`
+ and `...ThatCanHandleThisEvent`), plus a window-relative location
+ (**reported**, reverse-engineered, needs testing).
+- **Focus without raise.** yabai makes a window key without raising it by
+ posting event records with the private `SLPSPostEventRecordTo` (**source**:
+ `yabai/src/window_manager.c`).
+- **Known failures.**
+ - Chromium and Electron ignore background clicks unless they're built
+ with the 2026 `acceptsFirstMouse` fix (electron/electron#54493).
+ - Games and canvas apps often need real activation.
+ - Password fields (Secure Event Input) drop synthetic keys.
+ - IME composition, as for Chinese or Japanese input, isn't reliable.
+
+With a virtual display per window, most of this isn't needed. It's worth
+having for hovering over one panel while typing in another.
+
+## Encoding and transport
+
+- **Codec.** Keep H.264 4:2:0. Apple's High Performance Screen Sharing uses
+ 4:4:4 over two virtual displays (**documented**), but the Frame's Chromium
+ decodes H.264 in software, and no 4:4:4 decode path is confirmed there.
+- **Sharper static text.** When a panel has been still for a moment, send a
+ high-quality refresh, either a keyframe at low quantisation or a lossless
+ WebP overlay, so static text is sharp. Moving content stays as video.
+- **Transport.** Keep WebSocket over SSH for now, as it works everywhere.
+ WebRTC (UDP, congestion control) is the proven step up for Wi-Fi.
+ WebTransport over QUIC is promising, but its server side on macOS is
+ unverified.
diff --git a/docs/open-questions.md b/docs/open-questions.md
index 13b4c45..770f20a 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -102,10 +102,11 @@ Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
20. **F-Droid 2.0** (Compose 1.12): does it run? If so, the catalogue can
install it instead of 1.17.2.
-21. **DeoVR local files:** does DeoVR's file browser show `Videos → VR`
- (the symlink from `push-vr-video.sh`) or `Z:\home\steamos\Videos\VR`, and do
- the colour-coded test clips play in 3D (red left eye, cyan right) for both
- H.264 and H.265? Does the DLNA browser find a server on the Mac?
+21. **Owned media player:** worn-headset comfort, audio quality/lip sync, long
+ movies and 4K/8K decoding remain to check. Native spatial-photo container
+ extraction, large/immersive splats and existing-panel theatre docking need
+ further implementation. Remote eye isolation, short hardware decode and
+ owned-overlay cleanup are verified; see [vr-video.md](vr-video.md).
## Verified 2026-09-27
@@ -129,6 +130,19 @@ Still open: 4, 6, 7, 12–15, 16 (off-LAN and after a reboot), 17–21.
- The Mac EDL flashing script in `~/Downloads/steam-frame-recovery/` hasn't
been run against a Frame.
+## Mac in the headset (2026-09-28)
+
+The test pattern streams to the Frame as its own panel at about 60 fps
+(verified, build 20260925.6191901; see
+[mac-in-headset.md](mac-in-headset.md#checked-so-far-2026-09-28)). Still to
+check in the headset:
+
+- Laser clicks, drags and thumbstick scrolling in a viewer panel.
+- Real window capture and input once Screen Recording and Accessibility are
+ granted to Frame Control.
+- Keys from SteamVR's on-screen keyboard.
+- Whether Flathub Chromium decodes H.264 (otherwise use Compatible).
+
## Unconfirmed claims made in these docs
- `/home` and `/etc` persist across Frame OS updates. This is inferred from
diff --git a/docs/panels.md b/docs/panels.md
index b08720b..5b17828 100644
--- a/docs/panels.md
+++ b/docs/panels.md
@@ -121,5 +121,171 @@ a limit on the number of floating panels.
script needed.
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
-- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
- PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
+- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
+ separate from Frame Control. Public reports describe some Proton support;
+ Windows-only does not by itself prove a Frame app cannot run. Local status
+ and sources are in [VR utilities](vr-utilities.md).
+- **Our performance HUD:** Home → VR comfort and performance → Open HUD in
+ headset creates its own gamescope panel using built-in tools. It needs no
+ third-party overlay app. [Metrics and verification](vr-utilities.md).
+
+## Frame Control's panel switcher
+
+**Verified 2026-09-28**, SteamOS 0.4.1, BUILD_ID `20260925.6191901`,
+SteamVR 2.18.1: **Tools → Panel switcher** lists SteamVR's open main panels,
+including panels that are currently hidden. **Show** asks SteamVR to bring one
+forward. **Open in headset** opens the same switcher as its own panel; choose
+it again from Steam's dashboard after switching away. Refresh updates the list.
+This is a list, not thumbnail Exposé.
+
+
+
+This is our own Python/HTML implementation (`ui/frame_panels.py`), using the
+Frame's shipped `vrcmd` OpenVR client and gamescope. The headset page uses
+Chromium (Chromium XR when present, then system Chromium, then the existing
+Chromium Flatpak). No XSOverlay, OVR Toolkit, WayVR or other overlay application
+is needed. This dependency boundary also applies to future layout and panel
+persistence work: platform APIs and bundled libraries are fine; another app
+must not implement the feature for us.
+
+The companion runs the helper over SSH. Opening it in the headset installs a
+copy under `~/.local/share/frame-control/panels/` and starts a loopback HTTP
+server and an isolated Chromium profile. There is no startup service or global
+setting change. Close the switcher to stop its server and browser. Other
+Chromium profiles, Steam and SteamVR are left alone. If the window or runtime
+closes, use **Open in headset** again.
+
+The page carries a random, per-process access key in its URL fragment, removes
+it from the address bar, keeps it in tab session storage for page reloads, and
+sends it in a header. Panel lists and actions need
+that key; Host and Origin checks reject other sites. The key permits only
+listing panels, requesting focus and closing this switcher. Like Mac viewer
+launch tickets, it is initially readable by another process running as the
+same Frame user. Panel titles are rendered as text, never HTML. The companion
+retains its existing request guards. No Mac capture credentials cross this API.
+
+**Verified:** the real headset page rendered its panel list (image above), its
+HTTP focus request changed `GAMESCOPE_FOCUSED_APP` to `2000999030`, a request
+without the key returned HTTP 403, and Close stopped the helper and its browser.
+Opening an already running switcher requests its focus rather than creating a
+second one. The companion uses the same list/focus helper. **Unverified:** laser
+selection while wearing the headset, physical placement, and non-XR Chromium.
+The API reports that focus was *requested*: another action can take focus before
+we observe the result. Closed panels are rejected after re-enumeration.
+
+### Shared-device recheck, 2026-09-29
+
+**Verified:** the follow-up's atomic `mkdir /tmp/frame-test.lock` attempts
+failed because another thread held the lock. The existing lock was left alone;
+no applications were installed, launched or stopped in this follow-up. The last
+read-only battery check showed 62%, charging. The 180 Python and 8 website tests
+passed again locally.
+
+**Unverified in this follow-up:** the prepared browser-button test (Refresh,
+selection, reload and Close) and repeated OpenXR transition could not run under
+the shared lock. The device results elsewhere in this page are the earlier
+2026-09-28 observations, not results from this blocked recheck. In particular,
+HTTP focus is not evidence of worn-headset laser input. Follow the
+[shared-device test procedure](testing.md#headset-smoke-test) for the next run.
+
+## Saved spatial layouts: blocked on the current panel route
+
+**Verified 2026-09-28**, same build, using a temporary xterm panel with
+`STEAM_GAME=2000999031` and `FnTable:IVROverlay_028` from
+`/opt/steamvr/bin/linuxarm64/libopenvr_api.so`:
+
+| OpenVR call | Result |
+|---|---|
+| `FindOverlay("valve.steam.desktopgame.2000999031")` | Success |
+| `GetOverlayWidthInMeters` | Success, 2.67 m |
+| `SetOverlayWidthInMeters` (same width) | Success |
+| `GetOverlayTransformType` | Success, type 5 (`VROverlayTransform_DashboardTab`) |
+| `GetOverlayTransformAbsolute` | 18 (`WrongTransformType`) |
+| `SetOverlayTransformAbsolute` (identity rotation, 1.2 m up, 1.5 m forward) | 12 (`PermissionDenied`); type remained 5 |
+
+The public interface names type 5 **DashboardTab**; SteamVR's dashboard code
+places these panels through its scene graph. It owns the frame/docking
+transforms. A successful width setter does not grant permission to restore the
+position. `vrcmd --dock-overlay world ` dispatched a docking request but
+the dashboard logged `Failed to get SGTransform in setInitialTransformForLocation.
+Invalid transform ID`. This does not establish working world placement.
+
+**Inferred:** saving X11 pixel rectangles or Mac window IDs would not restore
+this spatial arrangement. Mac window IDs also change when an application
+reopens; viewer tickets and reconnect keys must not go into a layout file.
+The base Mac stream reconnects after a network break, but that is different
+from recreating windows and their room positions after a reboot.
+
+There is consequently no Save/Restore control yet. A durable layout needs a
+working transform restore path, stable source identity, and a fresh capture
+permission/ticket flow. The tested gamescope-owned overlay route denies that
+transform operation. A future Frame Control-owned overlay renderer, or a
+supported platform API for dashboard frame transforms, needs its own device
+proof before building layout UI. This is a blocker for the current approach,
+not a claim that all possible implementations are impossible. Reboot recovery
+was not tested: the shared headset was not rebooted.
+
+## Panels during an immersive session
+
+**Verified 2026-09-28**, same build: our Chromium switcher panel remained in
+OpenVR's overlay list before, during and after the Frame's shipped `helloxr -g
+Vulkan` sample. During the test `vrcmd --stats` identified
+`system.generated.openxr.helloxr.helloxr`, with 242 frame submissions. The test
+ended only its own sample process; no SteamVR, Steam, power or global settings
+were changed. The switcher was still selectable afterwards.
+
+This proves survival of that panel across an OpenXR scene session, **not** that
+it stayed visibly composited over the scene: OpenVR reported it `not_visible`
+before, during and after. **Verified:** calling `ShowOverlay` on our
+*gamescope-owned* switcher overlay returns 12 (`PermissionDenied`). A helper
+cannot force that panel visible using the public overlay call. Use the
+switcher/dashboard to request access to it; we do not fight the runtime with a
+repeated force-focus loop.
+
+**Verified in a second controlled run:** a live H.264 test-pattern stream from
+this checkout's Mac helper, through its own SSH tunnel and a temporary Chromium
+profile, survived the same OpenXR sample (257 scene-frame submissions). Its
+panel `2000999032` changed from `visible` before launch to `not_visible` during
+and after the scene. The Mac helper still reported the same `test` stream;
+captured frames increased from 35 to 232, with 29.5 decoded/drawn fps afterwards.
+The test did not capture personal Mac windows or inject Mac input. The sample,
+viewer, temporary profile, tunnel and Mac helper were cleaned up. This proves
+stream survival, and also shows why it must not be advertised as always visible.
+
+**Unverified:** persistent visible placement while playing a Steam-launched VR
+game, Plasma desktop and real Mac-window behavior during that launch, and worn
+headset input. Other threads were launching games and changing the runtime on
+the shared device, so those transitions were not treated as controlled evidence.
+A runtime/X-server restart can destroy the viewer windows; a network reconnect
+cannot recreate them. No “always visible during games” guarantee is shipped.
+
+## Keyboard passthrough feasibility
+
+**Verified 2026-09-28**, same build, using `FnTable:IVRTrackedCamera_006`:
+`HasCamera(0)` returned success and true. `GetCameraFrameSize` returned 100
+(`OperationFailed`), with zero dimensions, for all three public frame types
+(distorted, undistorted and maximum-undistorted), including after acquiring the
+video service. Acquisition returned success and a handle; release returned 101
+(`InvalidHandle`). The probe shut down its OpenVR client afterwards. No camera
+frames were captured and no camera settings were changed.
+
+**Documented:** the public OpenVR camera interface provides camera frame sizes,
+intrinsics, projections and streaming handles; these are prerequisites for a
+spatially aligned camera cutout. See Valve's
+[OpenVR C API](https://github.com/ValveSoftware/openvr/blob/master/headers/openvr_capi.h).
+
+**Inferred:** camera presence alone does not establish access to camera pixels.
+The failed frame-size path blocks a keyboard cutout in our current panel
+implementation. We have not established a keyboard detector or a calibrated
+camera-to-panel mapping. Built-in full-room passthrough is not proof of a
+public, selectively masked camera stream. No keyboard cutout is offered, and
+no third-party camera/overlay app is substituted for it.
+
+## Frame Control's media theatre
+
+[The owned media player](vr-video.md) can show its video or stereo image on a
+larger, head-relative screen with its own dark surround. **Verified remotely
+2026-09-28**, SteamOS 0.4.1 / BUILD_ID 20260925.6191901: screen, eye isolation,
+surround and cleanup. It does not alter panel docking or global settings.
+Its `Overlay` RGBA rendering hook is available to stream producers; applying
+SteamVR theatre docking to existing Mac/PC panels is still unverified here.
diff --git a/docs/privacy.md b/docs/privacy.md
new file mode 100644
index 0000000..db7004f
--- /dev/null
+++ b/docs/privacy.md
@@ -0,0 +1,143 @@
+# Privacy and analytics
+
+Frame Control sends anonymous analytics to [PostHog](https://posthog.com)
+(US cloud) so the maintainer can see how many people use it, which features
+matter and where installs fail. You choose how much in **Privacy & updates**,
+the last panel on the page. `ui/frame_telemetry.py` is the whole
+implementation.
+
+## The three levels
+
+| Level | Default | What it sends |
+|---|---|---|
+| Anonymous usage statistics | On, after a notice on first run | The events in the table below |
+| Share compatibility results | Off | Your Android compatibility reports and tests |
+| Send error details | Off | Scrubbed error messages and tracebacks |
+
+Nothing is sent until the first-run notice has been shown. The notice's
+**Share more to help fix problems** button turns on the second and third
+levels together. Either can be turned off later. Turning a level off
+deletes that level's events that haven't been sent yet.
+
+**Show what's been sent** in the panel lists the last 50 events that left your
+computer, exactly as they were sent.
+
+## Anonymous
+
+- Events carry a random id, made when Frame Control first runs and kept in
+ its data folder (`telemetry/settings.json`). It isn't derived from your
+ computer, account or network. To get a new one, delete that file.
+- Events are sent without person profiles (`$process_person_profile: false`)
+ and without location lookup (`$geoip_disable: true`). Each carries a
+ placeholder address (`$ip: 0.0.0.0`), so PostHog stores that instead of
+ yours.
+- Every event includes the app version, OS name (macOS, Windows or Linux),
+ CPU architecture and Python version.
+
+## Usage events
+
+| 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 | |
+| `frame_connected` | The first time a SteamOS build is seen | `steamos_build`, `steamos_version` |
+| `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`, `installer_code`, and see below |
+| `update_offered`, `update_started`, `update_failed` | The update banner | `to_version`, `error_category` |
+
+`install_finished` never includes a file name, path or error message. An
+error becomes one category from a fixed list (for example `apk_wrong_abi` or
+`frame_unreachable`), plus Android's own `INSTALL_FAILED_…` code when there
+is one. It names what was installed only when that's already public:
+
+- F-Droid catalogue apps: `package`. Never the version, since a local build can reuse a
+ catalogue app's package name
+- Flathub apps: `flatpak_id`
+- Steam games: `steam_appid`
+- A sideloaded title: only its runtime (Proton or Linux)
+
+Any other APK is sent as `catalog: false`, with no name.
+
+## Compatibility results (opt-in)
+
+Each report becomes a `compat_report` event with the fields the Report dialog
+shows: package, version, result or rating, your notes, how it was run, and the
+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,
+ 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.
+
+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.
+
+## Error details (opt-in)
+
+`$exception` events carry an error message, the Frame Control file, line and
+function it came from, and the request that failed (for example
+`POST /api/android install`). Before anything is sent, the message is
+scrubbed:
+
+- your home folder becomes `~`, and any user name becomes ``
+- IP and MAC addresses, email addresses, `.local`, `.lan` and Tailscale host
+ names, Steam ids, SSH and PEM keys, API tokens and long hex strings are
+ replaced
+- URLs are cut down to their scheme and a public host name, or ``. User
+ names, passwords, ports, paths and queries are dropped
+- `token=`, `key=`, `password=` and similar values are replaced
+
+The same error is sent at most once every 10 minutes.
+
+## Report a problem
+
+**Report a problem** is the warning-sign button in the header, also in the
+Privacy panel and under **Help → Report a Problem…**. It sends the report
+privately to Frame Control's PostHog project as a `problem_report` event, the
+same way as the analytics above, so only the maintainer can read it and
+nothing is published. It works whatever the analytics settings are, because
+the person sends it deliberately. The report has the kind, title and text you
+wrote, how to reach you if you gave it, a short reference shown after sending,
+and the diagnostics below. It has its own random id, so it isn't linked to
+your analytics events.
+
+With **Include diagnostics** ticked (the default), the report adds:
+
+- the app version and whether it's a built app
+- the OS, its release and CPU, and the Python version
+- the Frame's SteamOS build, if it has connected since the app started
+- which analytics levels are on
+
+**Also include recent activity and the server log** is off by default,
+because those lines can name files and apps. When ticked, it adds the newest
+Activity lines and server log lines, without the request lines.
+
+Everything is scrubbed like error details and limited to what fits in the
+report. Environment details are kept first, then the newest lines. **Show
+exactly what's included** shows the snapshot that will be sent, and later
+activity isn't added to it. If PostHog can't be reached, **Copy report** puts
+the whole report on the clipboard.
+
+The maintainer reads reports on the Frame Control dashboard in PostHog, or
+with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
+API key as `frame_compat_db.py sync`.
+
+## Turning it all off
+
+Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
+the environment that starts Frame Control. A copy run from a source checkout
+never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
+
+## Update checks
+
+The desktop app asks GitHub for the latest release shortly after starting,
+then every 6 hours: the latest release's `update.json` on GitHub, or
+`api.github.com/repos/saphid/frame-control/releases/latest` if that fails.
+Those requests carry no id. To stop it, set
+`FRAME_CONTROL_NO_UPDATE_CHECK=1`. See [releasing.md](releasing.md).
diff --git a/docs/releasing.md b/docs/releasing.md
new file mode 100644
index 0000000..7db5e3a
--- /dev/null
+++ b/docs/releasing.md
@@ -0,0 +1,59 @@
+# Releasing and updates
+
+Frame Control checks for updates itself. The desktop app offers a new version
+only once it's GitHub's **latest release**, and drafts and pre-releases never
+count. So a build reaches people only when you publish it, after testing it.
+
+## Steps
+
+1. Bump `version` in `app/package.json`, commit, and push a tag:
+
+ ```sh
+ git tag v0.4.0 && git push origin v0.4.0
+ ```
+
+ `.github/workflows/release.yml` builds macOS, Windows and Linux, and
+ attaches everything to a **draft** release for that tag. Nobody is
+ offered a draft.
+
+2. Download the draft's installers and test them. An installed copy of the
+ previous version won't offer the draft, so install it directly.
+
+3. Write the release notes on the draft. The update banner links to them.
+
+4. Publish:
+
+ ```sh
+ scripts/publish-release.sh v0.4.0
+ ```
+
+ The script checks that all eight installers are attached, each with the
+ SHA-256 digest GitHub records. It attaches `update.json` (the version, the
+ notes and each installer's digest), then publishes the release and marks it
+ latest. From then on, running copies see the update. They check about 8
+ seconds after starting, then every 6 hours, and anyone can use **Check for
+ Updates…** (the app menu on macOS, the Help menu elsewhere).
+
+To pull a bad release, mark the previous one as latest
+(`gh release edit v0.3.9 --latest`) or turn the bad one back into a draft.
+Copies that already updated stay on it. Nothing downgrades them.
+
+## How a copy updates itself
+
+`app/updater.js` reads `update.json` from
+`github.com/saphid/frame-control/releases/latest/download/`. It falls back to the
+REST API only when a release has no manifest, because the API allows just 60
+unauthenticated requests an hour per IP address, shared by a whole household.
+Then it downloads the installer for its platform and checks it
+against the SHA-256 digest GitHub publishes for the asset. It refuses if the
+digest is missing or doesn't match. Then:
+
+| Installed from | Update |
+|---|---|
+| macOS `.dmg`, app in a writable folder such as Applications | The `.zip` is unpacked next to the app and its version checked. After the app quits, a small script swaps the new app in, putting the old one back if that fails, and reopens it. Updates don't get the download quarantine, so there's no `xattr` step. |
+| Windows installer | The new `Setup` runs silently over the install (`/S --force-run`) and reopens the app. |
+| Linux AppImage | The new AppImage replaces the old file and is started. |
+| macOS app still on the disk image or translocated, Windows `.zip`, Linux `.deb` | The banner opens the release page instead. |
+
+Version 0.3.1 and earlier have no updater, so people on them have to download
+the new version once by hand.
diff --git a/docs/scripts.md b/docs/scripts.md
index ad11ebd..d9333ed 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -99,7 +99,7 @@ controls to place each panel. See [docs/panels.md](panels.md).
| `scripts/frame-ui.sh` | Mac | Start the Frame Control web UI (`ui/server.py`) and open it (**verified**) |
| `scripts/apk-catalog.sh` | Mac | Refresh the rated F-Droid catalogue that Frame Control's Android section shows (**verified**) |
| `scripts/compat-db-backup.sh` | Mac | Maintainer-only: back up the shared compatibility database locally and to Google Drive (**verified**) |
-| `scripts/push-vr-video.sh` | Mac → Frame | Upload VR180/360 videos to `~/Videos/VR`, linked into DeoVR's Proton prefix; `--launch` starts DeoVR (**verified**: upload and link; in-headset playback of local files not yet checked). See [docs/vr-video.md](vr-video.md) |
+| `scripts/push-vr-video.sh` | Computer → Frame | Upload movies, stereo PNG/JPEG or small `.splat` files to `~/Videos/FrameControl`; `--launch` starts our OpenVR player, `--theatre` adds a larger screen and dark surround. No separate viewer. **Verified remotely**: decode, stereo output and cleanup; worn-headset checks remain. See [docs/vr-video.md](vr-video.md) |
| `scripts/push.sh` | Mac → Frame | `rsync` files to `~/Downloads` (or a given path) on the Frame (**verified**) |
| `scripts/serve-bootstrap.sh` | Mac | Fallback: serve `bootstrap-on-frame.sh` with your public key embedded |
| `scripts/bootstrap-on-frame.sh` | Frame | Fallback: install the key and enable `sshd` |
diff --git a/docs/sidequest.md b/docs/sidequest.md
new file mode 100644
index 0000000..eba0f02
--- /dev/null
+++ b/docs/sidequest.md
@@ -0,0 +1,142 @@
+# SideQuest and Frame Control
+
+Researched 2026-09-28. SideQuest is both a Quest discovery website and a desktop
+sideloading/device-management app. Its Quest labels are **not** evidence that a
+game works on Lepton: inspect the APK for arm64/OpenXR, Android API requirements,
+VrApi and Meta services (see [VR APKs](vr-apks.md)).
+
+## Features worth borrowing
+
+Desktop evidence is the public [SideQuest source at af2ac70](https://github.com/SideQuestVR/SideQuest/tree/af2ac7043db122bca3c8db18f2b58f1660e9befb),
+especially [ADB operations](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/adb-client.service.ts),
+[drag and drop](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/drag-and-drop.service.ts),
+and the [legacy repository index](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/desktop-app/src/app/packages/package.service.ts).
+Website evidence: [SideQuest](https://sidequestvr.com/) and its public Angular
+bundle `main-4MMXZRXL.js`, inspected locally without browser automation.
+No SideQuest implementation code was copied.
+
+| SideQuest feature | Frame Control before this change | Borrow? / effort |
+|---|---|---|
+| Store descriptions, screenshots, banners, trailers, ratings | F-Droid names, icons, compatibility verdicts and reports; no equivalent rich VR store | Yes, from authorised sources; medium. Search and library workers own presentation/artwork. |
+| OBB expansion-file install | APK-only install | **Implemented helper and CLI**, medium. Essential for games whose assets are separate from the APK. |
+| App-data backup/restore | Persistent instances and optional keep-data uninstall, no portable save archive | **Implemented private-data helper and CLI**, medium. Back up before updates or experiments. |
+| File manager (list, upload, download, remove) | General Send to Frame, no Android file browser | Useful later, medium; requires clear instance selection and scoped paths. |
+| Installed-app management (launch, uninstall, backup) | List, launch, stop, remove, probe | Already mostly covered. Backup added here. |
+| Update notices / account library | Compatible-version lookup; no source-aware installed update notices | Useful later, medium; needs original version code and source identity recorded on install. |
+| Custom repositories | Built-in F-Droid catalogue and compatible-version indexes | Separate user-repos worker. Legacy SideQuest source has a fixed SideQuestRepos index; arbitrary current custom-repo support was not verified. |
+| Drag-and-drop APK/OBB install | APK drag-and-drop already works | OBB backend added here; future UI can call it. UI drop wiring is not included. |
+| Tags, price, headset filters, reviews | Text search and Lepton verdicts, not Quest headset metadata | Useful, medium; search worker owns filters. Keep source headset claims distinct from tested Frame compatibility. |
+| Screenshot/video capture and streaming | Frame screenshots/VR capture already present | Reuse existing tools; do not port Quest capture commands. |
+| Device settings and ADB utilities | Frame/Android display settings, SSH and own-instance tools | Borrow selectively; Quest CPU/GPU presets and wireless-ADB setup do not map directly to Lepton. |
+
+Priority: expansion files, then save backup/restore. Rich discovery and update
+notices follow once a permitted metadata source and source/version persistence
+are available. This patch deliberately exposes CLI/backend operations, leaving
+shared UI, install(), Steam artwork and launch behavior to sibling work.
+
+## SideQuest as a source: page-only
+
+[Terms](https://sidequestvr.com/terms), “Prohibited Activities”, (i) prohibits
+copying/distributing/disclosing the Service including automated or non-automated
+“scraping”; (xi) prohibits content access through means other than those provided
+or authorised by the Service; (xii) prohibits bypassing access restrictions.
+The terms describe downloading developer-posted games through the Service, but
+do not establish permission for this third-party API integration.
+
+[robots.txt](https://sidequestvr.com/robots.txt) requests a three-second crawl
+delay and disallows `/search/`, `/user/*` and `/sideload/*`. Robots permission
+would not override the terms. The API host's robots request returned HTTP 403;
+a request for the first shared website JS chunk also returned 403. No bypass,
+account token, cookies, browser session or private endpoint was used.
+
+The homepage publishes `https://api.sidequestvr.com` and
+`https://cdn.sidequestvr.com`. The website bundle calls `searchApps(...)` and
+`getApp(id, null)`; their actual HTTP search/detail routes could not be established
+from the retrieved bundle. Do not invent endpoints. The open-source desktop
+[install flow](https://github.com/SideQuestVR/SideQuest/blob/af2ac7043db122bca3c8db18f2b58f1660e9befb/electron/app.ts)
+POSTs `{token: ...}` to `/install-from-key`. It consumes
+`data.apps[].urls[]`, with `provider` values including `APK`, `OBB`,
+`Github Release` and `Mod`, and `link_url`. This is a website-issued install-key
+flow, not evidence of an anonymous download API. It is not implemented here.
+
+`ui/apk_sources/sidequest.py` implements the shared interface conservatively:
+
+- `sources()` marks SideQuest `page_only` and explains why.
+- `search()` raises a user-readable `SourceError` with the browse URL (zero
+ limit returns no rows). It does not invent app results or report a false
+ “no matching games”. The aggregate search UI should surface this source error.
+- `details()` accepts a numeric listing id and returns its canonical page link,
+ `downloadable: False`, empty versions/tags/headsets and the `images` shape
+ `{icon: None, banner: None, screenshots: []}`. Name is explicitly a listing id;
+ unknown facts, including free/VR status, stay `None`.
+- `download()` refuses with that page link. Paid/external listings cannot be
+ downloaded by this adapter either. No downloads means no verification claim.
+
+The JSON fixture records policy evidence, **not a purported live app response**.
+No listing metadata, artwork URLs, or OBB download URLs were scraped.
+The requested real SideQuest → OpenXR APK → `frame_android.py info` test is
+**blocked by the terms**, and was not performed. No alternate source is silently
+substituted. A future integration needs SideQuest's permission or an expressly
+supported third-party API, plus recorded search/detail/download fixtures,
+free/direct-download classification, and size/hash verification. A calculated
+local SHA-256 alone must not be called publisher verification.
+
+## OBB files
+
+```sh
+python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb
+python3 ui/frame_android.py install-obb org.example.game main.42.org.example.game.obb patch.42.org.example.game.obb
+```
+
+Install the APK first. The named instance must already be running; the helper
+never launches an app or uses Lepton Development. It requires standard
+`main|patch...obb` filenames and nonempty files, validates
+the entire batch before transfer, streams each file through SSH into that
+instance, checks its SHA-256 **inside Android**, then renames it into
+`/sdcard/Android/obb//`. `verified: True` here means transfer integrity
+against the local input, not publisher authentication. Publication is atomic per
+file, not for the whole batch; retry after a partial batch failure. Existing OBBs
+with different version codes remain. The filename version must match the game;
+the current install metadata does not expose its version code for comparison.
+Restart the game yourself after the transfer if it cached missing expansion data.
+
+Both read-only SSH attempts to the Frame timed out. Therefore the exact
+host-side `/sdcard` mapping and persistence of expansion data were **not verified**.
+`compatdata//internal/` is documented as `/data/data/`;
+it must not be mistaken for `/sdcard`. Using Android's path avoids guessing a
+host layout, but device verification across restart/update is still required.
+No OBB file was installed on the Frame during this work.
+
+## Private app-data backups
+
+```sh
+python3 ui/frame_android.py stop org.example.game
+python3 ui/frame_android.py backup-data org.example.game ./game-save.tar.gz
+python3 ui/frame_android.py restore-data org.example.game ./game-save.tar.gz
+```
+
+Keep the instance stopped throughout either operation; do not launch it from
+Steam concurrently. The remote guard fails if Podman cannot enumerate containers
+or reports that instance running. The helpers use `podman unshare` to read/write
+Android's mapped ownership without changing the live data's permissions.
+
+The archive covers **only** `compatdata//internal/`, not the
+APK, external `/sdcard/Android/data`, OBBs, keystore, or the full Android snapshot.
+It contains a package/instance manifest and regular files/directories. Backups
+are private (0600), validated before publication, and never overwrite an existing
+backup. Keep them safe: app data can contain credentials and is not encrypted.
+
+Restore checks the package and instance, rejects absolute/traversing/duplicate
+paths, links and devices, caps files at 100,000 and content at 20 GiB, and validates
+again on the Frame. It extracts into a separate directory, preserves numeric
+ownership, ordinary modes and timestamps, then swaps the private-data directory.
+Setuid/setgid bits are not restored. The previous directory remains beside it as
+`..before-restore-`; the returned `previous` path identifies
+it. This is an additional recovery copy, not an automatic deletion policy.
+
+Locally verified: archive round trip including recovery copy, malformed archive
+rejection, transfer command construction and failure handling. Not verified:
+real Frame UID mappings/permissions, Android app-level recovery, live FUSE OBB
+writes or persistence. Backups reject symlinks/special files; an app requiring
+those needs a separately designed backup format. These CLI features still need
+a real-device acceptance pass before being exposed as a polished UI workflow.
diff --git a/docs/steam-games.md b/docs/steam-games.md
index 1e48ee1..5548925 100644
--- a/docs/steam-games.md
+++ b/docs/steam-games.md
@@ -110,3 +110,11 @@ returns nothing without `cc`, so Frame Control takes the country from
- Installing when there's more than one library folder, such as a microSD card.
- Uninstalling. `steam://uninstall/` should open a confirmation in the
headset.
+
+## Optional VR software
+
+The [VR utilities list](vr-utilities.md#optional-software) is separate from our
+controls and HUD. It checks software ownership as well as games; paid utilities
+are installable only when already in the loaded Frame account library. No
+purchase flow is added. Public reports, local results and ownership are shown
+separately, and untested tools remain untested.
diff --git a/docs/streaming.md b/docs/streaming.md
index 707544e..773e698 100644
--- a/docs/streaming.md
+++ b/docs/streaming.md
@@ -5,7 +5,10 @@ This covers three directions, plus input:
- **A. Frame → Mac**: see and control the headset from the Mac.
- **B. Mac → Frame**: use the Mac's desktop inside the headset.
- **C. iPhone → Frame**: mirror the phone inside the headset.
+- **PC VR from Linux**: [feasibility and options](linux-vr-streaming.md),
+ including Valve's streaming and USB support. No Linux host tested yet.
- **Input**: type and point in the Frame from the Mac or iPhone.
+- **Live view and Control**: watch a panel flat and tap on it to use it.
The confidence labels are the same as in [ssh.md](ssh.md).
@@ -24,24 +27,29 @@ Mac with keyboard, mouse, and clipboard.
## B. Show the Mac's desktop inside the Frame
-The Frame's streaming features are built around a **Windows PC running
-SteamVR** plus the USB Wi-Fi 6E dongle. Even Linux hosts had VR-streaming
-problems at launch
+The Frame's VR streaming uses **SteamVR** on the host. Linux hosts had
+VR-streaming problems at launch
([Steam discussion](https://steamcommunity.com/app/4165890/discussions/0/528765047224280796/),
[gbl08ma](https://gbl08ma.com/posts/steam-frame-a-linux-machine-doesnt-support-linux/)).
+Valve's later 2.17.8 notes explicitly describe Steam Link fixes on Linux and
+initial USB streaming support (**documented**, not tested from a Linux host
+here). See [the current comparison](linux-vr-streaming.md#a-valves-own-path--recommended-first).
**macOS isn't a supported SteamVR host**, so for the Mac we're only looking at
flat 2D desktop streaming into a window on the Frame's Linux desktop.
| Option | Setup | Confidence | Verdict |
|---|---|---|---|
-| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Recommended.** Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
+| **Frame Control → Tools → Mac in the headset** | Nothing to install. Allow Screen Recording and Accessibility for Frame Control, then press **Show** next to any window or screen | **Verified 2026-09-28** on the Frame (panel in 1.5 s, measured with `scripts/macview-bench.py`: about 10–20 ms from the Mac drawing a frame to the viewer drawing it); laser input not yet tried while wearing it | **Recommended.** Hardware H.264 over an SSH tunnel, adapting to the link. Each window becomes its own panel you can place anywhere. Laser clicks and scrolls, and the Mac's keyboard types. See [mac-in-headset.md](mac-in-headset.md) |
+| **macOS Screen Sharing (VNC) → Remmina on the Frame** | **Mac:** System Settings → General → Sharing → Screen Sharing on → (i) → enable "VNC viewers may control screen with password". **Frame:** `./scripts/install-apps.sh remmina` from the Mac, then open Remmina in the headset and connect to `vnc://.local` | **Verified 2026-09-27** (Frame BUILD_ID 20260925.6191901, macOS 27.0), in its own panel via `panel-on-frame.sh mac-screen`. Remmina is on Flathub for **aarch64** with VNC and RDP ([Flathub](https://flathub.org/apps/org.remmina.Remmina)). The Frame desktop runs Flatpaks ([UploadVR](https://www.uploadvr.com/flatpaks-open-source-steam-frame/)). macOS VNC is built in. | **Fallback** (whole screens only). Nothing to install on the Mac, and it's easy to set up. Noticeable lag, even at lower Remmina quality settings on a good 5 GHz link, where neither Wi-Fi nor the Frame's CPU was the bottleneck. Usable for reading and coding, but not for games. You'll type the Mac's hostname once in Remmina on the headset, then save the profile. To avoid even that, the script can pre-seed a Remmina profile over SSH (see below). |
| Sunshine (Mac) → Moonlight (Frame Flatpak) | `brew install` Sunshine on the Mac, then `./scripts/install-apps.sh moonlight` | Moonlight Flatpak supports **aarch64** ([Flathub](https://flathub.org/apps/com.moonlight_stream.Moonlight)). **Sunshine on macOS is poorly supported**: install problems on Apple Silicon/Sequoia, and no virtual gamepads ([LizardByte discussion #777](https://github.com/orgs/LizardByte/discussions/777)). | Try it if VNC is too laggy. Expect some friction. |
| Steam Remote Play with the Mac as host | Steam on the Mac, Steam Link/Remote Play on the Frame | macOS-hosted Remote Play is reported broken or flaky in 2024–2026 ([Steam discussion](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)) | Not recommended. It's only for games, if it works at all. |
| Immersed / Virtual Desktop | Vendor apps | Immersed has a Mac agent but no known Frame client. Virtual Desktop's developer said he'd "try" to port it ([NewsBreak](https://www.newsbreak.com/news/4892834783961-virtual-desktop-dev-says-he-ll-try-to-bring-the-app-to-steam-frame)). | Not available as of 2026-09-25. Check again later. |
| WiVRn / ALVR | VR streaming from a Linux or Windows PC | Irrelevant for a Mac host (no SteamVR/OpenXR runtime on macOS) | N/A |
-For **VR video files** (180°/360° stereo), don't stream the Mac's screen. Play
-them on the Frame in DeoVR instead: see [vr-video.md](vr-video.md).
+For **local movies and stereo photos**, Frame Control's own OpenVR player
+runs on the Frame; see [vr-video.md](vr-video.md). It currently renders a flat
+stereo screen. VR180/360 projection is not implemented; the same page records
+DeoVR only as an optional, independently installed alternative.
### Pre-seeding the Remmina profile (no typing in the headset)
@@ -80,6 +88,21 @@ its header. (Verified 2026-09-27.)
Going the other way, pointing a controller at the panel moves the Mac's mouse,
because Remmina forwards input (`viewonly=0`).
+## First-party options, and why they do or don't fit
+
+Checked 2026-09-28. The first-party way is usually the best one, so these are
+listed first; the sections above and below explain the alternatives.
+
+| Goal | First-party option | Fits? | Why, and what would make it easier |
+|---|---|---|---|
+| Type and point from the **iPhone** | **KDE Connect** (KDE; official [iOS app](https://apps.apple.com/app/kde-connect/id1580245991)) remote touchpad and keyboard, plus clipboard and files | **Best candidate, untested on the Frame** | The Frame doesn't have it (verified: no `kdeconnectd`), it isn't on Flathub, and the root is read-only, so it would have to run from `~` or a container. On Wayland it types through KWin, so it can only reach the desktop panel, not SteamVR or games (**inferred**). Steam Deck users report its remote input breaking after SteamOS updates ([SteamOS #1939](https://github.com/ValveSoftware/SteamOS/issues/1939)). If it works, Frame Control could install it and pair it for you. |
+| Type and point from the **Mac** | KDE Connect for macOS (KDE builds) | Same as above | Its Mac app sends clipboard and files but has no keyboard/mouse sharing (**inferred**). |
+| Either | **Bluetooth keyboard and mouse** paired in SteamOS (Valve) | Yes, with real hardware | Neither device can pretend to be one: iOS refuses the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)), and macOS has no built-in way. |
+| Either | **xrdp** in Developer Mode (Valve) | No | Input goes into a *separate* desktop shown on the Mac, not into what you see in the headset. |
+| **Mac screen** in the Frame | **Screen Sharing** (Apple's VNC server) + Remmina (already installed on this Frame, profile pre-seeded by `install-apps.sh`) | **Yes, closest to first-party** | Only the Mac side is first-party; Remmina is the client. Turn on System Settings → General → Sharing → Screen Sharing → (i) → "VNC viewers may control screen with password". Still to test in the headset (open question 11). |
+| Mac screen | **Steam Remote Play** with the Mac as host (Valve) | Probably not | macOS isn't a SteamVR host, and Mac-hosted Remote Play is reported broken ([Steam forum](https://steamcommunity.com/groups/homestream/discussions/1/574921459914429988/)). One quick test is worth doing: Steam on the Mac, then Remote Play from the Frame's Steam. |
+| Mac or **iPhone screen** | **AirPlay** (Apple) | Not officially | It's Apple's own mirroring for both, but Apple only licenses receivers to TV and speaker makers; nothing official runs on Linux. UxPlay (below) is the unofficial receiver. |
+
## C. Show the iPhone's screen inside the Frame
iOS only shares its screen two ways: **AirPlay** (Screen Mirroring in Control
@@ -93,18 +116,149 @@ capture it.
## Input: type and point in the Frame from the Mac or iPhone
-**Verified 2026-09-27** on the headset: `steamos` is in the `input` group and
-`/dev/uinput` is `crw-rw-r-- root input`, so **our own code can create a
-virtual keyboard and mouse without sudo**. The Frame has no `python-evdev`,
-`ydotool`, `wtype` or KDE Connect; `kwin_wayland` and `plasmashell` run only
-while the desktop panel is open in the headset.
+**Built: Home → Keyboard and trackpad**, in every version of Frame Control
+(Mac, Windows, Linux, iPhone and iPad), with nothing to install on the device
+you're holding. On a phone the panel is a trackpad (drag to move, tap to click,
+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).
-| Option | Mac | iPhone | Notes |
+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.
+
+**Verified 2026-09-28** (SteamOS 0.4.1, build 20260925.6191901):
+
+- KDE Connect isn't installed on the Frame, so **Frame Control ships it**:
+ Valve's own build for the Frame (`kdeconnect` 24.02.2-1 from its `extra`
+ repository) plus the five libraries it links that the Frame lacks
+ (`kcontacts`, `kpeople`, `modemmanager-qt`, `pulseaudio-qt`, `libfakekey`),
+ pinned by SHA-256 in [`frame/kdeconnect/packages.json`](../frame/kdeconnect/packages.json).
+ The builds download them from the
+ [kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1)
+ (`app/build/fetch-deps.js`, `frame/kdeconnect/fetch.py`). The address of
+ Valve's repository for the Frame isn't to be shared, and Valve's public
+ aarch64 preview repository has KDE Connect 25.08, built against newer KDE
+ libraries than the Frame has.
+- On first use, the computer copies them to the Frame over the SSH connection
+ it already has. The iPhone app's bundle, already copied to the Frame, has
+ them too. The agent checks each SHA-256 and unpacks them into
+ `~/.local/share/frame-control/kdeconnect` (3.6 MB copied, 18 MB unpacked,
+ about 2 s). There's no internet download on the Frame, no root, and nothing
+ on the read-only system, so SteamOS updates leave it alone. A stamp there
+ (`root/.frame-control-packages`) records which build it is; a newer Frame
+ Control replaces it.
+- `pacman -Sp kdeconnect …` also pulls in ModemManager, libqmi, libmbim,
+ libqrtr-glib and ppp (packaging dependencies). `kdeconnectd` and its plugins
+ don't link any of them (checked with `ldd` against the six packages alone),
+ so Frame Control leaves them out.
+- Licences: the packages are GPL and LGPL; Frame Control stays MIT because it
+ only starts `kdeconnectd` and speaks its protocol. The notice, licence texts
+ and complete source are in [`frame/kdeconnect`](../frame/kdeconnect/NOTICE.md),
+ [`THIRD_PARTY_NOTICES.md`](../THIRD_PARTY_NOTICES.md) and the app's
+ **About and licences** (Tools).
+- It pairs by itself: the agent asks to pair and accepts on KDE Connect's side
+ over D-Bus (`qdbus6 … acceptPairing`). It keeps its identity in
+ `…/kdeconnect/bridge`, so later connections are already paired. (A pair
+ request to a device that's already paired makes KDE Connect unpair it, so
+ the agent only asks when it isn't paired.)
+- Protocol version 7: whoever opens the TCP connection sends its identity line
+ in plain text, then acts as the **TLS server** (KDE Connect's
+ `lanlinkprovider.cpp`). Remote input is `kdeconnect.mousepad.request` with
+ `dx`/`dy`, `singleclick`, `rightclick`, `singlehold`/`singlerelease`,
+ `scroll`, `key` (any text) or `specialKey` (1 Backspace … 14 Escape,
+ 21–32 F1–F12) and modifier flags.
+- **KDE Connect runs only while something uses the keyboard and trackpad.**
+ Each device gets its own KDE Connect identity (KDE Connect keeps one
+ connection per device, so a shared one would make a phone and a computer
+ knock each other off). The last one to disconnect stops KDE Connect, so it
+ isn't left running, or discoverable on your network, afterwards.
+- KDE Connect 24.02 **hangs or crashes when asked to unpair a device that's
+ offline** (seen twice: once spinning at 100% CPU with D-Bus unresponsive,
+ once exiting). Frame Control never unpairs. If its copy stops answering, the
+ agent restarts it once (tested by freezing it with `kill -STOP`).
+- Moves from the iPhone app (Simulator) and the Mac's server moved the Frame's
+ X pointer by exactly the amount sent, including with the bundled packages
+ copied over SSH (2026-09-28).
+- gamescope runs **two Xwayland displays**. `:0` holds Steam's VR bar and menus
+ and ignores injected pointer motion; `:1` holds apps such as Chromium and
+ takes it. KDE Connect runs on `:1`, so it reaches apps, not Steam's own menus.
+ There's also a `gamescope-0-ei` (libei) socket.
+- Typing through KDE Connect lands in a Chromium panel on `:1` (seen in the
+ panel's own capture, 2026-09-29). It **can't reach panels on `:0`** (Frame
+ Control's own panels, Steam's UI) and, since XTest positions are clamped to
+ `:1`'s 1280×720 root, can't reach beyond that in a bigger window. Control on
+ the live view (below) has neither limit.
+- **Not yet tested:** whether it reaches the KDE desktop panel (Plasma is its
+ own session).
+- **Known limit:** keys and clicks typed while the link is reconnecting wait
+ and are sent once it's back, but anything sent in the moment the Wi-Fi
+ drops, before SSH notices, can be lost. Confirming every event would add a
+ round trip to each pointer move.
+
+## Live view and Control: watch a panel and tap on it
+
+**Built: Home → Desktop / Headset view → Control.** The live view has two
+sources:
+
+- **Headset view**: what the lenses show (SteamVR's mirror, `/dev/video99`). It
+ moves with the wearer's head, so Control makes the view a trackpad: drag to
+ move the pointer, tap to click, press and hold to right-click, two fingers to
+ scroll. With a mouse, moving over the view moves the pointer.
+- **Desktop**: the app panel in use in the headset, from its own window, so it
+ stays still however the wearer looks around. Control makes taps and clicks
+ land exactly where you put them. Dragging is a mouse drag, press and hold is a
+ right-click, two fingers scroll, and on a computer the mouse, wheel and
+ keyboard work directly on it (⌘ is sent as Ctrl on a Mac). A picker shows any
+ other panel, view only.
+
+Below the view, a text field and key buttons type on the Frame from a phone.
+
+How (**verified 2026-09-29**, SteamOS 0.4.1, build 20260925.6191901):
+
+- **Input goes through gamescope's own injection.** gamescope serves an EIS
+ socket (`/run/user/1000/gamescope-0-ei`; Steam feeds Remote Play input through
+ it), and `libei` 1.4.1 is on the image. [`ui/frame_touch.py`](../ui/frame_touch.py)
+ talks to it with `ctypes`: nothing to install. gamescope offers one device,
+ "Gamescope Virtual Input", with relative and absolute pointer, buttons,
+ scroll and keyboard (Linux key codes; no text capability, so the text field
+ types printable ASCII on a US layout). Its absolute region is unbounded; the
+ pointer uses the focused panel's display coordinates, and gamescope fits each
+ window to its display, so a 1920×1080 window on the 1280×720 `:1` takes
+ positions at two thirds scale. Taps on a 1280×720 page landed on the exact
+ pixel.
+- **It reaches the panel that has focus** (`GAMESCOPE_FOCUSED_WINDOW` on `:0`'s
+ root), on either X display. In the OpenVR backend focus moves only on SteamVR
+ overlay events (the controller's laser entering or clicking a panel), or to a
+ new panel when none holds it (read from gamescope's `OpenVRBackend.cpp`, seen
+ with `gamescopectl focus_info`, which writes to the journal). Neither
+ `GAMESCOPECTRL_BASELAYER_WINDOW`/`_APPID` nor X focus moves it, and no
+ gamescope command does. So Control follows the wearer: whatever they last
+ used is what your taps reach. A window without a Steam app id (`STEAM_GAME`)
+ gets a connector of its own and doesn't hold focus.
+- **Keys in a burst can arrive out of order**, so the helper paces them (8 ms
+ apart).
+- **Known limit:** if focus moves to another panel in the middle of a drag, the
+ release goes to the panel that has focus then. Whether gamescope hands it to
+ the window that got the press isn't known yet. When the session ends, the
+ helper lets go of every button and key it still holds.
+- **The Desktop picture is the window's own pixels**: `ffmpeg -f x11grab
+ -window_id -i :` works on gamescope's redirected windows,
+ while grabbing the root gives black. It streams as H.264 like the headset view
+ (about 30 fps at 720p).
+- Tested from the iPhone app (Simulator): a tap on the Desktop view focused a
+ text box in the panel and the text field typed into it; a trackpad move went
+ exactly (+40, +25).
+
+Our own `uinput` keyboard and mouse would also work (`steamos` is in the
+`input` group and `/dev/uinput` is group-writable, verified 2026-09-27), and
+remains the fallback if the bundled KDE Connect ever stops working on a new SteamOS.
+
+| Other option | Mac | iPhone | Why not |
|---|---|---|---|
-| **A uinput keyboard and mouse in Frame Control's server** | ✓ | ✓ | **Recommended.** The server opens `/dev/uinput` with `ctypes` (standard library only) and the page sends key and pointer events through the tunnel it already has. On the phone: a trackpad area (drag to move, tap to click, two fingers to scroll) and the iOS keyboard for typing. On the Mac: a "control the Frame" mode that captures the keyboard and pointer (Esc to release). Uinput devices look like real hardware to the kernel, so libinput, KWin and gamescope should take them; [frame-voice](https://github.com/DeeJanuz/frame-voice) already types into a Frame through a uinput keyboard. **Untested**: which surfaces in VR (desktop panel, SteamVR dashboard, games, Android apps in Lepton) accept the pointer. About a day or two of work |
-| **Bluetooth keyboard and mouse** | – | – | Real hardware paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
+| **Bluetooth keyboard and mouse** | – | – | Needs real hardware, paired in SteamOS settings. The iPhone can't pretend to be a Bluetooth keyboard: iOS won't advertise the HID service ([Apple forums](https://developer.apple.com/forums/thread/733916)) |
| **Deskflow** (formerly Input Leap / Barrier) | ✓ | – | Moves the Mac's own mouse and keyboard onto the Frame's screen edge. Flathub has an aarch64 build ([Flathub](https://flathub.org/apps/org.deskflow.deskflow)); on Wayland it needs the InputCapture/libei portal, and only works while Plasma is running. No iPhone client |
-| **KDE Connect** | ~ | ✓ | Its iOS app has a remote touchpad and keyboard, but the Frame would need KDE Connect installed (not on Flathub; `pacman` on a read-only root). More moving parts than the uinput route |
| **Remmina / Steam Link / RDP** | ✓ | – | Input only reaches the streamed session, not the headset's own apps |
Other ways to get text in:
diff --git a/docs/testing.md b/docs/testing.md
index b06577d..82a1826 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -104,6 +104,20 @@ switch while SSH is down:
## Headset smoke test
+**Documented shared-device procedure:** before a test installs, launches or
+stops an application, acquire `ssh frame 'mkdir /tmp/frame-test.lock'`. If it
+fails, leave that lock alone and continue offline work. Only the thread that
+acquired it releases it with `ssh frame 'rmdir /tmp/frame-test.lock'`, after
+cleanup. Keep each device session to a few minutes.
+
+Check battery capacity and charging state under `/sys/class/power_supply`
+before and after; keep capacity above 20%. Stop only processes started by the
+test, remove temporary installs and profiles, and restore the prior dashboard
+state. Leave Steam and SteamVR running. Do not reboot or change global settings.
+Record the build, actual interaction results, cleanup and any unworn-headset
+limits alongside screenshots or logs. These are caller responsibilities; the
+smoke script below does not acquire this shared lock itself.
+
```sh
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
@@ -148,3 +162,51 @@ For example, on 2026-09-27 the smoke test found that Steam's `create-shortcut`
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.
+
+## Owned media player
+
+`tests/test_media.py` covers layout evidence and overrides, OU eye ordering,
+hardware-decoder command construction, malformed splats, stereo parallax and
+fake-Frame library/process ownership. `tests/e2e/test_media_transfer.py` checks the real
+HTTP/SSH upload and library listing without pretending the fake renders VR.
+Real decode timings and captured stereo output are recorded in
+[vr-video.md](vr-video.md). Generated media only; no external player required.
+
+**Verified 2026-09-28**, real Frame BUILD_ID 20260925.6191901: `~/.local`
+and `~/.local/share` are `steamos:steamos`, mode 0755. The fake supervisor
+sets those parent owners too; previously its root-created Steam manifests
+left the parents root-owned and incorrectly prevented user runtime installs.
+
+## Agent interfaces
+
+`tests/test_agent.py` exercises MCP stdio, exact-action human approvals and the
+assistant against an in-process HTTP endpoint with canned responses (no keys or
+external calls). `tests/e2e/test_agents.py` runs the MCP/HTTP/SSH path against the
+fake Frame for approved installs, clipboard and file transfer. Headset Chromium
+rendering and real screenshots still need a device; see [agent evidence](agents.md#evidence-and-limits).
+
+## Family and comfort
+
+`tests/test_comfort.py` uses an injected clock, fake headset sensor readings and
+actions, plus a Node fake of Steam's Home API. It covers warnings before Home,
+late/suspended sessions, cancellation, failed actions, duplicate alerts, reboot
+invalidation, per-zone thermal trips and shared on-headset state. The server
+guards reject invalid session settings before SSH. See
+[real-device evidence and limits](family-comfort.md#verification).
+
+## Panel switcher
+
+`tests/test_panels.py` supplies fake-Frame `vrcmd --overlays` output, checks
+main-panel filtering (including hidden panels), revalidates closed panels before
+focus, and drives the headset helper's real loopback HTTP server to test access
+keys, Host/Origin guards, malformed requests, offline errors and Close. It runs
+in the normal unit suite without OpenVR or a headset. The fixture format comes
+from SteamVR 2.18.1, BUILD_ID `20260925.6191901`; it does not simulate rendering.
+
+On the Frame, run `python3 -` over SSH with `ui/frame_panels.py` on stdin to
+list panels. `--focus ` rechecks the list and requests focus. In Frame
+Control, **Tools → Panel switcher → Open in headset** exercises installation,
+Chromium rendering and the same helper through HTTP. Close the switcher after
+testing. [The recorded device checks](panels.md#frame-controls-panel-switcher)
+cover actual focus, HTTP guards and an OpenXR sample transition, and separately
+identify the unverified Steam-game, spatial layout, reboot and laser behaviors.
diff --git a/docs/vr-apks.md b/docs/vr-apks.md
new file mode 100644
index 0000000..bce6817
--- /dev/null
+++ b/docs/vr-apks.md
@@ -0,0 +1,274 @@
+# VR APKs and Quest games in Lepton
+
+What it takes to run an immersive (OpenXR) Android app, including Meta Quest
+builds, on the Frame. Checked on SteamOS BUILD_ID 20260925.6191901, Lepton
+v2.8.14 (rootfs v2.8.11), SteamVR 2.18.1, on 2026-09-28, unless marked
+**inferred**.
+
+## How a VR APK reaches SteamVR (verified)
+
+- Lepton ships a standard Khronos system runtime manifest,
+ `/vendor/etc/openxr/1/active_runtime.json`, pointing at SteamVR's Android
+ client, `/data/steamvr/runtime/bin/androidarm64/vrclient.so`. That is the
+ host's `/opt/steamvr/bin/androidarm64/`, bind-mounted in.
+- An APK's own Khronos-style `libopenxr_loader.so` tries the runtime brokers
+ (`org.khronos.openxr.runtime_broker`, `…system_runtime_broker`), finds
+ neither, then falls back to that manifest. Nothing in the APK has to change
+ for discovery.
+- Install the APK without the flatscreen marker
+ (`python3 ui/frame_android.py install app.apk --vr`). The marker only
+ controls Lepton's 2D Android surface; the app itself has to start an
+ OpenXR session.
+- Lepton also loads Valve's `XR_APILAYER_VALVE_fdm_injection` layer from
+ `/vendor/etc/openxr/1/api_layers/implicit.d/`. Only layers in Valve's own
+ directories are picked up (`liblepton/vulkan_layers.sh`), so a third-party
+ layer has to ship inside the APK.
+
+**Open Brush 2.32.29, the Quest APK from its GitHub release (Unity OpenXR,
+Vulkan), works unmodified.** Its manifest already has `LAUNCHER` next to
+`com.oculus.intent.category.VR`. Unity asked for OpenXR 1.1, got
+`XR_ERROR_API_VERSION_UNSUPPORTED`, retried with 1.0 and succeeded. SteamVR
+took it as the scene app, created Touch, simple-controller and Frame-controller
+bindings, and the session reached `XR_SESSION_STATE_FOCUSED`. A headset capture
+(`ui/frame_vrshot.py`, after waking the compositor and closing the dashboard)
+showed a dark sky over a mountain horizon; nobody wore the headset to confirm
+it was Open Brush's scene or to try drawing.
+
+**Khronos `hello_xr` (Vulkan, 1.1.63 release APK) works unmodified:**
+`Instance RuntimeName=SteamVR/OpenXR RuntimeVersion=2.18.1`, 1728×1728
+swapchains per eye, session `IDLE → READY → SYNCHRONIZED` (the headset was
+not being worn, so it did not reach `FOCUSED`).
+
+## What SteamVR's Android runtime supports (verified, from `vrclient.so`)
+
+- **OpenXR 1.0 only.** An app requesting `XR_API_VERSION_1_0` works; one
+ requesting 1.1 (`XR_CURRENT_API_VERSION` in a 1.1 SDK) gets
+ `XR_ERROR_API_VERSION_UNSUPPORTED` from the runtime.
+- Extensions include `XR_KHR_opengl_es_enable`, `XR_KHR_vulkan_enable{,2}`,
+ `XR_KHR_composition_layer_depth`, `XR_KHR_locate_spaces`,
+ `XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`,
+ `XR_EXT_hand_tracking`, `XR_EXT_eye_gaze_interaction`, and these Meta ones:
+ `XR_FB_display_refresh_rate`, `XR_FB_foveation{,_configuration,_vulkan}`,
+ `XR_FB_space_warp`, `XR_FB_swapchain_update_state`,
+ `XR_META_foveation_eye_tracked`, `XR_META_recommended_layer_resolution`,
+ `XR_META_vulkan_swapchain_create_info`, `XR_META_performance_metrics`.
+- Not present: `XR_FB_passthrough`, `XR_FB_hand_tracking_*`,
+ `XR_FB_spatial_entity*`, `XR_FB_color_space`,
+ `XR_KHR_android_thread_settings`, `XR_OCULUS_*`.
+- Interaction profiles include `oculus/touch_controller`, `khr/simple_controller`,
+ `valve/frame_controller` and the usual PC controllers. Valve documents Touch
+ bindings as a working fallback on the Frame controllers.
+
+## What stops a Quest APK (verified with Wolvic 1.9, `oculusvr` build)
+
+1. **Lepton won't start it.** Lepton's `apk-info-extractor` only accepts an
+ activity whose intent filter has `android.intent.action.MAIN` and
+ `android.intent.category.LAUNCHER`. Quest apps use
+ `com.oculus.intent.category.VR` instead, so Lepton logs `APP_ACTIVITY is
+ empty` and exits. There is no override. **Fix:** add the `LAUNCHER`
+ category to that intent filter and re-sign. After that, Wolvic started.
+2. **OpenXR 1.1.** Wolvic's Quest build then requested OpenXR 1.1 and aborted
+ on `XR_ERROR_API_VERSION_UNSUPPORTED`. Unity's OpenXR plugin retries with
+ 1.0 (Open Brush, above), so this mostly bites native and non-Unity apps. **Fix (inferred):** an API layer
+ inside the APK that asks the runtime for 1.0 and maps the 1.1 core
+ functions to the extensions the runtime does have (`XR_KHR_locate_spaces`,
+ `XR_EXT_local_floor`, `XR_EXT_uuid`, `XR_EXT_palm_pose`).
+3. **Lepton's missing clipboard service** still applies to VR apps. The Godot
+ XR Tools demo's Quest build (itch.io) dies in `Godot.` casting the
+ null clipboard service to `ClipboardManager`, before any OpenXR call. See
+ the clipboard table in [apks.md](apks.md).
+4. **Not yet reached:** required Meta-only extensions (each app differs),
+ swapchain formats (the Lynx Wolvic build needed `GL_SRGB8_ALPHA8`), and
+ Meta platform services.
+
+The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
+Khronos-style loader and found SteamVR through `/vendor`.
+
+## In the Steam library
+
+Every successful APK install goes through the same mandatory artwork writer:
+CLI (including `scripts/install-apk.sh`), upload, catalogue, version finder,
+web download and source modules calling `frame_android.install`. Native
+Linux/Windows sideloads also use it, preserving their devkit runtime wiring.
+A new shortcut is rolled back if artwork fails; failure is never reported as
+an installed app with a blank tile.
+
+Artwork preference is **SteamGridDB → source images → generated fallback**.
+Set the optional free key in Frame Control's **Library artwork settings**, or
+`STEAMGRIDDB_API_KEY` (`FRAME_STEAMGRIDDB_API_KEY` also works). Environment
+settings override the saved key. Without a key there are no provider calls or
+warnings. Saved keys stay in host app data, mode 0600 on POSIX, and are never
+returned by the settings API or copied to the headset. Exact title matches
+(including a trailing “VR” variant) use the highest-scored returned static,
+non-NSFW image in each slot. Provider failures use the next source.
+
+Sources pass `install(apk_path, artwork={...})`: keys are `grid`, `wide`,
+`hero`, `logo`, `icon`, `banner`, `feature_graphic`, `screenshot`, or a list
+`screenshots`. Values are PNG/JPEG bytes or HTTP(S) URLs (12 MiB and
+4096×4096 pixels maximum; any PNG depth or interlace, since the Frame's
+Chromium decodes them). URLs must resolve to public addresses, follow at most
+three redirects and share one deadline per install. Any source that fails,
+for any reason, becomes a warning and generated art. Banners and feature graphics supply hero/wide art;
+screenshots are the next fallback. Source images are cached for refresh.
+All images are fitted to 600×900 portrait, 920×430 wide, 3840×1240 hero,
+1280×480 logo and 256×256 icon. Explicit logos retain transparency.
+Photo-based portrait, wide and hero slots are JPEG: Steam takes at most
+12 MiB per slot, and on the Frame (2026-09-28) a noise-heavy 3840×1240 hero
+came to more than 12 MiB as PNG, 3.7 MB as JPEG (2.7 s to render); a
+landscape photo hero 5.6 MB as PNG, 0.76 MB as JPEG (0.75 s). A render that
+still fails is retried once with generated art. Steam keeps a slot's `.png`
+and `.jpg` side by side, so each slot is cleared before it is set.
+
+Generated art uses the APK icon, a dominant-colour gradient, a blurred
+backdrop and large foreground icon with shadow. Steam's Chromium canvas and
+Motiva Sans render real text consistently regardless of the host OS; no
+Pillow, host font installation or bitmap font is needed. The hero has no
+title; the generated logo is a transparent title. APKs with no usable icon
+get a typographic monogram. The desktop package includes the renderer.
+
+Backfill installed Android apps without reinstalling or stopping them:
+
+```sh
+python3 ui/frame_android.py refresh-art org.godotengine.open_saber_plus
+python3 ui/frame_android.py refresh-art --all
+```
+
+Devkit titles installed by Frame Control have the same command,
+`python3 ui/frame_titles.py refresh-art ID|--all`. The settings panel's
+refresh covers both. The API is `POST /api/android` with
+`{"action":"refresh-art","all":true}` (apps and titles) or a `package`, and
+`POST /api/titles` with `{"action":"refresh-art","id":…}`; each returns a
+background job. Batch results retain per-item errors, and the CLIs exit
+nonzero if any failed. Apps and titles without complete artwork show **Add
+artwork** and `list` prints the command. Only entries marked `art_pending` at
+install (a title Steam registered after an install made while it wasn't
+running) are backfilled automatically, when Frame Control lists them with
+Steam running (at most every five minutes), and that backfill only fills
+slots Steam has no art for: names, icons, flags and any art the user set are
+kept. Older installs without the flag are refreshed only on request.
+
+Steam's app overviews carry no `devkit_gameid` (checked 2026-09-28, build
+20260925.6191901, on every non-Steam shortcut). A title's shortcut is found by
+its saved id, or by an executable or start folder inside
+`~/devkit-game//`, read from `appDetailsStore`; never by display name.
+That the devkit shortcut's exe/start folder sit inside the title folder is
+inferred from `docs/sideloading.md` (`proton waitforexitandrun
+"/home/steamos/devkit-game//"`), not yet seen in app details.
+Devkit titles keep the VR flag Steam gave them.
+
+**Verified on build 20260925.6191901, SteamVR 2.18.1 (2026-09-28):** both
+Open Saber Plus and SuperTux were backfilled. Steam's cached portrait, wide,
+hero and logo PNGs have the dimensions above; each shortcut points at its
+256×256 icon. This Frame client mishandles custom-art type 4 (documented as
+Icon), overwriting the wide capsule; the implementation uses custom types
+0–3 and **SetShortcutIcon** separately.
+
+Steam accepts display name, executable/start directory, icon, VR flag and
+sort-as name. Android apps join **Android**, immersive apps also **Android
+VR**; native sideloads join **Sideloaded**. Existing collection members and
+unrelated collections are preserved (both games retained **Played**).
+Dynamic/read-only collection conflicts produce warnings. The native notes
+API supports a managed **Installation details** note (package, version and
+source) while preserving other notes. Notes are keyed by sanitized shortcut
+name, so Steam itself cannot distinguish equal-name shortcut notes. No
+supported shortcut description/store-page, developer/publisher, release
+metadata or custom achievement API was found; these are not fabricated.
+
+The launcher supervises Lepton and handles TERM/INT/HUP and normal exit by
+stopping its own container and child process group. A lock refuses duplicate launches;
+a container still running while the lock is free was orphaned by a killed
+launcher and is stopped before the new launch. Lepton doesn't inherit the
+lock. Orphan recovery only stops the app's own, deterministically named
+container; a Lepton host process whose launcher was killed before it created
+the container may linger briefly. Removing an app or title still deletes its files when Steam isn't
+running; tidying Steam's collections and artwork is best effort. Steam Stop uses `TerminateApp` with the exact
+64-bit game ID string. Frame Control's Stop additionally has a direct-container
+fallback. The stable instance ID and compatdata paths remain unchanged.
+
+Lepton normally forwards the instance `SteamAppId` to Android, causing
+SteamVR to associate the scene with a different, artwork-less app. The
+launcher uses Lepton's supported `LEPTON_ENV_SteamAppId` passthrough to send
+the actual shortcut ID to Android while retaining the stable container ID.
+**Verified:** Open Saber was alive 22 seconds after Steam Play, SteamVR
+identified `steam.app.3346865537`, and its scene appeared in the headset
+capture without the previous blank Resume tile. Steam Stop then removed its
+tracked process and stopped the container. An earlier 32-second session was
+also tracked until Steam Stop. No global standby or dashboard overrides were
+installed; wear detection and other user-opened overlays still apply.
+
+**SuperTux limitation:** Steam launched and tracked it, but SDL crashed during
+activity creation because Lepton lacks `ClipboardManager`. Its container
+cleaned up on exit after about 17 seconds. Consequently sustained SuperTux
+Play/Stop and its VR scene could not be verified. This is an APK/runtime
+compatibility failure, separate from library presentation.
+
+Evidence is under `/tmp/vrlib-evidence/` on the development Mac: final artwork
+preview and three design passes, `steam-cache-final.log`,
+`steam-details-targets.json`, `opensaber-identity-session.log`,
+`opensaber-identity-headset.png`, and `supertux-lepton.log`. The preview is
+rendered artwork, not a Steam UI screenshot; CDP screenshot capture timed
+out. Authenticated SteamGridDB, Windows/Linux packaged builds and the sibling
+source-search endpoint remain unverified (the public install seam is tested).
+
+## Out of scope
+
+- **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.
+- **VrApi-era apps** (`libvrapi.so`, before OpenXR) need an API translator,
+ not a patch.
+
+## Frame Control does this for you
+
+APK uploads and `python3 ui/frame_android.py install app.apk` detect VR
+manifest categories, Samsung's `vr_only` flag and the arm64 OpenXR loader.
+VR apps default to immersive mode without the flatscreen marker. The upload
+selector or CLI `--flat` / `--vr` overrides that choice. Compatibility notes
+identify legacy VrApi, Meta platform SDK and OpenXR libraries.
+
+Lepton only starts an `` whose MAIN intent filter has LAUNCHER; it
+ignores ``, which is where Godot 4 exports put LAUNCHER. When no
+real activity qualifies, Frame Control adds LAUNCHER to the VR activity's MAIN
+filter, or to the activity the launcher alias targets, then repacks and v2-signs
+the APK locally before copying it; `meta.json` records
+`"patched": ["launcher"]`. Unchanged ZIP members retain their compressed
+bytes; stored libraries are aligned to 16 KiB. The RSA signing identity lives
+in Frame Control's per-user app-data directory as `apk-signing-key.json`
+(mode 0600). Keep this key to preserve the signer on subsequent patched
+updates. A re-signed APK cannot update an installation signed by its original
+publisher; Android also treats it as a different signer for signature checks.
+
+VR apps with an arm64 OpenXR loader also get the OpenXR compatibility layer
+([frame/openxr-compat](../frame/openxr-compat/README.md)): an implicit API
+layer in the APK's `assets/openxr/1/api_layers/implicit.d/`, which the app's
+own loader picks up next to Valve's layer. It asks SteamVR for OpenXR 1.0 when
+the app wants 1.1 and enables the extensions that became 1.1 core; maps
+`xrLocateSpaces` to `xrLocateSpacesKHR` and `grip_surface` to `palm_ext`; drops
+1.1 controller profiles SteamVR doesn't know; stubs
+`XR_KHR_android_thread_settings` and `XR_OCULUS_android_session_state_enable`;
+and keeps the current refresh rate when SteamVR refuses a requested one.
+`meta.json` records `"patched": ["openxr-compat"]`. Skip it with
+`install … --no-xr-compat`. Its decisions go to logcat under `FrameXrCompat`.
+
+Verified on the headset (2026-09-28):
+
+- **Wolvic 1.9, Quest build**, installed as downloaded: Frame Control added
+ `LAUNCHER` and the layer. The layer turned OpenXR 1.1.48 into 1.0.63, the
+ instance and session were created, and a 144 Hz refresh request that SteamVR
+ refused was kept at the current rate. The session reached `SYNCHRONIZED`;
+ then Wolvic's Gecko engine crashed (null SIGSEGV on its Gecko thread, the
+ same crash its Lynx build has), which is Wolvic's, not OpenXR's.
+- **Open Brush, Quest build**, with the layer: 1.1.54 → 1.0.63, the thread
+ settings stub in use, `bytedance/pico4_controller` bindings dropped, and the
+ session reached `FOCUSED`, the same as without the layer.
+
+Inspect or prepare an APK without contacting the headset:
+
+```sh
+python3 ui/frame_android.py info app.apk
+python3 ui/frame_android.py patch app.apk patched.apk
+python3 ui/frame_android.py patch app.apk patched.apk --add assets/openxr/1/api_layers/implicit.d/X.json=X.json --add lib/arm64-v8a/libX.so=libX.so
+```
+
+The patch fixes Lepton's launch-category requirement. It does not supply an
+OpenXR 1.1 translation layer, Meta services or a VrApi implementation.
diff --git a/docs/vr-utilities.md b/docs/vr-utilities.md
new file mode 100644
index 0000000..0e73669
--- /dev/null
+++ b/docs/vr-utilities.md
@@ -0,0 +1,139 @@
+# VR comfort and performance
+
+Frame Control owns its HUD and telemetry. They use SteamVR/OpenVR, gamescope,
+Python and xterm already on the Frame, plus the Frame's sensors. No feature
+requires fpsVR, OVR Advanced Settings, XSOverlay or another third-party app.
+The software list is a separate, optional convenience.
+
+This is **part of [#25](https://github.com/saphid/frame-control/issues/25)**,
+not completion of the issue. Playspace controls remain blocked on the device
+checks below. The PR stays draft.
+
+## Our performance HUD
+
+On **Home → VR comfort and performance**, the app shows a timestamped sample
+with each status refresh (30 seconds, or Refresh). **Open HUD in headset**
+starts our text HUD as a gamescope panel, refreshed every two seconds. In the
+SteamVR dashboard, select **Frame Control HUD**, then Float in World or dock
+it to a controller. **Close HUD**, or closing its terminal, ends it. Opening
+it twice reuses the existing process.
+
+| Value | Meaning and source |
+|---|---|
+| Compositor FPS / period | Differences between two `IVRCompositor_029::GetFrameTiming` frame indices and monotonic compositor timestamps, sampled 200 ms apart. Output cadence, not game FPS or a long-term average. |
+| Application FPS | Reciprocal of OpenVR's client frame interval. Unavailable if there is no positive interval; not inferred from refresh rate. |
+| Render GPU time | OpenVR total render GPU milliseconds, not GPU utilisation. |
+| Compositor CPU | OpenVR compositor render CPU milliseconds, not game CPU time. |
+| System CPU | `/proc/stat` busy-time delta across the sample, with guest time counted once and iowait treated as idle. |
+| GPU clock | `3d00000.gpu/cur_freq`, converted from Hz to MHz; frequency is not load. |
+| Hottest sensor / battery | Existing thermal-zone and battery sysfs reads from `frame_status.py`. |
+
+OpenVR uses background application mode, which does not start SteamVR or keep
+it running. This mode also returned live timing in a read-only device probe.
+
+Missing sensors, a stopped or incompatible SteamVR runtime, and non-advancing
+frame indices display **Unavailable**, never invented zero FPS. Failed status
+refreshes clear the HUD card rather than keeping a stale live-looking sample.
+The HUD itself adds CPU/GPU work; it is a diagnostic, not a zero-overhead benchmark.
+
+**Verified 2026-09-28**, SteamOS 0.4.1, build `20260925.6191901`, SteamVR
+2.18.1: the exact OpenVR interface and 192-byte timing layout returned advancing
+frame indices and live GPU/CPU timing. Sensor reads, creation of overlay
+`valve.steam.desktopgame.2000250025`, duplicate-open handling, and closing the
+HUD passed. The temporary probe overlay disappeared after its process closed.
+[Sanitized device sample](evidence/vr-utilities/device.json).
+
+**Unverified:** visual placement while wearing the headset, controller docking,
+and overhead during gameplay. The companion card was checked in the attached
+preview using live Frame data. Creating an overlay does not establish that it
+was visible to the wearer.
+
+The optional HUD copies only `frame_status.py` and `frame_vr.py` into
+`~/.local/share/frame-control/vr/`. It tags only the window whose X11 PID matches
+its own xterm, avoiding other threads' windows. Stop checks both PID and Linux
+process start time before sending SIGTERM. It does not stop Steam or SteamVR,
+edit their settings, install a service, or need sudo.
+
+## Playspace, seated height and recenter: paused
+
+**Verified 2026-09-28:** SteamVR exposes `IVRChaperoneSetup_006` and
+`IVRChaperone_004`. An initial 1 cm seated zero-pose translation committed,
+read back and restored numerically. A later trial, while the shared Frame was
+in use, changed universe IDs after commits and returned transforms that did
+not match the requested write or restore. Journal entries reported
+`CommitWorkingCopy`, `VREvent_ChaperoneUniverseHasChanged` and
+`VREvent_ChaperoneRoomSetupCommitted`. The collision-bound arrays and play-area
+size matched in the saved before/after records, but the origin matrices did not.
+
+Alex confirmed the headset was in use and asked to pause control tests. No
+further playspace writes were made. The exploratory control implementation was
+removed from the shipping API; `recenter`, `adjust` and `restore` are rejected.
+This is an **unresolved feasibility check**, not evidence that OpenVR controls
+cannot work. Concurrent use and the Frame driver's coordinate-system handling
+still need to be separated.
+
+The probe's original and last-read poses remain in the Frame's
+`~/.local/share/frame-control/vr/comfort.json` for investigation. This draft
+neither reads nor applies that baseline. Do not blindly replay it into a room
+that may have changed. No recenter test was reached in the later trial.
+
+Before adding controls, on an idle Frame:
+
+1. Establish current room and tracking state, and inspect the retained probe
+ evidence before considering any restoration.
+2. Prove seated and standing height/move operations in the Frame driver's
+ current coordinates, including delayed readback, coordinate rebasing and
+ recovery. Show that the physical safety boundary stays correct.
+3. Verify recenter independently, and test a full apply/restore cycle plus a
+ concurrent room-change refusal. Add a fake OpenVR test for those contracts.
+4. Check the apparent result in a seated and a standing app before exposing UI.
+
+**Documented:** seated and standing are tracking origins selected by an app;
+changing a seated origin cannot force every game to support seated play.
+
+**Inferred from the installed SteamVR defaults:** there is no generic snap-turn
+or locomotion-vignette setting. `dashboard.verticalOffsetCm_2` and
+`steamvr.panelMaskVignette` affect panels, not the player's height or game
+locomotion. The app gives game-setting hints for snap-turn, teleport movement
+and movement vignette instead of writing these unrelated settings.
+
+## Optional software
+
+**Verified 2026-09-28 (same build):** a read-only query of Steam's loaded
+`appStore.allApps` found none of these five apps on the Frame account. The query
+includes software, which the existing games-only library filter excludes.
+Steam's public app-details API listed only Desktop+ as free. No software was
+purchased, installed or launched during these ownership checks.
+
+| Utility | Local Frame status | Public evidence / optional source |
+|---|---|---|
+| OVR Advanced Settings (1009850) | **Untested**, not owned | Steam edition is paid. Developer's [free source and releases](https://github.com/OpenVR-Advanced-Settings/OpenVR-AdvancedSettings). No verified Frame result in this work. |
+| XSOverlay (1173510) | **Untested**, not owned | Supplied research attributes Proton support with tweaks to [Road to VR](https://www.roadtovr.com/valve-steam-frame-review/). This is a public report, not our verification. |
+| OVR Toolkit (1068820) | **Untested**, not owned | Same [public Frame report](https://www.roadtovr.com/valve-steam-frame-review/); no local verification. |
+| fpsVR (908520) | **Untested**, not owned | [Steam listing](https://store.steampowered.com/app/908520/). No Frame-specific result established in the supplied research. General PC VR reviews do not verify Frame support. |
+| Desktop+ (1494460) | **Untested**, free | [Developer source](https://github.com/elvissteinjr/DesktopPlus). No verified Frame result in this work. |
+
+**Documented (supplied research):** the XSOverlay/OVR Toolkit claims are kept as
+attributed leads. **Verified source check 2026-09-28:** fetching the linked
+review returned HTTP 200 and the expected review title, but neither utility name
+appeared in the fetched HTML or extracted text. The claims could not be
+corroborated from that page; this is not proof of incompatibility. They do not make those apps dependencies or mark them
+locally verified. No paid utility is auto-acquired. A server-side check blocks
+installation through the Steam endpoint if the paid utility is absent from the
+loaded Frame library; an empty/unavailable library fails closed. Desktop+ uses
+the existing free Steam install flow, which may need a license confirmation in
+the headset. None is advertised as known-good without local evidence.
+
+Compatibility reports reuse the existing `compat-db` storage and validation
+with `package: "steam:"`, rather than colliding with Android package IDs.
+The optional list shows the latest `works`, `issues` or `broken` report with its
+date, build and notes, separately from public sources and ownership. With no
+report the status stays **untested**. No shared database schema change or
+production deployment is needed; no reports were published by this work.
+
+## Validation and review
+
+166 unit tests and 8 website tests passed locally. The new fake-Frame cases
+passed in GitHub CI (Docker was unavailable locally). Desktop and phone-width
+preview checks passed. The two independent review attempts did not produce a
+verdict; [commands, real exit statuses and limitations](evidence/vr-utilities/review.md).
diff --git a/docs/vr-video.md b/docs/vr-video.md
index 19c4085..2232915 100644
--- a/docs/vr-video.md
+++ b/docs/vr-video.md
@@ -1,81 +1,176 @@
-# Watching VR video (180°/360°) on the Frame
+# Movies, stereo photos and splats
-The confidence labels are the same as in [ssh.md](ssh.md). Everything here
-was checked on SteamOS 0.3.0, build 20260922.6101926.
+Frame Control has its own Frame-side OpenVR player. It uses SteamOS's ffmpeg
+and V4L2 hardware decoder, Python and SteamVR. **No separate player or viewer
+is required.** Chromium and immersive WebXR are not in this playback path.
-## The short version
+## Use it
-1. Install **DeoVR Video Player** from Steam (free, app 837380) and start it once
- in the headset. That creates its Proton prefix.
-2. On the Mac: `scripts/push-vr-video.sh --launch ~/Movies/beach_180_LR.mp4`
-3. In the headset, open DeoVR's local file browser → **Videos → VR** and
- pick the file.
+In **Tools → Media in the headset**, send a file, choose its layout and press
+**Play**. **Theatre** gives it a larger screen and an 85% black surround.
+**Stop** removes both. Refresh reads the library and the player's state.
+The screen follows your head; it isn't a saved world-space panel.
-## Why DeoVR
-
-The Frame's Chromium can't play VR video in 3D. It has no immersive WebXR
-([how-the-frame-works.md](how-the-frame-works.md)). DeoVR's Windows build
-runs under Proton ARM64 + FEX as a real SteamVR app. **Verified 2026-09-25:**
-it found the Steam Frame headset and controller over OpenVR, and decoded
-7680×3840 and 8192×4096 H.265 VR180 side-by-side streams through AVPro's
-hardware Media Foundation path, mapped onto a 180° dome or fisheye mesh.
-
-Known quirks (verified):
-
-- The first launch takes about 45 s while it compiles shaders.
-- Grid thumbnails stay blank. Unity's own video player, which DeoVR uses for
- previews, fails with `0xc00d36bb` under Proton. Full playback uses AVPro
- and isn't affected.
-- The in-app store and web content are separate from local files. You don't
- need an account to play your own files.
-
-## Getting files onto the headset
-
-`scripts/push-vr-video.sh` copies files with `rsync --partial`, so an
-interrupted upload resumes. They go to `~/Videos/VR` on the Frame (`/home`
-has about 860 GB free). The script also links that folder into DeoVR's prefix
-as `C:\users\steamuser\Videos\VR`. It's reachable at
-`Z:\home\steamos\Videos\VR` as well. **Verified** that the upload and link
-work. **Not yet checked** whether DeoVR's file browser lands there
-(open question 21).
-
-Speed: a test upload over Wi-Fi ran at about 3–5 MB/s (verified 2026-09-25,
-one sample). At that rate an 8K file of several GB takes tens of minutes, so
-start big uploads before you put the headset on.
-
-## Naming files so they play correctly
-
-DeoVR guesses the projection from the file name. Its binary contains the tags
-`_180`, `_360`, `_fisheye`, `_fisheye190`, `_mkx200`, `_vrca220` and `_rf52`
-(verified). For stereo layout, the common DeoVR convention is `_LR`/`_SBS`
-(side by side) and `_TB` (top/bottom) (inferred). If a video looks wrong
-(doubled, warped, or flat), change the projection and stereo mode in DeoVR's
-player menu.
-
-Examples: `trip_180_LR.mp4`, `concert_360_TB.mp4`, `hike_fisheye190_LR.mp4`.
-
-Codecs: H.265 at 8K played (verified, streamed). Local H.264 and H.265
-files haven't been played yet. The test clips below cover that.
-
-## Test clips
-
-The script doesn't include these. To check a setup, make two 20 s clips:
-3840×1920, 180° side by side, with the left eye tinted red and the right eye
-cyan. In the headset each eye should see only its own colour. A single mixed
-colour means the stereo split is wrong.
+The command-line route uses the same player:
```sh
-ffmpeg -f lavfi -i testsrc2=size=1920x1920:rate=30:duration=20 \
- -filter_complex "[0:v]split[a][b];[a]colorchannelmixer=rr=1:gg=0.3:bb=0.3[l];[b]colorchannelmixer=rr=0.3:gg=1:bb=1[r];[l][r]hstack" \
- -c:v libx264 -pix_fmt yuv420p -b:v 20M frame-test_180_LR_h264.mp4
-scripts/push-vr-video.sh frame-test_180_LR_h264.mp4
+scripts/push-vr-video.sh --launch --theatre ~/Movies/film_SBS.mp4
+scripts/push-vr-video.sh --launch --layout ou ~/Pictures/stereo.png
+scripts/push-vr-video.sh --launch capture.splat
+scripts/push-vr-video.sh --list
+scripts/push-vr-video.sh --stop
```
-## Streaming from the Mac instead of copying (untested)
+Uploads live in `~/Videos/FrameControl//` on the Frame. Each upload gets
+its own directory, so sending another file with the same name doesn't replace
+it. The UI's existing upload limit is 8 GiB. Transfers use the app's rsync/scp
+path; resumable large uploads are not implemented here yet. Existing
+`~/Videos/VR` files and Proton prefixes are left alone. The script no longer
+launches DeoVR, links a Proton prefix, or accepts directories.
-DeoVR has a DLNA browser (the binary contains `Searching for DLNA
-devices...` and a UPnP ContentDirectory client). A DLNA server on the Mac
-should therefore appear in DeoVR without copying anything, for example
-`brew install rclone` then `rclone serve dlna ~/Movies/VR`. This is **inferred**, not
-tried. 8K VR video needs roughly 50–100 Mbit/s sustained, and the Wi-Fi
-sample above (about 30–40 Mbit/s) suggests copying first is the safer default.
+| Media | Supported preview |
+|---|---|
+| Movies | H.264 / H.265, mono or left-first SBS / top-first OU; audio through the Frame's PulseAudio-compatible server |
+| Stereo photos | PNG / JPEG containing both eyes, SBS or OU |
+| Gaussian splats | Common 32-byte `.splat` records, 1–20,000 Gaussians; a stationary stereo preview |
+
+Auto layout reads delimited filename tags: `_SBS`, `_HSBS`, `_LR`, `_OU`,
+`_TB`, `_HOU`, `_HTB`, `_FSBS`, `_FOU`, `_FTB`. SBS/OU without `F` means
+half-resolution packing. Full packing preserves each eye's original aspect.
+It also accepts FFmpeg's `stereo_mode` metadata (`left_right`, `top_bottom`,
+`mono`); left/right and top/bottom metadata are treated as full packing.
+Choose an explicit layout if that assumption doesn't match the file.
+Unknown or conflicting tags ask for a choice, rather than silently flattening
+stereo. The explicit selector always wins. Layout is not guessed from resolution.
+
+## What was verified
+
+**Verified remotely, 2026-09-28:** SteamOS 0.4.1, BUILD_ID
+`20260925.6191901`, SteamVR 2.18.1. Nobody wore the headset for these checks.
+All test media were generated by us. No paid content or DRM was involved.
+
+- Stock `ffmpeg` `h264_v4l2m2m` decoded 150 frames of our 1920×1080 H.264
+ test in 0.128 s (decode only); converting all frames to RGBA took 0.242 s.
+- Our ffmpeg → Python → OpenVR path submitted all 150 frames and exited 0.
+ The 1280×720 prototype took 4.775 s; the full 1920×1080 player took
+ 4.618 s. These are wall times, not in-headset frame-rate measurements.
+ Finishing a 5 s clip early showed those probes weren't paced, so the final
+ player paces output at 30 fps: all 150 frames then completed in **5.025 s**.
+- The H.265 hardware decoder also completed the generated 1080p clip
+ (60 frames, exit 0); this is a short compatibility check, not a 4K/8K benchmark.
+- Our actual player, launched through Frame Control's media API, displayed
+ SBS and OU photos with red only in the left-eye capture and cyan only in
+ the right. OpenVR's `SideBySide_Parallel` flag separates the eyes; we
+ rearrange OU rows ourselves.
+- A generated 48-Gaussian `.splat` rendered in our own CPU renderer and appeared
+ in both eyes with separate perspective projections.
+- Theatre's owned dark surround and screen appeared in captures. Steam's
+ dashboard remained available over them. Stop removed the owned overlays
+ and ended the dedicated user service. No global SteamVR setting was changed.
+- A generated H.264/AAC clip reached the Pulse audio output and completed
+ all 100 video frames with exit 0. This proves the output path, not audible
+ quality or lip sync.
+
+
+
+
+
+**Verified end to end, 2026-09-29** (same build; headset unworn): uploads
+through the HTTP API, the web UI and `scripts/push-vr-video.sh`, all played by
+the owned player. Our generated test files:
+
+| Case | Result |
+|---|---|
+| H.264 half-SBS 1920×1080 with AAC, theatre | 240/240 frames in 8.09 s; audio stream "Frame Control Media" in PulseAudio |
+| H.265 half-OU 1920×1080 | 180/180 frames in 6.03 s; red left eye, cyan right |
+| H.264 full-SBS 3840×1080, `stereo_mode=left_right` only | Detected from metadata; 150/150 frames in 5.03 s |
+| H.264 1280×720, explicit 2D | 150/150 frames in 5.02 s |
+| SBS PNG, OU JPEG (theatre) | Correct eye in each capture |
+| 3,000-Gaussian `.splat` | Rendered in about 5 s, then held until Stop |
+| 2D file on Auto, `_SBS_OU` file, HEIC, VP9 | Refused with the documented message |
+| Second Play while one runs | Refused: "Stop the current media…" |
+
+Stop always left the unit inactive, and no player process remained.
+
+**Standby (verified):** an unworn Frame turns its displays off a few seconds
+after it wakes. `SetOverlayRaw` then returns `RequestFailed` (23). The
+first run's movie died there. The player now drops frames while the headset
+is in standby, keeps the audio and its clock going, and resumes the picture
+when the headset wakes. The 8 s movie above dropped 40 frames and finished.
+Stills and the theatre surround are re-sent after waking. Five minutes
+without an accepted frame is reported as an error. Headset-view captures
+taken during standby show a flat dark frame, not our screen.
+
+
+
+**Verified failed route:** GStreamer 1.24.2's `playbin` selected
+`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
+in the basic appsink probe and the OpenVR probe. We do not ship that route.
+The ffmpeg path above completed instead.
+
+## Limits and blockers
+
+- **Not verified:** worn-headset comfort, sound quality/lip sync, long movies,
+ 4K/8K decode, HDR, controller interaction or behaviour on other OS builds.
+ Output is bounded to 1920×1080 packed pixels before OU rearrangement.
+- **Not implemented:** pause, seeking, subtitles, playlists, right-eye-first
+ layouts, non-square pixel correction, VR180/360 projection and fisheye.
+ This preview is a flat stereo screen, not a dome player.
+- **Native spatial-photo blocker:** HEIC/HEIF/AVIF/MPO stereo-container
+ extraction isn't implemented in our viewer. We reject these rather than
+ displaying one image and calling it spatial. Export both eyes to PNG/JPEG
+ first. This is a current implementation gap, not a claim that the Frame
+ cannot support these containers.
+- **Large/immersive splat blocker:** the owned CPU rasterizer projects 3D
+ covariance into each eye and alpha-composites Gaussians, but renders a
+ fixed view at 320×240 per eye. It caps each Gaussian footprint at 32 pixels
+ and normalizes the scene to a two-metre box. Large scenes, PLY/SPZ, live
+ head-position parallax and navigation need a GPU scene renderer; this
+ version rejects files above 20,000 records. It is a stereo preview, not
+ an immersive walk-through.
+- A single player can run at a time. It stops at EOF, on **Stop**, or after
+ four hours in any case, so longer movies are cut off. Still images remain
+ until Stop or that timeout. There is no delete action yet: remove old
+ uploads from `~/Videos/FrameControl/` over SSH. The log is
+ `~/.local/share/frame-control/media/player.log` on the Frame.
+- **Panel/stream theatre remains outside this media slice:** SteamVR's
+ `vrcmd --dock-overlay` accepts `theater`, but docking an existing panel
+ and its dimming behaviour were not verified here. The shared headset had
+ workspace and stream tests active. We did not reposition their panels.
+ Integration with [#22](https://github.com/saphid/frame-control/issues/22)
+ and [#31](https://github.com/saphid/frame-control/issues/31) can use the
+ owned rendering hook below; no second stream/window manager is introduced.
+
+## Integration hook
+
+`POST /api/upload` with `X-Mode: media` and `X-Filename` sends a file and
+returns its `id`. `POST /api/media` accepts:
+
+```json
+{"action":"play","id":"<32 hex characters>/film_SBS.mp4","layout":"auto","theatre":true}
+```
+
+Other actions are `list`, `status`, `stop`. These use the normal `X-Frame-UI`
+guard. Play reports **starting**, not a claim that frames reached the headset;
+read status for `playing`, `ended`, `stopped` or `error`.
+The own-code files are copied to `~/.local/share/frame-control/media/` and
+run in the `frame-control-media.service` systemd user unit. Stop affects only
+that unit, including its decoder child.
+
+For a Frame-side stream producer, `frame_media_player.Overlay` exposes
+`create(key, width, distance, stereo=False, aspect=1, order=1)`,
+`pixels(handle, rgba, width, height)` and `close()`. Use an owned unique key,
+pass interleaved RGBA bytes (left/right halves for stereo) and always close in
+`finally`. `create` uses a head-relative transform; it does not move another
+app's panel. The player demonstrates a separate black surround overlay.
+`frame_media.stereo_pixels` converts OU to SBS. This is the narrow rendering
+hook for stream/workspace work; it doesn't capture or manage a Mac/PC stream.
+
+## Optional separate app
+
+You can independently install **DeoVR Video Player** (free Steam app 837380)
+if you prefer its VR180/360 features. Earlier tests on SteamOS 0.3.0,
+build `20260922.6101926` (2026-09-25), verified its OpenVR initialization and
+8K H.265 streamed VR180 decoding under Proton. Frame Control's media features
+neither install nor launch it, and do not depend on it. Its behaviour and
+file-naming conventions are not evidence about our player.
diff --git a/frame/android/app-data.py b/frame/android/app-data.py
new file mode 100644
index 0000000..15c740b
--- /dev/null
+++ b/frame/android/app-data.py
@@ -0,0 +1,168 @@
+"""Private-data archives, run under podman unshare on the Frame. Stdlib only."""
+import contextlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import shutil
+import sys
+import tarfile
+import tempfile
+import time
+
+MAX_BYTES = 20 * 1024 ** 3
+MAX_FILES = 100000
+MAX_MANIFEST = 1024 * 1024
+
+
+def inspect_archive(path, package, instance):
+ names, total, manifest = set(), 0, None
+ with tarfile.open(path, 'r:gz') as archive:
+ for member in archive:
+ name = member.name
+ parts = PurePosixPath(name).parts
+ if (not parts or name.startswith('/') or '..' in parts or
+ name != '/'.join(parts) or name in names or '\\' in name):
+ raise ValueError('unsafe or duplicate archive path')
+ if name == 'data' and not member.isdir():
+ raise ValueError('data root must be a directory')
+ names.add(name)
+ if len(names) > MAX_FILES or not (member.isdir() or member.isfile()):
+ raise ValueError('archive has too many files, links or special files')
+ if member.uid < 0 or member.gid < 0 or member.uid > 65535 or member.gid > 65535:
+ raise ValueError('archive owner outside Android user namespace')
+ total += member.size
+ if total > MAX_BYTES:
+ raise ValueError('archive exceeds 20 GiB')
+ if name == 'manifest.json' and member.isfile() and member.size <= MAX_MANIFEST:
+ manifest = json.load(archive.extractfile(member))
+ elif parts[0] != 'data':
+ raise ValueError('unexpected archive member')
+ if (not isinstance(manifest, dict) or manifest.get('format') != 1 or
+ manifest.get('package') != package or manifest.get('instance') != instance or
+ 'data' not in names):
+ raise ValueError('backup does not match this package and instance')
+ return {'files': len(names) - 1, 'bytes': total, 'package': package, 'instance': instance,
+ 'skipped_links': manifest.get('skipped_link_count', 0)}
+
+
+def backup(root, package, instance, output):
+ import io
+ source = root / package
+ if source.is_symlink() or not source.is_dir():
+ raise ValueError('private app data does not exist or is a symlink')
+ count, total, links, skipped = 0, 0, [], 0
+
+ def checked(member):
+ nonlocal count, total, skipped
+ if member.issym(): # never followed or restored; listed in the manifest instead
+ skipped += 1
+ if len(links) < 1000:
+ links.append({'path': member.name[:512], 'target': member.linkname[:256]})
+ return None
+ if member.islnk(): # a second name for a file already archived: store its content again
+ member.type, member.linkname = tarfile.REGTYPE, ''
+ member.size = os.lstat(str(source / member.name[len('data/'):])).st_size
+ count += 1
+ total += member.size
+ if not (member.isdir() or member.isfile()) or count > MAX_FILES or total > MAX_BYTES:
+ raise ValueError('private data contains special files or exceeds backup limits')
+ return member
+
+ with tarfile.open(fileobj=output, mode='w|gz', dereference=False) as archive:
+ archive.add(str(source), arcname='data', filter=checked)
+ # Written last so that it can list what was skipped.
+ manifest = json.dumps({'format': 1, 'package': package, 'instance': instance,
+ 'skipped_links': links, 'skipped_link_count': skipped}).encode()
+ member = tarfile.TarInfo('manifest.json')
+ member.size, member.mode = len(manifest), 0o600
+ archive.addfile(member, io.BytesIO(manifest))
+
+
+def restore(root, package, instance, input_stream):
+ source = root / package
+ if source.is_symlink() or not source.is_dir():
+ raise ValueError('private app data does not exist or is a symlink')
+ with tempfile.TemporaryDirectory(prefix='.frame-restore-', dir=str(root)) as work:
+ work = Path(work)
+ archive_path = work / 'backup.tar.gz'
+ with archive_path.open('wb') as output:
+ size = 0
+ while True:
+ chunk = input_stream.read(1024 * 1024)
+ if not chunk:
+ break
+ size += len(chunk)
+ if size > MAX_BYTES:
+ raise ValueError('compressed backup exceeds 20 GiB')
+ output.write(chunk)
+ result = inspect_archive(archive_path, package, instance)
+ stage = work / 'stage'
+ stage.mkdir(mode=0o700)
+ with tarfile.open(archive_path, 'r:gz') as archive:
+ directories = []
+ for member in archive:
+ if member.name == 'manifest.json':
+ continue
+ target = stage / member.name
+ if member.isdir():
+ target.mkdir(parents=True, exist_ok=True)
+ directories.append((target, member))
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ with archive.extractfile(member) as src, target.open('xb') as dst:
+ shutil.copyfileobj(src, dst, 1024 * 1024)
+ apply_metadata(target, member)
+ for target, member in reversed(directories):
+ apply_metadata(target, member)
+ with package_lock(root, package): # another restore of this package must not delete our copy
+ previous = root / ('.' + package + '.before-restore-' + str(time.time_ns()))
+ source.rename(previous)
+ try:
+ (stage / 'data').rename(source)
+ except BaseException:
+ previous.rename(source)
+ raise
+ # Keep only the newest pre-restore copy of this package's data.
+ for old in root.glob('.' + package + '.before-restore-*'):
+ if old != previous and not old.is_symlink():
+ shutil.rmtree(str(old), ignore_errors=True)
+ result['previous'] = str(previous)
+ return result
+
+
+@contextlib.contextmanager
+def package_lock(root, package):
+ import fcntl
+ fd = os.open(str(root / ('.' + package + '.restore.lock')), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ yield
+ finally:
+ os.close(fd) # releases the lock
+
+
+def apply_metadata(path, member):
+ os.chown(str(path), member.uid, member.gid)
+ os.chmod(str(path), member.mode & 0o777)
+ os.utime(str(path), (member.mtime, member.mtime))
+
+
+def main():
+ action, package, instance = sys.argv[1:]
+ instance = int(instance)
+ root = Path.home() / '.local/share/Steam/steamapps/compatdata' / str(instance) / 'internal'
+ if root.is_symlink() or root.resolve() != root.absolute():
+ raise ValueError('private-data directory traverses a symlink')
+ if action == 'backup':
+ backup(root, package, instance, sys.stdout.buffer)
+ elif action == 'restore':
+ print(json.dumps(restore(root, package, instance, sys.stdin.buffer)))
+ else:
+ raise ValueError('unknown app-data action')
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except (OSError, ValueError, tarfile.TarError) as error:
+ sys.exit(str(error))
diff --git a/frame/android/lepton-app.sh b/frame/android/lepton-app.sh
index 63b0572..49080cb 100644
--- a/frame/android/lepton-app.sh
+++ b/frame/android/lepton-app.sh
@@ -19,6 +19,25 @@ done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
+[[ "$SteamAppId" =~ ^[0-9]+$ ]] || { echo "invalid instance.id" >&2; exit 1; }
+# Keep the stable Lepton context, but identify the Android VR client as its
+# actual Steam shortcut. Lepton applies LEPTON_ENV_* after its own passthrough.
+if [[ -f "$DIR/shortcut.id" ]]; then
+ shortcut="$(cat "$DIR/shortcut.id")"
+ [[ "$shortcut" =~ ^[0-9]+$ ]] || { echo "invalid shortcut.id" >&2; exit 1; }
+ export LEPTON_ENV_SteamAppId="$shortcut"
+fi
+exec 9>"$DIR/launch.lock"
+flock -n 9 || { echo "Android app is already running" >&2; exit 1; }
+CONTAINER="lepton-steamlaunch-$SteamAppId"
+# Holding the lock means no launcher owns a running container: it was orphaned
+# (this script SIGKILLed), so stop it rather than refuse every later Play. The
+# name is this app's alone. A Lepton host process whose launcher was killed
+# before it made the container may linger briefly; nothing else is killed.
+if [[ "$(podman inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" == true ]]; then
+ echo "Stopping orphaned $CONTAINER" >&2
+ podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
+fi
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
@@ -29,4 +48,29 @@ mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
# and SSH don't have; give it its own session instead.
export IS_PARENT=true
-exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
+# Keep this shell in Steam's process tree; setsid alone has no container cleanup.
+child=""
+cleanup() {
+ trap '' TERM INT HUP
+ if [[ -n "$child" ]]; then
+ kill -TERM -- "-$child" 2>/dev/null || true
+ kill -TERM "$child" 2>/dev/null || true
+ fi
+ podman stop -t 5 "$CONTAINER" >/dev/null 2>&1 || true
+ if [[ -n "$child" ]]; then
+ kill -KILL -- "-$child" 2>/dev/null || true
+ kill -KILL "$child" 2>/dev/null || true
+ wait "$child" 2>/dev/null || true
+ fi
+}
+trap cleanup EXIT
+trap 'exit 143' TERM
+trap 'exit 130' INT
+trap 'exit 129' HUP
+# 9>&-: the lock is this launcher's alone; Lepton's tree mustn't keep it held.
+setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk" 9>&- &
+child=$!
+rc=0
+wait "$child" || rc=$?
+child=""
+exit "$rc"
diff --git a/frame/android/library_artwork.js b/frame/android/library_artwork.js
new file mode 100644
index 0000000..89ef0d6
--- /dev/null
+++ b/frame/android/library_artwork.js
@@ -0,0 +1,146 @@
+// Runs in Steam's Chromium context: identical fonts/rendering from every host OS.
+async function renderLibraryArtwork(input) {
+ const sizes = {grid:[600,900], wide:[920,430], hero:[3840,1240], logo:[1280,480], icon:[256,256]};
+ const label = String(input.label || 'Untitled').trim().slice(0,180);
+ const font = '"Motiva Sans", "Noto Sans", Arial, sans-serif';
+ await document.fonts.load(`800 120px ${font}`, label);
+ const images = {}, warnings = [];
+ for (const [slot, item] of Object.entries(input.images || {})) {
+ try {
+ const img = new Image();
+ img.src = `data:image/${item[0]};base64,${item[1]}`;
+ await img.decode();
+ if (!img.width || !img.height || img.width*img.height > 16777216) throw Error('dimensions');
+ images[slot] = img;
+ } catch (_) { warnings.push(`${slot} could not be decoded; generated art used`); }
+ }
+ let icon = images.icon;
+ if (icon) {
+ // Remove only a near-black matte connected to the outside of an opaque icon.
+ const cut=document.createElement('canvas');cut.width=icon.width;cut.height=icon.height;
+ const c=cut.getContext('2d');c.drawImage(icon,0,0);
+ const pixels=c.getImageData(0,0,cut.width,cut.height), d=pixels.data, w=cut.width,h=cut.height;
+ const corners=[0,w-1,(h-1)*w,h*w-1];
+ if(corners.every(i=>d[i*4+3]>250 && Math.max(d[i*4],d[i*4+1],d[i*4+2])<24)) {
+ const seen=new Uint8Array(w*h), queue=corners.slice();
+ for(let q=0;q24)continue;
+ d[i*4+3]=0;
+ if(i%w)queue.push(i-1);if(i%w=w)queue.push(i-w);if(i220 || hi-lo<25) continue;
+ const key=rgb.map(v=>Math.round(v/32)*32).join(',');
+ bins.set(key,(bins.get(key)||0)+1);
+ }
+ const ranked=[...bins].sort((a,b)=>b[1]-a[1]);
+ if (ranked.length) {
+ colors[0]=ranked[0][0].split(',').map(Number);
+ colors[1]=(ranked.find(([key])=>key.split(',').reduce((n,v,i)=>n+Math.abs(Number(v)-colors[0][i]),0)>170)||ranked[0])[0].split(',').map(Number);
+ }
+ }
+ // Preserve hue while lifting muted icon colors into a richer background palette.
+ colors=colors.map(c=>{const low=Math.min(...c),range=Math.max(...c)-low||1;
+ return c.map(v=>45+(v-low)/range*165);});
+ const rgb=(c,a=1)=>`rgba(${c.map(v=>Math.min(255,Math.round(v))).join(',')},${a})`;
+ function image(ctx,img,x,y,w,h,cover=false) {
+ const scale=cover?Math.max(w/img.width,h/img.height):Math.min(w/img.width,h/img.height);
+ const dw=img.width*scale,dh=img.height*scale;
+ ctx.save(); ctx.beginPath(); ctx.rect(x,y,w,h); ctx.clip();
+ ctx.drawImage(img,x+(w-dw)/2,y+(h-dh)/2,dw,dh); ctx.restore();
+ }
+ function title(ctx,w,h,top,bottom,maxSize) {
+ let lines=[],size=maxSize;
+ const maxWidth=w*.84;
+ for (;size>=18;size-=2) {
+ ctx.font=`800 ${size}px ${font}`;
+ lines=[]; let line='';
+ for (const word of label.split(/\s+/)) {
+ const next=line?line+' '+word:word;
+ if (line && ctx.measureText(next).width>maxWidth) {lines.push(line);line=word;} else line=next;
+ }
+ lines.push(line);
+ if (lines.length*size*1.08<=bottom-top && lines.every(l=>ctx.measureText(l).width<=maxWidth)) break;
+ }
+ if(lines.length===2) {
+ const words=lines[0].split(' ');
+ if(words.length>1) {
+ const first=words.slice(0,-1).join(' '), second=words.slice(-1)[0]+' '+lines[1];
+ if(ctx.measureText(second).width<=maxWidth &&
+ Math.abs(ctx.measureText(first).width-ctx.measureText(second).width)<
+ Math.abs(ctx.measureText(lines[0]).width-ctx.measureText(lines[1]).width)) lines=[first,second];
+ }
+ }
+ // A long unbroken label is still fitted, including scripts without spaces.
+ ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillStyle='#fff';
+ ctx.shadowColor='rgba(0,0,0,.45)'; ctx.shadowBlur=size*.28; ctx.shadowOffsetY=size*.06;
+ let y=top+(bottom-top-lines.length*size*1.08)/2+size*.54;
+ for (const line of lines) {ctx.fillText(line,w/2,y,maxWidth); y+=size*1.08;}
+ ctx.shadowBlur=0; ctx.shadowOffsetY=0;
+ }
+ const result={};
+ for (const [slot,[w,h]] of Object.entries(sizes)) {
+ const canvas=document.createElement('canvas'); canvas.width=w; canvas.height=h;
+ const ctx=canvas.getContext('2d'); ctx.imageSmoothingQuality='high';
+ const direct=images[slot];
+ const feature=images.feature_graphic||images.banner;
+ const scene=direct || ((slot==='hero'||slot==='wide') && (feature||images.screenshot));
+ if (scene) {
+ if (slot==='logo'||slot==='icon') image(ctx,scene,0,0,w,h);
+ else image(ctx,scene,0,0,w,h,true);
+ } else if (slot==='logo') {
+ title(ctx,w,h,h*.08,h*.92,150);
+ } else {
+ const gradient=ctx.createLinearGradient(0,0,w,h);
+ gradient.addColorStop(0,rgb(colors[0].map(v=>v*.68)));
+ gradient.addColorStop(.6,rgb(colors[1].map(v=>v*.32)));
+ gradient.addColorStop(1,'#080c16'); ctx.fillStyle=gradient;ctx.fillRect(0,0,w,h);
+ if (icon) {
+ ctx.save();ctx.globalAlpha=.16;ctx.filter=`blur(${Math.round(w*.055)}px) saturate(1.4)`;
+ image(ctx,icon,-w*.15,-h*.15,w*1.3,h*1.3,true);ctx.restore();
+ }
+ const glow=ctx.createRadialGradient(w*.5,h*.32,0,w*.5,h*.32,w*.8);
+ glow.addColorStop(0,rgb(colors[0],.27));glow.addColorStop(1,rgb(colors[1],0));
+ ctx.fillStyle=glow;ctx.fillRect(0,0,w,h);
+ const vignette=ctx.createLinearGradient(0,h*.25,0,h);
+ vignette.addColorStop(0,'rgba(0,0,0,0)');vignette.addColorStop(1,'rgba(0,0,0,.56)');
+ ctx.fillStyle=vignette;ctx.fillRect(0,0,w,h);
+ const box=slot==='grid'?[w*.12,h*.14,w*.76,w*.76]:
+ slot==='wide'?[w*.36,h*.06,w*.28,h*.59]:
+ slot==='hero'?[w*.365,h*.12,w*.27,h*.78]:[w*.08,h*.08,w*.84,h*.84];
+ if (icon) {
+ ctx.save();ctx.shadowColor='rgba(0,0,0,.65)';ctx.shadowBlur=Math.min(w,h)*.055;
+ ctx.shadowOffsetY=Math.min(w,h)*.022;
+ // Opaque square icons read as deliberate app tiles, not pasted rectangles.
+ const [x,y,bw,bh]=box, side=Math.min(bw,bh);
+ if(slot!=='hero' && icon===images.icon) {
+ ctx.beginPath();ctx.roundRect(x+(bw-side)/2,y+(bh-side)/2,side,side,side*.14);ctx.clip();
+ }
+ image(ctx,icon,...box);ctx.restore();
+ } else if (slot !== 'hero') {
+ // A typographic monogram when the APK contains no usable image.
+ ctx.font=`800 ${Math.min(w,h)*.48}px ${font}`;ctx.fillStyle='rgba(255,255,255,.94)';
+ ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText([...label][0]||'A',w/2,h*.38);
+ }
+ if (slot==='grid') title(ctx,w,h,h*.7,h*.93,66);
+ if (slot==='wide') title(ctx,w,h,h*.69,h*.92,52);
+ // Hero intentionally has no title: Steam overlays the transparent logo.
+ }
+ // Photos as PNG can pass Steam's 12 MiB limit at hero size; the logo keeps its transparency.
+ const jpeg=scene && slot!=='logo' && slot!=='icon';
+ result[slot]=[jpeg?'jpg':'png', canvas.toDataURL(jpeg?'image/jpeg':'image/png',.9).split(',')[1]];
+ }
+ return {images:result,warnings,font};
+}
diff --git a/frame/android/steam_shortcuts.py b/frame/android/steam_shortcuts.py
index 6aea8de..bf614e5 100644
--- a/frame/android/steam_shortcuts.py
+++ b/frame/android/steam_shortcuts.py
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - < this`.
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
steam_shortcuts.py list -> JSON [{appid, name, exe}]
+ steam_shortcuts.py configure APPID NAME EXE START_DIR ICON VR ARTWORK_JSON
+ steam_shortcuts.py stop APPID
steam_shortcuts.py remove APPID
"""
-import base64, json, os, socket, struct, sys, urllib.request
+import base64, glob, json, os, re, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
@@ -22,10 +24,10 @@ def target_ws():
class WS:
"""Just enough RFC 6455 for one CDP request/response on loopback."""
- def __init__(self, url):
+ def __init__(self, url, timeout=20):
host_port, path = url[len('ws://'):].split('/', 1)
host, port = host_port.split(':')
- self.s = socket.create_connection((host, int(port)), timeout=20)
+ self.s = socket.create_connection((host, int(port)), timeout=timeout)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
@@ -69,8 +71,8 @@ class WS:
return msg.decode()
-def evaluate(js):
- ws = WS(target_ws())
+def evaluate(js, timeout=20):
+ ws = WS(target_ws(), timeout)
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
while True:
@@ -83,6 +85,206 @@ def evaluate(js):
return res.get('result', {}).get('value')
+# Steam's ELibraryAssetType (Capsule, Hero, Logo, Header, Icon).
+ASSETS = {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4}
+
+
+def collections_js(appid, wanted=()):
+ wanted = list(wanted)
+ return f'''async function syncCollections() {{
+ const wanted = {json.dumps(wanted)};
+ if (typeof collectionStore === "undefined" ||
+ typeof collectionStore.GetUserCollectionsByName !== "function" ||
+ typeof collectionStore.NewUnsavedCollection !== "function" ||
+ typeof collectionStore.SaveCollection !== "function")
+ return ["Steam collections API unavailable"];
+ const app = {{appid: {appid}}};
+ const warnings = [];
+ for (const name of ["Android", "Android VR", "Sideloaded"]) {{
+ const matches = collectionStore.GetUserCollectionsByName(name);
+ let collection = matches.find(c => !c.bIsDynamic && c.bAllowsDragAndDrop);
+ if (wanted.includes(name)) {{
+ if (!collection && matches.length) {{
+ warnings.push(name + " is an existing dynamic or read-only collection");
+ continue;
+ }}
+ if (!collection) {{
+ collection = collectionStore.NewUnsavedCollection(name, undefined, [app]);
+ }} else {{
+ collection.AsDragDropCollection().AddApps([app]);
+ }}
+ await collectionStore.SaveCollection(collection);
+ }} else if (collection) {{
+ collection.AsDragDropCollection().RemoveApps([app]);
+ await collectionStore.SaveCollection(collection);
+ }}
+ }}
+ return warnings;
+ }}'''
+
+
+
+def notes_js(name, details):
+ filename = 'notes_shortcut_' + re.sub(r'[!-/:-@ \[\\\]\^`]', '_', name.strip())
+ content = '\n'.join(str(details[k]) for k in ('package', 'version', 'source') if details.get(k))
+ return f'''if (SteamClient.GameNotes && typeof SteamClient.GameNotes.GetNotes === "function" &&
+ typeof SteamClient.GameNotes.SaveNotes === "function") {{
+ try {{
+ const file = {json.dumps(filename)};
+ const previous = await SteamClient.GameNotes.GetNotes(file, file + "_images/");
+ if (previous.result !== 1 && previous.result !== 9) throw Error("read " + previous.result);
+ const data = previous.result === 1 ? JSON.parse(previous.notes) : {{notes: [], shortcut_name: {json.dumps(name)}}};
+ if (!Array.isArray(data.notes)) throw Error("unexpected notes format");
+ const id = "frame-control-library", now = Math.floor(Date.now()/1000);
+ const old = data.notes.find(n => n.id === id);
+ const note = {{id, shortcut_name: {json.dumps(name)}, title: "Installation details",
+ content: {json.dumps(content)}, ordinal: old ? old.ordinal : data.notes.length,
+ time_created: old ? old.time_created : now, time_modified: now}};
+ data.notes = data.notes.filter(n => n.id !== id).concat([note]);
+ const result = await SteamClient.GameNotes.SaveNotes(file, JSON.stringify(data));
+ if (result !== 1) throw Error("save " + result);
+ }} catch (e) {{ warnings.push("Steam notes: " + String(e)); }}
+ }}'''
+
+
+MAX_ART = 12 * 1024 * 1024 # Steam's custom artwork limit per slot
+
+
+def render(plan):
+ with open(plan) as f:
+ source = json.load(f)
+ images = {}
+ for slot, path in source['images'].items():
+ ext = os.path.splitext(path)[1][1:]
+ with open(path, 'rb') as f:
+ data = f.read(MAX_ART + 1)
+ if len(data) > MAX_ART:
+ raise ValueError('source artwork too large')
+ images[slot] = [ext, base64.b64encode(data).decode()]
+ renderer = globals().get('ART_RENDERER')
+ if renderer is None:
+ with open(os.path.join(os.path.dirname(__file__), 'library_artwork.js')) as f:
+ renderer = f.read()
+ try:
+ return _render(plan, renderer, source['label'], images)
+ except (ValueError, OSError, EOFError, SystemExit) as e:
+ # Generated art from the icon alone always fits; a photo that didn't must not fail the install.
+ result = _render(plan, renderer, source['label'], {k: v for k, v in images.items() if k == 'icon'})
+ result['warnings'].insert(0, 'Source artwork could not be rendered (' + str(e)[:120] + '); generated art used')
+ return result
+
+
+def _render(plan, renderer, label, images):
+ # A 4K photo takes seconds to decode and encode on the Frame; allow well beyond that.
+ result = evaluate(renderer + '\nrenderLibraryArtwork(' + json.dumps({'label': label, 'images': images}) + ')',
+ timeout=75)
+ if not isinstance(result, dict) or set(result.get('images', {})) != set(ASSETS):
+ raise ValueError('incomplete artwork render')
+ paths = {}
+ for slot, (ext, encoded) in result['images'].items():
+ data = base64.b64decode(encoded, validate=True)
+ signature = {'png': b'\x89PNG\r\n\x1a\n', 'jpg': b'\xff\xd8\xff'}.get(ext)
+ if not signature or not data.startswith(signature) or len(data) > MAX_ART:
+ raise ValueError(slot + ' render is ' + str(len(data)) + ' bytes of ' + str(ext))
+ paths[slot] = os.path.join(os.path.dirname(plan), slot + '.' + ext)
+ with open(paths[slot] + '.tmp', 'wb') as f:
+ f.write(data)
+ for slot, path in paths.items():
+ os.replace(path + '.tmp', path)
+ for stale in ('png', 'jpg'):
+ other = os.path.join(os.path.dirname(plan), slot + '.' + stale)
+ if other != path and os.path.exists(other):
+ os.remove(other)
+ return {'paths': paths, 'warnings': list(result.get('warnings', []))}
+
+
+# Steam's own file for each custom-art type in userdata/*/config/grid/.
+GRID_FILES = {0: 'p', 1: '_hero', 2: '_logo', 3: ''}
+
+
+def custom_art(appid):
+ """The custom-art types this shortcut already has in Steam, for any local user."""
+ found = set()
+ for kind, suffix in GRID_FILES.items():
+ pattern = os.path.expanduser(f'~/.local/share/Steam/userdata/*/config/grid/{int(appid)}{suffix}.*')
+ if any(os.path.splitext(p)[1].lower() in ('.png', '.jpg', '.jpeg') for p in glob.glob(pattern)):
+ found.add(kind)
+ return found
+
+
+def configure(appid, name, exe, start_dir, icon, vr, artwork, options=None):
+ """options: category, details, fill_only (only empty slots and a missing icon; name and flags untouched)."""
+ options = options or {}
+ category = options.get('category', 'Android')
+ fill = bool(options.get('fill_only'))
+ existing = custom_art(appid) if fill else set()
+ if set(artwork) != set(ASSETS):
+ raise ValueError('all five Steam artwork slots are required')
+ images = []
+ for slot, path in artwork.items():
+ if slot not in ASSETS:
+ raise ValueError('unknown artwork slot')
+ ext = os.path.splitext(path)[1][1:]
+ if ext not in ('png', 'jpg'):
+ raise ValueError('artwork must be PNG or JPEG')
+ with open(path, 'rb') as f:
+ data = f.read(MAX_ART + 1)
+ if len(data) > MAX_ART:
+ raise ValueError('artwork is too large')
+ # Frame's custom-art API maps type 4 to Header; use SetShortcutIcon.
+ if slot != 'icon' and ASSETS[slot] not in existing:
+ images.append([ASSETS[slot], ext, base64.b64encode(data).decode()])
+ return evaluate(f'''(async () => {{
+ const id = {int(appid)}, warnings = [], fill = {json.dumps(fill)};
+ const overview = appStore.GetAppOverviewByAppID(id);
+ if (!fill) {{
+ SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
+ if ({json.dumps(exe)}) SteamClient.Apps.SetShortcutExe(id, {json.dumps(exe)});
+ if ({json.dumps(start_dir)}) SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
+ if (typeof SteamClient.Apps.SetShortcutSortAs === "function")
+ SteamClient.Apps.SetShortcutSortAs(id, {json.dumps(name)});
+ }}
+ if (!fill || !(overview && overview.icon_data)) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
+ // null (devkit titles) or filling gaps: leave the VR flag as Steam has it.
+ if ({json.dumps(vr)} !== null && !fill) {{
+ if (typeof SteamClient.Apps.SetShortcutIsVR === "function")
+ SteamClient.Apps.SetShortcutIsVR(id, {json.dumps(vr)});
+ else warnings.push("Steam VR shortcut flag API unavailable");
+ }}
+ if (typeof SteamClient.Apps.SetCustomArtworkForApp === "function") {{
+ for (const [type, ext, data] of {json.dumps(images)}) {{
+ // Steam keeps a slot's PNG and JPEG side by side; clear it so a stale one can't win.
+ if (!fill && typeof SteamClient.Apps.ClearCustomArtworkForApp === "function")
+ try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }} catch (e) {{}}
+ await SteamClient.Apps.SetCustomArtworkForApp(id, data, ext, type);
+ }}
+ }} else throw new Error("Steam artwork API unavailable; installation is incomplete");
+ {collections_js(int(appid), [category] + (['Android VR'] if vr and category == 'Android' else []))}
+ try {{ warnings.push(...await syncCollections()); }}
+ catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
+ {notes_js(name, options.get('details', {}))}
+ return {{warnings}};
+ }})()''', timeout=60)
+
+
+def remove(appid):
+ # Collections and artwork are tidy-up: only a missing RemoveShortcut may fail the removal.
+ return evaluate(f'''(async () => {{
+ const id = {int(appid)}, warnings = [];
+ {collections_js(int(appid))}
+ try {{ warnings.push(...await syncCollections()); }}
+ catch (e) {{ warnings.push("Steam collections: " + String(e)); }}
+ if (typeof SteamClient.Apps.ClearCustomArtworkForApp === "function") {{
+ for (const type of [0, 1, 2, 3]) {{
+ try {{ await SteamClient.Apps.ClearCustomArtworkForApp(id, type); }}
+ catch (e) {{ warnings.push("Steam artwork " + type + ": " + String(e)); }}
+ }}
+ }} else warnings.push("Steam artwork removal API unavailable");
+ SteamClient.Apps.RemoveShortcut(id);
+ return {{warnings}};
+ }})()''')
+
+
def main():
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'add':
@@ -97,12 +299,30 @@ def main():
}})()'''
print(evaluate(js))
elif cmd == 'list':
- js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
- .map(a => ({appid: a.appid, name: a.display_name})))()'''
+ # Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
+ js = '''(async () => Promise.all(appStore.allApps.filter(a => a.app_type === 1073741824).map(async a => {
+ let d = typeof appDetailsStore !== "undefined" && appDetailsStore.GetAppDetails(a.appid);
+ if (!d && typeof SteamClient.Apps.RegisterForAppDetails === "function") d = await new Promise(ok => {
+ let reg;
+ const timer = setTimeout(() => { if (reg) reg.unregister(); ok(null); }, 3000);
+ reg = SteamClient.Apps.RegisterForAppDetails(a.appid, x => {
+ clearTimeout(timer); setTimeout(() => reg && reg.unregister()); ok(x); });
+ });
+ return {appid: a.appid, name: a.display_name, devkit_gameid: a.devkit_gameid,
+ exe: d ? d.strShortcutExe || "" : "", start_dir: d ? d.strShortcutStartDir || "" : ""};
+ })))()'''
print(json.dumps(evaluate(js)))
+ elif cmd == 'render':
+ print(json.dumps(render(args[0])))
+ elif cmd == 'configure':
+ vr = {'1': True, '0': False}.get(args[5]) # '' leaves Steam's VR flag alone
+ print(json.dumps(configure(int(args[0]), *args[1:5], vr, json.loads(args[6]),
+ json.loads(args[7]) if len(args) > 7 else None)))
+ elif cmd == 'stop':
+ evaluate(f'SteamClient.Apps.TerminateApp({json.dumps(str((int(args[0]) << 32) | 0x02000000))}, false)')
+ print('stopping')
elif cmd == 'remove':
- evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
- print('removed')
+ print(json.dumps(remove(int(args[0]))))
else:
sys.exit(__doc__)
diff --git a/frame/kdeconnect/LICENSES/kcontacts/BSD-3-Clause.txt b/frame/kdeconnect/LICENSES/kcontacts/BSD-3-Clause.txt
new file mode 100644
index 0000000..0741db7
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kcontacts/BSD-3-Clause.txt
@@ -0,0 +1,26 @@
+Copyright (c) . All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+may be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/frame/kdeconnect/LICENSES/kcontacts/CC0-1.0.txt b/frame/kdeconnect/LICENSES/kcontacts/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kcontacts/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/frame/kdeconnect/LICENSES/kcontacts/LGPL-2.0-or-later.txt b/frame/kdeconnect/LICENSES/kcontacts/LGPL-2.0-or-later.txt
new file mode 100644
index 0000000..5c96471
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kcontacts/LGPL-2.0-or-later.txt
@@ -0,0 +1,446 @@
+GNU LIBRARY GENERAL PUBLIC LICENSE
+
+Version 2, June 1991 Copyright (C) 1991 Free Software Foundation, Inc.
+
+51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the library GPL. It is numbered 2 because
+it goes with version 2 of the ordinary GPL.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Library General Public License, applies to some specially
+designated Free Software Foundation software, and to any other libraries whose
+authors decide to use it. You can use it for your libraries, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library, or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+a program with the library, you must provide complete object files to the
+recipients so that they can relink them with the library, after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+Our method of protecting your rights has two steps: (1) copyright the library,
+and (2) offer you this license which gives you legal permission to copy, distribute
+and/or modify the library.
+
+Also, for each distributor's protection, we want to make certain that everyone
+understands that there is no warranty for this free library. If the library
+is modified by someone else and passed on, we want its recipients to know
+that what they have is not the original version, so that any problems introduced
+by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that companies distributing free software will individually
+obtain patent licenses, thus in effect transforming the program into proprietary
+software. To prevent this, we have made it clear that any patent must be licensed
+for everyone's free use or not licensed at all.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License, which was designed for utility programs. This license,
+the GNU Library General Public License, applies to certain designated libraries.
+This license is quite different from the ordinary one; be sure to read it
+in full, and don't assume that anything in it is the same as in the ordinary
+license.
+
+The reason we have a separate public license for some libraries is that they
+blur the distinction we usually make between modifying or adding to a program
+and simply using it. Linking a program with a library, without changing the
+library, is in some sense simply using the library, and is analogous to running
+a utility program or application program. However, in a textual and legal
+sense, the linked executable is a combined work, a derivative of the original
+library, and the ordinary General Public License treats it as such.
+
+Because of this blurred distinction, using the ordinary General Public License
+for libraries did not effectively promote software sharing, because most developers
+did not use the libraries. We concluded that weaker conditions might promote
+sharing better.
+
+However, unrestricted linking of non-free programs would deprive the users
+of those programs of all benefit from the free status of the libraries themselves.
+This Library General Public License is intended to permit developers of non-free
+programs to use free libraries, while preserving your freedom as a user of
+such programs to change the free libraries that are incorporated in them.
+(We have not seen how to achieve this as regards changes in header files,
+but we have achieved it as regards changes in the actual functions of the
+Library.) The hope is that this will lead to faster development of free libraries.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, while the latter only works together with the library.
+
+Note that it is possible for a library to be covered by the ordinary General
+Public License rather than by this special one.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library which contains a
+notice placed by the copyright holder or other authorized party saying it
+may be distributed under the terms of this Library General Public License
+(also called "this License"). Each licensee is addressed as "you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also compile or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+c) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+d) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the source code distributed need
+not include anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the operating
+system on which the executable runs, unless that component itself accompanies
+the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties to this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Library General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+one line to give the library's name and an idea of what it does.
+
+Copyright (C) year name of author
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Library General Public License as published by the Free
+Software Foundation; either version 2 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more
+details.
+
+You should have received a copy of the GNU Library General Public License
+along with this library; if not, write to the Free Software Foundation, Inc.,
+51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+signature of Ty Coon, 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/kcontacts/MIT.txt b/frame/kdeconnect/LICENSES/kcontacts/MIT.txt
new file mode 100644
index 0000000..204b93d
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kcontacts/MIT.txt
@@ -0,0 +1,19 @@
+MIT License Copyright (c)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice (including the next
+paragraph) shall be included in all copies or substantial portions of the
+Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
+OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
+OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frame/kdeconnect/LICENSES/kcontacts/Unicode-DFS-2016.txt b/frame/kdeconnect/LICENSES/kcontacts/Unicode-DFS-2016.txt
new file mode 100644
index 0000000..80bd8b3
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kcontacts/Unicode-DFS-2016.txt
@@ -0,0 +1,49 @@
+UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE
+
+Unicode Data Files include all data files under the directories http://www.unicode.org/Public/,
+http://www.unicode.org/reports/, http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/,
+http://www.unicode.org/ivd/data/, and http://www.unicode.org/utility/trac/browser/.
+
+Unicode Data Files do not include PDF online code charts under the directory
+http://www.unicode.org/Public/.
+
+Software includes any source code published in the Unicode Standard or under
+the directories http://www.unicode.org/Public/, http://www.unicode.org/reports/,
+http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/,
+and http://www.unicode.org/utility/trac/browser/.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY DOWNLOADING,
+INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S DATA FILES ("DATA FILES"),
+AND/OR SOFTWARE ("SOFTWARE"), YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND
+BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE,
+DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+COPYRIGHT AND PERMISSION NOTICE Copyright © 1991-2016 Unicode, Inc. All rights
+reserved. Distributed under the Terms of Use in http://www.unicode.org/copyright.html.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of the Unicode data files and any associated documentation (the "Data Files")
+or Unicode software and any associated documentation (the "Software") to deal
+in the Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell copies
+of the Data Files or Software, and to permit persons to whom the Data Files
+or Software are furnished to do so, provided that either
+
+(a) this copyright and permission notice appear with all copies of the Data
+Files or Software, or
+
+ (b) this copyright and permission notice appear in associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF THIRD PARTY RIGHTS.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER
+IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall not
+be used in advertising or otherwise to promote the sale, use or other dealings
+in these Data Files or Software without prior written authorization of the
+copyright holder.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/Apache-2.0.txt b/frame/kdeconnect/LICENSES/kdeconnect/Apache-2.0.txt
new file mode 100644
index 0000000..4ed90b9
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/Apache-2.0.txt
@@ -0,0 +1,208 @@
+Apache License
+
+Version 2.0, January 2004
+
+http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION,
+AND DISTRIBUTION
+
+ 1. Definitions.
+
+
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution
+as defined by Sections 1 through 9 of this document.
+
+
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+
+
+"Legal Entity" shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, "control" means (i) the power, direct
+or indirect, to cause the direction or management of such entity, whether
+by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
+of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions
+granted by this License.
+
+
+
+"Source" form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+
+
+"Object" form shall mean any form resulting from mechanical transformation
+or translation of a Source form, including but not limited to compiled object
+code, generated documentation, and conversions to other media types.
+
+
+
+"Work" shall mean the work of authorship, whether in Source or Object form,
+made available under the License, as indicated by a copyright notice that
+is included in or attached to the work (an example is provided in the Appendix
+below).
+
+
+
+"Derivative Works" shall mean any work, whether in Source or Object form,
+that is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative
+Works shall not include works that remain separable from, or merely link (or
+bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+
+
+"Contribution" shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative
+Works thereof, that is intentionally submitted to Licensor for inclusion in
+the Work by the copyright owner or by an individual or Legal Entity authorized
+to submit on behalf of the copyright owner. For the purposes of this definition,
+"submitted" means any form of electronic, verbal, or written communication
+sent to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor
+for the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as "Not a Contribution."
+
+
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently incorporated
+within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this
+License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable copyright license to reproduce, prepare
+Derivative Works of, publicly display, publicly perform, sublicense, and distribute
+the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License,
+each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section) patent
+license to make, have made, use, offer to sell, sell, import, and otherwise
+transfer the Work, where such license applies only to those patent claims
+licensable by such Contributor that are necessarily infringed by their Contribution(s)
+alone or by combination of their Contribution(s) with the Work to which such
+Contribution(s) was submitted. If You institute patent litigation against
+any entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that the Work or a Contribution incorporated within the Work constitutes direct
+or contributory patent infringement, then any patent licenses granted to You
+under this License for that Work shall terminate as of the date such litigation
+is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or
+Derivative Works thereof in any medium, with or without modifications, and
+in Source or Object form, provided that You meet the following conditions:
+
+(a) You must give any other recipients of the Work or Derivative Works a copy
+of this License; and
+
+(b) You must cause any modified files to carry prominent notices stating that
+You changed the files; and
+
+(c) You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source
+form of the Work, excluding those notices that do not pertain to any part
+of the Derivative Works; and
+
+(d) If the Work includes a "NOTICE" text file as part of its distribution,
+then any Derivative Works that You distribute must include a readable copy
+of the attribution notices contained within such NOTICE file, excluding those
+notices that do not pertain to any part of the Derivative Works, in at least
+one of the following places: within a NOTICE text file distributed as part
+of the Derivative Works; within the Source form or documentation, if provided
+along with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents
+of the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works
+that You distribute, alongside or as an addendum to the NOTICE text from the
+Work, provided that such additional attribution notices cannot be construed
+as modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction,
+or distribution of Your modifications, or for any such Derivative Works as
+a whole, provided Your use, reproduction, and distribution of the Work otherwise
+complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any
+Contribution intentionally submitted for inclusion in the Work by You to the
+Licensor shall be under the terms and conditions of this License, without
+any additional terms or conditions. Notwithstanding the above, nothing herein
+shall supersede or modify the terms of any separate license agreement you
+may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names,
+trademarks, service marks, or product names of the Licensor, except as required
+for reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to
+in writing, Licensor provides the Work (and each Contributor provides its
+Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied, including, without limitation, any warranties
+or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
+A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
+of using or redistributing the Work and assume any risks associated with Your
+exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether
+in tort (including negligence), contract, or otherwise, unless required by
+applicable law (such as deliberate and grossly negligent acts) or agreed to
+in writing, shall any Contributor be liable to You for damages, including
+any direct, indirect, special, incidental, or consequential damages of any
+character arising as a result of this License or out of the use or inability
+to use the Work (including but not limited to damages for loss of goodwill,
+work stoppage, computer failure or malfunction, or any and all other commercial
+damages or losses), even if such Contributor has been advised of the possibility
+of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work
+or Derivative Works thereof, You may choose to offer, and charge a fee for,
+acceptance of support, warranty, indemnity, or other liability obligations
+and/or rights consistent with this License. However, in accepting such obligations,
+You may act only on Your own behalf and on Your sole responsibility, not on
+behalf of any other Contributor, and only if You agree to indemnify, defend,
+and hold each Contributor harmless for any liability incurred by, or claims
+asserted against, such Contributor by reason of your accepting any such warranty
+or additional liability. END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets "[]" replaced with your own identifying
+information. (Don't include the brackets!) The text should be enclosed in
+the appropriate comment syntax for the file format. We also recommend that
+a file or class name and description of purpose be included on the same "printed
+page" as the copyright notice for easier identification within third-party
+archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+
+distributed under the License is distributed on an "AS IS" BASIS,
+
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+
+limitations under the License.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/BSD-3-Clause.txt b/frame/kdeconnect/LICENSES/kdeconnect/BSD-3-Clause.txt
new file mode 100644
index 0000000..0741db7
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/BSD-3-Clause.txt
@@ -0,0 +1,26 @@
+Copyright (c) . All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+may be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/CC0-1.0.txt b/frame/kdeconnect/LICENSES/kdeconnect/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-only.txt b/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-only.txt
new file mode 100644
index 0000000..0f3d641
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-only.txt
@@ -0,0 +1,319 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public License is intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users. This General Public License applies to
+most of the Free Software Foundation's software and to any other program whose
+authors commit to using it. (Some other Free Software Foundation software
+is covered by the GNU Lesser General Public License instead.) You can apply
+it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether gratis or
+for a fee, you must give the recipients all the rights that you have. You
+must make sure that they, too, receive or can get the source code. And you
+must show them these terms so they know their rights.
+
+We protect your rights with two steps: (1) copyright the software, and (2)
+offer you this license which gives you legal permission to copy, distribute
+and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain that
+everyone understands that there is no warranty for this free software. If
+the software is modified by someone else and passed on, we want its recipients
+to know that what they have is not the original, so that any problems introduced
+by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that redistributors of a free program will individually
+obtain patent licenses, in effect making the program proprietary. To prevent
+this, we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License applies to any program or other work which contains a notice
+placed by the copyright holder saying it may be distributed under the terms
+of this General Public License. The "Program", below, refers to any such program
+or work, and a "work based on the Program" means either the Program or any
+derivative work under copyright law: that is to say, a work containing the
+Program or a portion of it, either verbatim or with modifications and/or translated
+into another language. (Hereinafter, translation is included without limitation
+in the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running the Program
+is not restricted, and the output from the Program is covered only if its
+contents constitute a work based on the Program (independent of having been
+made by running the Program). Whether that is true depends on what the Program
+does.
+
+1. You may copy and distribute verbatim copies of the Program's source code
+as you receive it, in any medium, provided that you conspicuously and appropriately
+publish on each copy an appropriate copyright notice and disclaimer of warranty;
+keep intact all the notices that refer to this License and to the absence
+of any warranty; and give any other recipients of the Program a copy of this
+License along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion of it,
+thus forming a work based on the Program, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+a) You must cause the modified files to carry prominent notices stating that
+you changed the files and the date of any change.
+
+b) You must cause any work that you distribute or publish, that in whole or
+in part contains or is derived from the Program or any part thereof, to be
+licensed as a whole at no charge to all third parties under the terms of this
+License.
+
+c) If the modified program normally reads commands interactively when run,
+you must cause it, when started running for such interactive use in the most
+ordinary way, to print or display an announcement including an appropriate
+copyright notice and a notice that there is no warranty (or else, saying that
+you provide a warranty) and that users may redistribute the program under
+these conditions, and telling the user how to view a copy of this License.
+(Exception: if the Program itself is interactive but does not normally print
+such an announcement, your work based on the Program is not required to print
+an announcement.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Program, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Program, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Program.
+
+In addition, mere aggregation of another work not based on the Program with
+the Program (or with a work based on the Program) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may copy and distribute the Program (or a work based on it, under Section
+2) in object code or executable form under the terms of Sections 1 and 2 above
+provided that you also do one of the following:
+
+a) Accompany it with the complete corresponding machine-readable source code,
+which must be distributed under the terms of Sections 1 and 2 above on a medium
+customarily used for software interchange; or,
+
+b) Accompany it with a written offer, valid for at least three years, to give
+any third party, for a charge no more than your cost of physically performing
+source distribution, a complete machine-readable copy of the corresponding
+source code, to be distributed under the terms of Sections 1 and 2 above on
+a medium customarily used for software interchange; or,
+
+c) Accompany it with the information you received as to the offer to distribute
+corresponding source code. (This alternative is allowed only for noncommercial
+distribution and only if you received the program in object code or executable
+form with such an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for making
+modifications to it. For an executable work, complete source code means all
+the source code for all modules it contains, plus any associated interface
+definition files, plus the scripts used to control compilation and installation
+of the executable. However, as a special exception, the source code distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+If distribution of executable or object code is made by offering access to
+copy from a designated place, then offering equivalent access to copy the
+source code from the same place counts as distribution of the source code,
+even though third parties are not compelled to copy the source along with
+the object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program except
+as expressly provided under this License. Any attempt otherwise to copy, modify,
+sublicense or distribute the Program is void, and will automatically terminate
+your rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses terminated
+so long as such parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Program or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Program
+(or any work based on the Program), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the Program),
+the recipient automatically receives a license from the original licensor
+to copy, distribute or modify the Program subject to these terms and conditions.
+You may not impose any further restrictions on the recipients' exercise of
+the rights granted herein. You are not responsible for enforcing compliance
+by third parties to this License.
+
+7. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Program at all. For example, if a
+patent license would not permit royalty-free redistribution of the Program
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply and
+the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system, which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Program under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+9. The Free Software Foundation may publish revised and/or new versions of
+the General Public License from time to time. Such new versions will be similar
+in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Program does not specify a version number of this License, you may choose
+any version ever published by the Free Software Foundation.
+
+10. If you wish to incorporate parts of the Program into other free programs
+whose distribution conditions are different, write to the author to ask for
+permission. For software which is copyrighted by the Free Software Foundation,
+write to the Free Software Foundation; we sometimes make exceptions for this.
+Our decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing and reuse
+of software generally.
+
+ NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible
+use to the public, the best way to achieve this is to make it free software
+which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach
+them to the start of each source file to most effectively convey the exclusion
+of warranty; and each file should have at least the "copyright" line and a
+pointer to where the full notice is found.
+
+
+
+Copyright (C)< yyyy>
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this when
+it starts in an interactive mode:
+
+Gnomovision version 69, Copyright (C) year name of author Gnomovision comes
+with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software,
+and you are welcome to redistribute it under certain conditions; type `show
+c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may be
+called something other than `show w' and `show c'; they could even be mouse-clicks
+or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision'
+(which makes passes at compilers) written by James Hacker.
+
+, 1 April 1989 Ty Coon, President of Vice This General
+Public License does not permit incorporating your program into proprietary
+programs. If your program is a subroutine library, you may consider it more
+useful to permit linking proprietary applications with the library. If this
+is what you want to do, use the GNU Lesser General Public License instead
+of this License.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-or-later.txt b/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-or-later.txt
new file mode 100644
index 0000000..1d80ac3
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/GPL-2.0-or-later.txt
@@ -0,0 +1,319 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public License is intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users. This General Public License applies to
+most of the Free Software Foundation's software and to any other program whose
+authors commit to using it. (Some other Free Software Foundation software
+is covered by the GNU Lesser General Public License instead.) You can apply
+it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether gratis or
+for a fee, you must give the recipients all the rights that you have. You
+must make sure that they, too, receive or can get the source code. And you
+must show them these terms so they know their rights.
+
+We protect your rights with two steps: (1) copyright the software, and (2)
+offer you this license which gives you legal permission to copy, distribute
+and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain that
+everyone understands that there is no warranty for this free software. If
+the software is modified by someone else and passed on, we want its recipients
+to know that what they have is not the original, so that any problems introduced
+by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that redistributors of a free program will individually
+obtain patent licenses, in effect making the program proprietary. To prevent
+this, we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License applies to any program or other work which contains a notice
+placed by the copyright holder saying it may be distributed under the terms
+of this General Public License. The "Program", below, refers to any such program
+or work, and a "work based on the Program" means either the Program or any
+derivative work under copyright law: that is to say, a work containing the
+Program or a portion of it, either verbatim or with modifications and/or translated
+into another language. (Hereinafter, translation is included without limitation
+in the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running the Program
+is not restricted, and the output from the Program is covered only if its
+contents constitute a work based on the Program (independent of having been
+made by running the Program). Whether that is true depends on what the Program
+does.
+
+1. You may copy and distribute verbatim copies of the Program's source code
+as you receive it, in any medium, provided that you conspicuously and appropriately
+publish on each copy an appropriate copyright notice and disclaimer of warranty;
+keep intact all the notices that refer to this License and to the absence
+of any warranty; and give any other recipients of the Program a copy of this
+License along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion of it,
+thus forming a work based on the Program, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+a) You must cause the modified files to carry prominent notices stating that
+you changed the files and the date of any change.
+
+b) You must cause any work that you distribute or publish, that in whole or
+in part contains or is derived from the Program or any part thereof, to be
+licensed as a whole at no charge to all third parties under the terms of this
+License.
+
+c) If the modified program normally reads commands interactively when run,
+you must cause it, when started running for such interactive use in the most
+ordinary way, to print or display an announcement including an appropriate
+copyright notice and a notice that there is no warranty (or else, saying that
+you provide a warranty) and that users may redistribute the program under
+these conditions, and telling the user how to view a copy of this License.
+(Exception: if the Program itself is interactive but does not normally print
+such an announcement, your work based on the Program is not required to print
+an announcement.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Program, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Program, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Program.
+
+In addition, mere aggregation of another work not based on the Program with
+the Program (or with a work based on the Program) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may copy and distribute the Program (or a work based on it, under Section
+2) in object code or executable form under the terms of Sections 1 and 2 above
+provided that you also do one of the following:
+
+a) Accompany it with the complete corresponding machine-readable source code,
+which must be distributed under the terms of Sections 1 and 2 above on a medium
+customarily used for software interchange; or,
+
+b) Accompany it with a written offer, valid for at least three years, to give
+any third party, for a charge no more than your cost of physically performing
+source distribution, a complete machine-readable copy of the corresponding
+source code, to be distributed under the terms of Sections 1 and 2 above on
+a medium customarily used for software interchange; or,
+
+c) Accompany it with the information you received as to the offer to distribute
+corresponding source code. (This alternative is allowed only for noncommercial
+distribution and only if you received the program in object code or executable
+form with such an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for making
+modifications to it. For an executable work, complete source code means all
+the source code for all modules it contains, plus any associated interface
+definition files, plus the scripts used to control compilation and installation
+of the executable. However, as a special exception, the source code distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+If distribution of executable or object code is made by offering access to
+copy from a designated place, then offering equivalent access to copy the
+source code from the same place counts as distribution of the source code,
+even though third parties are not compelled to copy the source along with
+the object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program except
+as expressly provided under this License. Any attempt otherwise to copy, modify,
+sublicense or distribute the Program is void, and will automatically terminate
+your rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses terminated
+so long as such parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Program or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Program
+(or any work based on the Program), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the Program),
+the recipient automatically receives a license from the original licensor
+to copy, distribute or modify the Program subject to these terms and conditions.
+You may not impose any further restrictions on the recipients' exercise of
+the rights granted herein. You are not responsible for enforcing compliance
+by third parties to this License.
+
+7. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Program at all. For example, if a
+patent license would not permit royalty-free redistribution of the Program
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply and
+the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system, which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Program under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+9. The Free Software Foundation may publish revised and/or new versions of
+the General Public License from time to time. Such new versions will be similar
+in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Program does not specify a version number of this License, you may choose
+any version ever published by the Free Software Foundation.
+
+10. If you wish to incorporate parts of the Program into other free programs
+whose distribution conditions are different, write to the author to ask for
+permission. For software which is copyrighted by the Free Software Foundation,
+write to the Free Software Foundation; we sometimes make exceptions for this.
+Our decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing and reuse
+of software generally.
+
+ NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible
+use to the public, the best way to achieve this is to make it free software
+which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach
+them to the start of each source file to most effectively convey the exclusion
+of warranty; and each file should have at least the "copyright" line and a
+pointer to where the full notice is found.
+
+
+
+Copyright (C)
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this when
+it starts in an interactive mode:
+
+Gnomovision version 69, Copyright (C) year name of author Gnomovision comes
+with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software,
+and you are welcome to redistribute it under certain conditions; type `show
+c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may be
+called something other than `show w' and `show c'; they could even be mouse-clicks
+or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision'
+(which makes passes at compilers) written by James Hacker.
+
+, 1 April 1989 Ty Coon, President of Vice This General
+Public License does not permit incorporating your program into proprietary
+programs. If your program is a subroutine library, you may consider it more
+useful to permit linking proprietary applications with the library. If this
+is what you want to do, use the GNU Lesser General Public License instead
+of this License.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-only.txt b/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-only.txt
new file mode 100644
index 0000000..e142a52
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-only.txt
@@ -0,0 +1,625 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and
+other kinds of works.
+
+The licenses for most software and other practical works are designed to take
+away your freedom to share and change the works. By contrast, the GNU General
+Public License is intended to guarantee your freedom to share and change all
+versions of a program--to make sure it remains free software for all its users.
+We, the Free Software Foundation, use the GNU General Public License for most
+of our software; it applies also to any other work released this way by its
+authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for them if you wish), that
+you receive source code or can get it if you want it, that you can change
+the software or use pieces of it in new free programs, and that you know you
+can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights
+or asking you to surrender the rights. Therefore, you have certain responsibilities
+if you distribute copies of the software, or if you modify it: responsibilities
+to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or
+for a fee, you must pass on to the recipients the same freedoms that you received.
+You must make sure that they, too, receive or can get the source code. And
+you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert
+copyright on the software, and (2) offer you this License giving you legal
+permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that
+there is no warranty for this free software. For both users' and authors'
+sake, the GPL requires that modified versions be marked as changed, so that
+their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified
+versions of the software inside them, although the manufacturer can do so.
+This is fundamentally incompatible with the aim of protecting users' freedom
+to change the software. The systematic pattern of such abuse occurs in the
+area of products for individuals to use, which is precisely where it is most
+unacceptable. Therefore, we have designed this version of the GPL to prohibit
+the practice for those products. If such problems arise substantially in other
+domains, we stand ready to extend this provision to those domains in future
+versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States
+should not allow patents to restrict development and use of software on general-purpose
+computers, but in those that do, we wish to avoid the special danger that
+patents applied to a free program could make it effectively proprietary. To
+prevent this, the GPL assures that patents cannot be used to render the program
+non-free.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of works,
+such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this License.
+Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals
+or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work in
+a fashion requiring copyright permission, other than the making of an exact
+copy. The resulting work is called a "modified version" of the earlier work
+or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based on the
+Program.
+
+To "propagate" a work means to do anything with it that, without permission,
+would make you directly or secondarily liable for infringement under applicable
+copyright law, except executing it on a computer or modifying a private copy.
+Propagation includes copying, distribution (with or without modification),
+making available to the public, and in some countries other activities as
+well.
+
+To "convey" a work means any kind of propagation that enables other parties
+to make or receive copies. Mere interaction with a user through a computer
+network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to the
+extent that it includes a convenient and prominently visible feature that
+(1) displays an appropriate copyright notice, and (2) tells the user that
+there is no warranty for the work (except to the extent that warranties are
+provided), that licensees may convey the work under this License, and how
+to view a copy of this License. If the interface presents a list of user commands
+or options, such as a menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for making
+modifications to it. "Object code" means any non-source form of a work.
+
+A "Standard Interface" means an interface that either is an official standard
+defined by a recognized standards body, or, in the case of interfaces specified
+for a particular programming language, one that is widely used among developers
+working in that language.
+
+The "System Libraries" of an executable work include anything, other than
+the work as a whole, that (a) is included in the normal form of packaging
+a Major Component, but which is not part of that Major Component, and (b)
+serves only to enable use of the work with that Major Component, or to implement
+a Standard Interface for which an implementation is available to the public
+in source code form. A "Major Component", in this context, means a major essential
+component (kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to produce
+the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all the source
+code needed to generate, install, and (for an executable work) run the object
+code and to modify the work, including scripts to control those activities.
+However, it does not include the work's System Libraries, or general-purpose
+tools or generally available free programs which are used unmodified in performing
+those activities but which are not part of the work. For example, Corresponding
+Source includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically linked
+subprograms that the work is specifically designed to require, such as by
+intimate data communication or control flow between those subprograms and
+other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate
+automatically from other parts of the Corresponding Source.
+
+ The Corresponding Source for a work in source code form is that same work.
+
+ 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of copyright
+on the Program, and are irrevocable provided the stated conditions are met.
+This License explicitly affirms your unlimited permission to run the unmodified
+Program. The output from running a covered work is covered by this License
+only if the output, given its content, constitutes a covered work. This License
+acknowledges your rights of fair use or other equivalent, as provided by copyright
+law.
+
+You may make, run and propagate covered works that you do not convey, without
+conditions so long as your license otherwise remains in force. You may convey
+covered works to others for the sole purpose of having them make modifications
+exclusively for you, or provide you with facilities for running those works,
+provided that you comply with the terms of this License in conveying all material
+for which you do not control copyright. Those thus making or running the covered
+works for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of your copyrighted
+material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions
+stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological measure
+under any applicable law fulfilling obligations under article 11 of the WIPO
+copyright treaty adopted on 20 December 1996, or similar laws prohibiting
+or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention
+of technological measures to the extent such circumvention is effected by
+exercising rights under this License with respect to the covered work, and
+you disclaim any intention to limit operation or modification of the work
+as a means of enforcing, against the work's users, your or third parties'
+legal rights to forbid circumvention of technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you receive
+it, in any medium, provided that you conspicuously and appropriately publish
+on each copy an appropriate copyright notice; keep intact all notices stating
+that this License and any non-permissive terms added in accord with section
+7 apply to the code; keep intact all notices of the absence of any warranty;
+and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you
+may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to produce
+it from the Program, in the form of source code under the terms of section
+4, provided that you also meet all of these conditions:
+
+a) The work must carry prominent notices stating that you modified it, and
+giving a relevant date.
+
+b) The work must carry prominent notices stating that it is released under
+this License and any conditions added under section 7. This requirement modifies
+the requirement in section 4 to "keep intact all notices".
+
+c) You must license the entire work, as a whole, under this License to anyone
+who comes into possession of a copy. This License will therefore apply, along
+with any applicable section 7 additional terms, to the whole of the work,
+and all its parts, regardless of how they are packaged. This License gives
+no permission to license the work in any other way, but it does not invalidate
+such permission if you have separately received it.
+
+d) If the work has interactive user interfaces, each must display Appropriate
+Legal Notices; however, if the Program has interactive interfaces that do
+not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works,
+which are not by their nature extensions of the covered work, and which are
+not combined with it such as to form a larger program, in or on a volume of
+a storage or distribution medium, is called an "aggregate" if the compilation
+and its resulting copyright are not used to limit the access or legal rights
+of the compilation's users beyond what the individual works permit. Inclusion
+of a covered work in an aggregate does not cause this License to apply to
+the other parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of sections
+4 and 5, provided that you also convey the machine-readable Corresponding
+Source under the terms of this License, in one of these ways:
+
+a) Convey the object code in, or embodied in, a physical product (including
+a physical distribution medium), accompanied by the Corresponding Source fixed
+on a durable physical medium customarily used for software interchange.
+
+b) Convey the object code in, or embodied in, a physical product (including
+a physical distribution medium), accompanied by a written offer, valid for
+at least three years and valid for as long as you offer spare parts or customer
+support for that product model, to give anyone who possesses the object code
+either (1) a copy of the Corresponding Source for all the software in the
+product that is covered by this License, on a durable physical medium customarily
+used for software interchange, for a price no more than your reasonable cost
+of physically performing this conveying of source, or (2) access to copy the
+Corresponding Source from a network server at no charge.
+
+c) Convey individual copies of the object code with a copy of the written
+offer to provide the Corresponding Source. This alternative is allowed only
+occasionally and noncommercially, and only if you received the object code
+with such an offer, in accord with subsection 6b.
+
+d) Convey the object code by offering access from a designated place (gratis
+or for a charge), and offer equivalent access to the Corresponding Source
+in the same way through the same place at no further charge. You need not
+require recipients to copy the Corresponding Source along with the object
+code. If the place to copy the object code is a network server, the Corresponding
+Source may be on a different server (operated by you or a third party) that
+supports equivalent copying facilities, provided you maintain clear directions
+next to the object code saying where to find the Corresponding Source. Regardless
+of what server hosts the Corresponding Source, you remain obligated to ensure
+that it is available for as long as needed to satisfy these requirements.
+
+e) Convey the object code using peer-to-peer transmission, provided you inform
+other peers where the object code and Corresponding Source of the work are
+being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from
+the Corresponding Source as a System Library, need not be included in conveying
+the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any tangible
+personal property which is normally used for personal, family, or household
+purposes, or (2) anything designed or sold for incorporation into a dwelling.
+In determining whether a product is a consumer product, doubtful cases shall
+be resolved in favor of coverage. For a particular product received by a particular
+user, "normally used" refers to a typical or common use of that class of product,
+regardless of the status of the particular user or of the way in which the
+particular user actually uses, or expects or is expected to use, the product.
+A product is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent the
+only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods, procedures,
+authorization keys, or other information required to install and execute modified
+versions of a covered work in that User Product from a modified version of
+its Corresponding Source. The information must suffice to ensure that the
+continued functioning of the modified object code is in no case prevented
+or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically
+for use in, a User Product, and the conveying occurs as part of a transaction
+in which the right of possession and use of the User Product is transferred
+to the recipient in perpetuity or for a fixed term (regardless of how the
+transaction is characterized), the Corresponding Source conveyed under this
+section must be accompanied by the Installation Information. But this requirement
+does not apply if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has been installed
+in ROM).
+
+The requirement to provide Installation Information does not include a requirement
+to continue to provide support service, warranty, or updates for a work that
+has been modified or installed by the recipient, or for the User Product in
+which it has been modified or installed. Access to a network may be denied
+when the modification itself materially and adversely affects the operation
+of the network or violates the rules and protocols for communication across
+the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord
+with this section must be in a format that is publicly documented (and with
+an implementation available to the public in source code form), and must require
+no special password or key for unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this License
+by making exceptions from one or more of its conditions. Additional permissions
+that are applicable to the entire Program shall be treated as though they
+were included in this License, to the extent that they are valid under applicable
+law. If additional permissions apply only to part of the Program, that part
+may be used separately under those permissions, but the entire Program remains
+governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any
+additional permissions from that copy, or from any part of it. (Additional
+permissions may be written to require their own removal in certain cases when
+you modify the work.) You may place additional permissions on material, added
+by you to a covered work, for which you have or can give appropriate copyright
+permission.
+
+Notwithstanding any other provision of this License, for material you add
+to a covered work, you may (if authorized by the copyright holders of that
+material) supplement the terms of this License with terms:
+
+a) Disclaiming warranty or limiting liability differently from the terms of
+sections 15 and 16 of this License; or
+
+b) Requiring preservation of specified reasonable legal notices or author
+attributions in that material or in the Appropriate Legal Notices displayed
+by works containing it; or
+
+c) Prohibiting misrepresentation of the origin of that material, or requiring
+that modified versions of such material be marked in reasonable ways as different
+from the original version; or
+
+d) Limiting the use for publicity purposes of names of licensors or authors
+of the material; or
+
+e) Declining to grant rights under trademark law for use of some trade names,
+trademarks, or service marks; or
+
+f) Requiring indemnification of licensors and authors of that material by
+anyone who conveys the material (or modified versions of it) with contractual
+assumptions of liability to the recipient, for any liability that these contractual
+assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further restrictions"
+within the meaning of section 10. If the Program as you received it, or any
+part of it, contains a notice stating that it is governed by this License
+along with a term that is a further restriction, you may remove that term.
+If a license document contains a further restriction but permits relicensing
+or conveying under this License, you may add to a covered work material governed
+by the terms of that license document, provided that the further restriction
+does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place,
+in the relevant source files, a statement of the additional terms that apply
+to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form
+of a separately written license, or stated as exceptions; the above requirements
+apply either way.
+
+ 8. Termination.
+
+You may not propagate or modify a covered work except as expressly provided
+under this License. Any attempt otherwise to propagate or modify it is void,
+and will automatically terminate your rights under this License (including
+any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from
+a particular copyright holder is reinstated (a) provisionally, unless and
+until the copyright holder explicitly and finally terminates your license,
+and (b) permanently, if the copyright holder fails to notify you of the violation
+by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently
+if the copyright holder notifies you of the violation by some reasonable means,
+this is the first time you have received notice of violation of this License
+(for any work) from that copyright holder, and you cure the violation prior
+to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses
+of parties who have received copies or rights from you under this License.
+If your rights have been terminated and not permanently reinstated, you do
+not qualify to receive new licenses for the same material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run a copy
+of the Program. Ancillary propagation of a covered work occurring solely as
+a consequence of using peer-to-peer transmission to receive a copy likewise
+does not require acceptance. However, nothing other than this License grants
+you permission to propagate or modify any covered work. These actions infringe
+copyright if you do not accept this License. Therefore, by modifying or propagating
+a covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically receives
+a license from the original licensors, to run, modify and propagate that work,
+subject to this License. You are not responsible for enforcing compliance
+by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an organization,
+or substantially all assets of one, or subdividing an organization, or merging
+organizations. If propagation of a covered work results from an entity transaction,
+each party to that transaction who receives a copy of the work also receives
+whatever licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the Corresponding
+Source of the work from the predecessor in interest, if the predecessor has
+it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights
+granted or affirmed under this License. For example, you may not impose a
+license fee, royalty, or other charge for exercise of rights granted under
+this License, and you may not initiate litigation (including a cross-claim
+or counterclaim in a lawsuit) alleging that any patent claim is infringed
+by making, using, selling, offering for sale, or importing the Program or
+any portion of it.
+
+ 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this License
+of the Program or a work on which the Program is based. The work thus licensed
+is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned or controlled
+by the contributor, whether already acquired or hereafter acquired, that would
+be infringed by some manner, permitted by this License, of making, using,
+or selling its contributor version, but do not include claims that would be
+infringed only as a consequence of further modification of the contributor
+version. For purposes of this definition, "control" includes the right to
+grant patent sublicenses in a manner consistent with the requirements of this
+License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent
+license under the contributor's essential patent claims, to make, use, sell,
+offer for sale, import and otherwise run, modify and propagate the contents
+of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express agreement
+or commitment, however denominated, not to enforce a patent (such as an express
+permission to practice a patent or covenant not to sue for patent infringement).
+To "grant" such a patent license to a party means to make such an agreement
+or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the
+Corresponding Source of the work is not available for anyone to copy, free
+of charge and under the terms of this License, through a publicly available
+network server or other readily accessible means, then you must either (1)
+cause the Corresponding Source to be so available, or (2) arrange to deprive
+yourself of the benefit of the patent license for this particular work, or
+(3) arrange, in a manner consistent with the requirements of this License,
+to extend the patent license to downstream recipients. "Knowingly relying"
+means you have actual knowledge that, but for the patent license, your conveying
+the covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that country
+that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement,
+you convey, or propagate by procuring conveyance of, a covered work, and grant
+a patent license to some of the parties receiving the covered work authorizing
+them to use, propagate, modify or convey a specific copy of the covered work,
+then the patent license you grant is automatically extended to all recipients
+of the covered work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the scope
+of its coverage, prohibits the exercise of, or is conditioned on the non-exercise
+of one or more of the rights that are specifically granted under this License.
+You may not convey a covered work if you are a party to an arrangement with
+a third party that is in the business of distributing software, under which
+you make payment to the third party based on the extent of your activity of
+conveying the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by you
+(or copies made from those copies), or (b) primarily for and in connection
+with specific products or compilations that contain the covered work, unless
+you entered into that arrangement, or that patent license was granted, prior
+to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied
+license or other defenses to infringement that may otherwise be available
+to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or otherwise)
+that contradict the conditions of this License, they do not excuse you from
+the conditions of this License. If you cannot convey a covered work so as
+to satisfy simultaneously your obligations under this License and any other
+pertinent obligations, then as a consequence you may not convey it at all.
+For example, if you agree to terms that obligate you to collect a royalty
+for further conveying from those to whom you convey the Program, the only
+way you could satisfy both those terms and this License would be to refrain
+entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+Notwithstanding any other provision of this License, you have permission to
+link or combine any covered work with a work licensed under version 3 of the
+GNU Affero General Public License into a single combined work, and to convey
+the resulting work. The terms of this License will continue to apply to the
+part which is the covered work, but the special requirements of the GNU Affero
+General Public License, section 13, concerning interaction through a network
+will apply to the combination as such.
+
+ 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of the
+GNU General Public License from time to time. Such new versions will be similar
+in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+that a certain numbered version of the GNU General Public License "or any
+later version" applies to it, you have the option of following the terms and
+conditions either of that numbered version or of any later version published
+by the Free Software Foundation. If the Program does not specify a version
+number of the GNU General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of
+the GNU General Public License can be used, that proxy's public statement
+of acceptance of a version permanently authorizes you to choose that version
+for the Program.
+
+Later license versions may give you additional or different permissions. However,
+no additional obligations are imposed on any author or copyright holder as
+a result of your choosing to follow a later version.
+
+ 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
+LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
+EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM
+PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+ 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL
+ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM
+AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
+INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO
+USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
+INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
+PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided above cannot
+be given local legal effect according to their terms, reviewing courts shall
+apply local law that most closely approximates an absolute waiver of all civil
+liability in connection with the Program, unless a warranty or assumption
+of liability accompanies a copy of the Program in return for a fee. END OF
+TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible
+use to the public, the best way to achieve this is to make it free software
+which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach
+them to the start of each source file to most effectively state the exclusion
+of warranty; and each file should have at least the "copyright" line and a
+pointer to where the full notice is found.
+
+
+
+Copyright (C)
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation, either version 3 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like
+this when it starts in an interactive mode:
+
+ Copyright (C)
+
+This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+
+This is free software, and you are welcome to redistribute it under certain
+conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands might
+be different; for a GUI interface, you would use an "about box".
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. For
+more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General Public
+License instead of this License. But first, please read .
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-or-later.txt b/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-or-later.txt
new file mode 100644
index 0000000..f6cdd22
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/GPL-3.0-or-later.txt
@@ -0,0 +1,232 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and other kinds of works.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+TERMS AND CONDITIONS
+
+0. Definitions.
+
+“This License” refers to version 3 of the GNU General Public License.
+
+“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
+
+To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
+
+A “covered work” means either the unmodified Program or a work based on the Program.
+
+To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
+
+A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+2. Basic Permissions.
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+4. Conveying Verbatim Copies.
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
+
+ c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+7. Additional Terms.
+“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
+
+8. Termination.
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+11. Patents.
+A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
+
+A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+13. Use with the GNU Affero General Public License.
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
+
+14. Revised Versions of this License.
+The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+15. Disclaimer of Warranty.
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
+
+You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/LGPL-2.1-only.txt b/frame/kdeconnect/LICENSES/kdeconnect/LGPL-2.1-only.txt
new file mode 100644
index 0000000..130dffb
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/LGPL-2.1-only.txt
@@ -0,0 +1,467 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 2.1, February 1999
+
+Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts as the
+successor of the GNU Library Public License, version 2, hence the version
+number 2.1.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Lesser General Public License, applies to some specially
+designated software packages--typically libraries--of the Free Software Foundation
+and other authors who decide to use it. You can use it too, but we suggest
+you first think carefully about whether this license or the ordinary General
+Public License is the better strategy to use in any particular case, based
+on the explanations below.
+
+When we speak of free software, we are referring to freedom of use, not price.
+Our General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish); that you receive source code or can get it if you want it; that you
+can change the software and use pieces of it in new free programs; and that
+you are informed that you can do these things.
+
+To protect your rights, we need to make restrictions that forbid distributors
+to deny you these rights or to ask you to surrender these rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+other code with the library, you must provide complete object files to the
+recipients, so that they can relink them with the library after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+We protect your rights with a two-step method: (1) we copyright the library,
+and (2) we offer you this license, which gives you legal permission to copy,
+distribute and/or modify the library.
+
+To protect each distributor, we want to make it very clear that there is no
+warranty for the free library. Also, if the library is modified by someone
+else and passed on, the recipients should know that what they have is not
+the original version, so that the original author's reputation will not be
+affected by problems that might be introduced by others.
+
+Finally, software patents pose a constant threat to the existence of any free
+program. We wish to make sure that a company cannot effectively restrict the
+users of a free program by obtaining a restrictive license from a patent holder.
+Therefore, we insist that any patent license obtained for a version of the
+library must be consistent with the full freedom of use specified in this
+license.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License. This license, the GNU Lesser General Public License,
+applies to certain designated libraries, and is quite different from the ordinary
+General Public License. We use this license for certain libraries in order
+to permit linking those libraries into non-free programs.
+
+When a program is linked with a library, whether statically or using a shared
+library, the combination of the two is legally speaking a combined work, a
+derivative of the original library. The ordinary General Public License therefore
+permits such linking only if the entire combination fits its criteria of freedom.
+The Lesser General Public License permits more lax criteria for linking other
+code with the library.
+
+We call this license the "Lesser" General Public License because it does Less
+to protect the user's freedom than the ordinary General Public License. It
+also provides other free software developers Less of an advantage over competing
+non-free programs. These disadvantages are the reason we use the ordinary
+General Public License for many libraries. However, the Lesser license provides
+advantages in certain special circumstances.
+
+For example, on rare occasions, there may be a special need to encourage the
+widest possible use of a certain library, so that it becomes a de-facto standard.
+To achieve this, non-free programs must be allowed to use the library. A more
+frequent case is that a free library does the same job as widely used non-free
+libraries. In this case, there is little to gain by limiting the free library
+to free software only, so we use the Lesser General Public License.
+
+In other cases, permission to use a particular library in non-free programs
+enables a greater number of people to use a large body of free software. For
+example, permission to use the GNU C Library in non-free programs enables
+many more people to use the whole GNU operating system, as well as its variant,
+the GNU/Linux operating system.
+
+Although the Lesser General Public License is Less protective of the users'
+freedom, it does ensure that the user of a program that is linked with the
+Library has the freedom and the wherewithal to run that program using a modified
+version of the Library.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, whereas the latter must be combined with the library in
+order to run.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library or other program
+which contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Lesser General
+Public License (also called "this License"). Each licensee is addressed as
+"you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also combine or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (1) uses at run time a copy of the library
+already present on the user's computer system, rather than copying library
+functions into the executable, and (2) will operate properly with a modified
+version of the library, if the user installs one, as long as the modified
+version is interface-compatible with the version that the work was made with.
+
+c) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+d) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+e) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the materials to be distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties with this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+< one line to give the library's name and an idea of what it does. >
+
+Copyright (C) < year > < name of author >
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Lesser General Public License as published by the Free
+Software Foundation; either version 2.1 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this library; if not, write to the Free Software Foundation, Inc., 51
+Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information
+on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+< signature of Ty Coon > , 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/LGPL-3.0-only.txt b/frame/kdeconnect/LICENSES/kdeconnect/LGPL-3.0-only.txt
new file mode 100644
index 0000000..bd405af
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/LGPL-3.0-only.txt
@@ -0,0 +1,163 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+This version of the GNU Lesser General Public License incorporates the terms
+and conditions of version 3 of the GNU General Public License, supplemented
+by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+
+
+As used herein, "this License" refers to version 3 of the GNU Lesser General
+Public License, and the "GNU GPL" refers to version 3 of the GNU General Public
+License.
+
+
+
+"The Library" refers to a covered work governed by this License, other than
+an Application or a Combined Work as defined below.
+
+
+
+An "Application" is any work that makes use of an interface provided by the
+Library, but which is not otherwise based on the Library. Defining a subclass
+of a class defined by the Library is deemed a mode of using an interface provided
+by the Library.
+
+
+
+A "Combined Work" is a work produced by combining or linking an Application
+with the Library. The particular version of the Library with which the Combined
+Work was made is also called the "Linked Version".
+
+
+
+The "Minimal Corresponding Source" for a Combined Work means the Corresponding
+Source for the Combined Work, excluding any source code for portions of the
+Combined Work that, considered in isolation, are based on the Application,
+and not on the Linked Version.
+
+
+
+The "Corresponding Application Code" for a Combined Work means the object
+code and/or source code for the Application, including any data and utility
+programs needed for reproducing the Combined Work from the Application, but
+excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+You may convey a covered work under sections 3 and 4 of this License without
+being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+If you modify a copy of the Library, and, in your modifications, a facility
+refers to a function or data to be supplied by an Application that uses the
+facility (other than as an argument passed when the facility is invoked),
+then you may convey a copy of the modified version:
+
+a) under this License, provided that you make a good faith effort to ensure
+that, in the event an Application does not supply the function or data, the
+facility still operates, and performs whatever part of its purpose remains
+meaningful, or
+
+b) under the GNU GPL, with none of the additional permissions of this License
+applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+The object code form of an Application may incorporate material from a header
+file that is part of the Library. You may convey such object code under terms
+of your choice, provided that, if the incorporated material is not limited
+to numerical parameters, data structure layouts and accessors, or small macros,
+inline functions and templates (ten or fewer lines in length), you do both
+of the following:
+
+a) Give prominent notice with each copy of the object code that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the object code with a copy of the GNU GPL and this license document.
+
+ 4. Combined Works.
+
+You may convey a Combined Work under terms of your choice that, taken together,
+effectively do not restrict modification of the portions of the Library contained
+in the Combined Work and reverse engineering for debugging such modifications,
+if you also do each of the following:
+
+a) Give prominent notice with each copy of the Combined Work that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the Combined Work with a copy of the GNU GPL and this license
+document.
+
+c) For a Combined Work that displays copyright notices during execution, include
+the copyright notice for the Library among these notices, as well as a reference
+directing the user to the copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+0) Convey the Minimal Corresponding Source under the terms of this License,
+and the Corresponding Application Code in a form suitable for, and under terms
+that permit, the user to recombine or relink the Application with a modified
+version of the Linked Version to produce a modified Combined Work, in the
+manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
+
+1) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (a) uses at run time a copy of the Library
+already present on the user's computer system, and (b) will operate properly
+with a modified version of the Library that is interface-compatible with the
+Linked Version.
+
+e) Provide Installation Information, but only if you would otherwise be required
+to provide such information under section 6 of the GNU GPL, and only to the
+extent that such information is necessary to install and execute a modified
+version of the Combined Work produced by recombining or relinking the Application
+with a modified version of the Linked Version. (If you use option 4d0, the
+Installation Information must accompany the Minimal Corresponding Source and
+Corresponding Application Code. If you use option 4d1, you must provide the
+Installation Information in the manner specified by section 6 of the GNU GPL
+for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+You may place library facilities that are a work based on the Library side
+by side in a single library together with other library facilities that are
+not Applications and are not covered by this License, and convey such a combined
+library under terms of your choice, if you do both of the following:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities, conveyed under the
+terms of this License.
+
+b) Give prominent notice with the combined library that part of it is a work
+based on the Library, and explaining where to find the accompanying uncombined
+form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+The Free Software Foundation may publish revised and/or new versions of the
+GNU Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library as you
+received it specifies that a certain numbered version of the GNU Lesser General
+Public License "or any later version" applies to it, you have the option of
+following the terms and conditions either of that published version or of
+any later version published by the Free Software Foundation. If the Library
+as you received it does not specify a version number of the GNU Lesser General
+Public License, you may choose any version of the GNU Lesser General Public
+License ever published by the Free Software Foundation.
+
+If the Library as you received it specifies that a proxy can decide whether
+future versions of the GNU Lesser General Public License shall apply, that
+proxy's public statement of acceptance of any version is permanent authorization
+for you to choose that version for the Library.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-GPL.txt b/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-GPL.txt
new file mode 100644
index 0000000..60a2dff
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-GPL.txt
@@ -0,0 +1,12 @@
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License as
+published by the Free Software Foundation; either version 3 of
+the license or (at your option) at any later version that is
+accepted by the membership of KDE e.V. (or its successor
+approved by the membership of KDE e.V.), which shall act as a
+proxy as defined in Section 14 of version 3 of the license.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-LGPL.txt b/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-LGPL.txt
new file mode 100644
index 0000000..232b3c5
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/LicenseRef-KDE-Accepted-LGPL.txt
@@ -0,0 +1,12 @@
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 3 of the license or (at your option) any later version
+that is accepted by the membership of KDE e.V. (or its successor
+approved by the membership of KDE e.V.), which shall act as a
+proxy as defined in Section 6 of version 3 of the license.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
diff --git a/frame/kdeconnect/LICENSES/kdeconnect/MIT.txt b/frame/kdeconnect/LICENSES/kdeconnect/MIT.txt
new file mode 100644
index 0000000..2071b23
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kdeconnect/MIT.txt
@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/frame/kdeconnect/LICENSES/kpeople/BSD-3-Clause.txt b/frame/kdeconnect/LICENSES/kpeople/BSD-3-Clause.txt
new file mode 100644
index 0000000..ea890af
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kpeople/BSD-3-Clause.txt
@@ -0,0 +1,11 @@
+Copyright (c) .
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/frame/kdeconnect/LICENSES/kpeople/CC0-1.0.txt b/frame/kdeconnect/LICENSES/kpeople/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kpeople/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/frame/kdeconnect/LICENSES/kpeople/LGPL-2.1-or-later.txt b/frame/kdeconnect/LICENSES/kpeople/LGPL-2.1-or-later.txt
new file mode 100644
index 0000000..04bb156
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/kpeople/LGPL-2.1-or-later.txt
@@ -0,0 +1,468 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 2.1, February 1999
+
+Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts as the
+successor of the GNU Library Public License, version 2, hence the version
+number 2.1.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Lesser General Public License, applies to some specially
+designated software packages--typically libraries--of the Free Software Foundation
+and other authors who decide to use it. You can use it too, but we suggest
+you first think carefully about whether this license or the ordinary General
+Public License is the better strategy to use in any particular case, based
+on the explanations below.
+
+When we speak of free software, we are referring to freedom of use, not price.
+Our General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish); that you receive source code or can get it if you want it; that you
+can change the software and use pieces of it in new free programs; and that
+you are informed that you can do these things.
+
+To protect your rights, we need to make restrictions that forbid distributors
+to deny you these rights or to ask you to surrender these rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+other code with the library, you must provide complete object files to the
+recipients, so that they can relink them with the library after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+We protect your rights with a two-step method: (1) we copyright the library,
+and (2) we offer you this license, which gives you legal permission to copy,
+distribute and/or modify the library.
+
+To protect each distributor, we want to make it very clear that there is no
+warranty for the free library. Also, if the library is modified by someone
+else and passed on, the recipients should know that what they have is not
+the original version, so that the original author's reputation will not be
+affected by problems that might be introduced by others.
+
+Finally, software patents pose a constant threat to the existence of any free
+program. We wish to make sure that a company cannot effectively restrict the
+users of a free program by obtaining a restrictive license from a patent holder.
+Therefore, we insist that any patent license obtained for a version of the
+library must be consistent with the full freedom of use specified in this
+license.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License. This license, the GNU Lesser General Public License,
+applies to certain designated libraries, and is quite different from the ordinary
+General Public License. We use this license for certain libraries in order
+to permit linking those libraries into non-free programs.
+
+When a program is linked with a library, whether statically or using a shared
+library, the combination of the two is legally speaking a combined work, a
+derivative of the original library. The ordinary General Public License therefore
+permits such linking only if the entire combination fits its criteria of freedom.
+The Lesser General Public License permits more lax criteria for linking other
+code with the library.
+
+We call this license the "Lesser" General Public License because it does Less
+to protect the user's freedom than the ordinary General Public License. It
+also provides other free software developers Less of an advantage over competing
+non-free programs. These disadvantages are the reason we use the ordinary
+General Public License for many libraries. However, the Lesser license provides
+advantages in certain special circumstances.
+
+For example, on rare occasions, there may be a special need to encourage the
+widest possible use of a certain library, so that it becomes a de-facto standard.
+To achieve this, non-free programs must be allowed to use the library. A more
+frequent case is that a free library does the same job as widely used non-free
+libraries. In this case, there is little to gain by limiting the free library
+to free software only, so we use the Lesser General Public License.
+
+In other cases, permission to use a particular library in non-free programs
+enables a greater number of people to use a large body of free software. For
+example, permission to use the GNU C Library in non-free programs enables
+many more people to use the whole GNU operating system, as well as its variant,
+the GNU/Linux operating system.
+
+Although the Lesser General Public License is Less protective of the users'
+freedom, it does ensure that the user of a program that is linked with the
+Library has the freedom and the wherewithal to run that program using a modified
+version of the Library.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, whereas the latter must be combined with the library in
+order to run.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library or other program
+which contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Lesser General
+Public License (also called "this License"). Each licensee is addressed as
+"you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also combine or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (1) uses at run time a copy of the library
+already present on the user's computer system, rather than copying library
+functions into the executable, and (2) will operate properly with a modified
+version of the library, if the user installs one, as long as the modified
+version is interface-compatible with the version that the work was made with.
+
+c) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+d) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+e) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the materials to be distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties with this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+
+
+Copyright (C)
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Lesser General Public License as published by the Free
+Software Foundation; either version 2.1 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this library; if not, write to the Free Software Foundation, Inc., 51
+Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+< signature of Ty Coon > , 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/libfakekey/COPYRIGHT.txt b/frame/kdeconnect/LICENSES/libfakekey/COPYRIGHT.txt
new file mode 100644
index 0000000..8e5cc5b
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/libfakekey/COPYRIGHT.txt
@@ -0,0 +1,24 @@
+libFakeKey
+
+A simple library for converting utf8 chars into 'fake' keypresses.
+
+Uses ideas from Fontconfig, libvirtkeys.c, keysym2ucs.c and dasher.
+
+Authored By Matthew Allum
+
+Copyright (C) 2004 OpenedHand
+
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 2 of the License, or (at your option) any later version.
+
+This library is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public
+License along with this library; if not, write to the
+Free Software Foundation, Inc., 59 Temple Place - Suite 330,
+Boston, MA 02111-1307, USA.
diff --git a/frame/kdeconnect/LICENSES/libfakekey/LGPL-2.0-or-later.txt b/frame/kdeconnect/LICENSES/libfakekey/LGPL-2.0-or-later.txt
new file mode 100644
index 0000000..5c96471
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/libfakekey/LGPL-2.0-or-later.txt
@@ -0,0 +1,446 @@
+GNU LIBRARY GENERAL PUBLIC LICENSE
+
+Version 2, June 1991 Copyright (C) 1991 Free Software Foundation, Inc.
+
+51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the library GPL. It is numbered 2 because
+it goes with version 2 of the ordinary GPL.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Library General Public License, applies to some specially
+designated Free Software Foundation software, and to any other libraries whose
+authors decide to use it. You can use it for your libraries, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library, or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+a program with the library, you must provide complete object files to the
+recipients so that they can relink them with the library, after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+Our method of protecting your rights has two steps: (1) copyright the library,
+and (2) offer you this license which gives you legal permission to copy, distribute
+and/or modify the library.
+
+Also, for each distributor's protection, we want to make certain that everyone
+understands that there is no warranty for this free library. If the library
+is modified by someone else and passed on, we want its recipients to know
+that what they have is not the original version, so that any problems introduced
+by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that companies distributing free software will individually
+obtain patent licenses, thus in effect transforming the program into proprietary
+software. To prevent this, we have made it clear that any patent must be licensed
+for everyone's free use or not licensed at all.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License, which was designed for utility programs. This license,
+the GNU Library General Public License, applies to certain designated libraries.
+This license is quite different from the ordinary one; be sure to read it
+in full, and don't assume that anything in it is the same as in the ordinary
+license.
+
+The reason we have a separate public license for some libraries is that they
+blur the distinction we usually make between modifying or adding to a program
+and simply using it. Linking a program with a library, without changing the
+library, is in some sense simply using the library, and is analogous to running
+a utility program or application program. However, in a textual and legal
+sense, the linked executable is a combined work, a derivative of the original
+library, and the ordinary General Public License treats it as such.
+
+Because of this blurred distinction, using the ordinary General Public License
+for libraries did not effectively promote software sharing, because most developers
+did not use the libraries. We concluded that weaker conditions might promote
+sharing better.
+
+However, unrestricted linking of non-free programs would deprive the users
+of those programs of all benefit from the free status of the libraries themselves.
+This Library General Public License is intended to permit developers of non-free
+programs to use free libraries, while preserving your freedom as a user of
+such programs to change the free libraries that are incorporated in them.
+(We have not seen how to achieve this as regards changes in header files,
+but we have achieved it as regards changes in the actual functions of the
+Library.) The hope is that this will lead to faster development of free libraries.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, while the latter only works together with the library.
+
+Note that it is possible for a library to be covered by the ordinary General
+Public License rather than by this special one.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library which contains a
+notice placed by the copyright holder or other authorized party saying it
+may be distributed under the terms of this Library General Public License
+(also called "this License"). Each licensee is addressed as "you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also compile or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+c) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+d) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the source code distributed need
+not include anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the operating
+system on which the executable runs, unless that component itself accompanies
+the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties to this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Library General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+one line to give the library's name and an idea of what it does.
+
+Copyright (C) year name of author
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Library General Public License as published by the Free
+Software Foundation; either version 2 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Library General Public License for more
+details.
+
+You should have received a copy of the GNU Library General Public License
+along with this library; if not, write to the Free Software Foundation, Inc.,
+51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+signature of Ty Coon, 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/BSD-3-Clause.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/BSD-3-Clause.txt
new file mode 100644
index 0000000..0741db7
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/BSD-3-Clause.txt
@@ -0,0 +1,26 @@
+Copyright (c) . All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice,
+this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+may be used to endorse or promote products derived from this software without
+specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
+USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/CC0-1.0.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/CC0-1.0.txt
new file mode 100644
index 0000000..0e259d4
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/CC0-1.0.txt
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-2.0-only.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-2.0-only.txt
new file mode 100644
index 0000000..0f3d641
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-2.0-only.txt
@@ -0,0 +1,319 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public License is intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users. This General Public License applies to
+most of the Free Software Foundation's software and to any other program whose
+authors commit to using it. (Some other Free Software Foundation software
+is covered by the GNU Lesser General Public License instead.) You can apply
+it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether gratis or
+for a fee, you must give the recipients all the rights that you have. You
+must make sure that they, too, receive or can get the source code. And you
+must show them these terms so they know their rights.
+
+We protect your rights with two steps: (1) copyright the software, and (2)
+offer you this license which gives you legal permission to copy, distribute
+and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain that
+everyone understands that there is no warranty for this free software. If
+the software is modified by someone else and passed on, we want its recipients
+to know that what they have is not the original, so that any problems introduced
+by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that redistributors of a free program will individually
+obtain patent licenses, in effect making the program proprietary. To prevent
+this, we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License applies to any program or other work which contains a notice
+placed by the copyright holder saying it may be distributed under the terms
+of this General Public License. The "Program", below, refers to any such program
+or work, and a "work based on the Program" means either the Program or any
+derivative work under copyright law: that is to say, a work containing the
+Program or a portion of it, either verbatim or with modifications and/or translated
+into another language. (Hereinafter, translation is included without limitation
+in the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running the Program
+is not restricted, and the output from the Program is covered only if its
+contents constitute a work based on the Program (independent of having been
+made by running the Program). Whether that is true depends on what the Program
+does.
+
+1. You may copy and distribute verbatim copies of the Program's source code
+as you receive it, in any medium, provided that you conspicuously and appropriately
+publish on each copy an appropriate copyright notice and disclaimer of warranty;
+keep intact all the notices that refer to this License and to the absence
+of any warranty; and give any other recipients of the Program a copy of this
+License along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion of it,
+thus forming a work based on the Program, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+a) You must cause the modified files to carry prominent notices stating that
+you changed the files and the date of any change.
+
+b) You must cause any work that you distribute or publish, that in whole or
+in part contains or is derived from the Program or any part thereof, to be
+licensed as a whole at no charge to all third parties under the terms of this
+License.
+
+c) If the modified program normally reads commands interactively when run,
+you must cause it, when started running for such interactive use in the most
+ordinary way, to print or display an announcement including an appropriate
+copyright notice and a notice that there is no warranty (or else, saying that
+you provide a warranty) and that users may redistribute the program under
+these conditions, and telling the user how to view a copy of this License.
+(Exception: if the Program itself is interactive but does not normally print
+such an announcement, your work based on the Program is not required to print
+an announcement.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Program, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Program, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Program.
+
+In addition, mere aggregation of another work not based on the Program with
+the Program (or with a work based on the Program) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may copy and distribute the Program (or a work based on it, under Section
+2) in object code or executable form under the terms of Sections 1 and 2 above
+provided that you also do one of the following:
+
+a) Accompany it with the complete corresponding machine-readable source code,
+which must be distributed under the terms of Sections 1 and 2 above on a medium
+customarily used for software interchange; or,
+
+b) Accompany it with a written offer, valid for at least three years, to give
+any third party, for a charge no more than your cost of physically performing
+source distribution, a complete machine-readable copy of the corresponding
+source code, to be distributed under the terms of Sections 1 and 2 above on
+a medium customarily used for software interchange; or,
+
+c) Accompany it with the information you received as to the offer to distribute
+corresponding source code. (This alternative is allowed only for noncommercial
+distribution and only if you received the program in object code or executable
+form with such an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for making
+modifications to it. For an executable work, complete source code means all
+the source code for all modules it contains, plus any associated interface
+definition files, plus the scripts used to control compilation and installation
+of the executable. However, as a special exception, the source code distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+If distribution of executable or object code is made by offering access to
+copy from a designated place, then offering equivalent access to copy the
+source code from the same place counts as distribution of the source code,
+even though third parties are not compelled to copy the source along with
+the object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program except
+as expressly provided under this License. Any attempt otherwise to copy, modify,
+sublicense or distribute the Program is void, and will automatically terminate
+your rights under this License. However, parties who have received copies,
+or rights, from you under this License will not have their licenses terminated
+so long as such parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Program or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Program
+(or any work based on the Program), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the Program),
+the recipient automatically receives a license from the original licensor
+to copy, distribute or modify the Program subject to these terms and conditions.
+You may not impose any further restrictions on the recipients' exercise of
+the rights granted herein. You are not responsible for enforcing compliance
+by third parties to this License.
+
+7. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Program at all. For example, if a
+patent license would not permit royalty-free redistribution of the Program
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply and
+the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system, which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Program under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+9. The Free Software Foundation may publish revised and/or new versions of
+the General Public License from time to time. Such new versions will be similar
+in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Program does not specify a version number of this License, you may choose
+any version ever published by the Free Software Foundation.
+
+10. If you wish to incorporate parts of the Program into other free programs
+whose distribution conditions are different, write to the author to ask for
+permission. For software which is copyrighted by the Free Software Foundation,
+write to the Free Software Foundation; we sometimes make exceptions for this.
+Our decision will be guided by the two goals of preserving the free status
+of all derivatives of our free software and of promoting the sharing and reuse
+of software generally.
+
+ NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible
+use to the public, the best way to achieve this is to make it free software
+which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach
+them to the start of each source file to most effectively convey the exclusion
+of warranty; and each file should have at least the "copyright" line and a
+pointer to where the full notice is found.
+
+
+
+Copyright (C)< yyyy>
+
+This program is free software; you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation; either version 2 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
+Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this when
+it starts in an interactive mode:
+
+Gnomovision version 69, Copyright (C) year name of author Gnomovision comes
+with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software,
+and you are welcome to redistribute it under certain conditions; type `show
+c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may be
+called something other than `show w' and `show c'; they could even be mouse-clicks
+or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision'
+(which makes passes at compilers) written by James Hacker.
+
+, 1 April 1989 Ty Coon, President of Vice This General
+Public License does not permit incorporating your program into proprietary
+programs. If your program is a subroutine library, you may consider it more
+useful to permit linking proprietary applications with the library. If this
+is what you want to do, use the GNU Lesser General Public License instead
+of this License.
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-3.0-only.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-3.0-only.txt
new file mode 100644
index 0000000..e142a52
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/GPL-3.0-only.txt
@@ -0,0 +1,625 @@
+GNU GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and
+other kinds of works.
+
+The licenses for most software and other practical works are designed to take
+away your freedom to share and change the works. By contrast, the GNU General
+Public License is intended to guarantee your freedom to share and change all
+versions of a program--to make sure it remains free software for all its users.
+We, the Free Software Foundation, use the GNU General Public License for most
+of our software; it applies also to any other work released this way by its
+authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for them if you wish), that
+you receive source code or can get it if you want it, that you can change
+the software or use pieces of it in new free programs, and that you know you
+can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights
+or asking you to surrender the rights. Therefore, you have certain responsibilities
+if you distribute copies of the software, or if you modify it: responsibilities
+to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or
+for a fee, you must pass on to the recipients the same freedoms that you received.
+You must make sure that they, too, receive or can get the source code. And
+you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert
+copyright on the software, and (2) offer you this License giving you legal
+permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that
+there is no warranty for this free software. For both users' and authors'
+sake, the GPL requires that modified versions be marked as changed, so that
+their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified
+versions of the software inside them, although the manufacturer can do so.
+This is fundamentally incompatible with the aim of protecting users' freedom
+to change the software. The systematic pattern of such abuse occurs in the
+area of products for individuals to use, which is precisely where it is most
+unacceptable. Therefore, we have designed this version of the GPL to prohibit
+the practice for those products. If such problems arise substantially in other
+domains, we stand ready to extend this provision to those domains in future
+versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States
+should not allow patents to restrict development and use of software on general-purpose
+computers, but in those that do, we wish to avoid the special danger that
+patents applied to a free program could make it effectively proprietary. To
+prevent this, the GPL assures that patents cannot be used to render the program
+non-free.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of works,
+such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this License.
+Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals
+or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work in
+a fashion requiring copyright permission, other than the making of an exact
+copy. The resulting work is called a "modified version" of the earlier work
+or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based on the
+Program.
+
+To "propagate" a work means to do anything with it that, without permission,
+would make you directly or secondarily liable for infringement under applicable
+copyright law, except executing it on a computer or modifying a private copy.
+Propagation includes copying, distribution (with or without modification),
+making available to the public, and in some countries other activities as
+well.
+
+To "convey" a work means any kind of propagation that enables other parties
+to make or receive copies. Mere interaction with a user through a computer
+network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to the
+extent that it includes a convenient and prominently visible feature that
+(1) displays an appropriate copyright notice, and (2) tells the user that
+there is no warranty for the work (except to the extent that warranties are
+provided), that licensees may convey the work under this License, and how
+to view a copy of this License. If the interface presents a list of user commands
+or options, such as a menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for making
+modifications to it. "Object code" means any non-source form of a work.
+
+A "Standard Interface" means an interface that either is an official standard
+defined by a recognized standards body, or, in the case of interfaces specified
+for a particular programming language, one that is widely used among developers
+working in that language.
+
+The "System Libraries" of an executable work include anything, other than
+the work as a whole, that (a) is included in the normal form of packaging
+a Major Component, but which is not part of that Major Component, and (b)
+serves only to enable use of the work with that Major Component, or to implement
+a Standard Interface for which an implementation is available to the public
+in source code form. A "Major Component", in this context, means a major essential
+component (kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to produce
+the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all the source
+code needed to generate, install, and (for an executable work) run the object
+code and to modify the work, including scripts to control those activities.
+However, it does not include the work's System Libraries, or general-purpose
+tools or generally available free programs which are used unmodified in performing
+those activities but which are not part of the work. For example, Corresponding
+Source includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically linked
+subprograms that the work is specifically designed to require, such as by
+intimate data communication or control flow between those subprograms and
+other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate
+automatically from other parts of the Corresponding Source.
+
+ The Corresponding Source for a work in source code form is that same work.
+
+ 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of copyright
+on the Program, and are irrevocable provided the stated conditions are met.
+This License explicitly affirms your unlimited permission to run the unmodified
+Program. The output from running a covered work is covered by this License
+only if the output, given its content, constitutes a covered work. This License
+acknowledges your rights of fair use or other equivalent, as provided by copyright
+law.
+
+You may make, run and propagate covered works that you do not convey, without
+conditions so long as your license otherwise remains in force. You may convey
+covered works to others for the sole purpose of having them make modifications
+exclusively for you, or provide you with facilities for running those works,
+provided that you comply with the terms of this License in conveying all material
+for which you do not control copyright. Those thus making or running the covered
+works for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of your copyrighted
+material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions
+stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological measure
+under any applicable law fulfilling obligations under article 11 of the WIPO
+copyright treaty adopted on 20 December 1996, or similar laws prohibiting
+or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention
+of technological measures to the extent such circumvention is effected by
+exercising rights under this License with respect to the covered work, and
+you disclaim any intention to limit operation or modification of the work
+as a means of enforcing, against the work's users, your or third parties'
+legal rights to forbid circumvention of technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you receive
+it, in any medium, provided that you conspicuously and appropriately publish
+on each copy an appropriate copyright notice; keep intact all notices stating
+that this License and any non-permissive terms added in accord with section
+7 apply to the code; keep intact all notices of the absence of any warranty;
+and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you
+may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to produce
+it from the Program, in the form of source code under the terms of section
+4, provided that you also meet all of these conditions:
+
+a) The work must carry prominent notices stating that you modified it, and
+giving a relevant date.
+
+b) The work must carry prominent notices stating that it is released under
+this License and any conditions added under section 7. This requirement modifies
+the requirement in section 4 to "keep intact all notices".
+
+c) You must license the entire work, as a whole, under this License to anyone
+who comes into possession of a copy. This License will therefore apply, along
+with any applicable section 7 additional terms, to the whole of the work,
+and all its parts, regardless of how they are packaged. This License gives
+no permission to license the work in any other way, but it does not invalidate
+such permission if you have separately received it.
+
+d) If the work has interactive user interfaces, each must display Appropriate
+Legal Notices; however, if the Program has interactive interfaces that do
+not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works,
+which are not by their nature extensions of the covered work, and which are
+not combined with it such as to form a larger program, in or on a volume of
+a storage or distribution medium, is called an "aggregate" if the compilation
+and its resulting copyright are not used to limit the access or legal rights
+of the compilation's users beyond what the individual works permit. Inclusion
+of a covered work in an aggregate does not cause this License to apply to
+the other parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of sections
+4 and 5, provided that you also convey the machine-readable Corresponding
+Source under the terms of this License, in one of these ways:
+
+a) Convey the object code in, or embodied in, a physical product (including
+a physical distribution medium), accompanied by the Corresponding Source fixed
+on a durable physical medium customarily used for software interchange.
+
+b) Convey the object code in, or embodied in, a physical product (including
+a physical distribution medium), accompanied by a written offer, valid for
+at least three years and valid for as long as you offer spare parts or customer
+support for that product model, to give anyone who possesses the object code
+either (1) a copy of the Corresponding Source for all the software in the
+product that is covered by this License, on a durable physical medium customarily
+used for software interchange, for a price no more than your reasonable cost
+of physically performing this conveying of source, or (2) access to copy the
+Corresponding Source from a network server at no charge.
+
+c) Convey individual copies of the object code with a copy of the written
+offer to provide the Corresponding Source. This alternative is allowed only
+occasionally and noncommercially, and only if you received the object code
+with such an offer, in accord with subsection 6b.
+
+d) Convey the object code by offering access from a designated place (gratis
+or for a charge), and offer equivalent access to the Corresponding Source
+in the same way through the same place at no further charge. You need not
+require recipients to copy the Corresponding Source along with the object
+code. If the place to copy the object code is a network server, the Corresponding
+Source may be on a different server (operated by you or a third party) that
+supports equivalent copying facilities, provided you maintain clear directions
+next to the object code saying where to find the Corresponding Source. Regardless
+of what server hosts the Corresponding Source, you remain obligated to ensure
+that it is available for as long as needed to satisfy these requirements.
+
+e) Convey the object code using peer-to-peer transmission, provided you inform
+other peers where the object code and Corresponding Source of the work are
+being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from
+the Corresponding Source as a System Library, need not be included in conveying
+the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any tangible
+personal property which is normally used for personal, family, or household
+purposes, or (2) anything designed or sold for incorporation into a dwelling.
+In determining whether a product is a consumer product, doubtful cases shall
+be resolved in favor of coverage. For a particular product received by a particular
+user, "normally used" refers to a typical or common use of that class of product,
+regardless of the status of the particular user or of the way in which the
+particular user actually uses, or expects or is expected to use, the product.
+A product is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent the
+only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods, procedures,
+authorization keys, or other information required to install and execute modified
+versions of a covered work in that User Product from a modified version of
+its Corresponding Source. The information must suffice to ensure that the
+continued functioning of the modified object code is in no case prevented
+or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically
+for use in, a User Product, and the conveying occurs as part of a transaction
+in which the right of possession and use of the User Product is transferred
+to the recipient in perpetuity or for a fixed term (regardless of how the
+transaction is characterized), the Corresponding Source conveyed under this
+section must be accompanied by the Installation Information. But this requirement
+does not apply if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has been installed
+in ROM).
+
+The requirement to provide Installation Information does not include a requirement
+to continue to provide support service, warranty, or updates for a work that
+has been modified or installed by the recipient, or for the User Product in
+which it has been modified or installed. Access to a network may be denied
+when the modification itself materially and adversely affects the operation
+of the network or violates the rules and protocols for communication across
+the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord
+with this section must be in a format that is publicly documented (and with
+an implementation available to the public in source code form), and must require
+no special password or key for unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this License
+by making exceptions from one or more of its conditions. Additional permissions
+that are applicable to the entire Program shall be treated as though they
+were included in this License, to the extent that they are valid under applicable
+law. If additional permissions apply only to part of the Program, that part
+may be used separately under those permissions, but the entire Program remains
+governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any
+additional permissions from that copy, or from any part of it. (Additional
+permissions may be written to require their own removal in certain cases when
+you modify the work.) You may place additional permissions on material, added
+by you to a covered work, for which you have or can give appropriate copyright
+permission.
+
+Notwithstanding any other provision of this License, for material you add
+to a covered work, you may (if authorized by the copyright holders of that
+material) supplement the terms of this License with terms:
+
+a) Disclaiming warranty or limiting liability differently from the terms of
+sections 15 and 16 of this License; or
+
+b) Requiring preservation of specified reasonable legal notices or author
+attributions in that material or in the Appropriate Legal Notices displayed
+by works containing it; or
+
+c) Prohibiting misrepresentation of the origin of that material, or requiring
+that modified versions of such material be marked in reasonable ways as different
+from the original version; or
+
+d) Limiting the use for publicity purposes of names of licensors or authors
+of the material; or
+
+e) Declining to grant rights under trademark law for use of some trade names,
+trademarks, or service marks; or
+
+f) Requiring indemnification of licensors and authors of that material by
+anyone who conveys the material (or modified versions of it) with contractual
+assumptions of liability to the recipient, for any liability that these contractual
+assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further restrictions"
+within the meaning of section 10. If the Program as you received it, or any
+part of it, contains a notice stating that it is governed by this License
+along with a term that is a further restriction, you may remove that term.
+If a license document contains a further restriction but permits relicensing
+or conveying under this License, you may add to a covered work material governed
+by the terms of that license document, provided that the further restriction
+does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place,
+in the relevant source files, a statement of the additional terms that apply
+to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form
+of a separately written license, or stated as exceptions; the above requirements
+apply either way.
+
+ 8. Termination.
+
+You may not propagate or modify a covered work except as expressly provided
+under this License. Any attempt otherwise to propagate or modify it is void,
+and will automatically terminate your rights under this License (including
+any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from
+a particular copyright holder is reinstated (a) provisionally, unless and
+until the copyright holder explicitly and finally terminates your license,
+and (b) permanently, if the copyright holder fails to notify you of the violation
+by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently
+if the copyright holder notifies you of the violation by some reasonable means,
+this is the first time you have received notice of violation of this License
+(for any work) from that copyright holder, and you cure the violation prior
+to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses
+of parties who have received copies or rights from you under this License.
+If your rights have been terminated and not permanently reinstated, you do
+not qualify to receive new licenses for the same material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run a copy
+of the Program. Ancillary propagation of a covered work occurring solely as
+a consequence of using peer-to-peer transmission to receive a copy likewise
+does not require acceptance. However, nothing other than this License grants
+you permission to propagate or modify any covered work. These actions infringe
+copyright if you do not accept this License. Therefore, by modifying or propagating
+a covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically receives
+a license from the original licensors, to run, modify and propagate that work,
+subject to this License. You are not responsible for enforcing compliance
+by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an organization,
+or substantially all assets of one, or subdividing an organization, or merging
+organizations. If propagation of a covered work results from an entity transaction,
+each party to that transaction who receives a copy of the work also receives
+whatever licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the Corresponding
+Source of the work from the predecessor in interest, if the predecessor has
+it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights
+granted or affirmed under this License. For example, you may not impose a
+license fee, royalty, or other charge for exercise of rights granted under
+this License, and you may not initiate litigation (including a cross-claim
+or counterclaim in a lawsuit) alleging that any patent claim is infringed
+by making, using, selling, offering for sale, or importing the Program or
+any portion of it.
+
+ 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this License
+of the Program or a work on which the Program is based. The work thus licensed
+is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned or controlled
+by the contributor, whether already acquired or hereafter acquired, that would
+be infringed by some manner, permitted by this License, of making, using,
+or selling its contributor version, but do not include claims that would be
+infringed only as a consequence of further modification of the contributor
+version. For purposes of this definition, "control" includes the right to
+grant patent sublicenses in a manner consistent with the requirements of this
+License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent
+license under the contributor's essential patent claims, to make, use, sell,
+offer for sale, import and otherwise run, modify and propagate the contents
+of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express agreement
+or commitment, however denominated, not to enforce a patent (such as an express
+permission to practice a patent or covenant not to sue for patent infringement).
+To "grant" such a patent license to a party means to make such an agreement
+or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the
+Corresponding Source of the work is not available for anyone to copy, free
+of charge and under the terms of this License, through a publicly available
+network server or other readily accessible means, then you must either (1)
+cause the Corresponding Source to be so available, or (2) arrange to deprive
+yourself of the benefit of the patent license for this particular work, or
+(3) arrange, in a manner consistent with the requirements of this License,
+to extend the patent license to downstream recipients. "Knowingly relying"
+means you have actual knowledge that, but for the patent license, your conveying
+the covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that country
+that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement,
+you convey, or propagate by procuring conveyance of, a covered work, and grant
+a patent license to some of the parties receiving the covered work authorizing
+them to use, propagate, modify or convey a specific copy of the covered work,
+then the patent license you grant is automatically extended to all recipients
+of the covered work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the scope
+of its coverage, prohibits the exercise of, or is conditioned on the non-exercise
+of one or more of the rights that are specifically granted under this License.
+You may not convey a covered work if you are a party to an arrangement with
+a third party that is in the business of distributing software, under which
+you make payment to the third party based on the extent of your activity of
+conveying the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by you
+(or copies made from those copies), or (b) primarily for and in connection
+with specific products or compilations that contain the covered work, unless
+you entered into that arrangement, or that patent license was granted, prior
+to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied
+license or other defenses to infringement that may otherwise be available
+to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or otherwise)
+that contradict the conditions of this License, they do not excuse you from
+the conditions of this License. If you cannot convey a covered work so as
+to satisfy simultaneously your obligations under this License and any other
+pertinent obligations, then as a consequence you may not convey it at all.
+For example, if you agree to terms that obligate you to collect a royalty
+for further conveying from those to whom you convey the Program, the only
+way you could satisfy both those terms and this License would be to refrain
+entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+Notwithstanding any other provision of this License, you have permission to
+link or combine any covered work with a work licensed under version 3 of the
+GNU Affero General Public License into a single combined work, and to convey
+the resulting work. The terms of this License will continue to apply to the
+part which is the covered work, but the special requirements of the GNU Affero
+General Public License, section 13, concerning interaction through a network
+will apply to the combination as such.
+
+ 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of the
+GNU General Public License from time to time. Such new versions will be similar
+in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+that a certain numbered version of the GNU General Public License "or any
+later version" applies to it, you have the option of following the terms and
+conditions either of that numbered version or of any later version published
+by the Free Software Foundation. If the Program does not specify a version
+number of the GNU General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of
+the GNU General Public License can be used, that proxy's public statement
+of acceptance of a version permanently authorizes you to choose that version
+for the Program.
+
+Later license versions may give you additional or different permissions. However,
+no additional obligations are imposed on any author or copyright holder as
+a result of your choosing to follow a later version.
+
+ 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE
+LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER
+EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM
+PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+ 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL
+ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM
+AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL,
+INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO
+USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED
+INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
+PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided above cannot
+be given local legal effect according to their terms, reviewing courts shall
+apply local law that most closely approximates an absolute waiver of all civil
+liability in connection with the Program, unless a warranty or assumption
+of liability accompanies a copy of the Program in return for a fee. END OF
+TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible
+use to the public, the best way to achieve this is to make it free software
+which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach
+them to the start of each source file to most effectively state the exclusion
+of warranty; and each file should have at least the "copyright" line and a
+pointer to where the full notice is found.
+
+
+
+Copyright (C)
+
+This program is free software: you can redistribute it and/or modify it under
+the terms of the GNU General Public License as published by the Free Software
+Foundation, either version 3 of the License, or (at your option) any later
+version.
+
+This program is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with
+this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like
+this when it starts in an interactive mode:
+
+ Copyright (C)
+
+This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+
+This is free software, and you are welcome to redistribute it under certain
+conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands might
+be different; for a GUI interface, you would use an "about box".
+
+You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary. For
+more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General Public
+License instead of this License. But first, please read .
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-2.1-only.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-2.1-only.txt
new file mode 100644
index 0000000..130dffb
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-2.1-only.txt
@@ -0,0 +1,467 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 2.1, February 1999
+
+Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts as the
+successor of the GNU Library Public License, version 2, hence the version
+number 2.1.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Lesser General Public License, applies to some specially
+designated software packages--typically libraries--of the Free Software Foundation
+and other authors who decide to use it. You can use it too, but we suggest
+you first think carefully about whether this license or the ordinary General
+Public License is the better strategy to use in any particular case, based
+on the explanations below.
+
+When we speak of free software, we are referring to freedom of use, not price.
+Our General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish); that you receive source code or can get it if you want it; that you
+can change the software and use pieces of it in new free programs; and that
+you are informed that you can do these things.
+
+To protect your rights, we need to make restrictions that forbid distributors
+to deny you these rights or to ask you to surrender these rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+other code with the library, you must provide complete object files to the
+recipients, so that they can relink them with the library after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+We protect your rights with a two-step method: (1) we copyright the library,
+and (2) we offer you this license, which gives you legal permission to copy,
+distribute and/or modify the library.
+
+To protect each distributor, we want to make it very clear that there is no
+warranty for the free library. Also, if the library is modified by someone
+else and passed on, the recipients should know that what they have is not
+the original version, so that the original author's reputation will not be
+affected by problems that might be introduced by others.
+
+Finally, software patents pose a constant threat to the existence of any free
+program. We wish to make sure that a company cannot effectively restrict the
+users of a free program by obtaining a restrictive license from a patent holder.
+Therefore, we insist that any patent license obtained for a version of the
+library must be consistent with the full freedom of use specified in this
+license.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License. This license, the GNU Lesser General Public License,
+applies to certain designated libraries, and is quite different from the ordinary
+General Public License. We use this license for certain libraries in order
+to permit linking those libraries into non-free programs.
+
+When a program is linked with a library, whether statically or using a shared
+library, the combination of the two is legally speaking a combined work, a
+derivative of the original library. The ordinary General Public License therefore
+permits such linking only if the entire combination fits its criteria of freedom.
+The Lesser General Public License permits more lax criteria for linking other
+code with the library.
+
+We call this license the "Lesser" General Public License because it does Less
+to protect the user's freedom than the ordinary General Public License. It
+also provides other free software developers Less of an advantage over competing
+non-free programs. These disadvantages are the reason we use the ordinary
+General Public License for many libraries. However, the Lesser license provides
+advantages in certain special circumstances.
+
+For example, on rare occasions, there may be a special need to encourage the
+widest possible use of a certain library, so that it becomes a de-facto standard.
+To achieve this, non-free programs must be allowed to use the library. A more
+frequent case is that a free library does the same job as widely used non-free
+libraries. In this case, there is little to gain by limiting the free library
+to free software only, so we use the Lesser General Public License.
+
+In other cases, permission to use a particular library in non-free programs
+enables a greater number of people to use a large body of free software. For
+example, permission to use the GNU C Library in non-free programs enables
+many more people to use the whole GNU operating system, as well as its variant,
+the GNU/Linux operating system.
+
+Although the Lesser General Public License is Less protective of the users'
+freedom, it does ensure that the user of a program that is linked with the
+Library has the freedom and the wherewithal to run that program using a modified
+version of the Library.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, whereas the latter must be combined with the library in
+order to run.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library or other program
+which contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Lesser General
+Public License (also called "this License"). Each licensee is addressed as
+"you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also combine or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (1) uses at run time a copy of the library
+already present on the user's computer system, rather than copying library
+functions into the executable, and (2) will operate properly with a modified
+version of the library, if the user installs one, as long as the modified
+version is interface-compatible with the version that the work was made with.
+
+c) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+d) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+e) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the materials to be distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties with this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+< one line to give the library's name and an idea of what it does. >
+
+Copyright (C) < year > < name of author >
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Lesser General Public License as published by the Free
+Software Foundation; either version 2.1 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this library; if not, write to the Free Software Foundation, Inc., 51
+Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information
+on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+< signature of Ty Coon > , 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-3.0-only.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-3.0-only.txt
new file mode 100644
index 0000000..bd405af
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/LGPL-3.0-only.txt
@@ -0,0 +1,163 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+This version of the GNU Lesser General Public License incorporates the terms
+and conditions of version 3 of the GNU General Public License, supplemented
+by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+
+
+As used herein, "this License" refers to version 3 of the GNU Lesser General
+Public License, and the "GNU GPL" refers to version 3 of the GNU General Public
+License.
+
+
+
+"The Library" refers to a covered work governed by this License, other than
+an Application or a Combined Work as defined below.
+
+
+
+An "Application" is any work that makes use of an interface provided by the
+Library, but which is not otherwise based on the Library. Defining a subclass
+of a class defined by the Library is deemed a mode of using an interface provided
+by the Library.
+
+
+
+A "Combined Work" is a work produced by combining or linking an Application
+with the Library. The particular version of the Library with which the Combined
+Work was made is also called the "Linked Version".
+
+
+
+The "Minimal Corresponding Source" for a Combined Work means the Corresponding
+Source for the Combined Work, excluding any source code for portions of the
+Combined Work that, considered in isolation, are based on the Application,
+and not on the Linked Version.
+
+
+
+The "Corresponding Application Code" for a Combined Work means the object
+code and/or source code for the Application, including any data and utility
+programs needed for reproducing the Combined Work from the Application, but
+excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+You may convey a covered work under sections 3 and 4 of this License without
+being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+If you modify a copy of the Library, and, in your modifications, a facility
+refers to a function or data to be supplied by an Application that uses the
+facility (other than as an argument passed when the facility is invoked),
+then you may convey a copy of the modified version:
+
+a) under this License, provided that you make a good faith effort to ensure
+that, in the event an Application does not supply the function or data, the
+facility still operates, and performs whatever part of its purpose remains
+meaningful, or
+
+b) under the GNU GPL, with none of the additional permissions of this License
+applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+The object code form of an Application may incorporate material from a header
+file that is part of the Library. You may convey such object code under terms
+of your choice, provided that, if the incorporated material is not limited
+to numerical parameters, data structure layouts and accessors, or small macros,
+inline functions and templates (ten or fewer lines in length), you do both
+of the following:
+
+a) Give prominent notice with each copy of the object code that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the object code with a copy of the GNU GPL and this license document.
+
+ 4. Combined Works.
+
+You may convey a Combined Work under terms of your choice that, taken together,
+effectively do not restrict modification of the portions of the Library contained
+in the Combined Work and reverse engineering for debugging such modifications,
+if you also do each of the following:
+
+a) Give prominent notice with each copy of the Combined Work that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the Combined Work with a copy of the GNU GPL and this license
+document.
+
+c) For a Combined Work that displays copyright notices during execution, include
+the copyright notice for the Library among these notices, as well as a reference
+directing the user to the copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+0) Convey the Minimal Corresponding Source under the terms of this License,
+and the Corresponding Application Code in a form suitable for, and under terms
+that permit, the user to recombine or relink the Application with a modified
+version of the Linked Version to produce a modified Combined Work, in the
+manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
+
+1) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (a) uses at run time a copy of the Library
+already present on the user's computer system, and (b) will operate properly
+with a modified version of the Library that is interface-compatible with the
+Linked Version.
+
+e) Provide Installation Information, but only if you would otherwise be required
+to provide such information under section 6 of the GNU GPL, and only to the
+extent that such information is necessary to install and execute a modified
+version of the Combined Work produced by recombining or relinking the Application
+with a modified version of the Linked Version. (If you use option 4d0, the
+Installation Information must accompany the Minimal Corresponding Source and
+Corresponding Application Code. If you use option 4d1, you must provide the
+Installation Information in the manner specified by section 6 of the GNU GPL
+for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+You may place library facilities that are a work based on the Library side
+by side in a single library together with other library facilities that are
+not Applications and are not covered by this License, and convey such a combined
+library under terms of your choice, if you do both of the following:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities, conveyed under the
+terms of this License.
+
+b) Give prominent notice with the combined library that part of it is a work
+based on the Library, and explaining where to find the accompanying uncombined
+form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+The Free Software Foundation may publish revised and/or new versions of the
+GNU Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library as you
+received it specifies that a certain numbered version of the GNU Lesser General
+Public License "or any later version" applies to it, you have the option of
+following the terms and conditions either of that published version or of
+any later version published by the Free Software Foundation. If the Library
+as you received it does not specify a version number of the GNU Lesser General
+Public License, you may choose any version of the GNU Lesser General Public
+License ever published by the Free Software Foundation.
+
+If the Library as you received it specifies that a proxy can decide whether
+future versions of the GNU Lesser General Public License shall apply, that
+proxy's public statement of acceptance of any version is permanent authorization
+for you to choose that version for the Library.
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-GPL.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-GPL.txt
new file mode 100644
index 0000000..60a2dff
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-GPL.txt
@@ -0,0 +1,12 @@
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU General Public License as
+published by the Free Software Foundation; either version 3 of
+the license or (at your option) at any later version that is
+accepted by the membership of KDE e.V. (or its successor
+approved by the membership of KDE e.V.), which shall act as a
+proxy as defined in Section 14 of version 3 of the license.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
diff --git a/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-LGPL.txt b/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-LGPL.txt
new file mode 100644
index 0000000..232b3c5
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/modemmanager-qt/LicenseRef-KDE-Accepted-LGPL.txt
@@ -0,0 +1,12 @@
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 3 of the license or (at your option) any later version
+that is accepted by the membership of KDE e.V. (or its successor
+approved by the membership of KDE e.V.), which shall act as a
+proxy as defined in Section 6 of version 3 of the license.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
diff --git a/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-2.1-only.txt b/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-2.1-only.txt
new file mode 100644
index 0000000..130dffb
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-2.1-only.txt
@@ -0,0 +1,467 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 2.1, February 1999
+
+Copyright (C) 1991, 1999 Free Software Foundation, Inc.
+
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+[This is the first released version of the Lesser GPL. It also counts as the
+successor of the GNU Library Public License, version 2, hence the version
+number 2.1.]
+
+Preamble
+
+The licenses for most software are designed to take away your freedom to share
+and change it. By contrast, the GNU General Public Licenses are intended to
+guarantee your freedom to share and change free software--to make sure the
+software is free for all its users.
+
+This license, the Lesser General Public License, applies to some specially
+designated software packages--typically libraries--of the Free Software Foundation
+and other authors who decide to use it. You can use it too, but we suggest
+you first think carefully about whether this license or the ordinary General
+Public License is the better strategy to use in any particular case, based
+on the explanations below.
+
+When we speak of free software, we are referring to freedom of use, not price.
+Our General Public Licenses are designed to make sure that you have the freedom
+to distribute copies of free software (and charge for this service if you
+wish); that you receive source code or can get it if you want it; that you
+can change the software and use pieces of it in new free programs; and that
+you are informed that you can do these things.
+
+To protect your rights, we need to make restrictions that forbid distributors
+to deny you these rights or to ask you to surrender these rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of
+the library or if you modify it.
+
+For example, if you distribute copies of the library, whether gratis or for
+a fee, you must give the recipients all the rights that we gave you. You must
+make sure that they, too, receive or can get the source code. If you link
+other code with the library, you must provide complete object files to the
+recipients, so that they can relink them with the library after making changes
+to the library and recompiling it. And you must show them these terms so they
+know their rights.
+
+We protect your rights with a two-step method: (1) we copyright the library,
+and (2) we offer you this license, which gives you legal permission to copy,
+distribute and/or modify the library.
+
+To protect each distributor, we want to make it very clear that there is no
+warranty for the free library. Also, if the library is modified by someone
+else and passed on, the recipients should know that what they have is not
+the original version, so that the original author's reputation will not be
+affected by problems that might be introduced by others.
+
+Finally, software patents pose a constant threat to the existence of any free
+program. We wish to make sure that a company cannot effectively restrict the
+users of a free program by obtaining a restrictive license from a patent holder.
+Therefore, we insist that any patent license obtained for a version of the
+library must be consistent with the full freedom of use specified in this
+license.
+
+Most GNU software, including some libraries, is covered by the ordinary GNU
+General Public License. This license, the GNU Lesser General Public License,
+applies to certain designated libraries, and is quite different from the ordinary
+General Public License. We use this license for certain libraries in order
+to permit linking those libraries into non-free programs.
+
+When a program is linked with a library, whether statically or using a shared
+library, the combination of the two is legally speaking a combined work, a
+derivative of the original library. The ordinary General Public License therefore
+permits such linking only if the entire combination fits its criteria of freedom.
+The Lesser General Public License permits more lax criteria for linking other
+code with the library.
+
+We call this license the "Lesser" General Public License because it does Less
+to protect the user's freedom than the ordinary General Public License. It
+also provides other free software developers Less of an advantage over competing
+non-free programs. These disadvantages are the reason we use the ordinary
+General Public License for many libraries. However, the Lesser license provides
+advantages in certain special circumstances.
+
+For example, on rare occasions, there may be a special need to encourage the
+widest possible use of a certain library, so that it becomes a de-facto standard.
+To achieve this, non-free programs must be allowed to use the library. A more
+frequent case is that a free library does the same job as widely used non-free
+libraries. In this case, there is little to gain by limiting the free library
+to free software only, so we use the Lesser General Public License.
+
+In other cases, permission to use a particular library in non-free programs
+enables a greater number of people to use a large body of free software. For
+example, permission to use the GNU C Library in non-free programs enables
+many more people to use the whole GNU operating system, as well as its variant,
+the GNU/Linux operating system.
+
+Although the Lesser General Public License is Less protective of the users'
+freedom, it does ensure that the user of a program that is linked with the
+Library has the freedom and the wherewithal to run that program using a modified
+version of the Library.
+
+The precise terms and conditions for copying, distribution and modification
+follow. Pay close attention to the difference between a "work based on the
+library" and a "work that uses the library". The former contains code derived
+from the library, whereas the latter must be combined with the library in
+order to run.
+
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License Agreement applies to any software library or other program
+which contains a notice placed by the copyright holder or other authorized
+party saying it may be distributed under the terms of this Lesser General
+Public License (also called "this License"). Each licensee is addressed as
+"you".
+
+A "library" means a collection of software functions and/or data prepared
+so as to be conveniently linked with application programs (which use some
+of those functions and data) to form executables.
+
+The "Library", below, refers to any such software library or work which has
+been distributed under these terms. A "work based on the Library" means either
+the Library or any derivative work under copyright law: that is to say, a
+work containing the Library or a portion of it, either verbatim or with modifications
+and/or translated straightforwardly into another language. (Hereinafter, translation
+is included without limitation in the term "modification".)
+
+"Source code" for a work means the preferred form of the work for making modifications
+to it. For a library, complete source code means all the source code for all
+modules it contains, plus any associated interface definition files, plus
+the scripts used to control compilation and installation of the library.
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running a program
+using the Library is not restricted, and output from such a program is covered
+only if its contents constitute a work based on the Library (independent of
+the use of the Library in a tool for writing it). Whether that is true depends
+on what the Library does and what the program that uses the Library does.
+
+1. You may copy and distribute verbatim copies of the Library's complete source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and disclaimer
+of warranty; keep intact all the notices that refer to this License and to
+the absence of any warranty; and distribute a copy of this License along with
+the Library.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Library or any portion of it,
+thus forming a work based on the Library, and copy and distribute such modifications
+or work under the terms of Section 1 above, provided that you also meet all
+of these conditions:
+
+ a) The modified work must itself be a software library.
+
+b) You must cause the files modified to carry prominent notices stating that
+you changed the files and the date of any change.
+
+c) You must cause the whole of the work to be licensed at no charge to all
+third parties under the terms of this License.
+
+d) If a facility in the modified Library refers to a function or a table of
+data to be supplied by an application program that uses the facility, other
+than as an argument passed when the facility is invoked, then you must make
+a good faith effort to ensure that, in the event an application does not supply
+such function or table, the facility still operates, and performs whatever
+part of its purpose remains meaningful.
+
+(For example, a function in a library to compute square roots has a purpose
+that is entirely well-defined independent of the application. Therefore, Subsection
+2d requires that any application-supplied function or table used by this function
+must be optional: if the application does not supply it, the square root function
+must still compute square roots.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Library, and can be reasonably
+considered independent and separate works in themselves, then this License,
+and its terms, do not apply to those sections when you distribute them as
+separate works. But when you distribute the same sections as part of a whole
+which is a work based on the Library, the distribution of the whole must be
+on the terms of this License, whose permissions for other licensees extend
+to the entire whole, and thus to each and every part regardless of who wrote
+it.
+
+Thus, it is not the intent of this section to claim rights or contest your
+rights to work written entirely by you; rather, the intent is to exercise
+the right to control the distribution of derivative or collective works based
+on the Library.
+
+In addition, mere aggregation of another work not based on the Library with
+the Library (or with a work based on the Library) on a volume of a storage
+or distribution medium does not bring the other work under the scope of this
+License.
+
+3. You may opt to apply the terms of the ordinary GNU General Public License
+instead of this License to a given copy of the Library. To do this, you must
+alter all the notices that refer to this License, so that they refer to the
+ordinary GNU General Public License, version 2, instead of to this License.
+(If a newer version than version 2 of the ordinary GNU General Public License
+has appeared, then you can specify that version instead if you wish.) Do not
+make any other change in these notices.
+
+Once this change is made in a given copy, it is irreversible for that copy,
+so the ordinary GNU General Public License applies to all subsequent copies
+and derivative works made from that copy.
+
+This option is useful when you wish to copy part of the code of the Library
+into a program that is not a library.
+
+4. You may copy and distribute the Library (or a portion or derivative of
+it, under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you accompany it with the complete corresponding
+machine-readable source code, which must be distributed under the terms of
+Sections 1 and 2 above on a medium customarily used for software interchange.
+
+If distribution of object code is made by offering access to copy from a designated
+place, then offering equivalent access to copy the source code from the same
+place satisfies the requirement to distribute the source code, even though
+third parties are not compelled to copy the source along with the object code.
+
+5. A program that contains no derivative of any portion of the Library, but
+is designed to work with the Library by being compiled or linked with it,
+is called a "work that uses the Library". Such a work, in isolation, is not
+a derivative work of the Library, and therefore falls outside the scope of
+this License.
+
+However, linking a "work that uses the Library" with the Library creates an
+executable that is a derivative of the Library (because it contains portions
+of the Library), rather than a "work that uses the library". The executable
+is therefore covered by this License. Section 6 states terms for distribution
+of such executables.
+
+When a "work that uses the Library" uses material from a header file that
+is part of the Library, the object code for the work may be a derivative work
+of the Library even though the source code is not. Whether this is true is
+especially significant if the work can be linked without the Library, or if
+the work is itself a library. The threshold for this to be true is not precisely
+defined by law.
+
+If such an object file uses only numerical parameters, data structure layouts
+and accessors, and small macros and small inline functions (ten lines or less
+in length), then the use of the object file is unrestricted, regardless of
+whether it is legally a derivative work. (Executables containing this object
+code plus portions of the Library will still fall under Section 6.)
+
+Otherwise, if the work is a derivative of the Library, you may distribute
+the object code for the work under the terms of Section 6. Any executables
+containing that work also fall under Section 6, whether or not they are linked
+directly with the Library itself.
+
+6. As an exception to the Sections above, you may also combine or link a "work
+that uses the Library" with the Library to produce a work containing portions
+of the Library, and distribute that work under terms of your choice, provided
+that the terms permit modification of the work for the customer's own use
+and reverse engineering for debugging such modifications.
+
+You must give prominent notice with each copy of the work that the Library
+is used in it and that the Library and its use are covered by this License.
+You must supply a copy of this License. If the work during execution displays
+copyright notices, you must include the copyright notice for the Library among
+them, as well as a reference directing the user to the copy of this License.
+Also, you must do one of these things:
+
+a) Accompany the work with the complete corresponding machine-readable source
+code for the Library including whatever changes were used in the work (which
+must be distributed under Sections 1 and 2 above); and, if the work is an
+executable linked with the Library, with the complete machine-readable "work
+that uses the Library", as object code and/or source code, so that the user
+can modify the Library and then relink to produce a modified executable containing
+the modified Library. (It is understood that the user who changes the contents
+of definitions files in the Library will not necessarily be able to recompile
+the application to use the modified definitions.)
+
+b) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (1) uses at run time a copy of the library
+already present on the user's computer system, rather than copying library
+functions into the executable, and (2) will operate properly with a modified
+version of the library, if the user installs one, as long as the modified
+version is interface-compatible with the version that the work was made with.
+
+c) Accompany the work with a written offer, valid for at least three years,
+to give the same user the materials specified in Subsection 6a, above, for
+a charge no more than the cost of performing this distribution.
+
+d) If distribution of the work is made by offering access to copy from a designated
+place, offer equivalent access to copy the above specified materials from
+the same place.
+
+e) Verify that the user has already received a copy of these materials or
+that you have already sent this user a copy.
+
+For an executable, the required form of the "work that uses the Library" must
+include any data and utility programs needed for reproducing the executable
+from it. However, as a special exception, the materials to be distributed
+need not include anything that is normally distributed (in either source or
+binary form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component itself
+accompanies the executable.
+
+It may happen that this requirement contradicts the license restrictions of
+other proprietary libraries that do not normally accompany the operating system.
+Such a contradiction means you cannot use both them and the Library together
+in an executable that you distribute.
+
+7. You may place library facilities that are a work based on the Library side-by-side
+in a single library together with other library facilities not covered by
+this License, and distribute such a combined library, provided that the separate
+distribution of the work based on the Library and of the other library facilities
+is otherwise permitted, and provided that you do these two things:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities. This must be distributed
+under the terms of the Sections above.
+
+b) Give prominent notice with the combined library of the fact that part of
+it is a work based on the Library, and explaining where to find the accompanying
+uncombined form of the same work.
+
+8. You may not copy, modify, sublicense, link with, or distribute the Library
+except as expressly provided under this License. Any attempt otherwise to
+copy, modify, sublicense, link with, or distribute the Library is void, and
+will automatically terminate your rights under this License. However, parties
+who have received copies, or rights, from you under this License will not
+have their licenses terminated so long as such parties remain in full compliance.
+
+9. You are not required to accept this License, since you have not signed
+it. However, nothing else grants you permission to modify or distribute the
+Library or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the Library
+(or any work based on the Library), you indicate your acceptance of this License
+to do so, and all its terms and conditions for copying, distributing or modifying
+the Library or works based on it.
+
+10. Each time you redistribute the Library (or any work based on the Library),
+the recipient automatically receives a license from the original licensor
+to copy, distribute, link with or modify the Library subject to these terms
+and conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties with this License.
+
+11. If, as a consequence of a court judgment or allegation of patent infringement
+or for any other reason (not limited to patent issues), conditions are imposed
+on you (whether by court order, agreement or otherwise) that contradict the
+conditions of this License, they do not excuse you from the conditions of
+this License. If you cannot distribute so as to satisfy simultaneously your
+obligations under this License and any other pertinent obligations, then as
+a consequence you may not distribute the Library at all. For example, if a
+patent license would not permit royalty-free redistribution of the Library
+by all those who receive copies directly or indirectly through you, then the
+only way you could satisfy both it and this License would be to refrain entirely
+from distribution of the Library.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply,
+and the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents
+or other property right claims or to contest validity of any such claims;
+this section has the sole purpose of protecting the integrity of the free
+software distribution system which is implemented by public license practices.
+Many people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+12. If the distribution and/or use of the Library is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Library under this License may add an explicit geographical
+distribution limitation excluding those countries, so that distribution is
+permitted only in or among countries not thus excluded. In such case, this
+License incorporates the limitation as if written in the body of this License.
+
+13. The Free Software Foundation may publish revised and/or new versions of
+the Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that version
+or of any later version published by the Free Software Foundation. If the
+Library does not specify a license version number, you may choose any version
+ever published by the Free Software Foundation.
+
+14. If you wish to incorporate parts of the Library into other free programs
+whose distribution conditions are incompatible with these, write to the author
+to ask for permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make exceptions
+for this. Our decision will be guided by the two goals of preserving the free
+status of all derivatives of our free software and of promoting the sharing
+and reuse of software generally.
+
+ NO WARRANTY
+
+15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR
+THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY
+"AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE
+OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
+THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE
+THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE
+OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES
+OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH
+HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Libraries
+
+If you develop a new library, and you want it to be of the greatest possible
+use to the public, we recommend making it free software that everyone can
+redistribute and change. You can do so by permitting redistribution under
+these terms (or, alternatively, under the terms of the ordinary General Public
+License).
+
+To apply these terms, attach the following notices to the library. It is safest
+to attach them to the start of each source file to most effectively convey
+the exclusion of warranty; and each file should have at least the "copyright"
+line and a pointer to where the full notice is found.
+
+< one line to give the library's name and an idea of what it does. >
+
+Copyright (C) < year > < name of author >
+
+This library is free software; you can redistribute it and/or modify it under
+the terms of the GNU Lesser General Public License as published by the Free
+Software Foundation; either version 2.1 of the License, or (at your option)
+any later version.
+
+This library is distributed in the hope that it will be useful, but WITHOUT
+ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
+details.
+
+You should have received a copy of the GNU Lesser General Public License along
+with this library; if not, write to the Free Software Foundation, Inc., 51
+Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information
+on how to contact you by electronic and paper mail.
+
+You should also get your employer (if you work as a programmer) or your school,
+if any, to sign a "copyright disclaimer" for the library, if necessary. Here
+is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in
+
+the library `Frob' (a library for tweaking knobs) written
+
+by James Random Hacker.
+
+< signature of Ty Coon > , 1 April 1990
+
+Ty Coon, President of Vice
+
+That's all there is to it!
diff --git a/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-3.0-only.txt b/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-3.0-only.txt
new file mode 100644
index 0000000..bd405af
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/pulseaudio-qt/LGPL-3.0-only.txt
@@ -0,0 +1,163 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+
+Version 3, 29 June 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license
+document, but changing it is not allowed.
+
+This version of the GNU Lesser General Public License incorporates the terms
+and conditions of version 3 of the GNU General Public License, supplemented
+by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+
+
+As used herein, "this License" refers to version 3 of the GNU Lesser General
+Public License, and the "GNU GPL" refers to version 3 of the GNU General Public
+License.
+
+
+
+"The Library" refers to a covered work governed by this License, other than
+an Application or a Combined Work as defined below.
+
+
+
+An "Application" is any work that makes use of an interface provided by the
+Library, but which is not otherwise based on the Library. Defining a subclass
+of a class defined by the Library is deemed a mode of using an interface provided
+by the Library.
+
+
+
+A "Combined Work" is a work produced by combining or linking an Application
+with the Library. The particular version of the Library with which the Combined
+Work was made is also called the "Linked Version".
+
+
+
+The "Minimal Corresponding Source" for a Combined Work means the Corresponding
+Source for the Combined Work, excluding any source code for portions of the
+Combined Work that, considered in isolation, are based on the Application,
+and not on the Linked Version.
+
+
+
+The "Corresponding Application Code" for a Combined Work means the object
+code and/or source code for the Application, including any data and utility
+programs needed for reproducing the Combined Work from the Application, but
+excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+You may convey a covered work under sections 3 and 4 of this License without
+being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+If you modify a copy of the Library, and, in your modifications, a facility
+refers to a function or data to be supplied by an Application that uses the
+facility (other than as an argument passed when the facility is invoked),
+then you may convey a copy of the modified version:
+
+a) under this License, provided that you make a good faith effort to ensure
+that, in the event an Application does not supply the function or data, the
+facility still operates, and performs whatever part of its purpose remains
+meaningful, or
+
+b) under the GNU GPL, with none of the additional permissions of this License
+applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+The object code form of an Application may incorporate material from a header
+file that is part of the Library. You may convey such object code under terms
+of your choice, provided that, if the incorporated material is not limited
+to numerical parameters, data structure layouts and accessors, or small macros,
+inline functions and templates (ten or fewer lines in length), you do both
+of the following:
+
+a) Give prominent notice with each copy of the object code that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the object code with a copy of the GNU GPL and this license document.
+
+ 4. Combined Works.
+
+You may convey a Combined Work under terms of your choice that, taken together,
+effectively do not restrict modification of the portions of the Library contained
+in the Combined Work and reverse engineering for debugging such modifications,
+if you also do each of the following:
+
+a) Give prominent notice with each copy of the Combined Work that the Library
+is used in it and that the Library and its use are covered by this License.
+
+b) Accompany the Combined Work with a copy of the GNU GPL and this license
+document.
+
+c) For a Combined Work that displays copyright notices during execution, include
+the copyright notice for the Library among these notices, as well as a reference
+directing the user to the copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+0) Convey the Minimal Corresponding Source under the terms of this License,
+and the Corresponding Application Code in a form suitable for, and under terms
+that permit, the user to recombine or relink the Application with a modified
+version of the Linked Version to produce a modified Combined Work, in the
+manner specified by section 6 of the GNU GPL for conveying Corresponding Source.
+
+1) Use a suitable shared library mechanism for linking with the Library. A
+suitable mechanism is one that (a) uses at run time a copy of the Library
+already present on the user's computer system, and (b) will operate properly
+with a modified version of the Library that is interface-compatible with the
+Linked Version.
+
+e) Provide Installation Information, but only if you would otherwise be required
+to provide such information under section 6 of the GNU GPL, and only to the
+extent that such information is necessary to install and execute a modified
+version of the Combined Work produced by recombining or relinking the Application
+with a modified version of the Linked Version. (If you use option 4d0, the
+Installation Information must accompany the Minimal Corresponding Source and
+Corresponding Application Code. If you use option 4d1, you must provide the
+Installation Information in the manner specified by section 6 of the GNU GPL
+for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+You may place library facilities that are a work based on the Library side
+by side in a single library together with other library facilities that are
+not Applications and are not covered by this License, and convey such a combined
+library under terms of your choice, if you do both of the following:
+
+a) Accompany the combined library with a copy of the same work based on the
+Library, uncombined with any other library facilities, conveyed under the
+terms of this License.
+
+b) Give prominent notice with the combined library that part of it is a work
+based on the Library, and explaining where to find the accompanying uncombined
+form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+The Free Software Foundation may publish revised and/or new versions of the
+GNU Lesser General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to address
+new problems or concerns.
+
+Each version is given a distinguishing version number. If the Library as you
+received it specifies that a certain numbered version of the GNU Lesser General
+Public License "or any later version" applies to it, you have the option of
+following the terms and conditions either of that published version or of
+any later version published by the Free Software Foundation. If the Library
+as you received it does not specify a version number of the GNU Lesser General
+Public License, you may choose any version of the GNU Lesser General Public
+License ever published by the Free Software Foundation.
+
+If the Library as you received it specifies that a proxy can decide whether
+future versions of the GNU Lesser General Public License shall apply, that
+proxy's public statement of acceptance of any version is permanent authorization
+for you to choose that version for the Library.
diff --git a/frame/kdeconnect/LICENSES/pulseaudio-qt/LicenseRef-KDE-Accepted-LGPL.txt b/frame/kdeconnect/LICENSES/pulseaudio-qt/LicenseRef-KDE-Accepted-LGPL.txt
new file mode 100644
index 0000000..232b3c5
--- /dev/null
+++ b/frame/kdeconnect/LICENSES/pulseaudio-qt/LicenseRef-KDE-Accepted-LGPL.txt
@@ -0,0 +1,12 @@
+This library is free software; you can redistribute it and/or
+modify it under the terms of the GNU Lesser General Public
+License as published by the Free Software Foundation; either
+version 3 of the license or (at your option) any later version
+that is accepted by the membership of KDE e.V. (or its successor
+approved by the membership of KDE e.V.), which shall act as a
+proxy as defined in Section 6 of version 3 of the license.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
diff --git a/frame/kdeconnect/NOTICE.md b/frame/kdeconnect/NOTICE.md
new file mode 100644
index 0000000..522f242
--- /dev/null
+++ b/frame/kdeconnect/NOTICE.md
@@ -0,0 +1,47 @@
+# KDE Connect for the Steam Frame
+
+Frame Control's keyboard and trackpad work through KDE Connect running on the
+Frame. The Frame doesn't come with it, so Frame Control ships it: the six
+packages below, unchanged, exactly as Valve builds them for the Frame's
+SteamOS (aarch64, packaged by "GitLab CI Package Builder
+"). On first use, Frame Control copies them
+to the Frame over SSH and unpacks them into
+`~/.local/share/frame-control/kdeconnect`.
+
+They are separate programs and libraries under their own licences, listed
+below; their licence texts are in `LICENSES//`. Frame Control itself
+is MIT-licensed. It doesn't link to or include any of this code: it starts
+`kdeconnectd` and talks to it over KDE Connect's network protocol, as a phone
+would.
+
+| Package | Version (Valve's build) | Licence | Complete source (Valve's source package) | Upstream |
+|---|---|---|---|---|
+| kdeconnect 24.02.2-1 | 24.02.2-1 | GPL-2.0-only or GPL-3.0-only or any later version accepted by KDE e.V. (LicenseRef-KDE-Accepted-GPL); some files LGPL-2.1/3.0, MIT, BSD-3-Clause, Apache-2.0, CC0-1.0 | [kdeconnect-24.02.2-1.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kdeconnect-24.02.2-1.src.tar.gz) | [KDE](https://download.kde.org/stable/release-service/24.02.2/src/kdeconnect-kde-24.02.2.tar.xz) |
+| kcontacts 6.1.0-1 | 1:6.1.0-1 | LGPL-2.0-or-later; some files MIT, BSD-3-Clause, CC0-1.0, Unicode-DFS-2016 | [kcontacts-6.1.0-1.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kcontacts-6.1.0-1.src.tar.gz) | [KDE](https://download.kde.org/stable/frameworks/6.1/kcontacts-6.1.0.tar.xz) |
+| kpeople 6.1.0-1 | 6.1.0-1 | LGPL-2.1-or-later; some files BSD-3-Clause, CC0-1.0 | [kpeople-6.1.0-1.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kpeople-6.1.0-1.src.tar.gz) | [KDE](https://download.kde.org/stable/frameworks/6.1/kpeople-6.1.0.tar.xz) |
+| modemmanager-qt 6.1.0-1 | 6.1.0-1 | LGPL-2.1-only or LGPL-3.0-only (or later accepted by KDE e.V.); some files BSD-3-Clause, CC0-1.0 | [modemmanager-qt-6.1.0-1.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/modemmanager-qt-6.1.0-1.src.tar.gz) | [KDE](https://download.kde.org/stable/frameworks/6.1/modemmanager-qt-6.1.0.tar.xz) |
+| pulseaudio-qt 1.4.0-3 | 1.4.0-3 | LGPL-2.1-only or LGPL-3.0-only (or later accepted by KDE e.V.) | [pulseaudio-qt-1.4.0-3.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/pulseaudio-qt-1.4.0-3.src.tar.gz) | [KDE](https://download.kde.org/stable/pulseaudio-qt/pulseaudio-qt-1.4.0.tar.xz) |
+| libfakekey 0.3-2 | 0.3-2 | LGPL-2.0-or-later (Copyright © 2004 OpenedHand) | [libfakekey-0.3-2.src.tar.gz](https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/libfakekey-0.3-2.src.tar.gz) | [Yocto Project git, tag 0.3](https://git.yoctoproject.org/libfakekey/tag/?h=0.3) |
+
+## Source code
+
+Each source package is Valve's, copied unchanged: the upstream release
+tarball (with its signature where upstream signs it), the PKGBUILD Valve built
+from, and the signing keys. None of them carry patches. Frame Control
+publishes them next to the binaries, in the
+[kdeconnect-frame-24.02.2-1 release](https://github.com/saphid/frame-control/releases/tag/kdeconnect-frame-24.02.2-1),
+so the source is available from the same place as the binaries for as long as
+Frame Control distributes them. `packages.json` pins each binary and source
+package by SHA-256.
+
+If a link ever stops working, open an issue at
+https://github.com/saphid/frame-control/issues and we'll send you the source.
+
+## Replacing them
+
+These are ordinary files in your home folder on the Frame: you may replace
+them with your own builds, and Frame Control will use whatever `kdeconnectd`
+is at `~/.local/share/frame-control/kdeconnect/root/usr/lib/kdeconnectd`
+(or the Frame's own `/usr/lib/kdeconnectd`, if SteamOS ever ships one).
+Deleting `root/.frame-control-packages` there makes Frame Control copy its
+bundled build back on next use.
diff --git a/frame/kdeconnect/fetch.py b/frame/kdeconnect/fetch.py
new file mode 100644
index 0000000..9e7aa14
--- /dev/null
+++ b/frame/kdeconnect/fetch.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""Download the KDE Connect packages Frame Control ships for the Frame into
+frame/kdeconnect/packages, checking each against its pinned SHA-256
+(packages.json). Already-downloaded files that match are kept.
+
+ios/scripts/make_frame_bundle.py runs this; app/build/fetch-deps.js does the same
+in Node. Usage: python3 frame/kdeconnect/fetch.py
+"""
+import hashlib
+import json
+import sys
+import urllib.request
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+PACKAGES = HERE / "packages"
+
+
+def manifest():
+ return json.loads((HERE / "packages.json").read_text())
+
+
+def sha256(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def fetch():
+ m = manifest()
+ PACKAGES.mkdir(exist_ok=True)
+ for p in m["packages"]:
+ path = PACKAGES / p["file"]
+ if path.is_file() and sha256(path.read_bytes()) == p["sha256"]:
+ continue
+ url = m["release"] + p["file"]
+ with urllib.request.urlopen(url, timeout=120) as r:
+ data = r.read()
+ if sha256(data) != p["sha256"]:
+ raise SystemExit(f"checksum mismatch for {url}: {sha256(data)}")
+ tmp = path.with_suffix(".part")
+ tmp.write_bytes(data)
+ tmp.replace(path)
+ wanted = {p["file"] for p in m["packages"]}
+ for old in PACKAGES.iterdir():
+ if old.name not in wanted:
+ old.unlink()
+ return [PACKAGES / p["file"] for p in m["packages"]]
+
+
+if __name__ == "__main__":
+ for path in fetch():
+ print(path.relative_to(HERE.parent.parent), file=sys.stderr)
diff --git a/frame/kdeconnect/packages.json b/frame/kdeconnect/packages.json
new file mode 100644
index 0000000..1176342
--- /dev/null
+++ b/frame/kdeconnect/packages.json
@@ -0,0 +1,86 @@
+{
+ "about": "KDE Connect for the Steam Frame, as Valve builds it for the Frame's SteamOS (aarch64), and the libraries it needs that the Frame lacks. See NOTICE.md.",
+ "release": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/",
+ "packages": [
+ {
+ "name": "kdeconnect",
+ "version": "24.02.2-1",
+ "file": "kdeconnect-24.02.2-1-aarch64.pkg.tar.zst",
+ "sha256": "4cd88498f3dcd9563009f84022aeb9b60344b8afbcd1d3c5aeddc4d09727ef9d",
+ "size": 1390996,
+ "licenses": [
+ "GPL-2.0-or-later"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kdeconnect-24.02.2-1.src.tar.gz",
+ "source_sha256": "6e4b8e51f71a5492178231fa94d6cb858d7e752c51ea2b02208179f370e0733d",
+ "upstream": "https://download.kde.org/stable/release-service/24.02.2/src/kdeconnect-kde-24.02.2.tar.xz"
+ },
+ {
+ "name": "kcontacts",
+ "version": "6.1.0-1",
+ "file": "kcontacts-6.1.0-1-aarch64.pkg.tar.zst",
+ "sha256": "247c9dd8f8b781e69f818c96170756556a0b0bb75187ad88627217a5f924f90a",
+ "size": 838848,
+ "licenses": [
+ "LGPL-2.0-or-later"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kcontacts-6.1.0-1.src.tar.gz",
+ "source_sha256": "b3a1058e0eb13f6221de23835a128b166885d3b5f2a2126deafd62e556853316",
+ "upstream": "https://download.kde.org/stable/frameworks/6.1/kcontacts-6.1.0.tar.xz"
+ },
+ {
+ "name": "kpeople",
+ "version": "6.1.0-1",
+ "file": "kpeople-6.1.0-1-aarch64.pkg.tar.zst",
+ "sha256": "68d1e680f1fa6b25bc1e8c9d9b2ab3bfe53a74c2b507da0f437a82c58a542942",
+ "size": 464354,
+ "licenses": [
+ "LGPL-2.1-or-later"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/kpeople-6.1.0-1.src.tar.gz",
+ "source_sha256": "3ad93d2709dbea0b3ca4fbcfbd6052f739aff3d6053de03347593d1261bd5268",
+ "upstream": "https://download.kde.org/stable/frameworks/6.1/kpeople-6.1.0.tar.xz"
+ },
+ {
+ "name": "modemmanager-qt",
+ "version": "6.1.0-1",
+ "file": "modemmanager-qt-6.1.0-1-aarch64.pkg.tar.zst",
+ "sha256": "8e918c0c2c84ed4bb2383f3de9288a2cf25cc52a26d749b63df962a3d2cc6d73",
+ "size": 627059,
+ "licenses": [
+ "LGPL-2.1-only",
+ "LGPL-3.0-only"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/modemmanager-qt-6.1.0-1.src.tar.gz",
+ "source_sha256": "4004dfe8d537399e6a7a0cea0b0ec8a4c995ab34cc75a15fb97a12c315555b09",
+ "upstream": "https://download.kde.org/stable/frameworks/6.1/modemmanager-qt-6.1.0.tar.xz"
+ },
+ {
+ "name": "pulseaudio-qt",
+ "version": "1.4.0-3",
+ "file": "pulseaudio-qt-1.4.0-3-aarch64.pkg.tar.zst",
+ "sha256": "b57b91b3cc2b6e80e69a80a7cd93ea0dc4f22b5d54a5ac25540c854e31de92ce",
+ "size": 303766,
+ "licenses": [
+ "LGPL-2.1-only",
+ "LGPL-3.0-only"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/pulseaudio-qt-1.4.0-3.src.tar.gz",
+ "source_sha256": "032490884260eeb5a66d98215926fa7ae7d678f6bf93cafe4be19c0cfa5459da",
+ "upstream": "https://download.kde.org/stable/pulseaudio-qt/pulseaudio-qt-1.4.0.tar.xz"
+ },
+ {
+ "name": "libfakekey",
+ "version": "0.3-2",
+ "file": "libfakekey-0.3-2-aarch64.pkg.tar.zst",
+ "sha256": "a7563b1dcc3702f9ab9c7c07587fdc312119461c545ae5aee693e6a829e8206a",
+ "size": 6804,
+ "licenses": [
+ "LGPL-2.0-or-later"
+ ],
+ "source": "https://github.com/saphid/frame-control/releases/download/kdeconnect-frame-24.02.2-1/libfakekey-0.3-2.src.tar.gz",
+ "source_sha256": "0e8e989660b80ec2fe2569bde5710102841bd76f981acdb1e4b72d4d8d2b4da3",
+ "upstream": "https://git.yoctoproject.org/libfakekey/tag/?h=0.3"
+ }
+ ]
+}
diff --git a/frame/openxr-compat/.gitattributes b/frame/openxr-compat/.gitattributes
new file mode 100644
index 0000000..15fc64b
--- /dev/null
+++ b/frame/openxr-compat/.gitattributes
@@ -0,0 +1,2 @@
+# Preserve upstream headers byte-for-byte, including their existing whitespace.
+vendor/** -whitespace
diff --git a/frame/openxr-compat/.gitignore b/frame/openxr-compat/.gitignore
new file mode 100644
index 0000000..cebbcdf
--- /dev/null
+++ b/frame/openxr-compat/.gitignore
@@ -0,0 +1 @@
+/build-*/
diff --git a/frame/openxr-compat/CMakeLists.txt b/frame/openxr-compat/CMakeLists.txt
new file mode 100644
index 0000000..6f75b6b
--- /dev/null
+++ b/frame/openxr-compat/CMakeLists.txt
@@ -0,0 +1,25 @@
+cmake_minimum_required(VERSION 3.22)
+project(FrameXrCompat LANGUAGES CXX)
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+add_library(compat_logic INTERFACE)
+target_include_directories(compat_logic INTERFACE . vendor)
+if(ANDROID)
+ add_library(XrApiLayer_FRAME_compat SHARED layer.cpp)
+ target_link_libraries(XrApiLayer_FRAME_compat PRIVATE compat_logic log)
+ target_compile_definitions(XrApiLayer_FRAME_compat PRIVATE XR_USE_PLATFORM_ANDROID XR_NO_PROTOTYPES)
+ target_compile_options(XrApiLayer_FRAME_compat PRIVATE -O2 -fvisibility=hidden -Wall -Wextra -Werror)
+ target_link_options(XrApiLayer_FRAME_compat PRIVATE -Wl,-z,max-page-size=16384 -Wl,--no-undefined -Wl,--exclude-libs,ALL -Wl,-s)
+else()
+ enable_testing()
+ add_executable(compat_tests tests/compat_tests.cpp)
+ target_link_libraries(compat_tests PRIVATE compat_logic)
+ target_compile_options(compat_tests PRIVATE -Wall -Wextra -Werror)
+ add_test(NAME compat_logic COMMAND compat_tests)
+ add_executable(dispatch_tests tests/dispatch_tests.cpp)
+ target_link_libraries(dispatch_tests PRIVATE compat_logic)
+ target_include_directories(dispatch_tests PRIVATE tests/shims)
+ target_compile_definitions(dispatch_tests PRIVATE XR_USE_PLATFORM_ANDROID XR_NO_PROTOTYPES)
+ target_compile_options(dispatch_tests PRIVATE -Wall -Wextra -Werror)
+ add_test(NAME layer_dispatch COMMAND dispatch_tests)
+endif()
diff --git a/frame/openxr-compat/README.md b/frame/openxr-compat/README.md
new file mode 100644
index 0000000..5978979
--- /dev/null
+++ b/frame/openxr-compat/README.md
@@ -0,0 +1,254 @@
+# Frame OpenXR compatibility API layer
+
+`XR_APILAYER_FRAME_compat` is an APK-local implicit API layer for Steam Frame's
+OpenXR 1.0 Android runtime. It translates selected 1.1 functionality; it is not
+a conformant replacement for a complete 1.1 runtime. Device facts are in
+[../../docs/vr-apks.md](../../docs/vr-apks.md). No headset was contacted during
+implementation. Loading, Valve-layer coexistence, controller usability, and
+Wolvic startup/rendering on Lepton remain **unverified on device**.
+
+## Behavior
+
+- The negotiated `createApiLayerInstance` hook handles `xrCreateInstance`.
+ API requests >= 1.1 become `XR_API_VERSION_1_0` (1.0.63 with these headers),
+ rather than carrying an arbitrary application's patch into the 1.0 request.
+ Requests below 1.1 are unchanged. The loader may reject future API versions
+ before any layer runs.
+- Enumerate downstream extensions through the **next layer's** GIPA before
+ creation. Only advertised promoted extensions are added, with duplicates
+ removed. All other application extensions remain, except the two stubs below.
+ Log added, stubbed and missing extensions with tag `FrameXrCompat`.
+- Missing `XR_KHR_android_thread_settings` is advertised and accepted;
+ `xrSetAndroidApplicationThreadKHR` logs and returns success without changing
+ thread scheduling. Missing `XR_OCULUS_android_session_state_enable` is
+ advertised and accepted, with no commands or additional session behavior.
+ If downstream supports either extension, preserve it and its real commands.
+ Stubbing these capabilities is intentionally limited to accepting the app's
+ request; it does not provide Meta services.
+- The manifest's `instance_extensions` makes both stubs visible during the
+ loader's pre-instance enumeration and validation. The layer also exposes
+ an enumeration implementation with count/capacity handling. Before a next
+ dispatch is available it can enumerate only its own extensions; normal app
+ global enumeration is performed by the loader, merging runtime and manifest
+ entries. See [loader_core.cpp L106–145](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_core.cpp#L106)
+ and [manifest parsing L549–558](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L549).
+- Unknown missing extensions are never silently removed. Khronos may reject
+ them **before** entering the layer and name them in `OpenXR-Loader` logs
+ ([loader_instance.cpp L149–176](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_instance.cpp#L149)).
+ If they reach this layer, it logs their names and preserves them for the
+ downstream failure.
+- `xrLocateSpaces` dispatches to `xrLocateSpacesKHR` per session/instance.
+ This is the only command added in 1.1. The three relevant structure types
+ (`SPACES_LOCATE_INFO`, `SPACE_LOCATIONS`, `SPACE_VELOCITIES`) and their KHR
+ versions are **numeric aliases**, and the structs are typedef aliases in
+ these headers. Explicit type conversion of local input/output copies is
+ therefore an identity conversion; the velocity chain passes through without
+ mutating its types or linkage. Output arrays remain shared and receive the
+ runtime's results, including on error; caller struct headers remain intact.
+- With palm pose enabled, rewrite both hand paths ending in
+ `/input/grip_surface/pose` to `/input/palm_ext/pose` in `xrStringToPath` and
+ in binding suggestions (including paths obtained before this layer rewrote
+ them). Unknown paths are left alone.
+- `xrGetInstanceProperties` and unrelated commands pass through. The real
+ runtime's identity is not spoofed. Dispatch tables and session ownership are
+ locked, and removed after successful destruction. Downstream calls occur
+ outside the lock.
+
+## Spec basis and interaction profiles
+
+The pinned [1.1 promotions appendix, versions.adoc L16–156](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/sources/chapters/versions.adoc#L16)
+includes a generated promotion list. Its source is the
+[1.1.63 XML registry](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/registry/xr.xml),
+selecting `extension[@promotedto='XR_VERSION_1_1']`. The full list implemented:
+
+| Promoted extension | Handling |
+| --- | --- |
+| `XR_KHR_locate_spaces` | Enable if advertised; core command alias |
+| `XR_EXT_local_floor` | Enable if advertised; reference-space enum is an alias |
+| `XR_EXT_uuid` | Enable if advertised; type alias, no commands |
+| `XR_EXT_palm_pose` | Enable if advertised; rewrite grip-surface paths |
+| `XR_VARJO_quad_views` | Enable if advertised; view-configuration enum alias, support remains optional |
+| `XR_EXT_samsung_odyssey_controller` | Enable if advertised |
+| `XR_EXT_hp_mixed_reality_controller` | Enable if advertised |
+| `XR_HTC_vive_cosmos_controller_interaction` | Enable if advertised |
+| `XR_HTC_vive_focus3_controller_interaction` | Enable if advertised |
+| `XR_ML_ml2_controller_interaction` | Enable if advertised |
+| `XR_FB_touch_controller_pro` | Enable if advertised; old profile still usable |
+| `XR_META_touch_controller_plus` | Enable if advertised; old profile still usable |
+| `XR_BD_controller_interaction` | Enable if advertised |
+| `XR_KHR_maintenance1` | Enable if advertised (included in the pinned registry's promotion list) |
+
+`XR_EXT_hand_interaction` is **not** promoted to 1.1. Preserve it if requested;
+it is not auto-enabled merely because the runtime advertises it. The parent
+provided its availability; the device notes list extensions non-exhaustively.
+
+The registry's `XR_VERSION_1_1` feature introduces 13 profile paths. For
+Samsung Odyssey, HP mixed reality, HTC Cosmos/Focus3, ML2 and the three
+ByteDance Pico profiles, drop a whole suggestion call with success when the
+corresponding extension is absent. Keep it when the extension is enabled.
+Drop the five new Meta paths: `touch_pro_controller`, `touch_plus_controller`,
+`touch_controller_rift_cv1`, `touch_controller_quest_1_rift_s`, and
+`touch_controller_quest_2`. The verified runtime notes do not list these;
+Pro/Plus promotion also renames several components, so the old extension's
+presence alone does not prove support for the new profile. This layer does
+not implement those component conversions. Existing old Pro/Plus profile
+paths are passed through if the app uses them.
+
+Preserve the documented `oculus/touch_controller`, `khr/simple_controller`,
+`valve/frame_controller` and original PC profile paths. The notes' "usual PC
+controllers" is not a complete enumerated list: availability of promoted PC
+profiles is inferred from the runtime's extension advertisement, not invented
+from that phrase. OpenXR has no general profile enumeration API. Dropping
+suggestions prevents an unsupported 1.1 profile from aborting initialization;
+it does not create bindings, so the app must also suggest a supported fallback.
+
+## Headers and licensing
+
+The required public headers (`openxr.h`, `openxr_platform.h`,
+`openxr_platform_defines.h`) are unmodified from
+[OpenXR-SDK release-1.1.63](https://github.com/KhronosGroup/OpenXR-SDK/tree/release-1.1.63/include/openxr),
+commit [f2448a8](https://github.com/KhronosGroup/OpenXR-SDK/commit/f2448a8797c85814aa892efc1ab8707900fbcc78).
+The requested filename `loader_interfaces.h` no longer exists in 1.1 SDK
+releases: negotiation was ratified and moved to `openxr_loader_negotiation.h`
+in 1.0.33 ([spec history L166–186](https://github.com/KhronosGroup/OpenXR-Docs/blob/release-1.1.63/specification/sources/chapters/versions.adoc#L166)).
+To retain that requested interface filename, the layer uses the unmodified,
+ABI-compatible [last legacy header from SDK-Source release-1.0.32](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.32/src/common/loader_interfaces.h).
+Only these four headers are vendored. They offer Apache-2.0 OR MIT; this
+vendoring uses Apache-2.0, reproduced in [vendor/LICENSE](vendor/LICENSE).
+
+## Android discovery and coexistence
+
+**Minimum asset-discovery loader release: 1.0.25 (2022-09-02).** Its
+[release commit, 15c3d8e](https://github.com/KhronosGroup/OpenXR-SDK-Source/commit/15c3d8eb99994e5365d6b6f96eefaf4c51a65a9d)
+explicitly announces APK-packaged API layers;
+[manifest_file.cpp L665–723](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.25/src/loader/manifest_file.cpp#L665)
+implements discovery, and [L934–940](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.0.25/src/loader/manifest_file.cpp#L934)
+adds asset manifests after filesystem manifests. This is not a 1.1-only feature.
+
+**For an app requesting API 1.1, use a 1.1 loader (first release 1.1.36) or
+newer.** A 1.0 loader rejects the request before the layer can downgrade it;
+see [loader_core.cpp L217–230](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_core.cpp#L217).
+The runtime can remain 1.0.
+
+Source inspection at release-1.1.63 confirms:
+
+- [manifest_file.cpp L720–783](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L720)
+ uses the initialized Android asset manager and scans
+ `openxr/1/api_layers/implicit.d/` for JSON. The application must initialize
+ the loader with its Android context (`xrInitializeLoaderKHR`). In the APK,
+ the entry is `assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json`.
+- A bare `library_path`, as used here, is **not explicitly concatenated** with
+ `nativeLibraryDir`. [L878–900](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L878)
+ leaves a bare name for normal dynamic-linker search in the application's
+ namespace (including its native library directory). Relative paths with a
+ slash use [LocateLibraryInAssets L943–952](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L943),
+ which resolves against `GetAndroidNativeLibraryDir()`.
+ [loader_init_data.cpp L87–96](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/loader_init_data.cpp#L87)
+ obtains the asset manager and `ApplicationInfo.nativeLibraryDir` via JNI.
+ Wolvic's supplied manifest has `android:extractNativeLibs="true"`.
+- [android_utilities.cpp L267–322](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/android_utilities.cpp#L267)
+ handles runtime broker discovery, not APK layer discovery. Its
+ `native_lib_dir + so_filename` and `dlopen` refer to the runtime package.
+ They must not be confused with the app's layer library resolution.
+- [manifest_file.cpp L1008–1023](https://github.com/KhronosGroup/OpenXR-SDK-Source/blob/release-1.1.63/src/loader/manifest_file.cpp#L1008)
+ retains filesystem manifests and then appends asset manifests. This leaves
+ `/vendor`'s `XR_APILAYER_VALVE_fdm_injection` discoverable. This layer advances
+ `nextInfo` exactly once, uses next-layer GIPA, preserves the rest of the
+ create-info chain and never opens `vrclient.so` directly. No environment
+ layer-list override or assumed ordering is needed.
+
+`DISABLE_FRAME_XR_COMPAT` is the manifest's disable environment variable.
+Leave it unset to activate the implicit layer. No `enable_environment` is
+required.
+
+## Rebuild and verify
+
+NDK **r30 / 30.0.16248370**, installed at
+`~/Library/Android/ndk/30.0.16248370`, was the latest stable/LTS package shown
+by the [Android download page](https://developer.android.com/ndk/downloads)
+on 2026-09-28. Downloaded
+[android-ndk-r30-darwin.dmg](https://dl.google.com/android/repository/android-ndk-r30-darwin.dmg)
+(1,072,970,961 bytes). The page publishes SHA-1, not SHA-256:
+
+```
+expected: 48591224b6657f46eebbc9d95d4af09bbed8d107
+actual: 48591224b6657f46eebbc9d95d4af09bbed8d107 (PASS)
+```
+
+Mounted read-only with `hdiutil`; copied
+`AndroidNDK16248370.app/Contents/NDK` into that version directory. Despite the
+`darwin-x86_64` toolchain directory name, clang is universal arm64/x86_64 and
+ran on this Apple Silicon Mac.
+
+```sh
+frame/openxr-compat/build.sh
+# Or set ANDROID_NDK_HOME to an installed NDK.
+cmake -S frame/openxr-compat -B frame/openxr-compat/build-host -G Ninja
+cmake --build frame/openxr-compat/build-host
+ctest --test-dir frame/openxr-compat/build-host --output-on-failure
+```
+
+The script produces arm64-v8a / android-24, C++17, `-O2`, static libc++, hidden
+internal symbols, stripped output, and `-Wl,-z,max-page-size=16384`.
+The committed prebuilt is `prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so`.
+Its SHA-256 is recorded below with the APK checks.
+
+[Host test output](evidence/ctest.txt): two tests passed, covering pure logic
+and the actual layer with a fake next layer and host-only log/JNI shims.
+[Full llvm-readelf -d -s -l output](evidence/readelf.txt) records three LOAD
+segments aligned `0x4000`, only `xrNegotiateLoaderApiLayerInterface` exported,
+and only `libc.so`, `libm.so`, `libdl.so`, `liblog.so` as NEEDED libraries.
+All 46 undefined imports are versioned `@LIBC` except `__android_log_print`.
+There is no `libc++_shared.so` dependency and no unstripped `.symtab`.
+
+No independent model review was run: the task explicitly prohibits delegation.
+No `CODING_STANDARDS.md` exists in this worktree or the primary worktree.
+
+## Wolvic injection artifact (Mac only)
+
+Copied `/tmp/vrapk/wq-dec` to `/tmp/vrapk/wq-layer-dec`; retained its existing
+LAUNCHER fix and native-library extraction setting. Added only:
+
+```
+assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json
+lib/arm64-v8a/libXrApiLayer_FRAME_compat.so
+```
+
+Built using `/tmp/vrapk/jdk/Contents/Home/bin/java` and
+`/tmp/vrapk/apktool_3.0.3.jar`, then signed in place using
+`/tmp/vrapk/uber-apk-signer-1.3.0.jar --overwrite` (embedded debug certificate):
+
+```sh
+/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/apktool_3.0.3.jar \
+ b /tmp/vrapk/wq-layer-dec -o /tmp/vrapk/wolvic-quest-compat.apk
+/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/uber-apk-signer-1.3.0.jar \
+ -a /tmp/vrapk/wolvic-quest-compat.apk --overwrite
+/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/uber-apk-signer-1.3.0.jar \
+ -a /tmp/vrapk/wolvic-quest-compat.apk --onlyVerify
+```
+
+Final APK: `/tmp/vrapk/wolvic-quest-compat.apk` (not committed).
+The bundled `lib/arm64-v8a/libopenxr_loader.so` is byte-for-byte unchanged.
+Its strings include both `openxr/1/api_layers/implicit.d/` and
+`openxr/1/api_layers/explicit.d/`, `AddManifestFilesAndroid` error messages,
+and `xrLocateSpaces`. Thus it contains APK asset discovery and evidence of
+1.1 command support. Its exact upstream release number was not established
+from the binary; runtime execution of those paths remains unverified.
+
+Prebuilt library SHA-256:
+
+```
+479d31c374f137906e03f73209b581d65d7e6a41b8476e6425fa55a6aa40bd68
+```
+
+APK SHA-256:
+
+```
+355a681625e38b71563dcffecb031799eff27894d6ab910a659bace057e82c1f
+```
+
+Final `--onlyVerify` exited 0: zip alignment verified, v2/v3 signatures
+verified, one APK processed and zero errors. ZIP readback confirmed that the
+injected library and manifest match the committed inputs, the loader is
+unchanged, and the binary Android manifest retains the LAUNCHER category.
+See [APK verification evidence](evidence/apk-verification.txt).
diff --git a/frame/openxr-compat/XrApiLayer_FRAME_compat.json b/frame/openxr-compat/XrApiLayer_FRAME_compat.json
new file mode 100644
index 0000000..143d8e3
--- /dev/null
+++ b/frame/openxr-compat/XrApiLayer_FRAME_compat.json
@@ -0,0 +1,21 @@
+{
+ "file_format_version": "1.0.0",
+ "api_layer": {
+ "name": "XR_APILAYER_FRAME_compat",
+ "library_path": "libXrApiLayer_FRAME_compat.so",
+ "api_version": "1.1",
+ "implementation_version": "1",
+ "description": "Steam Frame OpenXR 1.1 to 1.0 compatibility",
+ "disable_environment": "DISABLE_FRAME_XR_COMPAT",
+ "instance_extensions": [
+ {
+ "name": "XR_KHR_android_thread_settings",
+ "extension_version": "6"
+ },
+ {
+ "name": "XR_OCULUS_android_session_state_enable",
+ "extension_version": "1"
+ }
+ ]
+ }
+}
diff --git a/frame/openxr-compat/build.sh b/frame/openxr-compat/build.sh
new file mode 100755
index 0000000..3b08089
--- /dev/null
+++ b/frame/openxr-compat/build.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+set -eu
+here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+ndk=${ANDROID_NDK_HOME:-"$HOME/Library/Android/ndk/30.0.16248370"}
+cmake -S "$here" -B "$here/build-android" -G Ninja \
+ -DCMAKE_TOOLCHAIN_FILE="$ndk/build/cmake/android.toolchain.cmake" \
+ -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-24 \
+ -DANDROID_STL=c++_static -DCMAKE_BUILD_TYPE=Release
+cmake --build "$here/build-android"
+mkdir -p "$here/prebuilt/arm64-v8a"
+cp "$here/build-android/libXrApiLayer_FRAME_compat.so" "$here/prebuilt/arm64-v8a/"
+shasum -a 256 "$here/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so"
diff --git a/frame/openxr-compat/compat_logic.h b/frame/openxr-compat/compat_logic.h
new file mode 100644
index 0000000..9d94fbc
--- /dev/null
+++ b/frame/openxr-compat/compat_logic.h
@@ -0,0 +1,88 @@
+#pragma once
+#include
+#include
+#include
+#include
+
+namespace frame {
+using Names = std::vector;
+inline constexpr const char* layerName = "XR_APILAYER_FRAME_compat";
+inline constexpr const char* stubs[] = {"XR_KHR_android_thread_settings", "XR_OCULUS_android_session_state_enable"};
+// OpenXR-Docs release-1.1.63 registry: promotedto="XR_VERSION_1_1".
+inline constexpr const char* promotions[] = {
+ "XR_KHR_locate_spaces", "XR_EXT_local_floor", "XR_EXT_uuid", "XR_EXT_palm_pose",
+ "XR_VARJO_quad_views", "XR_EXT_samsung_odyssey_controller", "XR_EXT_hp_mixed_reality_controller",
+ "XR_HTC_vive_cosmos_controller_interaction", "XR_HTC_vive_focus3_controller_interaction",
+ "XR_ML_ml2_controller_interaction", "XR_FB_touch_controller_pro", "XR_META_touch_controller_plus",
+ "XR_BD_controller_interaction", "XR_KHR_maintenance1"};
+inline bool has(const Names& names, const std::string& n) {
+ return std::find(names.begin(), names.end(), n) != names.end();
+}
+inline bool downgrade(XrVersion v) { return v >= XR_MAKE_VERSION(1, 1, 0); }
+inline XrVersion runtimeVersion(XrVersion v) { return downgrade(v) ? XR_API_VERSION_1_0 : v; }
+struct ExtensionPlan { Names downstream, stubbed, missing, added; };
+inline ExtensionPlan extensions(const Names& requested, const Names& available, bool promote) {
+ ExtensionPlan p;
+ for (const auto& n : requested) {
+ if (!has(available, n) && (n == stubs[0] || n == stubs[1])) p.stubbed.push_back(n);
+ else {
+ if (!has(p.downstream, n)) p.downstream.push_back(n);
+ if (!has(available, n)) p.missing.push_back(n);
+ }
+ }
+ if (promote) for (auto n : promotions) if (has(available, n) && !has(p.downstream, n)) {
+ p.downstream.push_back(n); p.added.push_back(n);
+ }
+ return p;
+}
+// 1.1's grip_surface is XR_EXT_palm_pose's palm_ext renamed (same pose), not the grip pose.
+inline std::string rewritePath(std::string p, bool palm) {
+ const std::string suffix = "/input/grip_surface/pose";
+ if (palm && (p == "/user/hand/left" + suffix || p == "/user/hand/right" + suffix))
+ p.replace(p.size() - suffix.size(), suffix.size(), "/input/palm_ext/pose");
+ return p;
+}
+// Profiles added to 1.1. The three legacy Meta splits have no 1.0 extension.
+// The renamed Pro/Plus profiles have different component semantics; do not
+// claim the old extension implements the new profile merely because it exists.
+inline bool dropProfile(const std::string& p, const Names& enabled) {
+ for (auto name : {"touch_pro_controller", "touch_plus_controller", "touch_controller_rift_cv1",
+ "touch_controller_quest_1_rift_s", "touch_controller_quest_2"})
+ if (p == std::string("/interaction_profiles/meta/") + name) return true;
+ const char* profiles[][2] = {
+ {"samsung/odyssey_controller", "XR_EXT_samsung_odyssey_controller"},
+ {"hp/mixed_reality_controller", "XR_EXT_hp_mixed_reality_controller"},
+ {"htc/vive_cosmos_controller", "XR_HTC_vive_cosmos_controller_interaction"},
+ {"htc/vive_focus3_controller", "XR_HTC_vive_focus3_controller_interaction"},
+ {"ml/ml2_controller", "XR_ML_ml2_controller_interaction"},
+ {"bytedance/pico_neo3_controller", "XR_BD_controller_interaction"},
+ {"bytedance/pico4_controller", "XR_BD_controller_interaction"},
+ {"bytedance/pico_g3_controller", "XR_BD_controller_interaction"}};
+ for (auto& entry : profiles) if (p == std::string("/interaction_profiles/") + entry[0])
+ return !has(enabled, entry[1]);
+ return false;
+}
+inline XrStructureType extensionType(XrStructureType t) {
+ switch (t) {
+ case XR_TYPE_SPACES_LOCATE_INFO: return XR_TYPE_SPACES_LOCATE_INFO_KHR;
+ case XR_TYPE_SPACE_LOCATIONS: return XR_TYPE_SPACE_LOCATIONS_KHR;
+ case XR_TYPE_SPACE_VELOCITIES: return XR_TYPE_SPACE_VELOCITIES_KHR;
+ default: return t;
+ }
+}
+inline XrResult locate(PFN_xrLocateSpacesKHR next, XrSession session,
+ const XrSpacesLocateInfo* info, XrSpaceLocations* locations) {
+ if (!info || !locations) return XR_ERROR_VALIDATION_FAILURE;
+ static_assert(XR_TYPE_SPACE_VELOCITIES == XR_TYPE_SPACE_VELOCITIES_KHR);
+ static_assert(XR_TYPE_SPACE_LOCATIONS == XR_TYPE_SPACE_LOCATIONS_KHR);
+ static_assert(XR_TYPE_SPACES_LOCATE_INFO == XR_TYPE_SPACES_LOCATE_INFO_KHR);
+ auto in = *info;
+ auto out = *locations;
+ in.type = extensionType(in.type); out.type = extensionType(out.type);
+ // The enums and typedefs are exact aliases. All chained velocities,
+ // including unknown next structures, can be forwarded without mutation.
+ const auto result = next(session, &in, &out);
+ locations->locationCount = out.locationCount;
+ return result;
+}
+}
diff --git a/frame/openxr-compat/evidence/apk-verification.txt b/frame/openxr-compat/evidence/apk-verification.txt
new file mode 100644
index 0000000..bfed1f5
--- /dev/null
+++ b/frame/openxr-compat/evidence/apk-verification.txt
@@ -0,0 +1,29 @@
+2026-09-28, Mac only; no device connection.
+APK: /tmp/vrapk/wolvic-quest-compat.apk
+SHA-256: 355a681625e38b71563dcffecb031799eff27894d6ab910a659bace057e82c1f
+
+Command:
+/tmp/vrapk/jdk/Contents/Home/bin/java -jar /tmp/vrapk/uber-apk-signer-1.3.0.jar -a /tmp/vrapk/wolvic-quest-compat.apk --onlyVerify
+Exit status: 0
+Relevant output:
+- zipalign verified
+- signature verified [v2, v3]
+Successfully processed 1 APKs and 0 errors in 0.63 seconds.
+
+ZIP readback checks: PASS
+- Injected JSON identical to frame/openxr-compat/XrApiLayer_FRAME_compat.json.
+- Injected SO identical to prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so.
+- Existing libopenxr_loader.so identical to /tmp/vrapk/wq-dec version.
+- Binary AndroidManifest.xml contains android.intent.category.LAUNCHER.
+
+Loader strings present:
+openxr/1/api_layers/implicit.d/
+openxr/1/api_layers/explicit.d/
+ApiLayerManifestFile::AddManifestFilesAndroid unable to open asset
+xrLocateSpaces
+
+NDK: 30.0.16248370 (r30)
+DMG size: 1072970961 bytes
+Download-page SHA-1: 48591224b6657f46eebbc9d95d4af09bbed8d107
+Measured SHA-1: 48591224b6657f46eebbc9d95d4af09bbed8d107
+SHA check: PASS
diff --git a/frame/openxr-compat/evidence/ctest.txt b/frame/openxr-compat/evidence/ctest.txt
new file mode 100644
index 0000000..3e3686d
--- /dev/null
+++ b/frame/openxr-compat/evidence/ctest.txt
@@ -0,0 +1,9 @@
+Test project /Users/saphid/projects/steam-frame-xrlayer/frame/openxr-compat/build-host
+ Start 1: compat_logic
+1/2 Test #1: compat_logic ..................... Passed 0.01 sec
+ Start 2: layer_dispatch
+2/2 Test #2: layer_dispatch ................... Passed 0.42 sec
+
+100% tests passed out of 2
+
+Total Test time (real) = 0.43 sec
diff --git a/frame/openxr-compat/evidence/readelf.txt b/frame/openxr-compat/evidence/readelf.txt
new file mode 100644
index 0000000..67a0dbe
--- /dev/null
+++ b/frame/openxr-compat/evidence/readelf.txt
@@ -0,0 +1,110 @@
+
+Elf file type is DYN (Shared object file)
+Entry point 0x0
+There are 9 program headers, starting at offset 64
+
+Program Headers:
+ Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
+ PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0001f8 0x0001f8 R 0x8
+ LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0169e0 0x0169e0 R E 0x4000
+ LOAD 0x0169e0 0x000000000001a9e0 0x000000000001a9e0 0x000a90 0x001620 RW 0x4000
+ LOAD 0x017470 0x000000000001f470 0x000000000001f470 0x000068 0x000bc0 RW 0x4000
+ DYNAMIC 0x017048 0x000000000001b048 0x000000000001b048 0x0001c0 0x0001c0 RW 0x8
+ GNU_RELRO 0x0169e0 0x000000000001a9e0 0x000000000001a9e0 0x000a90 0x001620 R 0x1
+ GNU_EH_FRAME 0x004fb4 0x0000000000004fb4 0x0000000000004fb4 0x00070c 0x00070c R 0x4
+ GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x0
+ NOTE 0x000238 0x0000000000000238 0x0000000000000238 0x0000bc 0x0000bc R 0x4
+
+ Section to Segment mapping:
+ Segment Sections...
+ 00
+ 01 .note.android.ident .note.gnu.build-id .dynsym .gnu.version .gnu.version_r .gnu.hash .dynstr .rela.dyn .rela.plt .gcc_except_table .rodata .eh_frame_hdr .eh_frame .text __lcxx_override .plt
+ 02 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
+ 03 .data .bss
+ 04 .dynamic
+ 05 .data.rel.ro .fini_array .init_array .dynamic .got .got.plt .relro_padding
+ 06 .eh_frame_hdr
+ 07
+ 08 .note.android.ident .note.gnu.build-id
+ None .comment .shstrtab
+Dynamic section at offset 0x17048 contains 28 entries:
+ Tag Type Name/Value
+ 0x0000000000000001 (NEEDED) Shared library: [liblog.so]
+ 0x0000000000000001 (NEEDED) Shared library: [libm.so]
+ 0x0000000000000001 (NEEDED) Shared library: [libdl.so]
+ 0x0000000000000001 (NEEDED) Shared library: [libc.so]
+ 0x000000000000000e (SONAME) Library soname: [libXrApiLayer_FRAME_compat.so]
+ 0x000000000000001e (FLAGS) BIND_NOW
+ 0x000000006ffffffb (FLAGS_1) NOW
+ 0x0000000000000007 (RELA) 0xae8
+ 0x0000000000000008 (RELASZ) 5424 (bytes)
+ 0x0000000000000009 (RELAENT) 24 (bytes)
+ 0x000000006ffffff9 (RELACOUNT) 225
+ 0x0000000000000017 (JMPREL) 0x2018
+ 0x0000000000000002 (PLTRELSZ) 1080 (bytes)
+ 0x0000000000000003 (PLTGOT) 0x1b2f0
+ 0x0000000000000014 (PLTREL) RELA
+ 0x0000000000000006 (SYMTAB) 0x2f8
+ 0x000000000000000b (SYMENT) 24 (bytes)
+ 0x0000000000000005 (STRTAB) 0x838
+ 0x000000000000000a (STRSZ) 681 (bytes)
+ 0x000000006ffffef5 (GNU_HASH) 0x818
+ 0x0000000000000019 (INIT_ARRAY) 0x1b030
+ 0x000000000000001b (INIT_ARRAYSZ) 24 (bytes)
+ 0x000000000000001a (FINI_ARRAY) 0x1b020
+ 0x000000000000001c (FINI_ARRAYSZ) 16 (bytes)
+ 0x000000006ffffff0 (VERSYM) 0x778
+ 0x000000006ffffffe (VERNEED) 0x7d8
+ 0x000000006fffffff (VERNEEDNUM) 2
+ 0x0000000000000000 (NULL) 0x0
+
+Symbol table '.dynsym' contains 48 entries:
+ Num: Value Size Type Bind Vis Ndx Name
+ 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
+ 1: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __cxa_finalize@LIBC
+ 2: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __cxa_atexit@LIBC
+ 3: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strcmp@LIBC
+ 4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __android_log_print
+ 5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@LIBC
+ 6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memmove@LIBC
+ 7: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strlen@LIBC
+ 8: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcpy@LIBC
+ 9: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __strcpy_chk@LIBC
+ 10: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memset@LIBC
+ 11: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcmp@LIBC
+ 12: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_lock@LIBC
+ 13: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_unlock@LIBC
+ 14: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __errno@LIBC
+ 15: 0000000000000000 0 FUNC GLOBAL DEFAULT UND snprintf@LIBC
+ 16: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_mutex_destroy@LIBC
+ 17: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strerror_r@LIBC
+ 18: 0000000000000000 0 FUNC GLOBAL DEFAULT UND abort@LIBC
+ 19: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_getspecific@LIBC
+ 20: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_setspecific@LIBC
+ 21: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_key_create@LIBC
+ 22: 0000000000000000 0 FUNC GLOBAL DEFAULT UND syscall@LIBC
+ 23: 0000000000000000 0 OBJECT GLOBAL DEFAULT UND __sF@LIBC
+ 24: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fwrite@LIBC
+ 25: 0000000000000000 0 FUNC GLOBAL DEFAULT UND vfprintf@LIBC
+ 26: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fputc@LIBC
+ 27: 0000000000000000 0 FUNC GLOBAL DEFAULT UND vasprintf@LIBC
+ 28: 0000000000000000 0 FUNC GLOBAL DEFAULT UND android_set_abort_message@LIBC
+ 29: 0000000000000000 0 FUNC GLOBAL DEFAULT UND openlog@LIBC
+ 30: 0000000000000000 0 FUNC GLOBAL DEFAULT UND syslog@LIBC
+ 31: 0000000000000000 0 FUNC GLOBAL DEFAULT UND closelog@LIBC
+ 32: 0000000000000000 0 FUNC GLOBAL DEFAULT UND malloc@LIBC
+ 33: 0000000000000000 0 FUNC GLOBAL DEFAULT UND free@LIBC
+ 34: 0000000000000000 0 FUNC GLOBAL DEFAULT UND posix_memalign@LIBC
+ 35: 0000000000000000 0 FUNC GLOBAL DEFAULT UND realloc@LIBC
+ 36: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_once@LIBC
+ 37: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_key_delete@LIBC
+ 38: 0000000000000000 0 FUNC GLOBAL DEFAULT UND getauxval@LIBC
+ 39: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __system_property_get@LIBC
+ 40: 0000000000000000 0 FUNC GLOBAL DEFAULT UND strncmp@LIBC
+ 41: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fprintf@LIBC
+ 42: 0000000000000000 0 FUNC GLOBAL DEFAULT UND fflush@LIBC
+ 43: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_wrlock@LIBC
+ 44: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_unlock@LIBC
+ 45: 0000000000000000 0 FUNC GLOBAL DEFAULT UND dl_iterate_phdr@LIBC
+ 46: 0000000000000000 0 FUNC GLOBAL DEFAULT UND pthread_rwlock_rdlock@LIBC
+ 47: 00000000000081a0 284 FUNC GLOBAL DEFAULT 14 xrNegotiateLoaderApiLayerInterface
diff --git a/frame/openxr-compat/layer.cpp b/frame/openxr-compat/layer.cpp
new file mode 100644
index 0000000..79b858c
--- /dev/null
+++ b/frame/openxr-compat/layer.cpp
@@ -0,0 +1,289 @@
+#include "compat_logic.h"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define LOG(...) __android_log_print(ANDROID_LOG_INFO, "FrameXrCompat", __VA_ARGS__)
+#define GUARD_END catch (const std::bad_alloc&) { return XR_ERROR_OUT_OF_MEMORY; } catch (...) { return XR_ERROR_RUNTIME_FAILURE; }
+namespace {
+struct Dispatch {
+ PFN_xrGetInstanceProcAddr gipa{};
+ PFN_xrDestroyInstance destroy{};
+ PFN_xrCreateSession createSession{};
+ PFN_xrDestroySession destroySession{};
+ PFN_xrLocateSpacesKHR locate{};
+ PFN_xrStringToPath stringToPath{};
+ PFN_xrPathToString pathToString{};
+ PFN_xrSuggestInteractionProfileBindings suggest{};
+ PFN_xrSetAndroidApplicationThreadKHR thread{};
+ PFN_xrRequestDisplayRefreshRateFB refresh{};
+ frame::Names enabled, stubbed;
+ XrInstance instance{};
+ bool promote{}, palm{};
+};
+std::mutex mutex;
+std::unordered_map> instances;
+std::unordered_map> sessions;
+// No runtime library is opened here: every call goes through the next layer.
+PFN_xrEnumerateInstanceExtensionProperties enumerateNext{};
+template T proc(PFN_xrGetInstanceProcAddr gipa, XrInstance i, const char* name) {
+ PFN_xrVoidFunction f{};
+ if (XR_FAILED(gipa(i, name, &f))) return nullptr;
+ return reinterpret_cast(f);
+}
+std::shared_ptr get(XrInstance i) {
+ std::lock_guard lock(mutex);
+ auto it = instances.find(i); return it == instances.end() ? nullptr : it->second;
+}
+std::shared_ptr get(XrSession s) {
+ std::lock_guard lock(mutex);
+ auto it = sessions.find(s); return it == sessions.end() ? nullptr : it->second;
+}
+XrResult properties(PFN_xrEnumerateInstanceExtensionProperties fn,
+ std::vector& out) {
+ if (!fn) return XR_ERROR_FUNCTION_UNSUPPORTED;
+ for (int attempt = 0; attempt < 4; ++attempt) {
+ uint32_t n{};
+ auto r = fn(nullptr, 0, &n, nullptr);
+ if (XR_FAILED(r)) return r;
+ out.assign(n, {XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0});
+ if (!n) return XR_SUCCESS;
+ r = fn(nullptr, n, &n, out.data());
+ if (r == XR_ERROR_SIZE_INSUFFICIENT) continue;
+ if (XR_FAILED(r)) return r;
+ out.resize(n); return XR_SUCCESS;
+ }
+ return XR_ERROR_RUNTIME_FAILURE;
+}
+XrResult XRAPI_CALL enumerate(const char* layer, uint32_t capacity, uint32_t* count,
+ XrExtensionProperties* output) try {
+ if (!count || (capacity && !output)) return XR_ERROR_VALIDATION_FAILURE;
+ const bool own = layer && std::strcmp(layer, frame::layerName) == 0;
+ PFN_xrEnumerateInstanceExtensionProperties next;
+ { std::lock_guard lock(mutex); next = enumerateNext; }
+ if (layer && *layer && !own) return next ? next(layer, capacity, count, output) : XR_ERROR_API_LAYER_NOT_PRESENT;
+ std::vector all;
+ if (!own && next) {
+ auto r = properties(next, all); if (XR_FAILED(r)) return r;
+ }
+ for (unsigned i = 0; i < 2; ++i) {
+ bool found = false;
+ for (auto& e : all) if (!std::strcmp(e.extensionName, frame::stubs[i])) found = true;
+ if (!found) {
+ XrExtensionProperties p{XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, i == 0 ? XR_KHR_android_thread_settings_SPEC_VERSION : 1u};
+ std::strcpy(p.extensionName, frame::stubs[i]); all.push_back(p);
+ }
+ }
+ *count = static_cast(all.size());
+ LOG("enumerate extensions: %u (includes stubs)", *count);
+ if (!capacity) return XR_SUCCESS;
+ if (capacity < all.size()) return XR_ERROR_SIZE_INSUFFICIENT;
+ for (size_t i = 0; i < all.size(); ++i) {
+ if (output[i].type != XR_TYPE_EXTENSION_PROPERTIES) return XR_ERROR_VALIDATION_FAILURE;
+ std::strcpy(output[i].extensionName, all[i].extensionName);
+ output[i].extensionVersion = all[i].extensionVersion;
+ }
+ return XR_SUCCESS;
+} GUARD_END
+XrResult XRAPI_CALL destroyInstance(XrInstance i) try {
+ auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
+ auto r = d->destroy(i);
+ if (XR_SUCCEEDED(r)) {
+ std::lock_guard lock(mutex);
+ for (auto it = sessions.begin(); it != sessions.end();) {
+ if (it->second == d) it = sessions.erase(it); else ++it;
+ }
+ instances.erase(i);
+ if (instances.empty()) enumerateNext = nullptr;
+ }
+ return r;
+} GUARD_END
+XrResult XRAPI_CALL createSession(XrInstance i, const XrSessionCreateInfo* info, XrSession* s) try {
+ auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
+ auto r = d->createSession(i, info, s);
+ if (XR_SUCCEEDED(r)) {
+ try { std::lock_guard lock(mutex); sessions.emplace(*s, d); }
+ catch (...) { d->destroySession(*s); *s = XR_NULL_HANDLE; throw; }
+ }
+ return r;
+} GUARD_END
+XrResult XRAPI_CALL destroySession(XrSession s) try {
+ auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
+ auto r = d->destroySession(s);
+ if (XR_SUCCEEDED(r)) { std::lock_guard lock(mutex); sessions.erase(s); }
+ return r;
+} GUARD_END
+XrResult XRAPI_CALL locateSpaces(XrSession s, const XrSpacesLocateInfo* in, XrSpaceLocations* out) try {
+ auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
+ if (!d->locate) return XR_ERROR_FUNCTION_UNSUPPORTED;
+ static std::once_flag logged; // called every frame
+ std::call_once(logged, [] { LOG("xrLocateSpaces -> xrLocateSpacesKHR (core/KHR type aliases)"); });
+ return frame::locate(d->locate, s, in, out);
+} GUARD_END
+XrResult XRAPI_CALL threadSettings(XrSession s, XrAndroidThreadTypeKHR type, uint32_t tid) try {
+ auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
+ if (frame::has(d->stubbed, frame::stubs[0])) {
+ LOG("stub xrSetAndroidApplicationThreadKHR type=%d tid=%u -> XR_SUCCESS (no scheduling change)", type, tid);
+ return XR_SUCCESS;
+ }
+ return d->thread ? d->thread(s, type, tid) : XR_ERROR_FUNCTION_UNSUPPORTED;
+} GUARD_END
+// SteamVR offers only the current refresh rate; Quest apps ask for 72/90/120 Hz
+// and abort on the error, so keep the current rate and report success.
+XrResult XRAPI_CALL requestRefreshRate(XrSession s, float hz) try {
+ auto d = get(s); if (!d) return XR_ERROR_HANDLE_INVALID;
+ auto r = d->refresh(s, hz);
+ if (r == XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB) {
+ LOG("xrRequestDisplayRefreshRateFB %.1f Hz unsupported; keeping the current rate", hz);
+ return XR_SUCCESS;
+ }
+ return r;
+} GUARD_END
+XrResult XRAPI_CALL stringToPath(XrInstance i, const char* path, XrPath* out) try {
+ auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
+ if (!path) return XR_ERROR_VALIDATION_FAILURE;
+ auto rewritten = frame::rewritePath(path, d->palm);
+ if (rewritten != path) LOG("path %s -> %s", path, rewritten.c_str());
+ return d->stringToPath(i, rewritten.c_str(), out);
+} GUARD_END
+XrResult pathString(const Dispatch& d, XrPath path, std::string& out) {
+ uint32_t n{};
+ auto r = d.pathToString(d.instance, path, 0, &n, nullptr);
+ if (XR_FAILED(r)) return r;
+ std::vector text(n);
+ r = d.pathToString(d.instance, path, n, &n, text.data());
+ if (XR_SUCCEEDED(r)) out.assign(text.data());
+ return r;
+}
+XrResult XRAPI_CALL suggest(XrInstance i, const XrInteractionProfileSuggestedBinding* info) try {
+ auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
+ if (!info || (info->countSuggestedBindings && !info->suggestedBindings)) return XR_ERROR_VALIDATION_FAILURE;
+ std::string profile;
+ auto r = pathString(*d, info->interactionProfile, profile);
+ if (XR_FAILED(r)) return r;
+ if (d->promote && frame::dropProfile(profile, d->enabled)) {
+ LOG("drop unsupported 1.1 interaction profile %s (%u suggestions)", profile.c_str(), info->countSuggestedBindings);
+ return XR_SUCCESS;
+ }
+ auto copy = *info;
+ std::vector bindings;
+ for (uint32_t index = 0; index < info->countSuggestedBindings; ++index) {
+ auto b = info->suggestedBindings[index];
+ std::string path;
+ r = pathString(*d, b.binding, path); if (XR_FAILED(r)) return r;
+ auto rewritten = frame::rewritePath(path, d->palm);
+ if (rewritten != path) {
+ LOG("binding %s -> %s", path.c_str(), rewritten.c_str());
+ r = d->stringToPath(i, rewritten.c_str(), &b.binding); if (XR_FAILED(r)) return r;
+ }
+ bindings.push_back(b);
+ }
+ copy.suggestedBindings = bindings.data();
+ return d->suggest(i, ©);
+} GUARD_END
+XrResult XRAPI_CALL gipa(XrInstance, const char*, PFN_xrVoidFunction*);
+XrResult XRAPI_CALL createLayer(const XrInstanceCreateInfo* info, const XrApiLayerCreateInfo* layer,
+ XrInstance* instance) try {
+ if (!info || !instance || !layer || layer->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO ||
+ layer->structVersion != XR_API_LAYER_CREATE_INFO_STRUCT_VERSION || layer->structSize != sizeof(*layer) ||
+ !layer->nextInfo || layer->nextInfo->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO ||
+ layer->nextInfo->structVersion != XR_API_LAYER_NEXT_INFO_STRUCT_VERSION ||
+ layer->nextInfo->structSize != sizeof(XrApiLayerNextInfo) ||
+ !layer->nextInfo->nextGetInstanceProcAddr || !layer->nextInfo->nextCreateApiLayerInstance ||
+ (info->enabledExtensionCount && !info->enabledExtensionNames)) return XR_ERROR_INITIALIZATION_FAILED;
+ *instance = XR_NULL_HANDLE;
+ auto next = layer->nextInfo->nextGetInstanceProcAddr;
+ auto enumFn = proc(next, XR_NULL_HANDLE, "xrEnumerateInstanceExtensionProperties");
+ std::vector available;
+ auto r = properties(enumFn, available); if (XR_FAILED(r)) { LOG("runtime extension enumeration failed: %d", r); return r; }
+ frame::Names requested, supported;
+ for (auto& p : available) supported.emplace_back(p.extensionName);
+ for (uint32_t n = 0; n < info->enabledExtensionCount; ++n) {
+ if (!info->enabledExtensionNames[n]) return XR_ERROR_VALIDATION_FAILURE;
+ requested.emplace_back(info->enabledExtensionNames[n]);
+ }
+ auto d = std::make_shared();
+ d->promote = frame::downgrade(info->applicationInfo.apiVersion);
+ auto plan = frame::extensions(requested, supported, d->promote);
+ for (auto& n : plan.added) LOG("enable promoted extension %s", n.c_str());
+ for (auto& n : plan.stubbed) LOG("emulate missing extension %s; remove downstream", n.c_str());
+ for (auto& n : plan.missing) LOG("unsupported extension %s retained; runtime must reject", n.c_str());
+ auto copy = *info;
+ copy.applicationInfo.apiVersion = frame::runtimeVersion(info->applicationInfo.apiVersion);
+ if (d->promote) LOG("API %u.%u.%u -> 1.0.%u", unsigned(XR_VERSION_MAJOR(info->applicationInfo.apiVersion)),
+ unsigned(XR_VERSION_MINOR(info->applicationInfo.apiVersion)), unsigned(XR_VERSION_PATCH(info->applicationInfo.apiVersion)),
+ unsigned(XR_VERSION_PATCH(copy.applicationInfo.apiVersion)));
+ std::vector names;
+ for (auto& n : plan.downstream) names.push_back(n.c_str());
+ copy.enabledExtensionCount = static_cast(names.size()); copy.enabledExtensionNames = names.data();
+ auto chain = *layer; chain.nextInfo = layer->nextInfo->next;
+ r = layer->nextInfo->nextCreateApiLayerInstance(©, &chain, instance);
+ if (XR_FAILED(r)) { LOG("downstream xrCreateInstance -> %d", r); return r; }
+ d->gipa = next; d->instance = *instance;
+ d->destroy = proc(next, *instance, "xrDestroyInstance");
+ // A successful runtime instance must expose xrDestroyInstance.
+ if (!d->destroy) { // nothing could ever destroy it, so don't hand it out
+ LOG("invalid downstream: missing xrDestroyInstance"); *instance = XR_NULL_HANDLE; return XR_ERROR_INITIALIZATION_FAILED;
+ }
+ try {
+ d->enabled = std::move(plan.downstream); d->stubbed = std::move(plan.stubbed);
+ d->palm = frame::has(d->enabled, "XR_EXT_palm_pose");
+#define LOAD(field, name) d->field = proc(next, *instance, #name)
+ LOAD(createSession, xrCreateSession); LOAD(destroySession, xrDestroySession);
+ LOAD(stringToPath, xrStringToPath); LOAD(pathToString, xrPathToString);
+ LOAD(suggest, xrSuggestInteractionProfileBindings);
+ if (frame::has(d->enabled, "XR_KHR_locate_spaces")) LOAD(locate, xrLocateSpacesKHR);
+ if (frame::has(d->enabled, frame::stubs[0])) LOAD(thread, xrSetAndroidApplicationThreadKHR);
+ if (frame::has(d->enabled, "XR_FB_display_refresh_rate")) LOAD(refresh, xrRequestDisplayRefreshRateFB);
+#undef LOAD
+ if (!d->createSession || !d->destroySession || !d->stringToPath || !d->pathToString || !d->suggest) {
+ d->destroy(*instance); *instance = XR_NULL_HANDLE; return XR_ERROR_INITIALIZATION_FAILED;
+ }
+ std::lock_guard lock(mutex);
+ instances.emplace(*instance, d); enumerateNext = enumFn;
+ } catch (...) { d->destroy(*instance); *instance = XR_NULL_HANDLE; throw; }
+ LOG("created instance; next-layer dispatch retained (Valve layer coexists)");
+ return r;
+} GUARD_END
+XrResult XRAPI_CALL gipa(XrInstance i, const char* name, PFN_xrVoidFunction* out) try {
+ if (!name || !out) return XR_ERROR_VALIDATION_FAILURE;
+ *out = nullptr;
+#define RETURN_PROC(n, fn) if (!std::strcmp(name, n)) { *out = reinterpret_cast(fn); return XR_SUCCESS; }
+ RETURN_PROC("xrGetInstanceProcAddr", gipa)
+ RETURN_PROC("xrCreateApiLayerInstance", createLayer)
+ RETURN_PROC("xrEnumerateInstanceExtensionProperties", enumerate)
+ auto d = get(i); if (!d) return XR_ERROR_HANDLE_INVALID;
+ RETURN_PROC("xrDestroyInstance", destroyInstance)
+ RETURN_PROC("xrCreateSession", createSession)
+ RETURN_PROC("xrDestroySession", destroySession)
+ RETURN_PROC("xrStringToPath", stringToPath)
+ RETURN_PROC("xrSuggestInteractionProfileBindings", suggest)
+ if (d->locate && d->promote) { RETURN_PROC("xrLocateSpaces", locateSpaces) }
+ if (frame::has(d->stubbed, frame::stubs[0])) { RETURN_PROC("xrSetAndroidApplicationThreadKHR", threadSettings) }
+ if (d->refresh) { RETURN_PROC("xrRequestDisplayRefreshRateFB", requestRefreshRate) }
+#undef RETURN_PROC
+ // Includes xrGetInstanceProperties: report the actual runtime's identity.
+ return d->gipa(i, name, out);
+} GUARD_END
+} // namespace
+extern "C" __attribute__((visibility("default"))) XrResult XRAPI_CALL xrNegotiateLoaderApiLayerInterface(
+ const XrNegotiateLoaderInfo* loader, const char* name, XrNegotiateApiLayerRequest* request) {
+ if (!loader || !name || !request || std::strcmp(name, frame::layerName) ||
+ loader->structType != XR_LOADER_INTERFACE_STRUCT_LOADER_INFO ||
+ loader->structVersion != XR_LOADER_INFO_STRUCT_VERSION || loader->structSize != sizeof(*loader) ||
+ request->structType != XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST ||
+ request->structVersion != XR_API_LAYER_INFO_STRUCT_VERSION || request->structSize != sizeof(*request) ||
+ loader->minInterfaceVersion > 1 || loader->maxInterfaceVersion < 1 ||
+ loader->minApiVersion > XR_CURRENT_API_VERSION || loader->maxApiVersion < XR_MAKE_VERSION(1, 1, 0))
+ return XR_ERROR_INITIALIZATION_FAILED;
+ request->layerInterfaceVersion = 1;
+ request->layerApiVersion = std::min(XR_CURRENT_API_VERSION, loader->maxApiVersion);
+ request->getInstanceProcAddr = gipa; request->createApiLayerInstance = createLayer;
+ LOG("negotiated interface 1, API 1.1");
+ return XR_SUCCESS;
+}
diff --git a/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so b/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so
new file mode 100755
index 0000000..608cdd1
Binary files /dev/null and b/frame/openxr-compat/prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so differ
diff --git a/frame/openxr-compat/tests/compat_tests.cpp b/frame/openxr-compat/tests/compat_tests.cpp
new file mode 100644
index 0000000..addf345
--- /dev/null
+++ b/frame/openxr-compat/tests/compat_tests.cpp
@@ -0,0 +1,70 @@
+#include "compat_logic.h"
+#include
+#include
+#include
+#define CHECK(expr) do { if (!(expr)) { std::cerr << __LINE__ << ": " #expr "\n"; std::exit(1); } } while (false)
+namespace {
+void* sentinel = reinterpret_cast(0x1234);
+XrResult XRAPI_CALL fakeLocate(XrSession, const XrSpacesLocateInfo* in, XrSpaceLocations* out) {
+ CHECK(in->type == XR_TYPE_SPACES_LOCATE_INFO_KHR);
+ CHECK(out->type == XR_TYPE_SPACE_LOCATIONS_KHR);
+ CHECK(in->next == sentinel);
+ auto* velocities = static_cast(out->next);
+ CHECK(velocities->type == XR_TYPE_SPACE_VELOCITIES_KHR);
+ CHECK(velocities->next == sentinel);
+ CHECK(in->spaceCount == 1 && in->time == 123);
+ out->locations[0].pose.position.x = 42;
+ velocities->velocities[0].linearVelocity.y = 7;
+ return XR_SUCCESS;
+}
+}
+int main() {
+ using namespace frame;
+ CHECK(!downgrade(XR_MAKE_VERSION(1, 0, 99)));
+ CHECK(runtimeVersion(XR_MAKE_VERSION(1, 0, 42)) == XR_MAKE_VERSION(1, 0, 42));
+ CHECK(runtimeVersion(XR_MAKE_VERSION(1, 1, 0)) == XR_API_VERSION_1_0);
+ CHECK(runtimeVersion(XR_MAKE_VERSION(1, 1, 999)) == XR_API_VERSION_1_0);
+ CHECK(runtimeVersion(XR_MAKE_VERSION(2, 0, 0)) == XR_API_VERSION_1_0);
+ Names available{"XR_KHR_locate_spaces", "XR_EXT_palm_pose", "XR_EXT_hand_interaction", "XR_VARJO_quad_views"};
+ Names requested{stubs[0], stubs[1], "XR_MISSING_test", "XR_EXT_palm_pose"};
+ auto p = extensions(requested, available, true);
+ CHECK(p.stubbed.size() == 2 && p.missing == Names{"XR_MISSING_test"});
+ CHECK(has(p.downstream, "XR_MISSING_test"));
+ CHECK(has(p.downstream, "XR_KHR_locate_spaces") && has(p.downstream, "XR_VARJO_quad_views"));
+ CHECK(!has(p.downstream, "XR_EXT_hand_interaction"));
+ CHECK(!has(p.downstream, "XR_EXT_local_floor"));
+ CHECK(std::count(p.downstream.begin(), p.downstream.end(), "XR_EXT_palm_pose") == 1);
+ available.push_back(stubs[0]);
+ CHECK(extensions(requested, available, true).stubbed == Names{stubs[1]});
+ CHECK(extensions({}, available, false).downstream.empty());
+ CHECK(rewritePath("/user/hand/left/input/grip_surface/pose", true) == "/user/hand/left/input/palm_ext/pose");
+ CHECK(rewritePath("/user/hand/right/input/grip_surface/pose", true) == "/user/hand/right/input/palm_ext/pose");
+ CHECK(rewritePath("/user/hand/left/input/grip_surface/pose", false) == "/user/hand/left/input/grip_surface/pose");
+ CHECK(rewritePath("/user/hand/left/input/grip_surface/pose/extra", true) == "/user/hand/left/input/grip_surface/pose/extra");
+ CHECK(rewritePath("/user/hand/left/input/grip/pose", true) == "/user/hand/left/input/grip/pose");
+ CHECK(dropProfile("/interaction_profiles/meta/touch_pro_controller", {}));
+ CHECK(dropProfile("/interaction_profiles/meta/touch_controller_quest_2", {}));
+ CHECK(dropProfile("/interaction_profiles/hp/mixed_reality_controller", {}));
+ CHECK(!dropProfile("/interaction_profiles/hp/mixed_reality_controller", {"XR_EXT_hp_mixed_reality_controller"}));
+ CHECK(!dropProfile("/interaction_profiles/oculus/touch_controller", {}));
+ CHECK(!dropProfile("/interaction_profiles/khr/simple_controller", {}));
+ CHECK(!dropProfile("/interaction_profiles/valve/frame_controller", {}));
+ CHECK(!dropProfile("/interaction_profiles/ext/hand_interaction_ext", {}));
+ CHECK(extensionType(XR_TYPE_SPACES_LOCATE_INFO) == XR_TYPE_SPACES_LOCATE_INFO_KHR);
+ CHECK(extensionType(XR_TYPE_SPACE_LOCATIONS) == XR_TYPE_SPACE_LOCATIONS_KHR);
+ CHECK(extensionType(XR_TYPE_SPACE_VELOCITIES) == XR_TYPE_SPACE_VELOCITIES_KHR);
+ CHECK(extensionType(XR_TYPE_INSTANCE_CREATE_INFO) == XR_TYPE_INSTANCE_CREATE_INFO);
+ static_assert(std::is_same_v);
+ XrSpace space{};
+ XrSpaceLocationData data{};
+ XrSpaceVelocityData velocity{};
+ XrSpaceVelocities velocities{XR_TYPE_SPACE_VELOCITIES, sentinel, 1, &velocity};
+ XrSpaceLocations locations{XR_TYPE_SPACE_LOCATIONS, &velocities, 1, &data};
+ XrSpacesLocateInfo info{XR_TYPE_SPACES_LOCATE_INFO, sentinel, XR_NULL_HANDLE, 123, 1, &space};
+ CHECK(locate(fakeLocate, XR_NULL_HANDLE, &info, &locations) == XR_SUCCESS);
+ CHECK(data.pose.position.x == 42 && velocity.linearVelocity.y == 7);
+ CHECK(info.next == sentinel && locations.next == &velocities && velocities.next == sentinel);
+ CHECK(info.type == XR_TYPE_SPACES_LOCATE_INFO && locations.type == XR_TYPE_SPACE_LOCATIONS && velocities.type == XR_TYPE_SPACE_VELOCITIES);
+ CHECK(locate(fakeLocate, XR_NULL_HANDLE, nullptr, &locations) == XR_ERROR_VALIDATION_FAILURE);
+ std::cout << "All version, extension, path/profile and locate-chain checks passed\n";
+}
diff --git a/frame/openxr-compat/tests/dispatch_tests.cpp b/frame/openxr-compat/tests/dispatch_tests.cpp
new file mode 100644
index 0000000..97aa48b
--- /dev/null
+++ b/frame/openxr-compat/tests/dispatch_tests.cpp
@@ -0,0 +1,134 @@
+// Compile the actual layer with host-only JNI/logging shims, and a fake next
+// layer. This exercises ABI routing without a runtime or a headset.
+#include "../layer.cpp"
+#include
+#include
+#define CHECK(expr) do { if (!(expr)) { std::cerr << __LINE__ << ": " #expr "\n"; std::exit(1); } } while (false)
+namespace {
+uintptr_t serial = 100;
+frame::Names seenExtensions;
+XrVersion seenVersion{};
+XrApiLayerNextInfo* seenNext{};
+int suggestionCalls{}, locateCalls{}, realThreadCalls{};
+bool nativeThread{};
+std::unordered_map paths;
+XrResult XRAPI_CALL fakeEnumerate(const char*, uint32_t capacity, uint32_t* count, XrExtensionProperties* out) {
+ const char* extensions[] = {"XR_KHR_locate_spaces", "XR_EXT_palm_pose", frame::stubs[0]};
+ *count = nativeThread ? 3 : 2;
+ if (!capacity) return XR_SUCCESS;
+ if (capacity < *count) return XR_ERROR_SIZE_INSUFFICIENT;
+ for (uint32_t j = 0; j < *count; ++j) { std::strcpy(out[j].extensionName, extensions[j]); out[j].extensionVersion = 1; }
+ return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakeCreate(const XrInstanceCreateInfo* in, const XrApiLayerCreateInfo* layer, XrInstance* i) {
+ seenVersion = in->applicationInfo.apiVersion; seenNext = layer->nextInfo;
+ seenExtensions.clear();
+ for (uint32_t j = 0; j < in->enabledExtensionCount; ++j) seenExtensions.emplace_back(in->enabledExtensionNames[j]);
+ if (frame::has(seenExtensions, "XR_MISSING_test")) return XR_ERROR_EXTENSION_NOT_PRESENT;
+ *i = reinterpret_cast(++serial); return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakeDestroy(XrInstance) { return XR_SUCCESS; }
+XrResult XRAPI_CALL fakeCreateSession(XrInstance, const XrSessionCreateInfo*, XrSession* s) {
+ *s = reinterpret_cast(++serial); return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakeDestroySession(XrSession) { return XR_SUCCESS; }
+XrResult XRAPI_CALL fakeString(XrInstance, const char* text, XrPath* p) {
+ *p = ++serial; paths[*p] = text; return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakePath(XrInstance, XrPath p, uint32_t cap, uint32_t* count, char* out) {
+ if (!paths.count(p)) return XR_ERROR_PATH_INVALID;
+ *count = static_cast(paths[p].size() + 1);
+ if (!cap) return XR_SUCCESS;
+ if (cap < *count) return XR_ERROR_SIZE_INSUFFICIENT;
+ std::strcpy(out, paths[p].c_str()); return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakeSuggest(XrInstance, const XrInteractionProfileSuggestedBinding* info) {
+ ++suggestionCalls;
+ if (info->countSuggestedBindings) CHECK(paths[info->suggestedBindings[0].binding] == "/user/hand/left/input/palm_ext/pose");
+ return XR_SUCCESS;
+}
+XrResult XRAPI_CALL fakeLocate(XrSession, const XrSpacesLocateInfo*, XrSpaceLocations*) { ++locateCalls; return XR_SUCCESS; }
+XrResult XRAPI_CALL fakeThread(XrSession, XrAndroidThreadTypeKHR, uint32_t) { ++realThreadCalls; return XR_TIMEOUT_EXPIRED; }
+XrResult XRAPI_CALL fakeProperties(XrInstance, XrInstanceProperties*) { return XR_SUCCESS; }
+XrResult XRAPI_CALL fakeGipa(XrInstance, const char* n, PFN_xrVoidFunction* out) {
+#define MAP(name, f) if (!std::strcmp(n, name)) { *out = reinterpret_cast(f); return XR_SUCCESS; }
+ MAP("xrEnumerateInstanceExtensionProperties", fakeEnumerate)
+ MAP("xrDestroyInstance", fakeDestroy)
+ MAP("xrCreateSession", fakeCreateSession) MAP("xrDestroySession", fakeDestroySession)
+ MAP("xrStringToPath", fakeString) MAP("xrPathToString", fakePath)
+ MAP("xrSuggestInteractionProfileBindings", fakeSuggest) MAP("xrLocateSpacesKHR", fakeLocate)
+ MAP("xrSetAndroidApplicationThreadKHR", fakeThread) MAP("xrGetInstanceProperties", fakeProperties)
+#undef MAP
+ *out = nullptr; return XR_ERROR_FUNCTION_UNSUPPORTED;
+}
+}
+int main() {
+ XrNegotiateLoaderInfo loader{XR_LOADER_INTERFACE_STRUCT_LOADER_INFO, 1, sizeof(XrNegotiateLoaderInfo), 1, 1,
+ XR_MAKE_VERSION(1,0,0), XR_MAKE_VERSION(1,1023,4095)};
+ XrNegotiateApiLayerRequest request{};
+ request.structType = XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST; request.structVersion = 1; request.structSize = sizeof(request);
+ CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_SUCCESS);
+ CHECK(request.getInstanceProcAddr && request.createApiLayerInstance);
+ loader.maxApiVersion = XR_MAKE_VERSION(1,1,0);
+ CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_SUCCESS);
+ CHECK(request.layerApiVersion == loader.maxApiVersion);
+ loader.maxApiVersion = XR_MAKE_VERSION(1,0,99);
+ CHECK(xrNegotiateLoaderApiLayerInterface(&loader, frame::layerName, &request) == XR_ERROR_INITIALIZATION_FAILED);
+ loader.maxApiVersion = XR_MAKE_VERSION(1,1023,4095);
+ CHECK(xrNegotiateLoaderApiLayerInterface(&loader, "wrong", &request) == XR_ERROR_INITIALIZATION_FAILED);
+ XrApiLayerNextInfo tail{};
+ XrApiLayerNextInfo next{XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO, 1, sizeof(XrApiLayerNextInfo), {}, fakeGipa, fakeCreate, &tail};
+ XrApiLayerCreateInfo layer{XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO, 1, sizeof(XrApiLayerCreateInfo), nullptr, {}, &next};
+ XrInstanceCreateInfo info{XR_TYPE_INSTANCE_CREATE_INFO, nullptr, 0, {}, 0, nullptr, 2, frame::stubs};
+ info.applicationInfo.apiVersion = XR_MAKE_VERSION(1,1,999);
+ XrInstance a{}, b{};
+ CHECK(createLayer(&info, &layer, &a) == XR_SUCCESS);
+ CHECK(seenVersion == XR_API_VERSION_1_0 && info.applicationInfo.apiVersion == XR_MAKE_VERSION(1,1,999));
+ CHECK(seenNext == &tail && layer.nextInfo == &next);
+ CHECK(!frame::has(seenExtensions, frame::stubs[0]) && frame::has(seenExtensions, "XR_KHR_locate_spaces"));
+ XrSession sa{}, sb{};
+ CHECK(createSession(a, nullptr, &sa) == XR_SUCCESS);
+ CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_SUCCESS);
+ nativeThread = true;
+ CHECK(createLayer(&info, &layer, &b) == XR_SUCCESS);
+ CHECK(frame::has(seenExtensions, frame::stubs[0]));
+ CHECK(createSession(b, nullptr, &sb) == XR_SUCCESS);
+ PFN_xrVoidFunction fn{};
+ CHECK(gipa(b, "xrSetAndroidApplicationThreadKHR", &fn) == XR_SUCCESS);
+ CHECK(reinterpret_cast(fn)(sb, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_TIMEOUT_EXPIRED);
+ CHECK(realThreadCalls == 1);
+ CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_SUCCESS);
+ CHECK(gipa(a, "xrGetInstanceProperties", &fn) == XR_SUCCESS && fn == reinterpret_cast(fakeProperties));
+ CHECK(gipa(a, "xrLocateSpaces", &fn) == XR_SUCCESS);
+ XrSpacesLocateInfo locateInfo{XR_TYPE_SPACES_LOCATE_INFO, nullptr, XR_NULL_HANDLE, 1, 0, nullptr};
+ XrSpaceLocations locations{XR_TYPE_SPACE_LOCATIONS, nullptr, 0, nullptr};
+ CHECK(reinterpret_cast(fn)(sa, &locateInfo, &locations) == XR_SUCCESS && locateCalls == 1);
+ XrPath binding{}, profile{};
+ CHECK(stringToPath(a, "/user/hand/left/input/grip_surface/pose", &binding) == XR_SUCCESS);
+ CHECK(paths[binding] == "/user/hand/left/input/palm_ext/pose");
+ fakeString(a, "/user/hand/left/input/grip_surface/pose", &binding); // preexisting path: rewrite in suggest too
+ fakeString(a, "/interaction_profiles/oculus/touch_controller", &profile);
+ XrActionSuggestedBinding action{XR_NULL_HANDLE, binding};
+ XrInteractionProfileSuggestedBinding suggested{XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING, nullptr, profile, 1, &action};
+ CHECK(suggest(a, &suggested) == XR_SUCCESS && suggestionCalls == 1);
+ CHECK(paths[action.binding] == "/user/hand/left/input/grip_surface/pose");
+ fakeString(a, "/interaction_profiles/meta/touch_pro_controller", &suggested.interactionProfile);
+ CHECK(suggest(a, &suggested) == XR_SUCCESS && suggestionCalls == 1);
+ uint32_t count{};
+ CHECK(enumerate(frame::layerName, 0, &count, nullptr) == XR_SUCCESS && count == 2);
+ XrExtensionProperties props[2]{{XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0}, {XR_TYPE_EXTENSION_PROPERTIES, nullptr, {}, 0}};
+ CHECK(enumerate(frame::layerName, 1, &count, props) == XR_ERROR_SIZE_INSUFFICIENT);
+ CHECK(enumerate(frame::layerName, 2, &count, props) == XR_SUCCESS && count == 2);
+ CHECK(enumerate(nullptr, 0, &count, nullptr) == XR_SUCCESS && count == 4);
+ const char* missing = "XR_MISSING_test";
+ info.enabledExtensionCount = 1; info.enabledExtensionNames = &missing;
+ XrInstance failed{};
+ CHECK(createLayer(&info, &layer, &failed) == XR_ERROR_EXTENSION_NOT_PRESENT && failed == XR_NULL_HANDLE);
+ CHECK(destroySession(sa) == XR_SUCCESS);
+ CHECK(threadSettings(sa, XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR, 1) == XR_ERROR_HANDLE_INVALID);
+ CHECK(destroyInstance(a) == XR_SUCCESS);
+ CHECK(get(sb) && !get(a));
+ CHECK(destroyInstance(b) == XR_SUCCESS && !get(sb));
+ CHECK(instances.empty() && sessions.empty() && enumerateNext == nullptr);
+ std::cout << "Negotiation, next-layer chaining, multi-instance dispatch, stubs, forwarding and cleanup passed\n";
+}
diff --git a/frame/openxr-compat/tests/shims/android/log.h b/frame/openxr-compat/tests/shims/android/log.h
new file mode 100644
index 0000000..dcaf894
--- /dev/null
+++ b/frame/openxr-compat/tests/shims/android/log.h
@@ -0,0 +1,9 @@
+#pragma once
+// Host-only logcat replacement. Android builds use the NDK header and liblog.
+#include
+#include
+#define ANDROID_LOG_INFO 4
+inline int __android_log_print(int, const char*, const char* fmt, ...) {
+ va_list args; va_start(args, fmt); auto r = std::vfprintf(stderr, fmt, args);
+ va_end(args); std::fputc('\n', stderr); return r;
+}
diff --git a/frame/openxr-compat/tests/shims/jni.h b/frame/openxr-compat/tests/shims/jni.h
new file mode 100644
index 0000000..6223bc2
--- /dev/null
+++ b/frame/openxr-compat/tests/shims/jni.h
@@ -0,0 +1,3 @@
+#pragma once
+// Only the opaque jobject declaration is needed by openxr_platform.h.
+typedef void* jobject;
diff --git a/frame/openxr-compat/vendor/LICENSE b/frame/openxr-compat/vendor/LICENSE
new file mode 100644
index 0000000..527a83a
--- /dev/null
+++ b/frame/openxr-compat/vendor/LICENSE
@@ -0,0 +1,208 @@
+Apache License
+
+Version 2.0, January 2004
+
+http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION,
+AND DISTRIBUTION
+
+ 1. Definitions.
+
+
+
+"License" shall mean the terms and conditions for use, reproduction, and distribution
+as defined by Sections 1 through 9 of this document.
+
+
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+
+
+"Legal Entity" shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, "control" means (i) the power, direct
+or indirect, to cause the direction or management of such entity, whether
+by contract or otherwise, or (ii) ownership of fifty percent (50%) or more
+of the outstanding shares, or (iii) beneficial ownership of such entity.
+
+
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising permissions
+granted by this License.
+
+
+
+"Source" form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+
+
+"Object" form shall mean any form resulting from mechanical transformation
+or translation of a Source form, including but not limited to compiled object
+code, generated documentation, and conversions to other media types.
+
+
+
+"Work" shall mean the work of authorship, whether in Source or Object form,
+made available under the License, as indicated by a copyright notice that
+is included in or attached to the work (an example is provided in the Appendix
+below).
+
+
+
+"Derivative Works" shall mean any work, whether in Source or Object form,
+that is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative
+Works shall not include works that remain separable from, or merely link (or
+bind by name) to the interfaces of, the Work and Derivative Works thereof.
+
+
+
+"Contribution" shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative
+Works thereof, that is intentionally submitted to Licensor for inclusion in
+the Work by the copyright owner or by an individual or Legal Entity authorized
+to submit on behalf of the copyright owner. For the purposes of this definition,
+"submitted" means any form of electronic, verbal, or written communication
+sent to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor
+for the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as "Not a Contribution."
+
+
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently incorporated
+within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of this
+License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable copyright license to reproduce, prepare
+Derivative Works of, publicly display, publicly perform, sublicense, and distribute
+the Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of this License,
+each Contributor hereby grants to You a perpetual, worldwide, non-exclusive,
+no-charge, royalty-free, irrevocable (except as stated in this section) patent
+license to make, have made, use, offer to sell, sell, import, and otherwise
+transfer the Work, where such license applies only to those patent claims
+licensable by such Contributor that are necessarily infringed by their Contribution(s)
+alone or by combination of their Contribution(s) with the Work to which such
+Contribution(s) was submitted. If You institute patent litigation against
+any entity (including a cross-claim or counterclaim in a lawsuit) alleging
+that the Work or a Contribution incorporated within the Work constitutes direct
+or contributory patent infringement, then any patent licenses granted to You
+under this License for that Work shall terminate as of the date such litigation
+is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the Work or
+Derivative Works thereof in any medium, with or without modifications, and
+in Source or Object form, provided that You meet the following conditions:
+
+(a) You must give any other recipients of the Work or Derivative Works a copy
+of this License; and
+
+(b) You must cause any modified files to carry prominent notices stating that
+You changed the files; and
+
+(c) You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source
+form of the Work, excluding those notices that do not pertain to any part
+of the Derivative Works; and
+
+(d) If the Work includes a "NOTICE" text file as part of its distribution,
+then any Derivative Works that You distribute must include a readable copy
+of the attribution notices contained within such NOTICE file, excluding those
+notices that do not pertain to any part of the Derivative Works, in at least
+one of the following places: within a NOTICE text file distributed as part
+of the Derivative Works; within the Source form or documentation, if provided
+along with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents
+of the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works
+that You distribute, alongside or as an addendum to the NOTICE text from the
+Work, provided that such additional attribution notices cannot be construed
+as modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction,
+or distribution of Your modifications, or for any such Derivative Works as
+a whole, provided Your use, reproduction, and distribution of the Work otherwise
+complies with the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise, any
+Contribution intentionally submitted for inclusion in the Work by You to the
+Licensor shall be under the terms and conditions of this License, without
+any additional terms or conditions. Notwithstanding the above, nothing herein
+shall supersede or modify the terms of any separate license agreement you
+may have executed with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade names,
+trademarks, service marks, or product names of the Licensor, except as required
+for reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or agreed to
+in writing, Licensor provides the Work (and each Contributor provides its
+Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied, including, without limitation, any warranties
+or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
+A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness
+of using or redistributing the Work and assume any risks associated with Your
+exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory, whether
+in tort (including negligence), contract, or otherwise, unless required by
+applicable law (such as deliberate and grossly negligent acts) or agreed to
+in writing, shall any Contributor be liable to You for damages, including
+any direct, indirect, special, incidental, or consequential damages of any
+character arising as a result of this License or out of the use or inability
+to use the Work (including but not limited to damages for loss of goodwill,
+work stoppage, computer failure or malfunction, or any and all other commercial
+damages or losses), even if such Contributor has been advised of the possibility
+of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing the Work
+or Derivative Works thereof, You may choose to offer, and charge a fee for,
+acceptance of support, warranty, indemnity, or other liability obligations
+and/or rights consistent with this License. However, in accepting such obligations,
+You may act only on Your own behalf and on Your sole responsibility, not on
+behalf of any other Contributor, and only if You agree to indemnify, defend,
+and hold each Contributor harmless for any liability incurred by, or claims
+asserted against, such Contributor by reason of your accepting any such warranty
+or additional liability. END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets "[]" replaced with your own identifying
+information. (Don't include the brackets!) The text should be enclosed in
+the appropriate comment syntax for the file format. We also recommend that
+a file or class name and description of purpose be included on the same "printed
+page" as the copyright notice for easier identification within third-party
+archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+
+distributed under the License is distributed on an "AS IS" BASIS,
+
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+
+limitations under the License.
diff --git a/frame/openxr-compat/vendor/loader_interfaces.h b/frame/openxr-compat/vendor/loader_interfaces.h
new file mode 100644
index 0000000..020c345
--- /dev/null
+++ b/frame/openxr-compat/vendor/loader_interfaces.h
@@ -0,0 +1,114 @@
+// Copyright (c) 2017-2023, The Khronos Group Inc.
+// Copyright (c) 2017 Valve Corporation
+// Copyright (c) 2017 LunarG, Inc.
+//
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+//
+// Initial Author: Mark Young
+//
+
+#pragma once
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+// Forward declare.
+typedef struct XrApiLayerCreateInfo XrApiLayerCreateInfo;
+
+// Function pointer prototype for the xrCreateApiLayerInstance function used in place of xrCreateInstance.
+// This function allows us to pass special API layer information to each layer during the process of creating an Instance.
+typedef XrResult(XRAPI_PTR *PFN_xrCreateApiLayerInstance)(const XrInstanceCreateInfo *info,
+ const XrApiLayerCreateInfo *apiLayerInfo, XrInstance *instance);
+
+// Loader/API Layer Interface versions
+// 1 - First version, introduces negotiation structure and functions
+#define XR_CURRENT_LOADER_API_LAYER_VERSION 1
+
+// Loader/Runtime Interface versions
+// 1 - First version, introduces negotiation structure and functions
+#define XR_CURRENT_LOADER_RUNTIME_VERSION 1
+
+// Version negotiation values
+typedef enum XrLoaderInterfaceStructs {
+ XR_LOADER_INTERFACE_STRUCT_UNINTIALIZED = 0,
+ XR_LOADER_INTERFACE_STRUCT_LOADER_INFO,
+ XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST,
+ XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST,
+ XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO,
+ XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO,
+} XrLoaderInterfaceStructs;
+
+#define XR_LOADER_INFO_STRUCT_VERSION 1
+typedef struct XrNegotiateLoaderInfo {
+ XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_LOADER_INFO
+ uint32_t structVersion; // XR_LOADER_INFO_STRUCT_VERSION
+ size_t structSize; // sizeof(XrNegotiateLoaderInfo)
+ uint32_t minInterfaceVersion;
+ uint32_t maxInterfaceVersion;
+ XrVersion minApiVersion;
+ XrVersion maxApiVersion;
+} XrNegotiateLoaderInfo;
+
+#define XR_API_LAYER_INFO_STRUCT_VERSION 1
+typedef struct XrNegotiateApiLayerRequest {
+ XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_REQUEST
+ uint32_t structVersion; // XR_API_LAYER_INFO_STRUCT_VERSION
+ size_t structSize; // sizeof(XrNegotiateApiLayerRequest)
+ uint32_t layerInterfaceVersion; // CURRENT_LOADER_API_LAYER_VERSION
+ XrVersion layerApiVersion;
+ PFN_xrGetInstanceProcAddr getInstanceProcAddr;
+ PFN_xrCreateApiLayerInstance createApiLayerInstance;
+} XrNegotiateApiLayerRequest;
+
+#define XR_RUNTIME_INFO_STRUCT_VERSION 1
+typedef struct XrNegotiateRuntimeRequest {
+ XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_RUNTIME_REQUEST
+ uint32_t structVersion; // XR_RUNTIME_INFO_STRUCT_VERSION
+ size_t structSize; // sizeof(XrNegotiateRuntimeRequest)
+ uint32_t runtimeInterfaceVersion; // CURRENT_LOADER_RUNTIME_VERSION
+ XrVersion runtimeApiVersion;
+ PFN_xrGetInstanceProcAddr getInstanceProcAddr;
+} XrNegotiateRuntimeRequest;
+
+// Function used to negotiate an interface betewen the loader and an API layer. Each library exposing one or
+// more API layers needs to expose at least this function.
+typedef XrResult(XRAPI_PTR *PFN_xrNegotiateLoaderApiLayerInterface)(const XrNegotiateLoaderInfo *loaderInfo,
+ const char *apiLayerName,
+ XrNegotiateApiLayerRequest *apiLayerRequest);
+
+// Function used to negotiate an interface betewen the loader and a runtime. Each runtime should expose
+// at least this function.
+typedef XrResult(XRAPI_PTR *PFN_xrNegotiateLoaderRuntimeInterface)(const XrNegotiateLoaderInfo *loaderInfo,
+ XrNegotiateRuntimeRequest *runtimeRequest);
+
+// Forward declare.
+typedef struct XrApiLayerNextInfo XrApiLayerNextInfo;
+
+#define XR_API_LAYER_NEXT_INFO_STRUCT_VERSION 1
+struct XrApiLayerNextInfo {
+ XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_NEXT_INFO
+ uint32_t structVersion; // XR_API_LAYER_NEXT_INFO_STRUCT_VERSION
+ size_t structSize; // sizeof(XrApiLayerNextInfo)
+ char layerName[XR_MAX_API_LAYER_NAME_SIZE]; // Name of API layer which should receive this info
+ PFN_xrGetInstanceProcAddr nextGetInstanceProcAddr; // Pointer to next API layer's xrGetInstanceProcAddr
+ PFN_xrCreateApiLayerInstance nextCreateApiLayerInstance; // Pointer to next API layer's xrCreateApiLayerInstance
+ XrApiLayerNextInfo *next; // Pointer to the next API layer info in the sequence
+};
+
+#define XR_API_LAYER_MAX_SETTINGS_PATH_SIZE 512
+#define XR_API_LAYER_CREATE_INFO_STRUCT_VERSION 1
+typedef struct XrApiLayerCreateInfo {
+ XrLoaderInterfaceStructs structType; // XR_LOADER_INTERFACE_STRUCT_API_LAYER_CREATE_INFO
+ uint32_t structVersion; // XR_API_LAYER_CREATE_INFO_STRUCT_VERSION
+ size_t structSize; // sizeof(XrApiLayerCreateInfo)
+ void *loaderInstance; // Pointer to the LoaderInstance class
+ char settings_file_location[XR_API_LAYER_MAX_SETTINGS_PATH_SIZE]; // Location to the found settings file (or empty '\0')
+ XrApiLayerNextInfo *nextInfo; // Pointer to the next API layer's Info
+} XrApiLayerCreateInfo;
+
+#ifdef __cplusplus
+} // extern "C"
+#endif
diff --git a/frame/openxr-compat/vendor/openxr/openxr.h b/frame/openxr-compat/vendor/openxr/openxr.h
new file mode 100644
index 0000000..af56c0f
--- /dev/null
+++ b/frame/openxr-compat/vendor/openxr/openxr.h
@@ -0,0 +1,14911 @@
+#ifndef OPENXR_H_
+#define OPENXR_H_ 1
+
+/*
+** Copyright 2017-2026 The Khronos Group Inc.
+**
+** SPDX-License-Identifier: Apache-2.0 OR MIT
+*/
+
+/*
+** This header is generated from the Khronos OpenXR XML API Registry.
+**
+*/
+
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+
+
+// XR_VERSION_1_0 is a preprocessor guard. Do not pass it to API calls.
+#define XR_VERSION_1_0 1
+#include "openxr_platform_defines.h"
+#define XR_MAKE_VERSION(major, minor, patch) \
+ ((((major) & 0xffffULL) << 48) | (((minor) & 0xffffULL) << 32) | ((patch) & 0xffffffffULL))
+
+// OpenXR current version number.
+#define XR_CURRENT_API_VERSION XR_MAKE_VERSION(1, 1, 63)
+
+// OpenXR 1.0 version number
+#define XR_API_VERSION_1_0 XR_MAKE_VERSION(1, 0, XR_VERSION_PATCH(XR_CURRENT_API_VERSION))
+
+#define XR_VERSION_MAJOR(version) (uint16_t)(((uint64_t)(version) >> 48)& 0xffffULL)
+#define XR_VERSION_MINOR(version) (uint16_t)(((uint64_t)(version) >> 32) & 0xffffULL)
+#define XR_VERSION_PATCH(version) (uint32_t)((uint64_t)(version) & 0xffffffffULL)
+
+#define XR_MIN_COMPOSITION_LAYERS_SUPPORTED 16
+
+
+#if !defined(XR_NULL_HANDLE)
+#if (XR_PTR_SIZE == 8) && XR_CPP_NULLPTR_SUPPORTED
+ #define XR_NULL_HANDLE nullptr
+#else
+ #define XR_NULL_HANDLE 0
+#endif
+#endif
+
+
+#define XR_NULL_SYSTEM_ID 0
+
+
+#define XR_NULL_PATH 0
+
+
+#define XR_SUCCEEDED(result) ((result) >= 0)
+
+
+#define XR_FAILED(result) ((result) < 0)
+
+
+#define XR_UNQUALIFIED_SUCCESS(result) ((result) == 0)
+
+
+#define XR_NO_DURATION 0
+
+
+#define XR_INFINITE_DURATION 0x7fffffffffffffffLL
+
+
+#define XR_MIN_HAPTIC_DURATION -1
+
+
+#define XR_FREQUENCY_UNSPECIFIED 0
+
+
+#define XR_MAX_EVENT_DATA_SIZE sizeof(XrEventDataBuffer)
+
+
+#define XR_EXTENSION_ENUM_BASE 1000000000
+
+
+#define XR_EXTENSION_ENUM_STRIDE 1000
+
+
+#if !defined(XR_MAY_ALIAS)
+#if defined(__clang__) || (defined(__GNUC__) && (__GNUC__ > 4))
+#define XR_MAY_ALIAS __attribute__((__may_alias__))
+#else
+#define XR_MAY_ALIAS
+#endif
+#endif
+
+
+#if !defined(XR_DEFINE_HANDLE)
+#if (XR_PTR_SIZE == 8)
+ #define XR_DEFINE_HANDLE(object) typedef struct object##_T* object;
+#else
+ #define XR_DEFINE_HANDLE(object) typedef uint64_t object;
+#endif
+#endif
+
+
+#if !defined(XR_DEFINE_OPAQUE_64)
+ #if (XR_PTR_SIZE == 8)
+ #define XR_DEFINE_OPAQUE_64(object) typedef struct object##_T* object;
+ #else
+ #define XR_DEFINE_OPAQUE_64(object) typedef uint64_t object;
+ #endif
+#endif
+
+
+#if !defined(XR_DEFINE_ATOM)
+ #define XR_DEFINE_ATOM(object) typedef uint64_t object;
+#endif
+
+typedef uint64_t XrVersion;
+typedef uint64_t XrFlags64;
+XR_DEFINE_ATOM(XrSystemId)
+typedef uint32_t XrBool32;
+XR_DEFINE_ATOM(XrPath)
+typedef int64_t XrTime;
+typedef int64_t XrDuration;
+XR_DEFINE_HANDLE(XrInstance)
+XR_DEFINE_HANDLE(XrSession)
+XR_DEFINE_HANDLE(XrSpace)
+XR_DEFINE_HANDLE(XrAction)
+XR_DEFINE_HANDLE(XrSwapchain)
+XR_DEFINE_HANDLE(XrActionSet)
+#define XR_TRUE 1
+#define XR_FALSE 0
+#define XR_MAX_EXTENSION_NAME_SIZE 128
+#define XR_MAX_API_LAYER_NAME_SIZE 256
+#define XR_MAX_API_LAYER_DESCRIPTION_SIZE 256
+#define XR_MAX_SYSTEM_NAME_SIZE 256
+#define XR_MAX_APPLICATION_NAME_SIZE 128
+#define XR_MAX_ENGINE_NAME_SIZE 128
+#define XR_MAX_RUNTIME_NAME_SIZE 128
+#define XR_MAX_PATH_LENGTH 256
+#define XR_MAX_STRUCTURE_NAME_SIZE 64
+#define XR_MAX_RESULT_STRING_SIZE 64
+#define XR_MAX_ACTION_SET_NAME_SIZE 64
+#define XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE 128
+#define XR_MAX_ACTION_NAME_SIZE 64
+#define XR_MAX_LOCALIZED_ACTION_NAME_SIZE 128
+
+typedef enum XrResult {
+ XR_SUCCESS = 0,
+ XR_TIMEOUT_EXPIRED = 1,
+ XR_SESSION_LOSS_PENDING = 3,
+ XR_EVENT_UNAVAILABLE = 4,
+ XR_SPACE_BOUNDS_UNAVAILABLE = 7,
+ XR_SESSION_NOT_FOCUSED = 8,
+ XR_FRAME_DISCARDED = 9,
+ XR_ERROR_VALIDATION_FAILURE = -1,
+ XR_ERROR_RUNTIME_FAILURE = -2,
+ XR_ERROR_OUT_OF_MEMORY = -3,
+ XR_ERROR_API_VERSION_UNSUPPORTED = -4,
+ XR_ERROR_INITIALIZATION_FAILED = -6,
+ XR_ERROR_FUNCTION_UNSUPPORTED = -7,
+ XR_ERROR_FEATURE_UNSUPPORTED = -8,
+ XR_ERROR_EXTENSION_NOT_PRESENT = -9,
+ XR_ERROR_LIMIT_REACHED = -10,
+ XR_ERROR_SIZE_INSUFFICIENT = -11,
+ XR_ERROR_HANDLE_INVALID = -12,
+ XR_ERROR_INSTANCE_LOST = -13,
+ XR_ERROR_SESSION_RUNNING = -14,
+ XR_ERROR_SESSION_NOT_RUNNING = -16,
+ XR_ERROR_SESSION_LOST = -17,
+ XR_ERROR_SYSTEM_INVALID = -18,
+ XR_ERROR_PATH_INVALID = -19,
+ XR_ERROR_PATH_COUNT_EXCEEDED = -20,
+ XR_ERROR_PATH_FORMAT_INVALID = -21,
+ XR_ERROR_PATH_UNSUPPORTED = -22,
+ XR_ERROR_LAYER_INVALID = -23,
+ XR_ERROR_LAYER_LIMIT_EXCEEDED = -24,
+ XR_ERROR_SWAPCHAIN_RECT_INVALID = -25,
+ XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED = -26,
+ XR_ERROR_ACTION_TYPE_MISMATCH = -27,
+ XR_ERROR_SESSION_NOT_READY = -28,
+ XR_ERROR_SESSION_NOT_STOPPING = -29,
+ XR_ERROR_TIME_INVALID = -30,
+ XR_ERROR_REFERENCE_SPACE_UNSUPPORTED = -31,
+ XR_ERROR_FILE_ACCESS_ERROR = -32,
+ XR_ERROR_FILE_CONTENTS_INVALID = -33,
+ XR_ERROR_FORM_FACTOR_UNSUPPORTED = -34,
+ XR_ERROR_FORM_FACTOR_UNAVAILABLE = -35,
+ XR_ERROR_API_LAYER_NOT_PRESENT = -36,
+ XR_ERROR_CALL_ORDER_INVALID = -37,
+ XR_ERROR_GRAPHICS_DEVICE_INVALID = -38,
+ XR_ERROR_POSE_INVALID = -39,
+ XR_ERROR_INDEX_OUT_OF_RANGE = -40,
+ XR_ERROR_VIEW_CONFIGURATION_TYPE_UNSUPPORTED = -41,
+ XR_ERROR_ENVIRONMENT_BLEND_MODE_UNSUPPORTED = -42,
+ XR_ERROR_NAME_DUPLICATED = -44,
+ XR_ERROR_NAME_INVALID = -45,
+ XR_ERROR_ACTIONSET_NOT_ATTACHED = -46,
+ XR_ERROR_ACTIONSETS_ALREADY_ATTACHED = -47,
+ XR_ERROR_LOCALIZED_NAME_DUPLICATED = -48,
+ XR_ERROR_LOCALIZED_NAME_INVALID = -49,
+ XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING = -50,
+ XR_ERROR_RUNTIME_UNAVAILABLE = -51,
+ XR_ERROR_EXTENSION_DEPENDENCY_NOT_ENABLED = -1000710001,
+ XR_ERROR_PERMISSION_INSUFFICIENT = -1000710000,
+ XR_ERROR_ANDROID_THREAD_SETTINGS_ID_INVALID_KHR = -1000003000,
+ XR_ERROR_ANDROID_THREAD_SETTINGS_FAILURE_KHR = -1000003001,
+ XR_ERROR_CREATE_SPATIAL_ANCHOR_FAILED_MSFT = -1000039001,
+ XR_ERROR_SECONDARY_VIEW_CONFIGURATION_TYPE_NOT_ENABLED_MSFT = -1000053000,
+ XR_ERROR_CONTROLLER_MODEL_KEY_INVALID_MSFT = -1000055000,
+ XR_ERROR_REPROJECTION_MODE_UNSUPPORTED_MSFT = -1000066000,
+ XR_ERROR_COMPUTE_NEW_SCENE_NOT_COMPLETED_MSFT = -1000097000,
+ XR_ERROR_SCENE_COMPONENT_ID_INVALID_MSFT = -1000097001,
+ XR_ERROR_SCENE_COMPONENT_TYPE_MISMATCH_MSFT = -1000097002,
+ XR_ERROR_SCENE_MESH_BUFFER_ID_INVALID_MSFT = -1000097003,
+ XR_ERROR_SCENE_COMPUTE_FEATURE_INCOMPATIBLE_MSFT = -1000097004,
+ XR_ERROR_SCENE_COMPUTE_CONSISTENCY_MISMATCH_MSFT = -1000097005,
+ XR_ERROR_DISPLAY_REFRESH_RATE_UNSUPPORTED_FB = -1000101000,
+ XR_ERROR_COLOR_SPACE_UNSUPPORTED_FB = -1000108000,
+ XR_ERROR_SPACE_COMPONENT_NOT_SUPPORTED_FB = -1000113000,
+ XR_ERROR_SPACE_COMPONENT_NOT_ENABLED_FB = -1000113001,
+ XR_ERROR_SPACE_COMPONENT_STATUS_PENDING_FB = -1000113002,
+ XR_ERROR_SPACE_COMPONENT_STATUS_ALREADY_SET_FB = -1000113003,
+ XR_ERROR_UNEXPECTED_STATE_PASSTHROUGH_FB = -1000118000,
+ XR_ERROR_FEATURE_ALREADY_CREATED_PASSTHROUGH_FB = -1000118001,
+ XR_ERROR_FEATURE_REQUIRED_PASSTHROUGH_FB = -1000118002,
+ XR_ERROR_NOT_PERMITTED_PASSTHROUGH_FB = -1000118003,
+ XR_ERROR_INSUFFICIENT_RESOURCES_PASSTHROUGH_FB = -1000118004,
+ XR_ERROR_UNKNOWN_PASSTHROUGH_FB = -1000118050,
+ XR_ERROR_RENDER_MODEL_KEY_INVALID_FB = -1000119000,
+ XR_RENDER_MODEL_UNAVAILABLE_FB = 1000119020,
+ XR_ERROR_MARKER_NOT_TRACKED_VARJO = -1000124000,
+ XR_ERROR_MARKER_ID_INVALID_VARJO = -1000124001,
+ XR_ERROR_MARKER_DETECTOR_PERMISSION_DENIED_ML = -1000138000,
+ XR_ERROR_MARKER_DETECTOR_LOCATE_FAILED_ML = -1000138001,
+ XR_ERROR_MARKER_DETECTOR_INVALID_DATA_QUERY_ML = -1000138002,
+ XR_ERROR_MARKER_DETECTOR_INVALID_CREATE_INFO_ML = -1000138003,
+ XR_ERROR_MARKER_INVALID_ML = -1000138004,
+ XR_ERROR_LOCALIZATION_MAP_INCOMPATIBLE_ML = -1000139000,
+ XR_ERROR_LOCALIZATION_MAP_UNAVAILABLE_ML = -1000139001,
+ XR_ERROR_LOCALIZATION_MAP_FAIL_ML = -1000139002,
+ XR_ERROR_LOCALIZATION_MAP_IMPORT_EXPORT_PERMISSION_DENIED_ML = -1000139003,
+ XR_ERROR_LOCALIZATION_MAP_PERMISSION_DENIED_ML = -1000139004,
+ XR_ERROR_LOCALIZATION_MAP_ALREADY_EXISTS_ML = -1000139005,
+ XR_ERROR_LOCALIZATION_MAP_CANNOT_EXPORT_CLOUD_MAP_ML = -1000139006,
+ XR_ERROR_SPATIAL_ANCHORS_PERMISSION_DENIED_ML = -1000140000,
+ XR_ERROR_SPATIAL_ANCHORS_NOT_LOCALIZED_ML = -1000140001,
+ XR_ERROR_SPATIAL_ANCHORS_OUT_OF_MAP_BOUNDS_ML = -1000140002,
+ XR_ERROR_SPATIAL_ANCHORS_SPACE_NOT_LOCATABLE_ML = -1000140003,
+ XR_ERROR_SPATIAL_ANCHORS_ANCHOR_NOT_FOUND_ML = -1000141000,
+ XR_ERROR_SPATIAL_ANCHOR_NAME_NOT_FOUND_MSFT = -1000142001,
+ XR_ERROR_SPATIAL_ANCHOR_NAME_INVALID_MSFT = -1000142002,
+ XR_SCENE_MARKER_DATA_NOT_STRING_MSFT = 1000147000,
+ XR_ERROR_SPACE_MAPPING_INSUFFICIENT_FB = -1000169000,
+ XR_ERROR_SPACE_LOCALIZATION_FAILED_FB = -1000169001,
+ XR_ERROR_SPACE_NETWORK_TIMEOUT_FB = -1000169002,
+ XR_ERROR_SPACE_NETWORK_REQUEST_FAILED_FB = -1000169003,
+ XR_ERROR_SPACE_CLOUD_STORAGE_DISABLED_FB = -1000169004,
+ XR_ERROR_SPACE_INSUFFICIENT_RESOURCES_META = -1000259000,
+ XR_ERROR_SPACE_STORAGE_AT_CAPACITY_META = -1000259001,
+ XR_ERROR_SPACE_INSUFFICIENT_VIEW_META = -1000259002,
+ XR_ERROR_SPACE_PERMISSION_INSUFFICIENT_META = -1000259003,
+ XR_ERROR_SPACE_RATE_LIMITED_META = -1000259004,
+ XR_ERROR_SPACE_TOO_DARK_META = -1000259005,
+ XR_ERROR_SPACE_TOO_BRIGHT_META = -1000259006,
+ XR_ERROR_PASSTHROUGH_COLOR_LUT_BUFFER_SIZE_MISMATCH_META = -1000266000,
+ XR_ENVIRONMENT_DEPTH_NOT_AVAILABLE_META = 1000291000,
+ XR_ERROR_RENDER_MODEL_ID_INVALID_EXT = -1000300000,
+ XR_ERROR_RENDER_MODEL_ASSET_UNAVAILABLE_EXT = -1000300001,
+ XR_ERROR_RENDER_MODEL_GLTF_EXTENSION_REQUIRED_EXT = -1000300002,
+ XR_ERROR_NOT_INTERACTION_RENDER_MODEL_EXT = -1000301000,
+ XR_ERROR_HINT_ALREADY_SET_QCOM = -1000306000,
+ XR_ERROR_NOT_AN_ANCHOR_HTC = -1000319000,
+ XR_ERROR_SPATIAL_ENTITY_ID_INVALID_BD = -1000389000,
+ XR_ERROR_SPATIAL_SENSING_SERVICE_UNAVAILABLE_BD = -1000389001,
+ XR_ERROR_ANCHOR_NOT_SUPPORTED_FOR_ENTITY_BD = -1000389002,
+ XR_ERROR_SPATIAL_ANCHOR_NOT_FOUND_BD = -1000390000,
+ XR_ERROR_SPATIAL_ANCHOR_SHARING_NETWORK_TIMEOUT_BD = -1000391000,
+ XR_ERROR_SPATIAL_ANCHOR_SHARING_AUTHENTICATION_FAILURE_BD = -1000391001,
+ XR_ERROR_SPATIAL_ANCHOR_SHARING_NETWORK_FAILURE_BD = -1000391002,
+ XR_ERROR_SPATIAL_ANCHOR_SHARING_LOCALIZATION_FAIL_BD = -1000391003,
+ XR_ERROR_SPATIAL_ANCHOR_SHARING_MAP_INSUFFICIENT_BD = -1000391004,
+ XR_ERROR_SCENE_CAPTURE_FAILURE_BD = -1000392000,
+ XR_ERROR_SPACE_NOT_LOCATABLE_EXT = -1000429000,
+ XR_ERROR_PLANE_DETECTION_PERMISSION_DENIED_EXT = -1000429001,
+ XR_ERROR_MISMATCHING_TRACKABLE_TYPE_ANDROID = -1000455000,
+ XR_ERROR_TRACKABLE_TYPE_NOT_SUPPORTED_ANDROID = -1000455001,
+ XR_ERROR_ANCHOR_ID_NOT_FOUND_ANDROID = -1000457000,
+ XR_ERROR_ANCHOR_ALREADY_PERSISTED_ANDROID = -1000457001,
+ XR_ERROR_ANCHOR_NOT_TRACKING_ANDROID = -1000457002,
+ XR_ERROR_PERSISTED_DATA_NOT_READY_ANDROID = -1000457003,
+ XR_ERROR_SERVICE_NOT_READY_ANDROID = -1000458000,
+ XR_ERROR_MESH_DATA_LIMIT_EXCEEDED_ANDROID = -1000462000,
+ XR_ERROR_FUTURE_PENDING_EXT = -1000469001,
+ XR_ERROR_FUTURE_INVALID_EXT = -1000469002,
+ XR_ERROR_SYSTEM_NOTIFICATION_PERMISSION_DENIED_ML = -1000473000,
+ XR_ERROR_SYSTEM_NOTIFICATION_INCOMPATIBLE_SKU_ML = -1000473001,
+ XR_ERROR_WORLD_MESH_DETECTOR_PERMISSION_DENIED_ML = -1000474000,
+ XR_ERROR_WORLD_MESH_DETECTOR_SPACE_NOT_LOCATABLE_ML = -1000474001,
+ XR_ERROR_FACIAL_EXPRESSION_PERMISSION_DENIED_ML = 1000482000,
+ XR_BOUNDARY_VISIBILITY_SUPPRESSION_NOT_ALLOWED_META = 1000528000,
+ XR_ERROR_COLOCATION_DISCOVERY_NETWORK_FAILED_META = -1000571001,
+ XR_ERROR_COLOCATION_DISCOVERY_NO_DISCOVERY_METHOD_META = -1000571002,
+ XR_COLOCATION_DISCOVERY_ALREADY_ADVERTISING_META = 1000571003,
+ XR_COLOCATION_DISCOVERY_ALREADY_DISCOVERING_META = 1000571004,
+ XR_ERROR_SPACE_GROUP_NOT_FOUND_META = -1000572002,
+ XR_ERROR_ANCHOR_NOT_OWNED_BY_CALLER_ANDROID = -1000701000,
+ XR_ERROR_IMAGE_FORMAT_UNSUPPORTED_ANDROID = -1000709000,
+ XR_ERROR_SPATIAL_CAPABILITY_UNSUPPORTED_EXT = -1000740001,
+ XR_ERROR_SPATIAL_ENTITY_ID_INVALID_EXT = -1000740002,
+ XR_ERROR_SPATIAL_BUFFER_ID_INVALID_EXT = -1000740003,
+ XR_ERROR_SPATIAL_COMPONENT_UNSUPPORTED_FOR_CAPABILITY_EXT = -1000740004,
+ XR_ERROR_SPATIAL_CAPABILITY_CONFIGURATION_INVALID_EXT = -1000740005,
+ XR_ERROR_SPATIAL_COMPONENT_NOT_ENABLED_EXT = -1000740006,
+ XR_ERROR_CAMERA_UNAVAILABLE_BD = -1000755000,
+ XR_ERROR_CAMERA_OCCUPIED_BD = -1000755001,
+ XR_ERROR_CAMERA_CAPTURE_SESSION_CAPTURING_BD = -1000755002,
+ XR_ERROR_CAMERA_CAPTURE_SESSION_NOT_CAPTURING_BD = -1000755003,
+ XR_ERROR_CAMERA_ID_INVALID_BD = -1000755004,
+ XR_ERROR_CAMERA_IMAGE_ID_INVALID_BD = -1000755005,
+ XR_ERROR_CAMERA_PROPERTY_TYPE_INVALID_BD = -1000755006,
+ XR_ERROR_CAMERA_CAPABILITY_TYPE_INVALID_BD = -1000755007,
+ XR_ERROR_IMAGE_BUFFER_EXHAUSTED_BD = -1000755008,
+ XR_ERROR_SPATIAL_PERSISTENCE_SCOPE_UNSUPPORTED_EXT = -1000763001,
+ XR_ERROR_SPATIAL_PERSISTENCE_SCOPE_INCOMPATIBLE_EXT = -1000781001,
+ XR_ERROR_SPATIAL_IMAGE_FORMAT_UNSUPPORTED_EXT = -1000782001,
+ XR_ERROR_SPATIAL_IMAGE_INVALID_EXT = -1000782002,
+ XR_ERROR_SPATIAL_IMAGE_SIZE_MISSING_EXT = -1000782003,
+ XR_ERROR_KEYLESS_AUTH_NOT_SETUP_ANDROID = -1000787000,
+ XR_ERROR_KEYLESS_AUTH_FAILED_ANDROID = -1000787001,
+ XR_ERROR_GEOSPATIAL_TRACKER_NOT_RUNNING_ANDROID = -1000789000,
+ XR_ERROR_GEOSPATIAL_COORDINATES_INVALID_ANDROID = -1000789001,
+ XR_ERROR_GEOSPATIAL_CLOUD_AUTH_FAILED_ANDROID = -1000789002,
+ XR_ERROR_SPATIAL_ANCHOR_ATTACHABLE_COMPONENT_NOT_FOUND_ANDROID = -1000790001,
+ XR_ERROR_SPATIAL_ANCHOR_ENTITY_ID_INVALID_ANDROID = -1000795001,
+ XR_ERROR_SURFACE_ANCHOR_LOCATION_UNSUPPORTED_ANDROID = -1000797000,
+ XR_ERROR_SPATIAL_CONTAINER_CLOSED_EXT = -1000810000,
+ XR_ERROR_SPATIAL_CONTAINERS_ENABLED_EXT = -1000810001,
+ XR_ERROR_SPATIAL_CONTAINERS_NOT_ENABLED_EXT = -1000810002,
+ XR_ERROR_COMPATIBLE_SPATIAL_CONTAINER_MISSING_EXT = -1000810003,
+ XR_ERROR_SPATIAL_CONTAINER_IS_RENDERING_EXT = -1000813001,
+ XR_ERROR_SPATIAL_CONTAINER_NOT_RENDERING_EXT = -1000813002,
+ XR_ERROR_SPATIAL_CONTAINER_MISSING_EXT = -1000813003,
+ XR_ERROR_SPATIAL_CONTAINER_DUPLICATE_EXT = -1000813004,
+ XR_ERROR_SPATIAL_CONTAINER_GRAPHICS_PRESENTATION_MISSING_EXT = -1000813005,
+ XR_ERROR_EXTENSION_DEPENDENCY_NOT_ENABLED_KHR = XR_ERROR_EXTENSION_DEPENDENCY_NOT_ENABLED,
+ XR_ERROR_PERMISSION_INSUFFICIENT_KHR = XR_ERROR_PERMISSION_INSUFFICIENT,
+ XR_RESULT_MAX_ENUM = 0x7FFFFFFF
+} XrResult;
+
+typedef enum XrStructureType {
+ XR_TYPE_UNKNOWN = 0,
+ XR_TYPE_API_LAYER_PROPERTIES = 1,
+ XR_TYPE_EXTENSION_PROPERTIES = 2,
+ XR_TYPE_INSTANCE_CREATE_INFO = 3,
+ XR_TYPE_SYSTEM_GET_INFO = 4,
+ XR_TYPE_SYSTEM_PROPERTIES = 5,
+ XR_TYPE_VIEW_LOCATE_INFO = 6,
+ XR_TYPE_VIEW = 7,
+ XR_TYPE_SESSION_CREATE_INFO = 8,
+ XR_TYPE_SWAPCHAIN_CREATE_INFO = 9,
+ XR_TYPE_SESSION_BEGIN_INFO = 10,
+ XR_TYPE_VIEW_STATE = 11,
+ XR_TYPE_FRAME_END_INFO = 12,
+ XR_TYPE_HAPTIC_VIBRATION = 13,
+ XR_TYPE_EVENT_DATA_BUFFER = 16,
+ XR_TYPE_EVENT_DATA_INSTANCE_LOSS_PENDING = 17,
+ XR_TYPE_EVENT_DATA_SESSION_STATE_CHANGED = 18,
+ XR_TYPE_ACTION_STATE_BOOLEAN = 23,
+ XR_TYPE_ACTION_STATE_FLOAT = 24,
+ XR_TYPE_ACTION_STATE_VECTOR2F = 25,
+ XR_TYPE_ACTION_STATE_POSE = 27,
+ XR_TYPE_ACTION_SET_CREATE_INFO = 28,
+ XR_TYPE_ACTION_CREATE_INFO = 29,
+ XR_TYPE_INSTANCE_PROPERTIES = 32,
+ XR_TYPE_FRAME_WAIT_INFO = 33,
+ XR_TYPE_COMPOSITION_LAYER_PROJECTION = 35,
+ XR_TYPE_COMPOSITION_LAYER_QUAD = 36,
+ XR_TYPE_REFERENCE_SPACE_CREATE_INFO = 37,
+ XR_TYPE_ACTION_SPACE_CREATE_INFO = 38,
+ XR_TYPE_EVENT_DATA_REFERENCE_SPACE_CHANGE_PENDING = 40,
+ XR_TYPE_VIEW_CONFIGURATION_VIEW = 41,
+ XR_TYPE_SPACE_LOCATION = 42,
+ XR_TYPE_SPACE_VELOCITY = 43,
+ XR_TYPE_FRAME_STATE = 44,
+ XR_TYPE_VIEW_CONFIGURATION_PROPERTIES = 45,
+ XR_TYPE_FRAME_BEGIN_INFO = 46,
+ XR_TYPE_COMPOSITION_LAYER_PROJECTION_VIEW = 48,
+ XR_TYPE_EVENT_DATA_EVENTS_LOST = 49,
+ XR_TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING = 51,
+ XR_TYPE_EVENT_DATA_INTERACTION_PROFILE_CHANGED = 52,
+ XR_TYPE_INTERACTION_PROFILE_STATE = 53,
+ XR_TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO = 55,
+ XR_TYPE_SWAPCHAIN_IMAGE_WAIT_INFO = 56,
+ XR_TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO = 57,
+ XR_TYPE_ACTION_STATE_GET_INFO = 58,
+ XR_TYPE_HAPTIC_ACTION_INFO = 59,
+ XR_TYPE_SESSION_ACTION_SETS_ATTACH_INFO = 60,
+ XR_TYPE_ACTIONS_SYNC_INFO = 61,
+ XR_TYPE_BOUND_SOURCES_FOR_ACTION_ENUMERATE_INFO = 62,
+ XR_TYPE_INPUT_SOURCE_LOCALIZED_NAME_GET_INFO = 63,
+ XR_TYPE_SPACES_LOCATE_INFO = 1000471000,
+ XR_TYPE_SPACE_LOCATIONS = 1000471001,
+ XR_TYPE_SPACE_VELOCITIES = 1000471002,
+ XR_TYPE_COMPOSITION_LAYER_CUBE_KHR = 1000006000,
+ XR_TYPE_INSTANCE_CREATE_INFO_ANDROID_KHR = 1000008000,
+ XR_TYPE_COMPOSITION_LAYER_DEPTH_INFO_KHR = 1000010000,
+ XR_TYPE_VULKAN_SWAPCHAIN_FORMAT_LIST_CREATE_INFO_KHR = 1000014000,
+ XR_TYPE_EVENT_DATA_PERF_SETTINGS_EXT = 1000015000,
+ XR_TYPE_COMPOSITION_LAYER_CYLINDER_KHR = 1000017000,
+ XR_TYPE_COMPOSITION_LAYER_EQUIRECT_KHR = 1000018000,
+ XR_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT = 1000019000,
+ XR_TYPE_DEBUG_UTILS_MESSENGER_CALLBACK_DATA_EXT = 1000019001,
+ XR_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT = 1000019002,
+ XR_TYPE_DEBUG_UTILS_LABEL_EXT = 1000019003,
+ XR_TYPE_GRAPHICS_BINDING_OPENGL_WIN32_KHR = 1000023000,
+ XR_TYPE_GRAPHICS_BINDING_OPENGL_XLIB_KHR = 1000023001,
+ XR_TYPE_GRAPHICS_BINDING_OPENGL_XCB_KHR = 1000023002,
+ XR_TYPE_GRAPHICS_BINDING_OPENGL_WAYLAND_KHR = 1000023003,
+ XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_KHR = 1000023004,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_KHR = 1000023005,
+ XR_TYPE_GRAPHICS_BINDING_OPENGL_ES_ANDROID_KHR = 1000024001,
+ XR_TYPE_SWAPCHAIN_IMAGE_OPENGL_ES_KHR = 1000024002,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_OPENGL_ES_KHR = 1000024003,
+ XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR = 1000025000,
+ XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR = 1000025001,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR = 1000025002,
+ XR_TYPE_GRAPHICS_BINDING_D3D11_KHR = 1000027000,
+ XR_TYPE_SWAPCHAIN_IMAGE_D3D11_KHR = 1000027001,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_D3D11_KHR = 1000027002,
+ XR_TYPE_GRAPHICS_BINDING_D3D12_KHR = 1000028000,
+ XR_TYPE_SWAPCHAIN_IMAGE_D3D12_KHR = 1000028001,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_D3D12_KHR = 1000028002,
+ XR_TYPE_GRAPHICS_BINDING_METAL_KHR = 1000029000,
+ XR_TYPE_SWAPCHAIN_IMAGE_METAL_KHR = 1000029001,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_METAL_KHR = 1000029002,
+ XR_TYPE_SYSTEM_EYE_GAZE_INTERACTION_PROPERTIES_EXT = 1000030000,
+ XR_TYPE_EYE_GAZE_SAMPLE_TIME_EXT = 1000030001,
+ XR_TYPE_VISIBILITY_MASK_KHR = 1000031000,
+ XR_TYPE_EVENT_DATA_VISIBILITY_MASK_CHANGED_KHR = 1000031001,
+ XR_TYPE_SESSION_CREATE_INFO_OVERLAY_EXTX = 1000033000,
+ XR_TYPE_EVENT_DATA_MAIN_SESSION_VISIBILITY_CHANGED_EXTX = 1000033003,
+ XR_TYPE_COMPOSITION_LAYER_COLOR_SCALE_BIAS_KHR = 1000034000,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_MSFT = 1000039000,
+ XR_TYPE_SPATIAL_ANCHOR_SPACE_CREATE_INFO_MSFT = 1000039001,
+ XR_TYPE_COMPOSITION_LAYER_IMAGE_LAYOUT_FB = 1000040000,
+ XR_TYPE_COMPOSITION_LAYER_ALPHA_BLEND_FB = 1000041001,
+ XR_TYPE_VIEW_CONFIGURATION_DEPTH_RANGE_EXT = 1000046000,
+ XR_TYPE_GRAPHICS_BINDING_EGL_MNDX = 1000048004,
+ XR_TYPE_SPATIAL_GRAPH_NODE_SPACE_CREATE_INFO_MSFT = 1000049000,
+ XR_TYPE_SPATIAL_GRAPH_STATIC_NODE_BINDING_CREATE_INFO_MSFT = 1000049001,
+ XR_TYPE_SPATIAL_GRAPH_NODE_BINDING_PROPERTIES_GET_INFO_MSFT = 1000049002,
+ XR_TYPE_SPATIAL_GRAPH_NODE_BINDING_PROPERTIES_MSFT = 1000049003,
+ XR_TYPE_SYSTEM_HAND_TRACKING_PROPERTIES_EXT = 1000051000,
+ XR_TYPE_HAND_TRACKER_CREATE_INFO_EXT = 1000051001,
+ XR_TYPE_HAND_JOINTS_LOCATE_INFO_EXT = 1000051002,
+ XR_TYPE_HAND_JOINT_LOCATIONS_EXT = 1000051003,
+ XR_TYPE_HAND_JOINT_VELOCITIES_EXT = 1000051004,
+ XR_TYPE_SYSTEM_HAND_TRACKING_MESH_PROPERTIES_MSFT = 1000052000,
+ XR_TYPE_HAND_MESH_SPACE_CREATE_INFO_MSFT = 1000052001,
+ XR_TYPE_HAND_MESH_UPDATE_INFO_MSFT = 1000052002,
+ XR_TYPE_HAND_MESH_MSFT = 1000052003,
+ XR_TYPE_HAND_POSE_TYPE_INFO_MSFT = 1000052004,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_SESSION_BEGIN_INFO_MSFT = 1000053000,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_STATE_MSFT = 1000053001,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_FRAME_STATE_MSFT = 1000053002,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_FRAME_END_INFO_MSFT = 1000053003,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_LAYER_INFO_MSFT = 1000053004,
+ XR_TYPE_SECONDARY_VIEW_CONFIGURATION_SWAPCHAIN_CREATE_INFO_MSFT = 1000053005,
+ XR_TYPE_CONTROLLER_MODEL_KEY_STATE_MSFT = 1000055000,
+ XR_TYPE_CONTROLLER_MODEL_NODE_PROPERTIES_MSFT = 1000055001,
+ XR_TYPE_CONTROLLER_MODEL_PROPERTIES_MSFT = 1000055002,
+ XR_TYPE_CONTROLLER_MODEL_NODE_STATE_MSFT = 1000055003,
+ XR_TYPE_CONTROLLER_MODEL_STATE_MSFT = 1000055004,
+ XR_TYPE_VIEW_CONFIGURATION_VIEW_FOV_EPIC = 1000059000,
+ XR_TYPE_HOLOGRAPHIC_WINDOW_ATTACHMENT_MSFT = 1000063000,
+ XR_TYPE_COMPOSITION_LAYER_REPROJECTION_INFO_MSFT = 1000066000,
+ XR_TYPE_COMPOSITION_LAYER_REPROJECTION_PLANE_OVERRIDE_MSFT = 1000066001,
+ XR_TYPE_ANDROID_SURFACE_SWAPCHAIN_CREATE_INFO_FB = 1000070000,
+ XR_TYPE_COMPOSITION_LAYER_SECURE_CONTENT_FB = 1000072000,
+ XR_TYPE_BODY_TRACKER_CREATE_INFO_FB = 1000076001,
+ XR_TYPE_BODY_JOINTS_LOCATE_INFO_FB = 1000076002,
+ XR_TYPE_SYSTEM_BODY_TRACKING_PROPERTIES_FB = 1000076004,
+ XR_TYPE_BODY_JOINT_LOCATIONS_FB = 1000076005,
+ XR_TYPE_BODY_SKELETON_FB = 1000076006,
+ XR_TYPE_INTERACTION_PROFILE_DPAD_BINDING_EXT = 1000078000,
+ XR_TYPE_INTERACTION_PROFILE_ANALOG_THRESHOLD_VALVE = 1000079000,
+ XR_TYPE_HAND_JOINTS_MOTION_RANGE_INFO_EXT = 1000080000,
+ XR_TYPE_LOADER_INIT_INFO_ANDROID_KHR = 1000089000,
+ XR_TYPE_VULKAN_INSTANCE_CREATE_INFO_KHR = 1000090000,
+ XR_TYPE_VULKAN_DEVICE_CREATE_INFO_KHR = 1000090001,
+ XR_TYPE_VULKAN_GRAPHICS_DEVICE_GET_INFO_KHR = 1000090003,
+ XR_TYPE_COMPOSITION_LAYER_EQUIRECT2_KHR = 1000091000,
+ XR_TYPE_SCENE_OBSERVER_CREATE_INFO_MSFT = 1000097000,
+ XR_TYPE_SCENE_CREATE_INFO_MSFT = 1000097001,
+ XR_TYPE_NEW_SCENE_COMPUTE_INFO_MSFT = 1000097002,
+ XR_TYPE_VISUAL_MESH_COMPUTE_LOD_INFO_MSFT = 1000097003,
+ XR_TYPE_SCENE_COMPONENTS_MSFT = 1000097004,
+ XR_TYPE_SCENE_COMPONENTS_GET_INFO_MSFT = 1000097005,
+ XR_TYPE_SCENE_COMPONENT_LOCATIONS_MSFT = 1000097006,
+ XR_TYPE_SCENE_COMPONENTS_LOCATE_INFO_MSFT = 1000097007,
+ XR_TYPE_SCENE_OBJECTS_MSFT = 1000097008,
+ XR_TYPE_SCENE_COMPONENT_PARENT_FILTER_INFO_MSFT = 1000097009,
+ XR_TYPE_SCENE_OBJECT_TYPES_FILTER_INFO_MSFT = 1000097010,
+ XR_TYPE_SCENE_PLANES_MSFT = 1000097011,
+ XR_TYPE_SCENE_PLANE_ALIGNMENT_FILTER_INFO_MSFT = 1000097012,
+ XR_TYPE_SCENE_MESHES_MSFT = 1000097013,
+ XR_TYPE_SCENE_MESH_BUFFERS_GET_INFO_MSFT = 1000097014,
+ XR_TYPE_SCENE_MESH_BUFFERS_MSFT = 1000097015,
+ XR_TYPE_SCENE_MESH_VERTEX_BUFFER_MSFT = 1000097016,
+ XR_TYPE_SCENE_MESH_INDICES_UINT32_MSFT = 1000097017,
+ XR_TYPE_SCENE_MESH_INDICES_UINT16_MSFT = 1000097018,
+ XR_TYPE_SERIALIZED_SCENE_FRAGMENT_DATA_GET_INFO_MSFT = 1000098000,
+ XR_TYPE_SCENE_DESERIALIZE_INFO_MSFT = 1000098001,
+ XR_TYPE_EVENT_DATA_DISPLAY_REFRESH_RATE_CHANGED_FB = 1000101000,
+ XR_TYPE_VIVE_TRACKER_PATHS_HTCX = 1000103000,
+ XR_TYPE_EVENT_DATA_VIVE_TRACKER_CONNECTED_HTCX = 1000103001,
+ XR_TYPE_SYSTEM_FACIAL_TRACKING_PROPERTIES_HTC = 1000104000,
+ XR_TYPE_FACIAL_TRACKER_CREATE_INFO_HTC = 1000104001,
+ XR_TYPE_FACIAL_EXPRESSIONS_HTC = 1000104002,
+ XR_TYPE_SYSTEM_COLOR_SPACE_PROPERTIES_FB = 1000108000,
+ XR_TYPE_HAND_TRACKING_MESH_FB = 1000110001,
+ XR_TYPE_HAND_TRACKING_SCALE_FB = 1000110003,
+ XR_TYPE_HAND_TRACKING_AIM_STATE_FB = 1000111001,
+ XR_TYPE_HAND_TRACKING_CAPSULES_STATE_FB = 1000112000,
+ XR_TYPE_SYSTEM_SPATIAL_ENTITY_PROPERTIES_FB = 1000113004,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_FB = 1000113003,
+ XR_TYPE_SPACE_COMPONENT_STATUS_SET_INFO_FB = 1000113007,
+ XR_TYPE_SPACE_COMPONENT_STATUS_FB = 1000113001,
+ XR_TYPE_EVENT_DATA_SPATIAL_ANCHOR_CREATE_COMPLETE_FB = 1000113005,
+ XR_TYPE_EVENT_DATA_SPACE_SET_STATUS_COMPLETE_FB = 1000113006,
+ XR_TYPE_FOVEATION_PROFILE_CREATE_INFO_FB = 1000114000,
+ XR_TYPE_SWAPCHAIN_CREATE_INFO_FOVEATION_FB = 1000114001,
+ XR_TYPE_SWAPCHAIN_STATE_FOVEATION_FB = 1000114002,
+ XR_TYPE_FOVEATION_LEVEL_PROFILE_CREATE_INFO_FB = 1000115000,
+ XR_TYPE_KEYBOARD_SPACE_CREATE_INFO_FB = 1000116009,
+ XR_TYPE_KEYBOARD_TRACKING_QUERY_FB = 1000116004,
+ XR_TYPE_SYSTEM_KEYBOARD_TRACKING_PROPERTIES_FB = 1000116002,
+ XR_TYPE_TRIANGLE_MESH_CREATE_INFO_FB = 1000117001,
+ XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES_FB = 1000118000,
+ XR_TYPE_PASSTHROUGH_CREATE_INFO_FB = 1000118001,
+ XR_TYPE_PASSTHROUGH_LAYER_CREATE_INFO_FB = 1000118002,
+ XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB = 1000118003,
+ XR_TYPE_GEOMETRY_INSTANCE_CREATE_INFO_FB = 1000118004,
+ XR_TYPE_GEOMETRY_INSTANCE_TRANSFORM_FB = 1000118005,
+ XR_TYPE_SYSTEM_PASSTHROUGH_PROPERTIES2_FB = 1000118006,
+ XR_TYPE_PASSTHROUGH_STYLE_FB = 1000118020,
+ XR_TYPE_PASSTHROUGH_COLOR_MAP_MONO_TO_RGBA_FB = 1000118021,
+ XR_TYPE_PASSTHROUGH_COLOR_MAP_MONO_TO_MONO_FB = 1000118022,
+ XR_TYPE_PASSTHROUGH_BRIGHTNESS_CONTRAST_SATURATION_FB = 1000118023,
+ XR_TYPE_EVENT_DATA_PASSTHROUGH_STATE_CHANGED_FB = 1000118030,
+ XR_TYPE_RENDER_MODEL_PATH_INFO_FB = 1000119000,
+ XR_TYPE_RENDER_MODEL_PROPERTIES_FB = 1000119001,
+ XR_TYPE_RENDER_MODEL_BUFFER_FB = 1000119002,
+ XR_TYPE_RENDER_MODEL_LOAD_INFO_FB = 1000119003,
+ XR_TYPE_SYSTEM_RENDER_MODEL_PROPERTIES_FB = 1000119004,
+ XR_TYPE_RENDER_MODEL_CAPABILITIES_REQUEST_FB = 1000119005,
+ XR_TYPE_BINDING_MODIFICATIONS_KHR = 1000120000,
+ XR_TYPE_VIEW_LOCATE_FOVEATED_RENDERING_VARJO = 1000121000,
+ XR_TYPE_FOVEATED_VIEW_CONFIGURATION_VIEW_VARJO = 1000121001,
+ XR_TYPE_SYSTEM_FOVEATED_RENDERING_PROPERTIES_VARJO = 1000121002,
+ XR_TYPE_COMPOSITION_LAYER_DEPTH_TEST_VARJO = 1000122000,
+ XR_TYPE_SYSTEM_MARKER_TRACKING_PROPERTIES_VARJO = 1000124000,
+ XR_TYPE_EVENT_DATA_MARKER_TRACKING_UPDATE_VARJO = 1000124001,
+ XR_TYPE_MARKER_SPACE_CREATE_INFO_VARJO = 1000124002,
+ XR_TYPE_FRAME_END_INFO_ML = 1000135000,
+ XR_TYPE_GLOBAL_DIMMER_FRAME_END_INFO_ML = 1000136000,
+ XR_TYPE_COORDINATE_SPACE_CREATE_INFO_ML = 1000137000,
+ XR_TYPE_SYSTEM_MARKER_UNDERSTANDING_PROPERTIES_ML = 1000138000,
+ XR_TYPE_MARKER_DETECTOR_CREATE_INFO_ML = 1000138001,
+ XR_TYPE_MARKER_DETECTOR_ARUCO_INFO_ML = 1000138002,
+ XR_TYPE_MARKER_DETECTOR_SIZE_INFO_ML = 1000138003,
+ XR_TYPE_MARKER_DETECTOR_APRIL_TAG_INFO_ML = 1000138004,
+ XR_TYPE_MARKER_DETECTOR_CUSTOM_PROFILE_INFO_ML = 1000138005,
+ XR_TYPE_MARKER_DETECTOR_SNAPSHOT_INFO_ML = 1000138006,
+ XR_TYPE_MARKER_DETECTOR_STATE_ML = 1000138007,
+ XR_TYPE_MARKER_SPACE_CREATE_INFO_ML = 1000138008,
+ XR_TYPE_LOCALIZATION_MAP_ML = 1000139000,
+ XR_TYPE_EVENT_DATA_LOCALIZATION_CHANGED_ML = 1000139001,
+ XR_TYPE_MAP_LOCALIZATION_REQUEST_INFO_ML = 1000139002,
+ XR_TYPE_LOCALIZATION_MAP_IMPORT_INFO_ML = 1000139003,
+ XR_TYPE_LOCALIZATION_ENABLE_EVENTS_INFO_ML = 1000139004,
+ XR_TYPE_SPATIAL_ANCHORS_CREATE_INFO_FROM_POSE_ML = 1000140000,
+ XR_TYPE_CREATE_SPATIAL_ANCHORS_COMPLETION_ML = 1000140001,
+ XR_TYPE_SPATIAL_ANCHOR_STATE_ML = 1000140002,
+ XR_TYPE_SPATIAL_ANCHORS_CREATE_STORAGE_INFO_ML = 1000141000,
+ XR_TYPE_SPATIAL_ANCHORS_QUERY_INFO_RADIUS_ML = 1000141001,
+ XR_TYPE_SPATIAL_ANCHORS_QUERY_COMPLETION_ML = 1000141002,
+ XR_TYPE_SPATIAL_ANCHORS_CREATE_INFO_FROM_UUIDS_ML = 1000141003,
+ XR_TYPE_SPATIAL_ANCHORS_PUBLISH_INFO_ML = 1000141004,
+ XR_TYPE_SPATIAL_ANCHORS_PUBLISH_COMPLETION_ML = 1000141005,
+ XR_TYPE_SPATIAL_ANCHORS_DELETE_INFO_ML = 1000141006,
+ XR_TYPE_SPATIAL_ANCHORS_DELETE_COMPLETION_ML = 1000141007,
+ XR_TYPE_SPATIAL_ANCHORS_UPDATE_EXPIRATION_INFO_ML = 1000141008,
+ XR_TYPE_SPATIAL_ANCHORS_UPDATE_EXPIRATION_COMPLETION_ML = 1000141009,
+ XR_TYPE_SPATIAL_ANCHORS_PUBLISH_COMPLETION_DETAILS_ML = 1000141010,
+ XR_TYPE_SPATIAL_ANCHORS_DELETE_COMPLETION_DETAILS_ML = 1000141011,
+ XR_TYPE_SPATIAL_ANCHORS_UPDATE_EXPIRATION_COMPLETION_DETAILS_ML = 1000141012,
+ XR_TYPE_EVENT_DATA_HEADSET_FIT_CHANGED_ML = 1000472000,
+ XR_TYPE_EVENT_DATA_EYE_CALIBRATION_CHANGED_ML = 1000472001,
+ XR_TYPE_USER_CALIBRATION_ENABLE_EVENTS_INFO_ML = 1000472002,
+ XR_TYPE_SPATIAL_ANCHOR_PERSISTENCE_INFO_MSFT = 1000142000,
+ XR_TYPE_SPATIAL_ANCHOR_FROM_PERSISTED_ANCHOR_CREATE_INFO_MSFT = 1000142001,
+ XR_TYPE_SCENE_MARKERS_MSFT = 1000147000,
+ XR_TYPE_SCENE_MARKER_TYPE_FILTER_MSFT = 1000147001,
+ XR_TYPE_SCENE_MARKER_QR_CODES_MSFT = 1000147002,
+ XR_TYPE_SPACE_QUERY_INFO_FB = 1000156001,
+ XR_TYPE_SPACE_QUERY_RESULTS_FB = 1000156002,
+ XR_TYPE_SPACE_STORAGE_LOCATION_FILTER_INFO_FB = 1000156003,
+ XR_TYPE_SPACE_UUID_FILTER_INFO_FB = 1000156054,
+ XR_TYPE_SPACE_COMPONENT_FILTER_INFO_FB = 1000156052,
+ XR_TYPE_EVENT_DATA_SPACE_QUERY_RESULTS_AVAILABLE_FB = 1000156103,
+ XR_TYPE_EVENT_DATA_SPACE_QUERY_COMPLETE_FB = 1000156104,
+ XR_TYPE_SPACE_SAVE_INFO_FB = 1000158000,
+ XR_TYPE_SPACE_ERASE_INFO_FB = 1000158001,
+ XR_TYPE_EVENT_DATA_SPACE_SAVE_COMPLETE_FB = 1000158106,
+ XR_TYPE_EVENT_DATA_SPACE_ERASE_COMPLETE_FB = 1000158107,
+ XR_TYPE_SWAPCHAIN_IMAGE_FOVEATION_VULKAN_FB = 1000160000,
+ XR_TYPE_SWAPCHAIN_STATE_ANDROID_SURFACE_DIMENSIONS_FB = 1000161000,
+ XR_TYPE_SWAPCHAIN_STATE_SAMPLER_OPENGL_ES_FB = 1000162000,
+ XR_TYPE_SWAPCHAIN_STATE_SAMPLER_VULKAN_FB = 1000163000,
+ XR_TYPE_SPACE_SHARE_INFO_FB = 1000169001,
+ XR_TYPE_EVENT_DATA_SPACE_SHARE_COMPLETE_FB = 1000169002,
+ XR_TYPE_COMPOSITION_LAYER_SPACE_WARP_INFO_FB = 1000171000,
+ XR_TYPE_SYSTEM_SPACE_WARP_PROPERTIES_FB = 1000171001,
+ XR_TYPE_HAPTIC_AMPLITUDE_ENVELOPE_VIBRATION_FB = 1000173001,
+ XR_TYPE_SEMANTIC_LABELS_FB = 1000175000,
+ XR_TYPE_ROOM_LAYOUT_FB = 1000175001,
+ XR_TYPE_BOUNDARY_2D_FB = 1000175002,
+ XR_TYPE_SEMANTIC_LABELS_SUPPORT_INFO_FB = 1000175010,
+ XR_TYPE_DIGITAL_LENS_CONTROL_ALMALENCE = 1000196000,
+ XR_TYPE_EVENT_DATA_SCENE_CAPTURE_COMPLETE_FB = 1000198001,
+ XR_TYPE_SCENE_CAPTURE_REQUEST_INFO_FB = 1000198050,
+ XR_TYPE_SPACE_CONTAINER_FB = 1000199000,
+ XR_TYPE_FOVEATION_EYE_TRACKED_PROFILE_CREATE_INFO_META = 1000200000,
+ XR_TYPE_FOVEATION_EYE_TRACKED_STATE_META = 1000200001,
+ XR_TYPE_SYSTEM_FOVEATION_EYE_TRACKED_PROPERTIES_META = 1000200002,
+ XR_TYPE_SYSTEM_FACE_TRACKING_PROPERTIES_FB = 1000201004,
+ XR_TYPE_FACE_TRACKER_CREATE_INFO_FB = 1000201005,
+ XR_TYPE_FACE_EXPRESSION_INFO_FB = 1000201002,
+ XR_TYPE_FACE_EXPRESSION_WEIGHTS_FB = 1000201006,
+ XR_TYPE_EYE_TRACKER_CREATE_INFO_FB = 1000202001,
+ XR_TYPE_EYE_GAZES_INFO_FB = 1000202002,
+ XR_TYPE_EYE_GAZES_FB = 1000202003,
+ XR_TYPE_SYSTEM_EYE_TRACKING_PROPERTIES_FB = 1000202004,
+ XR_TYPE_PASSTHROUGH_KEYBOARD_HANDS_INTENSITY_FB = 1000203002,
+ XR_TYPE_COMPOSITION_LAYER_SETTINGS_FB = 1000204000,
+ XR_TYPE_HAPTIC_PCM_VIBRATION_FB = 1000209001,
+ XR_TYPE_DEVICE_PCM_SAMPLE_RATE_STATE_FB = 1000209002,
+ XR_TYPE_FRAME_SYNTHESIS_INFO_EXT = 1000211000,
+ XR_TYPE_FRAME_SYNTHESIS_CONFIG_VIEW_EXT = 1000211001,
+ XR_TYPE_COMPOSITION_LAYER_DEPTH_TEST_FB = 1000212000,
+ XR_TYPE_LOCAL_DIMMING_FRAME_END_INFO_META = 1000216000,
+ XR_TYPE_PASSTHROUGH_PREFERENCES_META = 1000217000,
+ XR_TYPE_SYSTEM_VIRTUAL_KEYBOARD_PROPERTIES_META = 1000219001,
+ XR_TYPE_VIRTUAL_KEYBOARD_CREATE_INFO_META = 1000219002,
+ XR_TYPE_VIRTUAL_KEYBOARD_SPACE_CREATE_INFO_META = 1000219003,
+ XR_TYPE_VIRTUAL_KEYBOARD_LOCATION_INFO_META = 1000219004,
+ XR_TYPE_VIRTUAL_KEYBOARD_MODEL_VISIBILITY_SET_INFO_META = 1000219005,
+ XR_TYPE_VIRTUAL_KEYBOARD_ANIMATION_STATE_META = 1000219006,
+ XR_TYPE_VIRTUAL_KEYBOARD_MODEL_ANIMATION_STATES_META = 1000219007,
+ XR_TYPE_VIRTUAL_KEYBOARD_TEXTURE_DATA_META = 1000219009,
+ XR_TYPE_VIRTUAL_KEYBOARD_INPUT_INFO_META = 1000219010,
+ XR_TYPE_VIRTUAL_KEYBOARD_TEXT_CONTEXT_CHANGE_INFO_META = 1000219011,
+ XR_TYPE_EVENT_DATA_VIRTUAL_KEYBOARD_COMMIT_TEXT_META = 1000219014,
+ XR_TYPE_EVENT_DATA_VIRTUAL_KEYBOARD_BACKSPACE_META = 1000219015,
+ XR_TYPE_EVENT_DATA_VIRTUAL_KEYBOARD_ENTER_META = 1000219016,
+ XR_TYPE_EVENT_DATA_VIRTUAL_KEYBOARD_SHOWN_META = 1000219017,
+ XR_TYPE_EVENT_DATA_VIRTUAL_KEYBOARD_HIDDEN_META = 1000219018,
+ XR_TYPE_EXTERNAL_CAMERA_OCULUS = 1000226000,
+ XR_TYPE_VULKAN_SWAPCHAIN_CREATE_INFO_META = 1000227000,
+ XR_TYPE_PERFORMANCE_METRICS_STATE_META = 1000232001,
+ XR_TYPE_PERFORMANCE_METRICS_COUNTER_META = 1000232002,
+ XR_TYPE_SPACE_LIST_SAVE_INFO_FB = 1000238000,
+ XR_TYPE_EVENT_DATA_SPACE_LIST_SAVE_COMPLETE_FB = 1000238001,
+ XR_TYPE_SPACE_USER_CREATE_INFO_FB = 1000241001,
+ XR_TYPE_SYSTEM_HEADSET_ID_PROPERTIES_META = 1000245000,
+ XR_TYPE_SYSTEM_SPACE_DISCOVERY_PROPERTIES_META = 1000247000,
+ XR_TYPE_SPACE_DISCOVERY_INFO_META = 1000247001,
+ XR_TYPE_SPACE_FILTER_UUID_META = 1000247003,
+ XR_TYPE_SPACE_FILTER_COMPONENT_META = 1000247004,
+ XR_TYPE_SPACE_DISCOVERY_RESULT_META = 1000247005,
+ XR_TYPE_SPACE_DISCOVERY_RESULTS_META = 1000247006,
+ XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_RESULTS_AVAILABLE_META = 1000247007,
+ XR_TYPE_EVENT_DATA_SPACE_DISCOVERY_COMPLETE_META = 1000247008,
+ XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_META = 1000254000,
+ XR_TYPE_RECOMMENDED_LAYER_RESOLUTION_GET_INFO_META = 1000254001,
+ XR_TYPE_SYSTEM_SPACE_PERSISTENCE_PROPERTIES_META = 1000259000,
+ XR_TYPE_SPACES_SAVE_INFO_META = 1000259001,
+ XR_TYPE_EVENT_DATA_SPACES_SAVE_RESULT_META = 1000259002,
+ XR_TYPE_SPACES_ERASE_INFO_META = 1000259003,
+ XR_TYPE_EVENT_DATA_SPACES_ERASE_RESULT_META = 1000259004,
+ XR_TYPE_SYSTEM_PASSTHROUGH_COLOR_LUT_PROPERTIES_META = 1000266000,
+ XR_TYPE_PASSTHROUGH_COLOR_LUT_CREATE_INFO_META = 1000266001,
+ XR_TYPE_PASSTHROUGH_COLOR_LUT_UPDATE_INFO_META = 1000266002,
+ XR_TYPE_PASSTHROUGH_COLOR_MAP_LUT_META = 1000266100,
+ XR_TYPE_PASSTHROUGH_COLOR_MAP_INTERPOLATED_LUT_META = 1000266101,
+ XR_TYPE_SPACE_TRIANGLE_MESH_GET_INFO_META = 1000269001,
+ XR_TYPE_SPACE_TRIANGLE_MESH_META = 1000269002,
+ XR_TYPE_SYSTEM_PROPERTIES_BODY_TRACKING_FULL_BODY_META = 1000274000,
+ XR_TYPE_EVENT_DATA_PASSTHROUGH_LAYER_RESUMED_META = 1000282000,
+ XR_TYPE_BODY_TRACKING_CALIBRATION_INFO_META = 1000283002,
+ XR_TYPE_BODY_TRACKING_CALIBRATION_STATUS_META = 1000283003,
+ XR_TYPE_SYSTEM_PROPERTIES_BODY_TRACKING_CALIBRATION_META = 1000283004,
+ XR_TYPE_BODY_TRACKING_FIDELITY_STATUS_META = 1000284000,
+ XR_TYPE_SYSTEM_PROPERTIES_BODY_TRACKING_FIDELITY_META = 1000284001,
+ XR_TYPE_SYSTEM_FACE_TRACKING_PROPERTIES2_FB = 1000287013,
+ XR_TYPE_FACE_TRACKER_CREATE_INFO2_FB = 1000287014,
+ XR_TYPE_FACE_EXPRESSION_INFO2_FB = 1000287015,
+ XR_TYPE_FACE_EXPRESSION_WEIGHTS2_FB = 1000287016,
+ XR_TYPE_SYSTEM_SPATIAL_ENTITY_SHARING_PROPERTIES_META = 1000290000,
+ XR_TYPE_SHARE_SPACES_INFO_META = 1000290001,
+ XR_TYPE_EVENT_DATA_SHARE_SPACES_COMPLETE_META = 1000290002,
+ XR_TYPE_ENVIRONMENT_DEPTH_PROVIDER_CREATE_INFO_META = 1000291000,
+ XR_TYPE_ENVIRONMENT_DEPTH_SWAPCHAIN_CREATE_INFO_META = 1000291001,
+ XR_TYPE_ENVIRONMENT_DEPTH_SWAPCHAIN_STATE_META = 1000291002,
+ XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_ACQUIRE_INFO_META = 1000291003,
+ XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_VIEW_META = 1000291004,
+ XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_META = 1000291005,
+ XR_TYPE_ENVIRONMENT_DEPTH_HAND_REMOVAL_SET_INFO_META = 1000291006,
+ XR_TYPE_SYSTEM_ENVIRONMENT_DEPTH_PROPERTIES_META = 1000291007,
+ XR_TYPE_ENVIRONMENT_DEPTH_IMAGE_TIMESTAMP_META = 1000291008,
+ XR_TYPE_RENDER_MODEL_CREATE_INFO_EXT = 1000300000,
+ XR_TYPE_RENDER_MODEL_PROPERTIES_GET_INFO_EXT = 1000300001,
+ XR_TYPE_RENDER_MODEL_PROPERTIES_EXT = 1000300002,
+ XR_TYPE_RENDER_MODEL_SPACE_CREATE_INFO_EXT = 1000300003,
+ XR_TYPE_RENDER_MODEL_STATE_GET_INFO_EXT = 1000300004,
+ XR_TYPE_RENDER_MODEL_STATE_EXT = 1000300005,
+ XR_TYPE_RENDER_MODEL_ASSET_CREATE_INFO_EXT = 1000300006,
+ XR_TYPE_RENDER_MODEL_ASSET_DATA_GET_INFO_EXT = 1000300007,
+ XR_TYPE_RENDER_MODEL_ASSET_DATA_EXT = 1000300008,
+ XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_GET_INFO_EXT = 1000300009,
+ XR_TYPE_RENDER_MODEL_ASSET_PROPERTIES_EXT = 1000300010,
+ XR_TYPE_INTERACTION_RENDER_MODEL_IDS_ENUMERATE_INFO_EXT = 1000301000,
+ XR_TYPE_INTERACTION_RENDER_MODEL_SUBACTION_PATH_INFO_EXT = 1000301001,
+ XR_TYPE_EVENT_DATA_INTERACTION_RENDER_MODELS_CHANGED_EXT = 1000301002,
+ XR_TYPE_INTERACTION_RENDER_MODEL_TOP_LEVEL_USER_PATH_GET_INFO_EXT = 1000301003,
+ XR_TYPE_PASSTHROUGH_CREATE_INFO_HTC = 1000317001,
+ XR_TYPE_PASSTHROUGH_COLOR_HTC = 1000317002,
+ XR_TYPE_PASSTHROUGH_MESH_TRANSFORM_INFO_HTC = 1000317003,
+ XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_HTC = 1000317004,
+ XR_TYPE_FOVEATION_APPLY_INFO_HTC = 1000318000,
+ XR_TYPE_FOVEATION_DYNAMIC_MODE_INFO_HTC = 1000318001,
+ XR_TYPE_FOVEATION_CUSTOM_MODE_INFO_HTC = 1000318002,
+ XR_TYPE_SYSTEM_ANCHOR_PROPERTIES_HTC = 1000319000,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_HTC = 1000319001,
+ XR_TYPE_SYSTEM_BODY_TRACKING_PROPERTIES_HTC = 1000320000,
+ XR_TYPE_BODY_TRACKER_CREATE_INFO_HTC = 1000320001,
+ XR_TYPE_BODY_JOINTS_LOCATE_INFO_HTC = 1000320002,
+ XR_TYPE_BODY_JOINT_LOCATIONS_HTC = 1000320003,
+ XR_TYPE_BODY_SKELETON_HTC = 1000320004,
+ XR_TYPE_ACTIVE_ACTION_SET_PRIORITIES_EXT = 1000373000,
+ XR_TYPE_SYSTEM_FORCE_FEEDBACK_CURL_PROPERTIES_MNDX = 1000375000,
+ XR_TYPE_FORCE_FEEDBACK_CURL_APPLY_LOCATIONS_MNDX = 1000375001,
+ XR_TYPE_BODY_TRACKER_CREATE_INFO_BD = 1000385001,
+ XR_TYPE_BODY_JOINTS_LOCATE_INFO_BD = 1000385002,
+ XR_TYPE_BODY_JOINT_LOCATIONS_BD = 1000385003,
+ XR_TYPE_SYSTEM_BODY_TRACKING_PROPERTIES_BD = 1000385004,
+ XR_TYPE_SYSTEM_FACIAL_SIMULATION_PROPERTIES_BD = 1000386001,
+ XR_TYPE_FACE_TRACKER_CREATE_INFO_BD = 1000386002,
+ XR_TYPE_FACIAL_SIMULATION_DATA_GET_INFO_BD = 1000386003,
+ XR_TYPE_FACIAL_SIMULATION_DATA_BD = 1000386004,
+ XR_TYPE_LIP_EXPRESSION_DATA_BD = 1000386005,
+ XR_TYPE_SYSTEM_SPATIAL_SENSING_PROPERTIES_BD = 1000389000,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_GET_INFO_BD = 1000389001,
+ XR_TYPE_SPATIAL_ENTITY_LOCATION_GET_INFO_BD = 1000389002,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_LOCATION_BD = 1000389003,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_SEMANTIC_BD = 1000389004,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_BOUNDING_BOX_2D_BD = 1000389005,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_POLYGON_BD = 1000389006,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_BOUNDING_BOX_3D_BD = 1000389007,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_TRIANGLE_MESH_BD = 1000389008,
+ XR_TYPE_SENSE_DATA_PROVIDER_CREATE_INFO_BD = 1000389009,
+ XR_TYPE_SENSE_DATA_PROVIDER_START_INFO_BD = 1000389010,
+ XR_TYPE_EVENT_DATA_SENSE_DATA_PROVIDER_STATE_CHANGED_BD = 1000389011,
+ XR_TYPE_EVENT_DATA_SENSE_DATA_UPDATED_BD = 1000389012,
+ XR_TYPE_SENSE_DATA_QUERY_INFO_BD = 1000389013,
+ XR_TYPE_SENSE_DATA_QUERY_COMPLETION_BD = 1000389014,
+ XR_TYPE_SENSE_DATA_FILTER_UUID_BD = 1000389015,
+ XR_TYPE_SENSE_DATA_FILTER_SEMANTIC_BD = 1000389016,
+ XR_TYPE_QUERIED_SENSE_DATA_GET_INFO_BD = 1000389017,
+ XR_TYPE_QUERIED_SENSE_DATA_BD = 1000389018,
+ XR_TYPE_SPATIAL_ENTITY_STATE_BD = 1000389019,
+ XR_TYPE_SPATIAL_ENTITY_ANCHOR_CREATE_INFO_BD = 1000389020,
+ XR_TYPE_ANCHOR_SPACE_CREATE_INFO_BD = 1000389021,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_SPHERE_BD = 1000389022,
+ XR_TYPE_SYSTEM_SPATIAL_ANCHOR_PROPERTIES_BD = 1000390000,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_BD = 1000390001,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_COMPLETION_BD = 1000390002,
+ XR_TYPE_SPATIAL_ANCHOR_PERSIST_INFO_BD = 1000390003,
+ XR_TYPE_SPATIAL_ANCHOR_UNPERSIST_INFO_BD = 1000390004,
+ XR_TYPE_SYSTEM_SPATIAL_ANCHOR_SHARING_PROPERTIES_BD = 1000391000,
+ XR_TYPE_SPATIAL_ANCHOR_SHARE_INFO_BD = 1000391001,
+ XR_TYPE_SHARED_SPATIAL_ANCHOR_DOWNLOAD_INFO_BD = 1000391002,
+ XR_TYPE_SYSTEM_SPATIAL_SCENE_PROPERTIES_BD = 1000392000,
+ XR_TYPE_SCENE_CAPTURE_INFO_BD = 1000392001,
+ XR_TYPE_SYSTEM_SPATIAL_MESH_PROPERTIES_BD = 1000393000,
+ XR_TYPE_SENSE_DATA_PROVIDER_CREATE_INFO_SPATIAL_MESH_BD = 1000393001,
+ XR_TYPE_FUTURE_POLL_RESULT_PROGRESS_BD = 1000394001,
+ XR_TYPE_BODY_TRACKING_POSTURE_DATA_BD = 1000395001,
+ XR_TYPE_BODY_JOINT_VELOCITIES_BD = 1000395002,
+ XR_TYPE_BODY_JOINT_ACCELERATIONS_BD = 1000395003,
+ XR_TYPE_BODY_TRACKING_STATE_BD = 1000395004,
+ XR_TYPE_SYSTEM_SPATIAL_PLANE_PROPERTIES_BD = 1000396000,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_PLANE_ORIENTATION_BD = 1000396001,
+ XR_TYPE_SENSE_DATA_FILTER_PLANE_ORIENTATION_BD = 1000396002,
+ XR_TYPE_SYSTEM_LIGHT_ESTIMATION_PROPERTIES_BD = 1000397000,
+ XR_TYPE_SENSE_DATA_PROVIDER_CREATE_INFO_LIGHT_ESTIMATION_BD = 1000397001,
+ XR_TYPE_ENVIRONMENT_TEXTURE_CREATE_CONFIG_INFO_BD = 1000397002,
+ XR_TYPE_LIGHT_ESTIMATION_DATA_ENVIRONMENT_TEXTURE_RAW_BD = 1000397003,
+ XR_TYPE_LIGHT_ESTIMATION_DATA_SPHERICAL_HARMONICS_BD = 1000397005,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_LIGHT_ESTIMATION_BD = 1000397006,
+ XR_TYPE_SPATIAL_AUDIO_RENDERER_CREATE_INFO_BD = 1000409000,
+ XR_TYPE_AUDIO_BUFFER_BD = 1000409001,
+ XR_TYPE_SOUND_OBJECT_DIRECTIVITY_CARDIOID_BD = 1000409003,
+ XR_TYPE_SOUND_OBJECT_SHAPE_SPHERE_BD = 1000409004,
+ XR_TYPE_SOUND_OBJECT_DISTANCE_ATTENUATION_BD = 1000409005,
+ XR_TYPE_SOUND_OBJECT_DISTANCE_ATTENUATION_CURVE_BD = 1000409006,
+ XR_TYPE_SOUND_OBJECT_CONFIG_BD = 1000409007,
+ XR_TYPE_SOUND_FIELD_CONFIG_BD = 1000409008,
+ XR_TYPE_SOUND_FIELD_CHANNEL_DEFINITION_SURROUND_BD = 1000409009,
+ XR_TYPE_SOUND_FIELD_CHANNEL_DEFINITION_AMBIX_BD = 1000409010,
+ XR_TYPE_SOUND_FIELD_CHANNEL_DEFINITION_FUMA_BD = 1000409011,
+ XR_TYPE_SOUND_TRIANGLE_MESH_BD = 1000409012,
+ XR_TYPE_SOUND_OBSTACLE_CONFIG_BD = 1000409013,
+ XR_TYPE_SOUND_OBSTACLE_MATERIAL_CONFIG_BD = 1000409014,
+ XR_TYPE_HAND_TRACKING_DATA_SOURCE_INFO_EXT = 1000428000,
+ XR_TYPE_HAND_TRACKING_DATA_SOURCE_STATE_EXT = 1000428001,
+ XR_TYPE_PLANE_DETECTOR_CREATE_INFO_EXT = 1000429001,
+ XR_TYPE_PLANE_DETECTOR_BEGIN_INFO_EXT = 1000429002,
+ XR_TYPE_PLANE_DETECTOR_GET_INFO_EXT = 1000429003,
+ XR_TYPE_PLANE_DETECTOR_LOCATIONS_EXT = 1000429004,
+ XR_TYPE_PLANE_DETECTOR_LOCATION_EXT = 1000429005,
+ XR_TYPE_PLANE_DETECTOR_POLYGON_BUFFER_EXT = 1000429006,
+ XR_TYPE_SYSTEM_PLANE_DETECTION_PROPERTIES_EXT = 1000429007,
+ XR_TYPE_TRACKABLE_GET_INFO_ANDROID = 1000455000,
+ XR_TYPE_ANCHOR_SPACE_CREATE_INFO_ANDROID = 1000455001,
+ XR_TYPE_TRACKABLE_PLANE_ANDROID = 1000455003,
+ XR_TYPE_TRACKABLE_TRACKER_CREATE_INFO_ANDROID = 1000455004,
+ XR_TYPE_SYSTEM_TRACKABLES_PROPERTIES_ANDROID = 1000455005,
+ XR_TYPE_EYES_ANDROID = 1000456000,
+ XR_TYPE_EYE_TRACKER_CREATE_INFO_ANDROID = 1000456001,
+ XR_TYPE_EYES_GET_INFO_ANDROID = 1000456002,
+ XR_TYPE_SYSTEM_EYE_TRACKING_PROPERTIES_ANDROID = 1000456004,
+ XR_TYPE_PERSISTED_ANCHOR_SPACE_CREATE_INFO_ANDROID = 1000457001,
+ XR_TYPE_PERSISTED_ANCHOR_SPACE_INFO_ANDROID = 1000457002,
+ XR_TYPE_DEVICE_ANCHOR_PERSISTENCE_CREATE_INFO_ANDROID = 1000457003,
+ XR_TYPE_SYSTEM_DEVICE_ANCHOR_PERSISTENCE_PROPERTIES_ANDROID = 1000457004,
+ XR_TYPE_FACE_TRACKER_CREATE_INFO_ANDROID = 1000458000,
+ XR_TYPE_FACE_STATE_GET_INFO_ANDROID = 1000458001,
+ XR_TYPE_FACE_STATE_ANDROID = 1000458002,
+ XR_TYPE_SYSTEM_FACE_TRACKING_PROPERTIES_ANDROID = 1000458003,
+ XR_TYPE_PASSTHROUGH_CAMERA_STATE_GET_INFO_ANDROID = 1000460000,
+ XR_TYPE_SYSTEM_PASSTHROUGH_CAMERA_STATE_PROPERTIES_ANDROID = 1000460001,
+ XR_TYPE_EVENT_DATA_RECOMMENDED_RESOLUTION_CHANGED_ANDROID = 1000461000,
+ XR_TYPE_PASSTHROUGH_LAYER_CREATE_INFO_ANDROID = 1000462000,
+ XR_TYPE_PASSTHROUGH_LAYER_MESH_ANDROID = 1000462001,
+ XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_ANDROID = 1000462002,
+ XR_TYPE_SYSTEM_PASSTHROUGH_LAYER_PROPERTIES_ANDROID = 1000462003,
+ XR_TYPE_RAYCAST_INFO_ANDROID = 1000463000,
+ XR_TYPE_RAYCAST_HIT_RESULTS_ANDROID = 1000463001,
+ XR_TYPE_PERFORMANCE_METRICS_STATE_ANDROID = 1000465000,
+ XR_TYPE_PERFORMANCE_METRICS_COUNTER_ANDROID = 1000465001,
+ XR_TYPE_TRACKABLE_OBJECT_ANDROID = 1000466000,
+ XR_TYPE_TRACKABLE_OBJECT_CONFIGURATION_ANDROID = 1000466001,
+ XR_TYPE_FUTURE_CANCEL_INFO_EXT = 1000469000,
+ XR_TYPE_FUTURE_POLL_INFO_EXT = 1000469001,
+ XR_TYPE_FUTURE_COMPLETION_EXT = 1000469002,
+ XR_TYPE_FUTURE_POLL_RESULT_EXT = 1000469003,
+ XR_TYPE_EVENT_DATA_USER_PRESENCE_CHANGED_EXT = 1000470000,
+ XR_TYPE_SYSTEM_USER_PRESENCE_PROPERTIES_EXT = 1000470001,
+ XR_TYPE_SYSTEM_NOTIFICATIONS_SET_INFO_ML = 1000473000,
+ XR_TYPE_WORLD_MESH_DETECTOR_CREATE_INFO_ML = 1000474001,
+ XR_TYPE_WORLD_MESH_STATE_REQUEST_INFO_ML = 1000474002,
+ XR_TYPE_WORLD_MESH_BLOCK_STATE_ML = 1000474003,
+ XR_TYPE_WORLD_MESH_STATE_REQUEST_COMPLETION_ML = 1000474004,
+ XR_TYPE_WORLD_MESH_BUFFER_RECOMMENDED_SIZE_INFO_ML = 1000474005,
+ XR_TYPE_WORLD_MESH_BUFFER_SIZE_ML = 1000474006,
+ XR_TYPE_WORLD_MESH_BUFFER_ML = 1000474007,
+ XR_TYPE_WORLD_MESH_BLOCK_REQUEST_ML = 1000474008,
+ XR_TYPE_WORLD_MESH_GET_INFO_ML = 1000474009,
+ XR_TYPE_WORLD_MESH_BLOCK_ML = 1000474010,
+ XR_TYPE_WORLD_MESH_REQUEST_COMPLETION_ML = 1000474011,
+ XR_TYPE_WORLD_MESH_REQUEST_COMPLETION_INFO_ML = 1000474012,
+ XR_TYPE_SYSTEM_FACIAL_EXPRESSION_PROPERTIES_ML = 1000482004,
+ XR_TYPE_FACIAL_EXPRESSION_CLIENT_CREATE_INFO_ML = 1000482005,
+ XR_TYPE_FACIAL_EXPRESSION_BLEND_SHAPE_GET_INFO_ML = 1000482006,
+ XR_TYPE_FACIAL_EXPRESSION_BLEND_SHAPE_PROPERTIES_ML = 1000482007,
+ XR_TYPE_SYSTEM_BOUNDARY_VISIBILITY_PROPERTIES_META = 1000528000,
+ XR_TYPE_EVENT_DATA_BOUNDARY_VISIBILITY_CHANGED_META = 1000528001,
+ XR_TYPE_SYSTEM_SIMULTANEOUS_HANDS_AND_CONTROLLERS_PROPERTIES_META = 1000532001,
+ XR_TYPE_SIMULTANEOUS_HANDS_AND_CONTROLLERS_TRACKING_RESUME_INFO_META = 1000532002,
+ XR_TYPE_SIMULTANEOUS_HANDS_AND_CONTROLLERS_TRACKING_PAUSE_INFO_META = 1000532003,
+ XR_TYPE_FACE_TRACKING_VISEMES_META = 1000541000,
+ XR_TYPE_SYSTEM_FACE_TRACKING_VISEMES_PROPERTIES_META = 1000541001,
+ XR_TYPE_ROOM_MESH_FACE_INDICES_META = 1000553000,
+ XR_TYPE_SPACE_ROOM_MESH_GET_INFO_META = 1000553001,
+ XR_TYPE_ROOM_MESH_META = 1000553002,
+ XR_TYPE_COLOCATION_DISCOVERY_START_INFO_META = 1000571010,
+ XR_TYPE_COLOCATION_DISCOVERY_STOP_INFO_META = 1000571011,
+ XR_TYPE_COLOCATION_ADVERTISEMENT_START_INFO_META = 1000571012,
+ XR_TYPE_COLOCATION_ADVERTISEMENT_STOP_INFO_META = 1000571013,
+ XR_TYPE_EVENT_DATA_START_COLOCATION_ADVERTISEMENT_COMPLETE_META = 1000571020,
+ XR_TYPE_EVENT_DATA_STOP_COLOCATION_ADVERTISEMENT_COMPLETE_META = 1000571021,
+ XR_TYPE_EVENT_DATA_COLOCATION_ADVERTISEMENT_COMPLETE_META = 1000571022,
+ XR_TYPE_EVENT_DATA_START_COLOCATION_DISCOVERY_COMPLETE_META = 1000571023,
+ XR_TYPE_EVENT_DATA_COLOCATION_DISCOVERY_RESULT_META = 1000571024,
+ XR_TYPE_EVENT_DATA_COLOCATION_DISCOVERY_COMPLETE_META = 1000571025,
+ XR_TYPE_EVENT_DATA_STOP_COLOCATION_DISCOVERY_COMPLETE_META = 1000571026,
+ XR_TYPE_SYSTEM_COLOCATION_DISCOVERY_PROPERTIES_META = 1000571030,
+ XR_TYPE_SHARE_SPACES_RECIPIENT_GROUPS_META = 1000572000,
+ XR_TYPE_SPACE_GROUP_UUID_FILTER_INFO_META = 1000572001,
+ XR_TYPE_SYSTEM_SPATIAL_ENTITY_GROUP_SHARING_PROPERTIES_META = 1000572100,
+ XR_TYPE_SYSTEM_ENVIRONMENT_RAYCAST_PROPERTIES_META = 1000592000,
+ XR_TYPE_ENVIRONMENT_RAYCASTER_CREATE_INFO_META = 1000592001,
+ XR_TYPE_ENVIRONMENT_RAYCASTER_CREATE_COMPLETION_META = 1000592002,
+ XR_TYPE_ENVIRONMENT_RAYCAST_HIT_GET_INFO_META = 1000592003,
+ XR_TYPE_ENVIRONMENT_RAYCAST_HIT_META = 1000592004,
+ XR_TYPE_ENVIRONMENT_RAYCAST_FILTER_DISTANCE_META = 1000592005,
+ XR_TYPE_TILE_PROPERTIES_META = 1000609000,
+ XR_TYPE_TILE_PROPERTIES_HINT_META = 1000609001,
+ XR_TYPE_HAND_TRACKING_UNEXTRAPOLATED_POSES_REQUEST_META = 1000693000,
+ XR_TYPE_HAND_TRACKING_UNEXTRAPOLATED_POSES_META = 1000693001,
+ XR_TYPE_LIGHT_ESTIMATOR_CREATE_INFO_ANDROID = 1000700000,
+ XR_TYPE_LIGHT_ESTIMATE_GET_INFO_ANDROID = 1000700001,
+ XR_TYPE_LIGHT_ESTIMATE_ANDROID = 1000700002,
+ XR_TYPE_DIRECTIONAL_LIGHT_ANDROID = 1000700003,
+ XR_TYPE_SPHERICAL_HARMONICS_ANDROID = 1000700004,
+ XR_TYPE_AMBIENT_LIGHT_ANDROID = 1000700005,
+ XR_TYPE_SYSTEM_LIGHT_ESTIMATION_PROPERTIES_ANDROID = 1000700006,
+ XR_TYPE_ANCHOR_SHARING_INFO_ANDROID = 1000701000,
+ XR_TYPE_ANCHOR_SHARING_TOKEN_ANDROID = 1000701001,
+ XR_TYPE_SYSTEM_ANCHOR_SHARING_EXPORT_PROPERTIES_ANDROID = 1000701002,
+ XR_TYPE_SYSTEM_MARKER_TRACKING_PROPERTIES_ANDROID = 1000707000,
+ XR_TYPE_TRACKABLE_MARKER_CONFIGURATION_ANDROID = 1000707001,
+ XR_TYPE_TRACKABLE_MARKER_ANDROID = 1000707002,
+ XR_TYPE_SYSTEM_QR_CODE_TRACKING_PROPERTIES_ANDROID = 1000708000,
+ XR_TYPE_TRACKABLE_QR_CODE_CONFIGURATION_ANDROID = 1000708001,
+ XR_TYPE_TRACKABLE_QR_CODE_ANDROID = 1000708002,
+ XR_TYPE_SYSTEM_IMAGE_TRACKING_PROPERTIES_ANDROID = 1000709000,
+ XR_TYPE_TRACKABLE_IMAGE_DATABASE_ENTRY_ANDROID = 1000709001,
+ XR_TYPE_TRACKABLE_IMAGE_DATABASE_CREATE_INFO_ANDROID = 1000709002,
+ XR_TYPE_CREATE_TRACKABLE_IMAGE_DATABASE_COMPLETION_ANDROID = 1000709003,
+ XR_TYPE_TRACKABLE_IMAGE_CONFIGURATION_ANDROID = 1000709004,
+ XR_TYPE_TRACKABLE_IMAGE_ANDROID = 1000709005,
+ XR_TYPE_EVENT_DATA_IMAGE_TRACKING_LOST_ANDROID = 1000709006,
+ XR_TYPE_SYSTEM_SCENE_MESHING_PROPERTIES_ANDROID = 1000718000,
+ XR_TYPE_SCENE_MESHING_TRACKER_CREATE_INFO_ANDROID = 1000718001,
+ XR_TYPE_SCENE_MESH_SNAPSHOT_CREATE_INFO_ANDROID = 1000718002,
+ XR_TYPE_SCENE_MESH_SNAPSHOT_CREATION_RESULT_ANDROID = 1000718003,
+ XR_TYPE_SCENE_SUBMESH_STATE_ANDROID = 1000718004,
+ XR_TYPE_SCENE_SUBMESH_DATA_ANDROID = 1000718005,
+ XR_TYPE_SPATIAL_CAPABILITY_COMPONENT_TYPES_EXT = 1000740000,
+ XR_TYPE_SPATIAL_CONTEXT_CREATE_INFO_EXT = 1000740001,
+ XR_TYPE_CREATE_SPATIAL_CONTEXT_COMPLETION_EXT = 1000740002,
+ XR_TYPE_SPATIAL_DISCOVERY_SNAPSHOT_CREATE_INFO_EXT = 1000740003,
+ XR_TYPE_CREATE_SPATIAL_DISCOVERY_SNAPSHOT_COMPLETION_INFO_EXT = 1000740004,
+ XR_TYPE_CREATE_SPATIAL_DISCOVERY_SNAPSHOT_COMPLETION_EXT = 1000740005,
+ XR_TYPE_SPATIAL_COMPONENT_DATA_QUERY_CONDITION_EXT = 1000740006,
+ XR_TYPE_SPATIAL_COMPONENT_DATA_QUERY_RESULT_EXT = 1000740007,
+ XR_TYPE_SPATIAL_BUFFER_GET_INFO_EXT = 1000740008,
+ XR_TYPE_SPATIAL_COMPONENT_BOUNDED_2D_LIST_EXT = 1000740009,
+ XR_TYPE_SPATIAL_COMPONENT_BOUNDED_3D_LIST_EXT = 1000740010,
+ XR_TYPE_SPATIAL_COMPONENT_PARENT_LIST_EXT = 1000740011,
+ XR_TYPE_SPATIAL_COMPONENT_MESH_3D_LIST_EXT = 1000740012,
+ XR_TYPE_SPATIAL_ENTITY_FROM_ID_CREATE_INFO_EXT = 1000740013,
+ XR_TYPE_SPATIAL_UPDATE_SNAPSHOT_CREATE_INFO_EXT = 1000740014,
+ XR_TYPE_EVENT_DATA_SPATIAL_DISCOVERY_RECOMMENDED_EXT = 1000740015,
+ XR_TYPE_SPATIAL_FILTER_TRACKING_STATE_EXT = 1000740016,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_PLANE_TRACKING_EXT = 1000741000,
+ XR_TYPE_SPATIAL_COMPONENT_PLANE_ALIGNMENT_LIST_EXT = 1000741001,
+ XR_TYPE_SPATIAL_COMPONENT_MESH_2D_LIST_EXT = 1000741002,
+ XR_TYPE_SPATIAL_COMPONENT_POLYGON_2D_LIST_EXT = 1000741003,
+ XR_TYPE_SPATIAL_COMPONENT_PLANE_SEMANTIC_LABEL_LIST_EXT = 1000741004,
+ XR_TYPE_STATIONARY_REFERENCE_SPACE_GENERATION_ID_GET_INFO_EXT = 1000742001,
+ XR_TYPE_STATIONARY_REFERENCE_SPACE_GENERATION_ID_RESULT_EXT = 1000742002,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_QR_CODE_EXT = 1000743000,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_MICRO_QR_CODE_EXT = 1000743001,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_ARUCO_MARKER_EXT = 1000743002,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_APRIL_TAG_EXT = 1000743003,
+ XR_TYPE_SPATIAL_MARKER_SIZE_EXT = 1000743004,
+ XR_TYPE_SPATIAL_MARKER_STATIC_OPTIMIZATION_EXT = 1000743005,
+ XR_TYPE_SPATIAL_COMPONENT_MARKER_LIST_EXT = 1000743006,
+ XR_TYPE_SYSTEM_DYNAMIC_OBJECT_TRACKING_PROPERTIES_BD = 1000746000,
+ XR_TYPE_SENSE_DATA_PROVIDER_CREATE_INFO_DYNAMIC_OBJECT_BD = 1000746001,
+ XR_TYPE_SPATIAL_ENTITY_COMPONENT_DATA_DYNAMIC_OBJECT_BD = 1000746002,
+ XR_TYPE_DYNAMIC_OBJECT_DATA_BD = 1000746003,
+ XR_TYPE_SENSE_DATA_FILTER_DYNAMIC_OBJECT_TYPE_BD = 1000746004,
+ XR_TYPE_SYSTEM_DYNAMIC_OBJECT_KEYBOARD_PROPERTIES_BD = 1000747000,
+ XR_TYPE_SYSTEM_DYNAMIC_OBJECT_MOUSE_PROPERTIES_BD = 1000748000,
+ XR_TYPE_AVAILABLE_CAMERAS_ENUMERATE_INFO_BD = 1000755000,
+ XR_TYPE_CAMERA_PROPERTIES_GET_INFO_BD = 1000755001,
+ XR_TYPE_CAMERA_PROPERTIES_BD = 1000755002,
+ XR_TYPE_CAMERA_PROPERTY_FACING_BD = 1000755003,
+ XR_TYPE_CAMERA_PROPERTY_POSITION_BD = 1000755004,
+ XR_TYPE_CAMERA_PROPERTY_CAMERA_TYPE_BD = 1000755005,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITIES_GET_INFO_BD = 1000755006,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITIES_BD = 1000755007,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITY_IMAGE_RESOLUTION_AND_FRAME_RATE_BD = 1000755008,
+ XR_TYPE_CAMERA_CAPABILITY_IMAGE_RESOLUTION_AND_FRAME_RATE_BD = 1000755009,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITY_DATA_TRANSFER_TYPE_BD = 1000755010,
+ XR_TYPE_CAMERA_CAPABILITY_DATA_TRANSFER_TYPE_BD = 1000755011,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITY_IMAGE_FORMAT_BD = 1000755012,
+ XR_TYPE_CAMERA_CAPABILITY_IMAGE_FORMAT_BD = 1000755013,
+ XR_TYPE_CAMERA_SUPPORTED_CAPABILITY_CAMERA_MODEL_BD = 1000755014,
+ XR_TYPE_CAMERA_CAPABILITY_CAMERA_MODEL_BD = 1000755015,
+ XR_TYPE_CAMERA_DEVICE_CREATE_INFO_BD = 1000755016,
+ XR_TYPE_CREATE_CAMERA_DEVICE_COMPLETION_BD = 1000755017,
+ XR_TYPE_CAMERA_CAPTURE_SESSION_CREATE_INFO_BD = 1000755018,
+ XR_TYPE_CREATE_CAMERA_CAPTURE_SESSION_COMPLETION_BD = 1000755019,
+ XR_TYPE_CAMERA_INTRINSICS_BD = 1000755020,
+ XR_TYPE_CAMERA_EXTRINSICS_BD = 1000755021,
+ XR_TYPE_CAMERA_CAPTURE_BEGIN_INFO_BD = 1000755022,
+ XR_TYPE_CAMERA_IMAGE_ACQUIRE_INFO_BD = 1000755023,
+ XR_TYPE_CAMERA_IMAGE_BD = 1000755024,
+ XR_TYPE_CAMERA_IMAGE_DATA_RAW_BUFFER_BD = 1000755025,
+ XR_TYPE_CAMERA_CAPABILITIES_BD = 1000755026,
+ XR_TYPE_CAMERA_CAPABILITY_TYPES_ENUMERATE_INFO_BD = 1000755027,
+ XR_TYPE_CAMERA_CAPABILITY_TYPES_BD = 1000755028,
+ XR_TYPE_CAMERA_PROPERTY_TYPES_ENUMERATE_INFO_BD = 1000755029,
+ XR_TYPE_CAMERA_PROPERTY_TYPES_BD = 1000755030,
+ XR_TYPE_AVAILABLE_CAMERA_BD = 1000755031,
+ XR_TYPE_SPATIAL_BOUNDS_SPHEREF_ANDROID = 1000761000,
+ XR_TYPE_SPATIAL_BOUNDS_BOXF_ANDROID = 1000761001,
+ XR_TYPE_SPATIAL_BOUNDS_FRUSTUMF_ANDROID = 1000761002,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_ANCHOR_EXT = 1000762000,
+ XR_TYPE_SPATIAL_COMPONENT_ANCHOR_LIST_EXT = 1000762001,
+ XR_TYPE_SPATIAL_ANCHOR_CREATE_INFO_EXT = 1000762002,
+ XR_TYPE_SPATIAL_PERSISTENCE_CONTEXT_CREATE_INFO_EXT = 1000763000,
+ XR_TYPE_CREATE_SPATIAL_PERSISTENCE_CONTEXT_COMPLETION_EXT = 1000763001,
+ XR_TYPE_SPATIAL_CONTEXT_PERSISTENCE_CONFIG_EXT = 1000763002,
+ XR_TYPE_SPATIAL_DISCOVERY_PERSISTENCE_UUID_FILTER_EXT = 1000763003,
+ XR_TYPE_SPATIAL_COMPONENT_PERSISTENCE_LIST_EXT = 1000763004,
+ XR_TYPE_HAPTIC_PARAMETRIC_VIBRATION_EXT = 1000775000,
+ XR_TYPE_HAPTIC_PARAMETRIC_PROPERTIES_EXT = 1000775001,
+ XR_TYPE_SYSTEM_HAPTIC_PARAMETRIC_PROPERTIES_EXT = 1000775002,
+ XR_TYPE_COLOR_SPACES_ENUMERATE_INFO_SONY = 1000776000,
+ XR_TYPE_SWAPCHAIN_CREATE_INFO_COLOR_SPACE_SONY = 1000776001,
+ XR_TYPE_HDR_METADATA_SONY = 1000777000,
+ XR_TYPE_SPATIAL_ENTITY_PERSIST_INFO_EXT = 1000781000,
+ XR_TYPE_PERSIST_SPATIAL_ENTITY_COMPLETION_EXT = 1000781001,
+ XR_TYPE_SPATIAL_ENTITY_UNPERSIST_INFO_EXT = 1000781002,
+ XR_TYPE_UNPERSIST_SPATIAL_ENTITY_COMPLETION_EXT = 1000781003,
+ XR_TYPE_SPATIAL_REFERENCE_IMAGE_EXT = 1000782000,
+ XR_TYPE_SPATIAL_IMAGE_STATIC_OPTIMIZATION_EXT = 1000782001,
+ XR_TYPE_SPATIAL_IMAGE_SIZE_EXT = 1000782002,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_IMAGE_TRACKING_EXT = 1000782003,
+ XR_TYPE_SPATIAL_IMAGE_TRACKING_DATABASE_CREATE_INFO_EXT = 1000782004,
+ XR_TYPE_SPATIAL_COMPONENT_IMAGE_2D_LIST_EXT = 1000782005,
+ XR_TYPE_CREATE_SPATIAL_IMAGE_TRACKING_DATABASE_COMPLETION_EXT = 1000782006,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_OBJECT_TRACKING_ANDROID = 1000785000,
+ XR_TYPE_SPATIAL_COMPONENT_OBJECT_SEMANTIC_LABEL_LIST_ANDROID = 1000785001,
+ XR_TYPE_SPATIAL_CAPABILITY_CONFIGURATION_DEPTH_RAYCAST_ANDROID = 1000786000,
+ XR_TYPE_SPATIAL_RAYCAST_INFO_ANDROID = 1000786001,
+ XR_TYPE_SPATIAL_COMPONENT_RAYCAST_RESULT_LIST_ANDROID = 1000786002,
+ XR_TYPE_SPATIAL_RAYCAST_SNAPSHOT_CREATE_INFO_ANDROID = 1000786003,
+ XR_TYPE_GOOGLE_CLOUD_AUTH_INFO_API_KEY_ANDROID = 1000787000,
+ XR_TYPE_GOOGLE_CLOUD_AUTH_INFO_TOKEN_ANDROID = 1000787001,
+ XR_TYPE_GOOGLE_CLOUD_AUTH_INFO_KEYLESS_ANDROID = 1000787002,
+ XR_TYPE_GOOGLE_CLOUD_AUTH_ERROR_RESULT_ANDROID = 1000787003,
+ XR_TYPE_SYSTEM_GEOSPATIAL_PROPERTIES_ANDROID = 1000789000,
+ XR_TYPE_GEOSPATIAL_TRACKER_CREATE_INFO_ANDROID = 1000789001,
+ XR_TYPE_EVENT_DATA_GEOSPATIAL_TRACKER_STATE_CHANGED_ANDROID = 1000789002,
+ XR_TYPE_GEOSPATIAL_POSE_FROM_POSE_LOCATE_INFO_ANDROID = 1000789003,
+ XR_TYPE_GEOSPATIAL_POSE_RESULT_ANDROID = 1000789004,
+ XR_TYPE_GEOSPATIAL_POSE_LOCATE_INFO_ANDROID = 1000789005,
+ XR_TYPE_VPS_AVAILABILITY_CHECK_COMPLETION_ANDROID = 1000789006,
+ XR_TYPE_SPATIAL_ANCHOR_PARENT_ANDROID = 1000790000,
+ XR_TYPE_SPATIAL_DISCOVERY_UNIQUE_ENTITIES_FILTER_ANDROID = 1000791001,
+ XR_TYPE_SPATIAL_COMPONENT_SUBSUMED_BY_LIST_ANDROID = 1000791002,
+ XR_TYPE_SPATIAL_ANCHOR_SPACE_FROM_ID_CREATE_INFO_ANDROID = 1000795000,
+ XR_TYPE_GEOSPATIAL_ANCHOR_CREATE_INFO_ANDROID = 1000797000,
+ XR_TYPE_SURFACE_ANCHOR_CREATE_INFO_ANDROID = 1000797001,
+ XR_TYPE_SURFACE_ANCHOR_CREATE_COMPLETION_ANDROID = 1000797002,
+ XR_TYPE_SYSTEM_GEOSPATIAL_ANCHOR_PROPERTIES_ANDROID = 1000797003,
+ XR_TYPE_GEOSPATIAL_TRACKER_ANCHOR_TRACKING_INFO_ANDROID = 1000797004,
+ XR_TYPE_SPATIAL_CONTAINER_CREATE_INFO_EXT = 1000810000,
+ XR_TYPE_SPATIAL_CONTAINER_SPACE_CREATE_INFO_EXT = 1000810001,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_CLOSED_EXT = 1000810003,
+ XR_TYPE_SYSTEM_SPATIAL_CONTAINER_PROPERTIES_EXT = 1000810004,
+ XR_TYPE_SPATIAL_CONTAINER_BOUNDS_EXT = 1000810005,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_BOUNDS_CHANGED_EXT = 1000810006,
+ XR_TYPE_SPATIAL_CONTAINER_BOUNDS_GET_INFO_EXT = 1000810007,
+ XR_TYPE_SPATIAL_CONTAINER_STATE_GET_INFO_EXT = 1000810008,
+ XR_TYPE_SPATIAL_CONTAINER_VISIBLE_REQUEST_INFO_EXT = 1000810009,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_VISIBLE_CHANGED_EXT = 1000810010,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_VISIBLE_REQUEST_DENIED_EXT = 1000810011,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_INTERACTABLE_CHANGED_EXT = 1000810012,
+ XR_TYPE_SPATIAL_CONTAINER_BOUNDS_MODE_REQUEST_INFO_EXT = 1000810013,
+ XR_TYPE_EVENT_DATA_SPATIAL_CONTAINER_BOUNDS_MODE_REQUEST_DENIED_EXT = 1000810014,
+ XR_TYPE_SPATIAL_CONTAINER_STATE_EXT = 1000810015,
+ XR_TYPE_SESSION_CREATE_INFO_SPATIAL_CONTAINERS_EXT = 1000810016,
+ XR_TYPE_SPATIAL_CONTAINER_VIEW_LOCATE_INFO_EXT = 1000813000,
+ XR_TYPE_SPATIAL_CONTAINER_VIEWS_LOCATE_INFO_EXT = 1000813001,
+ XR_TYPE_SPATIAL_CONTAINER_VIEW_STATE_EXT = 1000813002,
+ XR_TYPE_SPATIAL_CONTAINER_LAYER_EXT = 1000813003,
+ XR_TYPE_SPATIAL_CONTAINER_LAYER_FRAME_END_INFO_EXT = 1000813004,
+ XR_TYPE_SPATIAL_CONTAINER_BEGIN_INFO_EXT = 1000813005,
+ XR_TYPE_SPATIAL_CONTAINER_COMPOSITION_LAYER_VIEW_CONFIGURATION_EXT = 1000813006,
+ XR_TYPE_SPATIAL_CONTAINER_END_INFO_EXT = 1000813007,
+ XR_TYPE_SPATIAL_CONTAINER_LAYER_VOLUME_CLIPPING_EXT = 1000813008,
+ XR_TYPE_BATTERY_STATE_DISPLAY_EXT = 1000836000,
+ XR_TYPE_LOADER_INIT_INFO_PROPERTIES_EXT = 1000838000,
+ XR_TYPE_EVENT_DATA_VIEW_CONFIGURATION_VIEWS_CHANGED_EXT = 1000839000,
+ XR_TYPE_GRAPHICS_BINDING_VULKAN2_KHR = XR_TYPE_GRAPHICS_BINDING_VULKAN_KHR,
+ XR_TYPE_SWAPCHAIN_IMAGE_VULKAN2_KHR = XR_TYPE_SWAPCHAIN_IMAGE_VULKAN_KHR,
+ XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN2_KHR = XR_TYPE_GRAPHICS_REQUIREMENTS_VULKAN_KHR,
+ XR_TYPE_DEVICE_PCM_SAMPLE_RATE_GET_INFO_FB = XR_TYPE_DEVICE_PCM_SAMPLE_RATE_STATE_FB,
+ XR_TYPE_SPACES_LOCATE_INFO_KHR = XR_TYPE_SPACES_LOCATE_INFO,
+ XR_TYPE_SPACE_LOCATIONS_KHR = XR_TYPE_SPACE_LOCATIONS,
+ XR_TYPE_SPACE_VELOCITIES_KHR = XR_TYPE_SPACE_VELOCITIES,
+ XR_STRUCTURE_TYPE_MAX_ENUM = 0x7FFFFFFF
+} XrStructureType;
+
+typedef enum XrFormFactor {
+ XR_FORM_FACTOR_HEAD_MOUNTED_DISPLAY = 1,
+ XR_FORM_FACTOR_HANDHELD_DISPLAY = 2,
+ XR_FORM_FACTOR_MAX_ENUM = 0x7FFFFFFF
+} XrFormFactor;
+
+typedef enum XrEnvironmentBlendMode {
+ XR_ENVIRONMENT_BLEND_MODE_OPAQUE = 1,
+ XR_ENVIRONMENT_BLEND_MODE_ADDITIVE = 2,
+ XR_ENVIRONMENT_BLEND_MODE_ALPHA_BLEND = 3,
+ XR_ENVIRONMENT_BLEND_MODE_MAX_ENUM = 0x7FFFFFFF
+} XrEnvironmentBlendMode;
+
+typedef enum XrViewConfigurationType {
+ XR_VIEW_CONFIGURATION_TYPE_PRIMARY_MONO = 1,
+ XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO = 2,
+ XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO_WITH_FOVEATED_INSET = 1000037000,
+ XR_VIEW_CONFIGURATION_TYPE_SECONDARY_MONO_FIRST_PERSON_OBSERVER_MSFT = 1000054000,
+ XR_VIEW_CONFIGURATION_TYPE_PRIMARY_QUAD_VARJO = XR_VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO_WITH_FOVEATED_INSET,
+ XR_VIEW_CONFIGURATION_TYPE_MAX_ENUM = 0x7FFFFFFF
+} XrViewConfigurationType;
+
+typedef enum XrReferenceSpaceType {
+ XR_REFERENCE_SPACE_TYPE_VIEW = 1,
+ XR_REFERENCE_SPACE_TYPE_LOCAL = 2,
+ XR_REFERENCE_SPACE_TYPE_STAGE = 3,
+ XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR = 1000426000,
+ XR_REFERENCE_SPACE_TYPE_UNBOUNDED_MSFT = 1000038000,
+ XR_REFERENCE_SPACE_TYPE_COMBINED_EYE_VARJO = 1000121000,
+ XR_REFERENCE_SPACE_TYPE_LOCALIZATION_MAP_ML = 1000139000,
+ XR_REFERENCE_SPACE_TYPE_UNBOUNDED_ANDROID = 1000467000,
+ XR_REFERENCE_SPACE_TYPE_STATIONARY_EXT = 1000742000,
+ XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR_EXT = XR_REFERENCE_SPACE_TYPE_LOCAL_FLOOR,
+ XR_REFERENCE_SPACE_TYPE_MAX_ENUM = 0x7FFFFFFF
+} XrReferenceSpaceType;
+
+typedef enum XrEyeVisibility {
+ XR_EYE_VISIBILITY_BOTH = 0,
+ XR_EYE_VISIBILITY_LEFT = 1,
+ XR_EYE_VISIBILITY_RIGHT = 2,
+ XR_EYE_VISIBILITY_MAX_ENUM = 0x7FFFFFFF
+} XrEyeVisibility;
+
+typedef enum XrActionType {
+ XR_ACTION_TYPE_BOOLEAN_INPUT = 1,
+ XR_ACTION_TYPE_FLOAT_INPUT = 2,
+ XR_ACTION_TYPE_VECTOR2F_INPUT = 3,
+ XR_ACTION_TYPE_POSE_INPUT = 4,
+ XR_ACTION_TYPE_VIBRATION_OUTPUT = 100,
+ XR_ACTION_TYPE_MAX_ENUM = 0x7FFFFFFF
+} XrActionType;
+
+typedef enum XrSessionState {
+ XR_SESSION_STATE_UNKNOWN = 0,
+ XR_SESSION_STATE_IDLE = 1,
+ XR_SESSION_STATE_READY = 2,
+ XR_SESSION_STATE_SYNCHRONIZED = 3,
+ XR_SESSION_STATE_VISIBLE = 4,
+ XR_SESSION_STATE_FOCUSED = 5,
+ XR_SESSION_STATE_STOPPING = 6,
+ XR_SESSION_STATE_LOSS_PENDING = 7,
+ XR_SESSION_STATE_EXITING = 8,
+ XR_SESSION_STATE_MAX_ENUM = 0x7FFFFFFF
+} XrSessionState;
+
+typedef enum XrObjectType {
+ XR_OBJECT_TYPE_UNKNOWN = 0,
+ XR_OBJECT_TYPE_INSTANCE = 1,
+ XR_OBJECT_TYPE_SESSION = 2,
+ XR_OBJECT_TYPE_SWAPCHAIN = 3,
+ XR_OBJECT_TYPE_SPACE = 4,
+ XR_OBJECT_TYPE_ACTION_SET = 5,
+ XR_OBJECT_TYPE_ACTION = 6,
+ XR_OBJECT_TYPE_DEBUG_UTILS_MESSENGER_EXT = 1000019000,
+ XR_OBJECT_TYPE_SPATIAL_ANCHOR_MSFT = 1000039000,
+ XR_OBJECT_TYPE_SPATIAL_GRAPH_NODE_BINDING_MSFT = 1000049000,
+ XR_OBJECT_TYPE_HAND_TRACKER_EXT = 1000051000,
+ XR_OBJECT_TYPE_BODY_TRACKER_FB = 1000076000,
+ XR_OBJECT_TYPE_SCENE_OBSERVER_MSFT = 1000097000,
+ XR_OBJECT_TYPE_SCENE_MSFT = 1000097001,
+ XR_OBJECT_TYPE_FACIAL_TRACKER_HTC = 1000104000,
+ XR_OBJECT_TYPE_FOVEATION_PROFILE_FB = 1000114000,
+ XR_OBJECT_TYPE_TRIANGLE_MESH_FB = 1000117000,
+ XR_OBJECT_TYPE_PASSTHROUGH_FB = 1000118000,
+ XR_OBJECT_TYPE_PASSTHROUGH_LAYER_FB = 1000118002,
+ XR_OBJECT_TYPE_GEOMETRY_INSTANCE_FB = 1000118004,
+ XR_OBJECT_TYPE_MARKER_DETECTOR_ML = 1000138000,
+ XR_OBJECT_TYPE_EXPORTED_LOCALIZATION_MAP_ML = 1000139000,
+ XR_OBJECT_TYPE_SPATIAL_ANCHORS_STORAGE_ML = 1000141000,
+ XR_OBJECT_TYPE_SPATIAL_ANCHOR_STORE_CONNECTION_MSFT = 1000142000,
+ XR_OBJECT_TYPE_FACE_TRACKER_FB = 1000201000,
+ XR_OBJECT_TYPE_EYE_TRACKER_FB = 1000202000,
+ XR_OBJECT_TYPE_VIRTUAL_KEYBOARD_META = 1000219000,
+ XR_OBJECT_TYPE_SPACE_USER_FB = 1000241000,
+ XR_OBJECT_TYPE_PASSTHROUGH_COLOR_LUT_META = 1000266000,
+ XR_OBJECT_TYPE_FACE_TRACKER2_FB = 1000287012,
+ XR_OBJECT_TYPE_ENVIRONMENT_DEPTH_PROVIDER_META = 1000291000,
+ XR_OBJECT_TYPE_ENVIRONMENT_DEPTH_SWAPCHAIN_META = 1000291001,
+ XR_OBJECT_TYPE_RENDER_MODEL_EXT = 1000300000,
+ XR_OBJECT_TYPE_RENDER_MODEL_ASSET_EXT = 1000300001,
+ XR_OBJECT_TYPE_PASSTHROUGH_HTC = 1000317000,
+ XR_OBJECT_TYPE_BODY_TRACKER_HTC = 1000320000,
+ XR_OBJECT_TYPE_BODY_TRACKER_BD = 1000385000,
+ XR_OBJECT_TYPE_FACE_TRACKER_BD = 1000386000,
+ XR_OBJECT_TYPE_SENSE_DATA_PROVIDER_BD = 1000389000,
+ XR_OBJECT_TYPE_SENSE_DATA_SNAPSHOT_BD = 1000389001,
+ XR_OBJECT_TYPE_ANCHOR_BD = 1000389002,
+ XR_OBJECT_TYPE_SPATIAL_AUDIO_RENDERER_BD = 1000409000,
+ XR_OBJECT_TYPE_SOUND_FIELD_BD = 1000409001,
+ XR_OBJECT_TYPE_SOUND_OBJECT_BD = 1000409002,
+ XR_OBJECT_TYPE_SOUND_OBSTACLE_BD = 1000409003,
+ XR_OBJECT_TYPE_SOUND_OBSTACLE_MATERIAL_BD = 1000409004,
+ XR_OBJECT_TYPE_PLANE_DETECTOR_EXT = 1000429000,
+ XR_OBJECT_TYPE_TRACKABLE_TRACKER_ANDROID = 1000455001,
+ XR_OBJECT_TYPE_EYE_TRACKER_ANDROID = 1000456000,
+ XR_OBJECT_TYPE_DEVICE_ANCHOR_PERSISTENCE_ANDROID = 1000457000,
+ XR_OBJECT_TYPE_FACE_TRACKER_ANDROID = 1000458000,
+ XR_OBJECT_TYPE_PASSTHROUGH_LAYER_ANDROID = 1000462000,
+ XR_OBJECT_TYPE_WORLD_MESH_DETECTOR_ML = 1000474000,
+ XR_OBJECT_TYPE_FACIAL_EXPRESSION_CLIENT_ML = 1000482000,
+ XR_OBJECT_TYPE_ENVIRONMENT_RAYCASTER_META = 1000592000,
+ XR_OBJECT_TYPE_LIGHT_ESTIMATOR_ANDROID = 1000700000,
+ XR_OBJECT_TYPE_TRACKABLE_IMAGE_DATABASE_ANDROID = 1000709000,
+ XR_OBJECT_TYPE_SCENE_MESHING_TRACKER_ANDROID = 1000718000,
+ XR_OBJECT_TYPE_SCENE_MESH_SNAPSHOT_ANDROID = 1000718001,
+ XR_OBJECT_TYPE_SPATIAL_ENTITY_EXT = 1000740000,
+ XR_OBJECT_TYPE_SPATIAL_CONTEXT_EXT = 1000740001,
+ XR_OBJECT_TYPE_SPATIAL_SNAPSHOT_EXT = 1000740002,
+ XR_OBJECT_TYPE_CAMERA_DEVICE_BD = 1000755000,
+ XR_OBJECT_TYPE_CAMERA_CAPTURE_SESSION_BD = 1000755001,
+ XR_OBJECT_TYPE_SPATIAL_PERSISTENCE_CONTEXT_EXT = 1000763000,
+ XR_OBJECT_TYPE_SPATIAL_IMAGE_TRACKING_DATABASE_EXT = 1000782000,
+ XR_OBJECT_TYPE_GEOSPATIAL_TRACKER_ANDROID = 1000789000,
+ XR_OBJECT_TYPE_SPATIAL_CONTAINER_EXT = 1000810000,
+ XR_OBJECT_TYPE_MAX_ENUM = 0x7FFFFFFF
+} XrObjectType;
+typedef XrFlags64 XrInstanceCreateFlags;
+
+// Flag bits for XrInstanceCreateFlags
+
+typedef XrFlags64 XrSessionCreateFlags;
+
+// Flag bits for XrSessionCreateFlags
+
+typedef XrFlags64 XrSpaceLocationFlags;
+
+// Flag bits for XrSpaceLocationFlags
+static const XrSpaceLocationFlags XR_SPACE_LOCATION_ORIENTATION_VALID_BIT = 0x00000001;
+static const XrSpaceLocationFlags XR_SPACE_LOCATION_POSITION_VALID_BIT = 0x00000002;
+static const XrSpaceLocationFlags XR_SPACE_LOCATION_ORIENTATION_TRACKED_BIT = 0x00000004;
+static const XrSpaceLocationFlags XR_SPACE_LOCATION_POSITION_TRACKED_BIT = 0x00000008;
+
+typedef XrFlags64 XrSpaceVelocityFlags;
+
+// Flag bits for XrSpaceVelocityFlags
+static const XrSpaceVelocityFlags XR_SPACE_VELOCITY_LINEAR_VALID_BIT = 0x00000001;
+static const XrSpaceVelocityFlags XR_SPACE_VELOCITY_ANGULAR_VALID_BIT = 0x00000002;
+
+typedef XrFlags64 XrSwapchainCreateFlags;
+
+// Flag bits for XrSwapchainCreateFlags
+static const XrSwapchainCreateFlags XR_SWAPCHAIN_CREATE_PROTECTED_CONTENT_BIT = 0x00000001;
+static const XrSwapchainCreateFlags XR_SWAPCHAIN_CREATE_STATIC_IMAGE_BIT = 0x00000002;
+
+typedef XrFlags64 XrSwapchainUsageFlags;
+
+// Flag bits for XrSwapchainUsageFlags
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT = 0x00000001;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT = 0x00000002;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_UNORDERED_ACCESS_BIT = 0x00000004;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_TRANSFER_SRC_BIT = 0x00000008;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_TRANSFER_DST_BIT = 0x00000010;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_SAMPLED_BIT = 0x00000020;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT = 0x00000040;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_INPUT_ATTACHMENT_BIT_MND = 0x00000080;
+static const XrSwapchainUsageFlags XR_SWAPCHAIN_USAGE_INPUT_ATTACHMENT_BIT_KHR = 0x00000080; // alias of XR_SWAPCHAIN_USAGE_INPUT_ATTACHMENT_BIT_MND
+
+typedef XrFlags64 XrCompositionLayerFlags;
+
+// Flag bits for XrCompositionLayerFlags
+// XR_COMPOSITION_LAYER_CORRECT_CHROMATIC_ABERRATION_BIT is deprecated and should not be used
+static const XrCompositionLayerFlags XR_COMPOSITION_LAYER_CORRECT_CHROMATIC_ABERRATION_BIT = 0x00000001;
+static const XrCompositionLayerFlags XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT = 0x00000002;
+static const XrCompositionLayerFlags XR_COMPOSITION_LAYER_UNPREMULTIPLIED_ALPHA_BIT = 0x00000004;
+static const XrCompositionLayerFlags XR_COMPOSITION_LAYER_INVERTED_ALPHA_BIT_EXT = 0x00000008;
+
+typedef XrFlags64 XrViewStateFlags;
+
+// Flag bits for XrViewStateFlags
+static const XrViewStateFlags XR_VIEW_STATE_ORIENTATION_VALID_BIT = 0x00000001;
+static const XrViewStateFlags XR_VIEW_STATE_POSITION_VALID_BIT = 0x00000002;
+static const XrViewStateFlags XR_VIEW_STATE_ORIENTATION_TRACKED_BIT = 0x00000004;
+static const XrViewStateFlags XR_VIEW_STATE_POSITION_TRACKED_BIT = 0x00000008;
+
+typedef XrFlags64 XrInputSourceLocalizedNameFlags;
+
+// Flag bits for XrInputSourceLocalizedNameFlags
+static const XrInputSourceLocalizedNameFlags XR_INPUT_SOURCE_LOCALIZED_NAME_USER_PATH_BIT = 0x00000001;
+static const XrInputSourceLocalizedNameFlags XR_INPUT_SOURCE_LOCALIZED_NAME_INTERACTION_PROFILE_BIT = 0x00000002;
+static const XrInputSourceLocalizedNameFlags XR_INPUT_SOURCE_LOCALIZED_NAME_COMPONENT_BIT = 0x00000004;
+
+typedef void (XRAPI_PTR *PFN_xrVoidFunction)(void);
+typedef struct XrApiLayerProperties {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ char layerName[XR_MAX_API_LAYER_NAME_SIZE];
+ XrVersion specVersion;
+ uint32_t layerVersion;
+ char description[XR_MAX_API_LAYER_DESCRIPTION_SIZE];
+} XrApiLayerProperties;
+
+typedef struct XrExtensionProperties {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ char extensionName[XR_MAX_EXTENSION_NAME_SIZE];
+ uint32_t extensionVersion;
+} XrExtensionProperties;
+
+typedef struct XrApplicationInfo {
+ char applicationName[XR_MAX_APPLICATION_NAME_SIZE];
+ uint32_t applicationVersion;
+ char engineName[XR_MAX_ENGINE_NAME_SIZE];
+ uint32_t engineVersion;
+ XrVersion apiVersion;
+} XrApplicationInfo;
+
+typedef struct XrInstanceCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrInstanceCreateFlags createFlags;
+ XrApplicationInfo applicationInfo;
+ uint32_t enabledApiLayerCount;
+ const char* const* enabledApiLayerNames;
+ uint32_t enabledExtensionCount;
+ const char* const* enabledExtensionNames;
+} XrInstanceCreateInfo;
+
+typedef struct XrInstanceProperties {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVersion runtimeVersion;
+ char runtimeName[XR_MAX_RUNTIME_NAME_SIZE];
+} XrInstanceProperties;
+
+typedef struct XrEventDataBuffer {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint8_t varying[4000];
+} XrEventDataBuffer;
+
+typedef struct XrSystemGetInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFormFactor formFactor;
+} XrSystemGetInfo;
+
+typedef struct XrSystemGraphicsProperties {
+ uint32_t maxSwapchainImageHeight;
+ uint32_t maxSwapchainImageWidth;
+ uint32_t maxLayerCount;
+} XrSystemGraphicsProperties;
+
+typedef struct XrSystemTrackingProperties {
+ XrBool32 orientationTracking;
+ XrBool32 positionTracking;
+} XrSystemTrackingProperties;
+
+typedef struct XrSystemProperties {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSystemId systemId;
+ uint32_t vendorId;
+ char systemName[XR_MAX_SYSTEM_NAME_SIZE];
+ XrSystemGraphicsProperties graphicsProperties;
+ XrSystemTrackingProperties trackingProperties;
+} XrSystemProperties;
+
+typedef struct XrSessionCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSessionCreateFlags createFlags;
+ XrSystemId systemId;
+} XrSessionCreateInfo;
+
+typedef struct XrQuaternionf {
+ float x;
+ float y;
+ float z;
+ float w;
+} XrQuaternionf;
+
+typedef struct XrVector3f {
+ float x;
+ float y;
+ float z;
+} XrVector3f;
+
+typedef struct XrPosef {
+ XrQuaternionf orientation;
+ XrVector3f position;
+} XrPosef;
+
+typedef struct XrReferenceSpaceCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrReferenceSpaceType referenceSpaceType;
+ XrPosef poseInReferenceSpace;
+} XrReferenceSpaceCreateInfo;
+
+typedef struct XrActionSpaceCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAction action;
+ XrPath subactionPath;
+ XrPosef poseInActionSpace;
+} XrActionSpaceCreateInfo;
+
+typedef struct XrSpaceLocation {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+} XrSpaceLocation;
+
+// XrSpaceVelocity extends XrSpaceLocation
+typedef struct XrSpaceVelocity {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpaceVelocityFlags velocityFlags;
+ XrVector3f linearVelocity;
+ XrVector3f angularVelocity;
+} XrSpaceVelocity;
+
+typedef struct XrExtent2Df {
+ float width;
+ float height;
+} XrExtent2Df;
+
+typedef struct XrViewConfigurationProperties {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+ XrBool32 fovMutable;
+} XrViewConfigurationProperties;
+
+typedef struct XrViewConfigurationView {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t recommendedImageRectWidth;
+ uint32_t maxImageRectWidth;
+ uint32_t recommendedImageRectHeight;
+ uint32_t maxImageRectHeight;
+ uint32_t recommendedSwapchainSampleCount;
+ uint32_t maxSwapchainSampleCount;
+} XrViewConfigurationView;
+
+typedef struct XrSwapchainCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSwapchainCreateFlags createFlags;
+ XrSwapchainUsageFlags usageFlags;
+ int64_t format;
+ uint32_t sampleCount;
+ uint32_t width;
+ uint32_t height;
+ uint32_t faceCount;
+ uint32_t arraySize;
+ uint32_t mipCount;
+} XrSwapchainCreateInfo;
+
+typedef struct XR_MAY_ALIAS XrSwapchainImageBaseHeader {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrSwapchainImageBaseHeader;
+
+typedef struct XrSwapchainImageAcquireInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSwapchainImageAcquireInfo;
+
+typedef struct XrSwapchainImageWaitInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrDuration timeout;
+} XrSwapchainImageWaitInfo;
+
+typedef struct XrSwapchainImageReleaseInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSwapchainImageReleaseInfo;
+
+typedef struct XrSessionBeginInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType primaryViewConfigurationType;
+} XrSessionBeginInfo;
+
+typedef struct XrFrameState {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTime predictedDisplayTime;
+ XrDuration predictedDisplayPeriod;
+ XrBool32 shouldRender;
+} XrFrameState;
+
+typedef struct XrFrameWaitInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrFrameWaitInfo;
+
+typedef struct XrFrameBeginInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrFrameBeginInfo;
+
+typedef struct XR_MAY_ALIAS XrCompositionLayerBaseHeader {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+} XrCompositionLayerBaseHeader;
+
+typedef struct XrFrameEndInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime displayTime;
+ XrEnvironmentBlendMode environmentBlendMode;
+ uint32_t layerCount;
+ const XrCompositionLayerBaseHeader* const* layers;
+} XrFrameEndInfo;
+
+typedef struct XrViewState {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrViewStateFlags viewStateFlags;
+} XrViewState;
+
+typedef struct XrViewLocateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+ XrTime displayTime;
+ XrSpace space;
+} XrViewLocateInfo;
+
+typedef struct XrFovf {
+ float angleLeft;
+ float angleRight;
+ float angleUp;
+ float angleDown;
+} XrFovf;
+
+typedef struct XrView {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPosef pose;
+ XrFovf fov;
+} XrView;
+
+typedef struct XrActionSetCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ char actionSetName[XR_MAX_ACTION_SET_NAME_SIZE];
+ char localizedActionSetName[XR_MAX_LOCALIZED_ACTION_SET_NAME_SIZE];
+ uint32_t priority;
+} XrActionSetCreateInfo;
+
+typedef struct XrActionCreateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ char actionName[XR_MAX_ACTION_NAME_SIZE];
+ XrActionType actionType;
+ uint32_t countSubactionPaths;
+ const XrPath* subactionPaths;
+ char localizedActionName[XR_MAX_LOCALIZED_ACTION_NAME_SIZE];
+} XrActionCreateInfo;
+
+typedef struct XrActionSuggestedBinding {
+ XrAction action;
+ XrPath binding;
+} XrActionSuggestedBinding;
+
+typedef struct XrInteractionProfileSuggestedBinding {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPath interactionProfile;
+ uint32_t countSuggestedBindings;
+ const XrActionSuggestedBinding* suggestedBindings;
+} XrInteractionProfileSuggestedBinding;
+
+typedef struct XrSessionActionSetsAttachInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t countActionSets;
+ const XrActionSet* actionSets;
+} XrSessionActionSetsAttachInfo;
+
+typedef struct XrInteractionProfileState {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPath interactionProfile;
+} XrInteractionProfileState;
+
+typedef struct XrActionStateGetInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAction action;
+ XrPath subactionPath;
+} XrActionStateGetInfo;
+
+typedef struct XrActionStateBoolean {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 currentState;
+ XrBool32 changedSinceLastSync;
+ XrTime lastChangeTime;
+ XrBool32 isActive;
+} XrActionStateBoolean;
+
+typedef struct XrActionStateFloat {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ float currentState;
+ XrBool32 changedSinceLastSync;
+ XrTime lastChangeTime;
+ XrBool32 isActive;
+} XrActionStateFloat;
+
+typedef struct XrVector2f {
+ float x;
+ float y;
+} XrVector2f;
+
+typedef struct XrActionStateVector2f {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVector2f currentState;
+ XrBool32 changedSinceLastSync;
+ XrTime lastChangeTime;
+ XrBool32 isActive;
+} XrActionStateVector2f;
+
+typedef struct XrActionStatePose {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+} XrActionStatePose;
+
+typedef struct XrActiveActionSet {
+ XrActionSet actionSet;
+ XrPath subactionPath;
+} XrActiveActionSet;
+
+typedef struct XrActionsSyncInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t countActiveActionSets;
+ const XrActiveActionSet* activeActionSets;
+} XrActionsSyncInfo;
+
+typedef struct XrBoundSourcesForActionEnumerateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAction action;
+} XrBoundSourcesForActionEnumerateInfo;
+
+typedef struct XrInputSourceLocalizedNameGetInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPath sourcePath;
+ XrInputSourceLocalizedNameFlags whichComponents;
+} XrInputSourceLocalizedNameGetInfo;
+
+typedef struct XR_MAY_ALIAS XrHapticBaseHeader {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrHapticBaseHeader;
+
+typedef struct XrHapticActionInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAction action;
+ XrPath subactionPath;
+} XrHapticActionInfo;
+
+typedef struct XR_MAY_ALIAS XrBaseInStructure {
+ XrStructureType type;
+ const struct XrBaseInStructure* next;
+} XrBaseInStructure;
+
+typedef struct XR_MAY_ALIAS XrBaseOutStructure {
+ XrStructureType type;
+ struct XrBaseOutStructure* next;
+} XrBaseOutStructure;
+
+typedef struct XrOffset2Di {
+ int32_t x;
+ int32_t y;
+} XrOffset2Di;
+
+typedef struct XrExtent2Di {
+ int32_t width;
+ int32_t height;
+} XrExtent2Di;
+
+typedef struct XrRect2Di {
+ XrOffset2Di offset;
+ XrExtent2Di extent;
+} XrRect2Di;
+
+typedef struct XrSwapchainSubImage {
+ XrSwapchain swapchain;
+ XrRect2Di imageRect;
+ uint32_t imageArrayIndex;
+} XrSwapchainSubImage;
+
+typedef struct XrCompositionLayerProjectionView {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPosef pose;
+ XrFovf fov;
+ XrSwapchainSubImage subImage;
+} XrCompositionLayerProjectionView;
+
+typedef struct XrCompositionLayerProjection {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ uint32_t viewCount;
+ const XrCompositionLayerProjectionView* views;
+} XrCompositionLayerProjection;
+
+typedef struct XrCompositionLayerQuad {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrEyeVisibility eyeVisibility;
+ XrSwapchainSubImage subImage;
+ XrPosef pose;
+ XrExtent2Df size;
+} XrCompositionLayerQuad;
+
+typedef struct XR_MAY_ALIAS XrEventDataBaseHeader {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEventDataBaseHeader;
+
+typedef struct XrEventDataEventsLost {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t lostEventCount;
+} XrEventDataEventsLost;
+
+typedef struct XrEventDataInstanceLossPending {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime lossTime;
+} XrEventDataInstanceLossPending;
+
+typedef struct XrEventDataSessionStateChanged {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+ XrSessionState state;
+ XrTime time;
+} XrEventDataSessionStateChanged;
+
+typedef struct XrEventDataReferenceSpaceChangePending {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+ XrReferenceSpaceType referenceSpaceType;
+ XrTime changeTime;
+ XrBool32 poseValid;
+ XrPosef poseInPreviousSpace;
+} XrEventDataReferenceSpaceChangePending;
+
+typedef struct XrEventDataInteractionProfileChanged {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+} XrEventDataInteractionProfileChanged;
+
+typedef struct XrHapticVibration {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrDuration duration;
+ float frequency;
+ float amplitude;
+} XrHapticVibration;
+
+typedef struct XrOffset2Df {
+ float x;
+ float y;
+} XrOffset2Df;
+
+typedef struct XrRect2Df {
+ XrOffset2Df offset;
+ XrExtent2Df extent;
+} XrRect2Df;
+
+typedef struct XrVector4f {
+ float x;
+ float y;
+ float z;
+ float w;
+} XrVector4f;
+
+typedef struct XrColor4f {
+ float r;
+ float g;
+ float b;
+ float a;
+} XrColor4f;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetInstanceProcAddr)(XrInstance instance, const char* name, PFN_xrVoidFunction* function);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateApiLayerProperties)(uint32_t propertyCapacityInput, uint32_t* propertyCountOutput, XrApiLayerProperties* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateInstanceExtensionProperties)(const char* layerName, uint32_t propertyCapacityInput, uint32_t* propertyCountOutput, XrExtensionProperties* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateInstance)(const XrInstanceCreateInfo* createInfo, XrInstance* instance);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyInstance)(XrInstance instance);
+typedef XrResult (XRAPI_PTR *PFN_xrGetInstanceProperties)(XrInstance instance, XrInstanceProperties* instanceProperties);
+typedef XrResult (XRAPI_PTR *PFN_xrPollEvent)(XrInstance instance, XrEventDataBuffer* eventData);
+typedef XrResult (XRAPI_PTR *PFN_xrResultToString)(XrInstance instance, XrResult value, char buffer[XR_MAX_RESULT_STRING_SIZE]);
+typedef XrResult (XRAPI_PTR *PFN_xrStructureTypeToString)(XrInstance instance, XrStructureType value, char buffer[XR_MAX_STRUCTURE_NAME_SIZE]);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSystem)(XrInstance instance, const XrSystemGetInfo* getInfo, XrSystemId* systemId);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSystemProperties)(XrInstance instance, XrSystemId systemId, XrSystemProperties* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateEnvironmentBlendModes)(XrInstance instance, XrSystemId systemId, XrViewConfigurationType viewConfigurationType, uint32_t environmentBlendModeCapacityInput, uint32_t* environmentBlendModeCountOutput, XrEnvironmentBlendMode* environmentBlendModes);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSession)(XrInstance instance, const XrSessionCreateInfo* createInfo, XrSession* session);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySession)(XrSession session);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateReferenceSpaces)(XrSession session, uint32_t spaceCapacityInput, uint32_t* spaceCountOutput, XrReferenceSpaceType* spaces);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateReferenceSpace)(XrSession session, const XrReferenceSpaceCreateInfo* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrGetReferenceSpaceBoundsRect)(XrSession session, XrReferenceSpaceType referenceSpaceType, XrExtent2Df* bounds);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateActionSpace)(XrSession session, const XrActionSpaceCreateInfo* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateSpace)(XrSpace space, XrSpace baseSpace, XrTime time, XrSpaceLocation* location);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpace)(XrSpace space);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateViewConfigurations)(XrInstance instance, XrSystemId systemId, uint32_t viewConfigurationTypeCapacityInput, uint32_t* viewConfigurationTypeCountOutput, XrViewConfigurationType* viewConfigurationTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrGetViewConfigurationProperties)(XrInstance instance, XrSystemId systemId, XrViewConfigurationType viewConfigurationType, XrViewConfigurationProperties* configurationProperties);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateViewConfigurationViews)(XrInstance instance, XrSystemId systemId, XrViewConfigurationType viewConfigurationType, uint32_t viewCapacityInput, uint32_t* viewCountOutput, XrViewConfigurationView* views);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSwapchainFormats)(XrSession session, uint32_t formatCapacityInput, uint32_t* formatCountOutput, int64_t* formats);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSwapchain)(XrSession session, const XrSwapchainCreateInfo* createInfo, XrSwapchain* swapchain);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySwapchain)(XrSwapchain swapchain);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSwapchainImages)(XrSwapchain swapchain, uint32_t imageCapacityInput, uint32_t* imageCountOutput, XrSwapchainImageBaseHeader* images);
+typedef XrResult (XRAPI_PTR *PFN_xrAcquireSwapchainImage)(XrSwapchain swapchain, const XrSwapchainImageAcquireInfo* acquireInfo, uint32_t* index);
+typedef XrResult (XRAPI_PTR *PFN_xrWaitSwapchainImage)(XrSwapchain swapchain, const XrSwapchainImageWaitInfo* waitInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrReleaseSwapchainImage)(XrSwapchain swapchain, const XrSwapchainImageReleaseInfo* releaseInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrBeginSession)(XrSession session, const XrSessionBeginInfo* beginInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEndSession)(XrSession session);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestExitSession)(XrSession session);
+typedef XrResult (XRAPI_PTR *PFN_xrWaitFrame)(XrSession session, const XrFrameWaitInfo* frameWaitInfo, XrFrameState* frameState);
+typedef XrResult (XRAPI_PTR *PFN_xrBeginFrame)(XrSession session, const XrFrameBeginInfo* frameBeginInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEndFrame)(XrSession session, const XrFrameEndInfo* frameEndInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateViews)(XrSession session, const XrViewLocateInfo* viewLocateInfo, XrViewState* viewState, uint32_t viewCapacityInput, uint32_t* viewCountOutput, XrView* views);
+typedef XrResult (XRAPI_PTR *PFN_xrStringToPath)(XrInstance instance, const char* pathString, XrPath* path);
+typedef XrResult (XRAPI_PTR *PFN_xrPathToString)(XrInstance instance, XrPath path, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateActionSet)(XrInstance instance, const XrActionSetCreateInfo* createInfo, XrActionSet* actionSet);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyActionSet)(XrActionSet actionSet);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateAction)(XrActionSet actionSet, const XrActionCreateInfo* createInfo, XrAction* action);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyAction)(XrAction action);
+typedef XrResult (XRAPI_PTR *PFN_xrSuggestInteractionProfileBindings)(XrInstance instance, const XrInteractionProfileSuggestedBinding* suggestedBindings);
+typedef XrResult (XRAPI_PTR *PFN_xrAttachSessionActionSets)(XrSession session, const XrSessionActionSetsAttachInfo* attachInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetCurrentInteractionProfile)(XrSession session, XrPath topLevelUserPath, XrInteractionProfileState* interactionProfile);
+typedef XrResult (XRAPI_PTR *PFN_xrGetActionStateBoolean)(XrSession session, const XrActionStateGetInfo* getInfo, XrActionStateBoolean* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetActionStateFloat)(XrSession session, const XrActionStateGetInfo* getInfo, XrActionStateFloat* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetActionStateVector2f)(XrSession session, const XrActionStateGetInfo* getInfo, XrActionStateVector2f* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetActionStatePose)(XrSession session, const XrActionStateGetInfo* getInfo, XrActionStatePose* state);
+typedef XrResult (XRAPI_PTR *PFN_xrSyncActions)(XrSession session, const XrActionsSyncInfo* syncInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateBoundSourcesForAction)(XrSession session, const XrBoundSourcesForActionEnumerateInfo* enumerateInfo, uint32_t sourceCapacityInput, uint32_t* sourceCountOutput, XrPath* sources);
+typedef XrResult (XRAPI_PTR *PFN_xrGetInputSourceLocalizedName)(XrSession session, const XrInputSourceLocalizedNameGetInfo* getInfo, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrApplyHapticFeedback)(XrSession session, const XrHapticActionInfo* hapticActionInfo, const XrHapticBaseHeader* hapticFeedback);
+typedef XrResult (XRAPI_PTR *PFN_xrStopHapticFeedback)(XrSession session, const XrHapticActionInfo* hapticActionInfo);
+
+#ifndef XR_NO_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProcAddr(
+ XrInstance instance,
+ const char* name,
+ PFN_xrVoidFunction* function);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateApiLayerProperties(
+ uint32_t propertyCapacityInput,
+ uint32_t* propertyCountOutput,
+ XrApiLayerProperties* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInstanceExtensionProperties(
+ const char* layerName,
+ uint32_t propertyCapacityInput,
+ uint32_t* propertyCountOutput,
+ XrExtensionProperties* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateInstance(
+ const XrInstanceCreateInfo* createInfo,
+ XrInstance* instance);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyInstance(
+ XrInstance instance);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetInstanceProperties(
+ XrInstance instance,
+ XrInstanceProperties* instanceProperties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPollEvent(
+ XrInstance instance,
+ XrEventDataBuffer* eventData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrResultToString(
+ XrInstance instance,
+ XrResult value,
+ char buffer[XR_MAX_RESULT_STRING_SIZE]);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStructureTypeToString(
+ XrInstance instance,
+ XrStructureType value,
+ char buffer[XR_MAX_STRUCTURE_NAME_SIZE]);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSystem(
+ XrInstance instance,
+ const XrSystemGetInfo* getInfo,
+ XrSystemId* systemId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSystemProperties(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrSystemProperties* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateEnvironmentBlendModes(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrViewConfigurationType viewConfigurationType,
+ uint32_t environmentBlendModeCapacityInput,
+ uint32_t* environmentBlendModeCountOutput,
+ XrEnvironmentBlendMode* environmentBlendModes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSession(
+ XrInstance instance,
+ const XrSessionCreateInfo* createInfo,
+ XrSession* session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySession(
+ XrSession session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateReferenceSpaces(
+ XrSession session,
+ uint32_t spaceCapacityInput,
+ uint32_t* spaceCountOutput,
+ XrReferenceSpaceType* spaces);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateReferenceSpace(
+ XrSession session,
+ const XrReferenceSpaceCreateInfo* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetReferenceSpaceBoundsRect(
+ XrSession session,
+ XrReferenceSpaceType referenceSpaceType,
+ XrExtent2Df* bounds);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateActionSpace(
+ XrSession session,
+ const XrActionSpaceCreateInfo* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpace(
+ XrSpace space,
+ XrSpace baseSpace,
+ XrTime time,
+ XrSpaceLocation* location);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpace(
+ XrSpace space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViewConfigurations(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t viewConfigurationTypeCapacityInput,
+ uint32_t* viewConfigurationTypeCountOutput,
+ XrViewConfigurationType* viewConfigurationTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetViewConfigurationProperties(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrViewConfigurationType viewConfigurationType,
+ XrViewConfigurationProperties* configurationProperties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViewConfigurationViews(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrViewConfigurationType viewConfigurationType,
+ uint32_t viewCapacityInput,
+ uint32_t* viewCountOutput,
+ XrViewConfigurationView* views);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainFormats(
+ XrSession session,
+ uint32_t formatCapacityInput,
+ uint32_t* formatCountOutput,
+ int64_t* formats);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchain(
+ XrSession session,
+ const XrSwapchainCreateInfo* createInfo,
+ XrSwapchain* swapchain);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySwapchain(
+ XrSwapchain swapchain);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSwapchainImages(
+ XrSwapchain swapchain,
+ uint32_t imageCapacityInput,
+ uint32_t* imageCountOutput,
+ XrSwapchainImageBaseHeader* images);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAcquireSwapchainImage(
+ XrSwapchain swapchain,
+ const XrSwapchainImageAcquireInfo* acquireInfo,
+ uint32_t* index);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrWaitSwapchainImage(
+ XrSwapchain swapchain,
+ const XrSwapchainImageWaitInfo* waitInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrReleaseSwapchainImage(
+ XrSwapchain swapchain,
+ const XrSwapchainImageReleaseInfo* releaseInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrBeginSession(
+ XrSession session,
+ const XrSessionBeginInfo* beginInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEndSession(
+ XrSession session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestExitSession(
+ XrSession session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrWaitFrame(
+ XrSession session,
+ const XrFrameWaitInfo* frameWaitInfo,
+ XrFrameState* frameState);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrBeginFrame(
+ XrSession session,
+ const XrFrameBeginInfo* frameBeginInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEndFrame(
+ XrSession session,
+ const XrFrameEndInfo* frameEndInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateViews(
+ XrSession session,
+ const XrViewLocateInfo* viewLocateInfo,
+ XrViewState* viewState,
+ uint32_t viewCapacityInput,
+ uint32_t* viewCountOutput,
+ XrView* views);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStringToPath(
+ XrInstance instance,
+ const char* pathString,
+ XrPath* path);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPathToString(
+ XrInstance instance,
+ XrPath path,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateActionSet(
+ XrInstance instance,
+ const XrActionSetCreateInfo* createInfo,
+ XrActionSet* actionSet);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyActionSet(
+ XrActionSet actionSet);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateAction(
+ XrActionSet actionSet,
+ const XrActionCreateInfo* createInfo,
+ XrAction* action);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyAction(
+ XrAction action);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSuggestInteractionProfileBindings(
+ XrInstance instance,
+ const XrInteractionProfileSuggestedBinding* suggestedBindings);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAttachSessionActionSets(
+ XrSession session,
+ const XrSessionActionSetsAttachInfo* attachInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetCurrentInteractionProfile(
+ XrSession session,
+ XrPath topLevelUserPath,
+ XrInteractionProfileState* interactionProfile);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateBoolean(
+ XrSession session,
+ const XrActionStateGetInfo* getInfo,
+ XrActionStateBoolean* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateFloat(
+ XrSession session,
+ const XrActionStateGetInfo* getInfo,
+ XrActionStateFloat* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStateVector2f(
+ XrSession session,
+ const XrActionStateGetInfo* getInfo,
+ XrActionStateVector2f* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetActionStatePose(
+ XrSession session,
+ const XrActionStateGetInfo* getInfo,
+ XrActionStatePose* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSyncActions(
+ XrSession session,
+ const XrActionsSyncInfo* syncInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateBoundSourcesForAction(
+ XrSession session,
+ const XrBoundSourcesForActionEnumerateInfo* enumerateInfo,
+ uint32_t sourceCapacityInput,
+ uint32_t* sourceCountOutput,
+ XrPath* sources);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetInputSourceLocalizedName(
+ XrSession session,
+ const XrInputSourceLocalizedNameGetInfo* getInfo,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrApplyHapticFeedback(
+ XrSession session,
+ const XrHapticActionInfo* hapticActionInfo,
+ const XrHapticBaseHeader* hapticFeedback);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStopHapticFeedback(
+ XrSession session,
+ const XrHapticActionInfo* hapticActionInfo);
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_VERSION_1_1 is a preprocessor guard. Do not pass it to API calls.
+#define XR_VERSION_1_1 1
+// OpenXR 1.1 version number
+#define XR_API_VERSION_1_1 XR_MAKE_VERSION(1, 1, XR_VERSION_PATCH(XR_CURRENT_API_VERSION))
+
+#define XR_UUID_SIZE 16
+typedef struct XrColor3f {
+ float r;
+ float g;
+ float b;
+} XrColor3f;
+
+typedef struct XrExtent3Df {
+ float width;
+ float height;
+ float depth;
+} XrExtent3Df;
+
+typedef struct XrSpheref {
+ XrPosef center;
+ float radius;
+} XrSpheref;
+
+typedef struct XrBoxf {
+ XrPosef center;
+ XrExtent3Df extents;
+} XrBoxf;
+
+typedef struct XrFrustumf {
+ XrPosef pose;
+ XrFovf fov;
+ float nearZ;
+ float farZ;
+} XrFrustumf;
+
+typedef struct XrUuid {
+ uint8_t data[XR_UUID_SIZE];
+} XrUuid;
+
+typedef struct XrSpacesLocateInfo {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ uint32_t spaceCount;
+ const XrSpace* spaces;
+} XrSpacesLocateInfo;
+
+typedef struct XrSpaceLocationData {
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+} XrSpaceLocationData;
+
+typedef struct XrSpaceLocations {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t locationCount;
+ XrSpaceLocationData* locations;
+} XrSpaceLocations;
+
+typedef struct XrSpaceVelocityData {
+ XrSpaceVelocityFlags velocityFlags;
+ XrVector3f linearVelocity;
+ XrVector3f angularVelocity;
+} XrSpaceVelocityData;
+
+// XrSpaceVelocities extends XrSpaceLocations
+typedef struct XrSpaceVelocities {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t velocityCount;
+ XrSpaceVelocityData* velocities;
+} XrSpaceVelocities;
+
+typedef XrResult (XRAPI_PTR *PFN_xrLocateSpaces)(XrSession session, const XrSpacesLocateInfo* locateInfo, XrSpaceLocations* spaceLocations);
+
+#ifndef XR_NO_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpaces(
+ XrSession session,
+ const XrSpacesLocateInfo* locateInfo,
+ XrSpaceLocations* spaceLocations);
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_KHR_composition_layer_cube is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_cube 1
+#define XR_KHR_composition_layer_cube_SPEC_VERSION 8
+#define XR_KHR_COMPOSITION_LAYER_CUBE_EXTENSION_NAME "XR_KHR_composition_layer_cube"
+typedef struct XrCompositionLayerCubeKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrEyeVisibility eyeVisibility;
+ XrSwapchain swapchain;
+ uint32_t imageArrayIndex;
+ XrQuaternionf orientation;
+} XrCompositionLayerCubeKHR;
+
+
+
+// XR_KHR_composition_layer_depth is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_depth 1
+#define XR_KHR_composition_layer_depth_SPEC_VERSION 6
+#define XR_KHR_COMPOSITION_LAYER_DEPTH_EXTENSION_NAME "XR_KHR_composition_layer_depth"
+// XrCompositionLayerDepthInfoKHR extends XrCompositionLayerProjectionView
+typedef struct XrCompositionLayerDepthInfoKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSwapchainSubImage subImage;
+ float minDepth;
+ float maxDepth;
+ float nearZ;
+ float farZ;
+} XrCompositionLayerDepthInfoKHR;
+
+
+
+// XR_KHR_composition_layer_cylinder is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_cylinder 1
+#define XR_KHR_composition_layer_cylinder_SPEC_VERSION 4
+#define XR_KHR_COMPOSITION_LAYER_CYLINDER_EXTENSION_NAME "XR_KHR_composition_layer_cylinder"
+typedef struct XrCompositionLayerCylinderKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrEyeVisibility eyeVisibility;
+ XrSwapchainSubImage subImage;
+ XrPosef pose;
+ float radius;
+ float centralAngle;
+ float aspectRatio;
+} XrCompositionLayerCylinderKHR;
+
+
+
+// XR_KHR_composition_layer_equirect is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_equirect 1
+#define XR_KHR_composition_layer_equirect_SPEC_VERSION 3
+#define XR_KHR_COMPOSITION_LAYER_EQUIRECT_EXTENSION_NAME "XR_KHR_composition_layer_equirect"
+typedef struct XrCompositionLayerEquirectKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrEyeVisibility eyeVisibility;
+ XrSwapchainSubImage subImage;
+ XrPosef pose;
+ float radius;
+ XrVector2f scale;
+ XrVector2f bias;
+} XrCompositionLayerEquirectKHR;
+
+
+
+// XR_KHR_visibility_mask is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_visibility_mask 1
+#define XR_KHR_visibility_mask_SPEC_VERSION 2
+#define XR_KHR_VISIBILITY_MASK_EXTENSION_NAME "XR_KHR_visibility_mask"
+
+typedef enum XrVisibilityMaskTypeKHR {
+ XR_VISIBILITY_MASK_TYPE_HIDDEN_TRIANGLE_MESH_KHR = 1,
+ XR_VISIBILITY_MASK_TYPE_VISIBLE_TRIANGLE_MESH_KHR = 2,
+ XR_VISIBILITY_MASK_TYPE_LINE_LOOP_KHR = 3,
+ XR_VISIBILITY_MASK_TYPE_MAX_ENUM_KHR = 0x7FFFFFFF
+} XrVisibilityMaskTypeKHR;
+typedef struct XrVisibilityMaskKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector2f* vertices;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrVisibilityMaskKHR;
+
+typedef struct XrEventDataVisibilityMaskChangedKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+ XrViewConfigurationType viewConfigurationType;
+ uint32_t viewIndex;
+} XrEventDataVisibilityMaskChangedKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetVisibilityMaskKHR)(XrSession session, XrViewConfigurationType viewConfigurationType, uint32_t viewIndex, XrVisibilityMaskTypeKHR visibilityMaskType, XrVisibilityMaskKHR* visibilityMask);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVisibilityMaskKHR(
+ XrSession session,
+ XrViewConfigurationType viewConfigurationType,
+ uint32_t viewIndex,
+ XrVisibilityMaskTypeKHR visibilityMaskType,
+ XrVisibilityMaskKHR* visibilityMask);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_KHR_composition_layer_color_scale_bias is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_color_scale_bias 1
+#define XR_KHR_composition_layer_color_scale_bias_SPEC_VERSION 5
+#define XR_KHR_COMPOSITION_LAYER_COLOR_SCALE_BIAS_EXTENSION_NAME "XR_KHR_composition_layer_color_scale_bias"
+// XrCompositionLayerColorScaleBiasKHR extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerColorScaleBiasKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrColor4f colorScale;
+ XrColor4f colorBias;
+} XrCompositionLayerColorScaleBiasKHR;
+
+
+
+// XR_KHR_loader_init is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_loader_init 1
+#define XR_KHR_loader_init_SPEC_VERSION 2
+#define XR_KHR_LOADER_INIT_EXTENSION_NAME "XR_KHR_loader_init"
+typedef struct XR_MAY_ALIAS XrLoaderInitInfoBaseHeaderKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrLoaderInitInfoBaseHeaderKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrInitializeLoaderKHR)(const XrLoaderInitInfoBaseHeaderKHR* loaderInitInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrInitializeLoaderKHR(
+ const XrLoaderInitInfoBaseHeaderKHR* loaderInitInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_KHR_composition_layer_equirect2 is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_composition_layer_equirect2 1
+#define XR_KHR_composition_layer_equirect2_SPEC_VERSION 1
+#define XR_KHR_COMPOSITION_LAYER_EQUIRECT2_EXTENSION_NAME "XR_KHR_composition_layer_equirect2"
+typedef struct XrCompositionLayerEquirect2KHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrEyeVisibility eyeVisibility;
+ XrSwapchainSubImage subImage;
+ XrPosef pose;
+ float radius;
+ float centralHorizontalAngle;
+ float upperVerticalAngle;
+ float lowerVerticalAngle;
+} XrCompositionLayerEquirect2KHR;
+
+
+
+// XR_KHR_binding_modification is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_binding_modification 1
+#define XR_KHR_binding_modification_SPEC_VERSION 1
+#define XR_KHR_BINDING_MODIFICATION_EXTENSION_NAME "XR_KHR_binding_modification"
+typedef struct XR_MAY_ALIAS XrBindingModificationBaseHeaderKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrBindingModificationBaseHeaderKHR;
+
+// XrBindingModificationsKHR extends XrInteractionProfileSuggestedBinding
+typedef struct XrBindingModificationsKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t bindingModificationCount;
+ const XrBindingModificationBaseHeaderKHR* const* bindingModifications;
+} XrBindingModificationsKHR;
+
+
+
+// XR_KHR_extended_struct_name_lengths is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_extended_struct_name_lengths 1
+#define XR_KHR_extended_struct_name_lengths_SPEC_VERSION 2
+#define XR_KHR_EXTENDED_STRUCT_NAME_LENGTHS_EXTENSION_NAME "XR_KHR_extended_struct_name_lengths"
+#define XR_MAX_STRUCTURE_NAME_SIZE_EXTENDED_KHR 256
+typedef XrResult (XRAPI_PTR *PFN_xrStructureTypeToString2KHR)(XrInstance instance, XrStructureType value, char buffer[XR_MAX_STRUCTURE_NAME_SIZE_EXTENDED_KHR]);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrStructureTypeToString2KHR(
+ XrInstance instance,
+ XrStructureType value,
+ char buffer[XR_MAX_STRUCTURE_NAME_SIZE_EXTENDED_KHR]);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_KHR_swapchain_usage_input_attachment_bit is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_swapchain_usage_input_attachment_bit 1
+#define XR_KHR_swapchain_usage_input_attachment_bit_SPEC_VERSION 3
+#define XR_KHR_SWAPCHAIN_USAGE_INPUT_ATTACHMENT_BIT_EXTENSION_NAME "XR_KHR_swapchain_usage_input_attachment_bit"
+
+
+// XR_KHR_locate_spaces is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_locate_spaces 1
+#define XR_KHR_locate_spaces_SPEC_VERSION 1
+#define XR_KHR_LOCATE_SPACES_EXTENSION_NAME "XR_KHR_locate_spaces"
+typedef XrSpacesLocateInfo XrSpacesLocateInfoKHR;
+
+typedef XrSpaceLocationData XrSpaceLocationDataKHR;
+
+typedef XrSpaceLocations XrSpaceLocationsKHR;
+
+typedef XrSpaceVelocityData XrSpaceVelocityDataKHR;
+
+typedef XrSpaceVelocities XrSpaceVelocitiesKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrLocateSpacesKHR)(XrSession session, const XrSpacesLocateInfo* locateInfo, XrSpaceLocations* spaceLocations);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpacesKHR(
+ XrSession session,
+ const XrSpacesLocateInfo* locateInfo,
+ XrSpaceLocations* spaceLocations);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_KHR_maintenance1 is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_maintenance1 1
+#define XR_KHR_maintenance1_SPEC_VERSION 1
+#define XR_KHR_MAINTENANCE1_EXTENSION_NAME "XR_KHR_maintenance1"
+typedef XrColor3f XrColor3fKHR;
+
+typedef XrExtent3Df XrExtent3DfKHR;
+
+typedef XrSpheref XrSpherefKHR;
+
+typedef XrBoxf XrBoxfKHR;
+
+typedef XrFrustumf XrFrustumfKHR;
+
+
+
+// XR_KHR_generic_controller is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_generic_controller 1
+#define XR_KHR_generic_controller_SPEC_VERSION 1
+#define XR_KHR_GENERIC_CONTROLLER_EXTENSION_NAME "XR_KHR_generic_controller"
+
+
+// XR_KHR_extended_result_name_lengths is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_extended_result_name_lengths 1
+#define XR_KHR_extended_result_name_lengths_SPEC_VERSION 1
+#define XR_KHR_EXTENDED_RESULT_NAME_LENGTHS_EXTENSION_NAME "XR_KHR_extended_result_name_lengths"
+#define XR_MAX_RESULT_STRING_SIZE_EXTENDED_KHR 256
+typedef XrResult (XRAPI_PTR *PFN_xrResultToString2KHR)(XrInstance instance, XrResult value, char buffer[XR_MAX_RESULT_STRING_SIZE_EXTENDED_KHR]);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrResultToString2KHR(
+ XrInstance instance,
+ XrResult value,
+ char buffer[XR_MAX_RESULT_STRING_SIZE_EXTENDED_KHR]);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_performance_settings is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_performance_settings 1
+#define XR_EXT_performance_settings_SPEC_VERSION 4
+#define XR_EXT_PERFORMANCE_SETTINGS_EXTENSION_NAME "XR_EXT_performance_settings"
+
+typedef enum XrPerfSettingsDomainEXT {
+ XR_PERF_SETTINGS_DOMAIN_CPU_EXT = 1,
+ XR_PERF_SETTINGS_DOMAIN_GPU_EXT = 2,
+ XR_PERF_SETTINGS_DOMAIN_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPerfSettingsDomainEXT;
+
+typedef enum XrPerfSettingsSubDomainEXT {
+ XR_PERF_SETTINGS_SUB_DOMAIN_COMPOSITING_EXT = 1,
+ XR_PERF_SETTINGS_SUB_DOMAIN_RENDERING_EXT = 2,
+ XR_PERF_SETTINGS_SUB_DOMAIN_THERMAL_EXT = 3,
+ XR_PERF_SETTINGS_SUB_DOMAIN_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPerfSettingsSubDomainEXT;
+
+typedef enum XrPerfSettingsLevelEXT {
+ XR_PERF_SETTINGS_LEVEL_POWER_SAVINGS_EXT = 0,
+ XR_PERF_SETTINGS_LEVEL_SUSTAINED_LOW_EXT = 25,
+ XR_PERF_SETTINGS_LEVEL_SUSTAINED_HIGH_EXT = 50,
+ XR_PERF_SETTINGS_LEVEL_BOOST_EXT = 75,
+ XR_PERF_SETTINGS_LEVEL_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPerfSettingsLevelEXT;
+
+typedef enum XrPerfSettingsNotificationLevelEXT {
+ XR_PERF_SETTINGS_NOTIF_LEVEL_NORMAL_EXT = 0,
+ XR_PERF_SETTINGS_NOTIF_LEVEL_WARNING_EXT = 25,
+ XR_PERF_SETTINGS_NOTIF_LEVEL_IMPAIRED_EXT = 75,
+ XR_PERF_SETTINGS_NOTIFICATION_LEVEL_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPerfSettingsNotificationLevelEXT;
+typedef struct XrEventDataPerfSettingsEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPerfSettingsDomainEXT domain;
+ XrPerfSettingsSubDomainEXT subDomain;
+ XrPerfSettingsNotificationLevelEXT fromLevel;
+ XrPerfSettingsNotificationLevelEXT toLevel;
+} XrEventDataPerfSettingsEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrPerfSettingsSetPerformanceLevelEXT)(XrSession session, XrPerfSettingsDomainEXT domain, XrPerfSettingsLevelEXT level);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrPerfSettingsSetPerformanceLevelEXT(
+ XrSession session,
+ XrPerfSettingsDomainEXT domain,
+ XrPerfSettingsLevelEXT level);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_thermal_query is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_thermal_query 1
+#define XR_EXT_thermal_query_SPEC_VERSION 2
+#define XR_EXT_THERMAL_QUERY_EXTENSION_NAME "XR_EXT_thermal_query"
+typedef XrResult (XRAPI_PTR *PFN_xrThermalGetTemperatureTrendEXT)(XrSession session, XrPerfSettingsDomainEXT domain, XrPerfSettingsNotificationLevelEXT* notificationLevel, float* tempHeadroom, float* tempSlope);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrThermalGetTemperatureTrendEXT(
+ XrSession session,
+ XrPerfSettingsDomainEXT domain,
+ XrPerfSettingsNotificationLevelEXT* notificationLevel,
+ float* tempHeadroom,
+ float* tempSlope);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_debug_utils is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_debug_utils 1
+XR_DEFINE_HANDLE(XrDebugUtilsMessengerEXT)
+#define XR_EXT_debug_utils_SPEC_VERSION 5
+#define XR_EXT_DEBUG_UTILS_EXTENSION_NAME "XR_EXT_debug_utils"
+typedef XrFlags64 XrDebugUtilsMessageSeverityFlagsEXT;
+
+// Flag bits for XrDebugUtilsMessageSeverityFlagsEXT
+static const XrDebugUtilsMessageSeverityFlagsEXT XR_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT = 0x00000001;
+static const XrDebugUtilsMessageSeverityFlagsEXT XR_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT = 0x00000010;
+static const XrDebugUtilsMessageSeverityFlagsEXT XR_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT = 0x00000100;
+static const XrDebugUtilsMessageSeverityFlagsEXT XR_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT = 0x00001000;
+
+typedef XrFlags64 XrDebugUtilsMessageTypeFlagsEXT;
+
+// Flag bits for XrDebugUtilsMessageTypeFlagsEXT
+static const XrDebugUtilsMessageTypeFlagsEXT XR_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT = 0x00000001;
+static const XrDebugUtilsMessageTypeFlagsEXT XR_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT = 0x00000002;
+static const XrDebugUtilsMessageTypeFlagsEXT XR_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT = 0x00000004;
+static const XrDebugUtilsMessageTypeFlagsEXT XR_DEBUG_UTILS_MESSAGE_TYPE_CONFORMANCE_BIT_EXT = 0x00000008;
+
+typedef struct XrDebugUtilsObjectNameInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrObjectType objectType;
+ uint64_t objectHandle;
+ const char* objectName;
+} XrDebugUtilsObjectNameInfoEXT;
+
+typedef struct XrDebugUtilsLabelEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const char* labelName;
+} XrDebugUtilsLabelEXT;
+
+typedef struct XrDebugUtilsMessengerCallbackDataEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const char* messageId;
+ const char* functionName;
+ const char* message;
+ uint32_t objectCount;
+ XrDebugUtilsObjectNameInfoEXT* objects;
+ uint32_t sessionLabelCount;
+ XrDebugUtilsLabelEXT* sessionLabels;
+} XrDebugUtilsMessengerCallbackDataEXT;
+
+typedef XrBool32 (XRAPI_PTR *PFN_xrDebugUtilsMessengerCallbackEXT)(
+ XrDebugUtilsMessageSeverityFlagsEXT messageSeverity,
+ XrDebugUtilsMessageTypeFlagsEXT messageTypes,
+ const XrDebugUtilsMessengerCallbackDataEXT* callbackData,
+ void* userData);
+
+
+// XrDebugUtilsMessengerCreateInfoEXT extends XrInstanceCreateInfo
+typedef struct XrDebugUtilsMessengerCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrDebugUtilsMessageSeverityFlagsEXT messageSeverities;
+ XrDebugUtilsMessageTypeFlagsEXT messageTypes;
+ PFN_xrDebugUtilsMessengerCallbackEXT userCallback;
+ void* XR_MAY_ALIAS userData;
+} XrDebugUtilsMessengerCreateInfoEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetDebugUtilsObjectNameEXT)(XrInstance instance, const XrDebugUtilsObjectNameInfoEXT* nameInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateDebugUtilsMessengerEXT)(XrInstance instance, const XrDebugUtilsMessengerCreateInfoEXT* createInfo, XrDebugUtilsMessengerEXT* messenger);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyDebugUtilsMessengerEXT)(XrDebugUtilsMessengerEXT messenger);
+typedef XrResult (XRAPI_PTR *PFN_xrSubmitDebugUtilsMessageEXT)(XrInstance instance, XrDebugUtilsMessageSeverityFlagsEXT messageSeverity, XrDebugUtilsMessageTypeFlagsEXT messageTypes, const XrDebugUtilsMessengerCallbackDataEXT* callbackData);
+typedef XrResult (XRAPI_PTR *PFN_xrSessionBeginDebugUtilsLabelRegionEXT)(XrSession session, const XrDebugUtilsLabelEXT* labelInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrSessionEndDebugUtilsLabelRegionEXT)(XrSession session);
+typedef XrResult (XRAPI_PTR *PFN_xrSessionInsertDebugUtilsLabelEXT)(XrSession session, const XrDebugUtilsLabelEXT* labelInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetDebugUtilsObjectNameEXT(
+ XrInstance instance,
+ const XrDebugUtilsObjectNameInfoEXT* nameInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateDebugUtilsMessengerEXT(
+ XrInstance instance,
+ const XrDebugUtilsMessengerCreateInfoEXT* createInfo,
+ XrDebugUtilsMessengerEXT* messenger);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyDebugUtilsMessengerEXT(
+ XrDebugUtilsMessengerEXT messenger);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSubmitDebugUtilsMessageEXT(
+ XrInstance instance,
+ XrDebugUtilsMessageSeverityFlagsEXT messageSeverity,
+ XrDebugUtilsMessageTypeFlagsEXT messageTypes,
+ const XrDebugUtilsMessengerCallbackDataEXT* callbackData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSessionBeginDebugUtilsLabelRegionEXT(
+ XrSession session,
+ const XrDebugUtilsLabelEXT* labelInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSessionEndDebugUtilsLabelRegionEXT(
+ XrSession session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSessionInsertDebugUtilsLabelEXT(
+ XrSession session,
+ const XrDebugUtilsLabelEXT* labelInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_eye_gaze_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_eye_gaze_interaction 1
+#define XR_EXT_eye_gaze_interaction_SPEC_VERSION 2
+#define XR_EXT_EYE_GAZE_INTERACTION_EXTENSION_NAME "XR_EXT_eye_gaze_interaction"
+// XrSystemEyeGazeInteractionPropertiesEXT extends XrSystemProperties
+typedef struct XrSystemEyeGazeInteractionPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsEyeGazeInteraction;
+} XrSystemEyeGazeInteractionPropertiesEXT;
+
+// XrEyeGazeSampleTimeEXT extends XrSpaceLocation
+typedef struct XrEyeGazeSampleTimeEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrEyeGazeSampleTimeEXT;
+
+
+
+// XR_EXTX_overlay is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXTX_overlay 1
+#define XR_EXTX_overlay_SPEC_VERSION 5
+#define XR_EXTX_OVERLAY_EXTENSION_NAME "XR_EXTX_overlay"
+typedef XrFlags64 XrOverlaySessionCreateFlagsEXTX;
+
+// Flag bits for XrOverlaySessionCreateFlagsEXTX
+
+typedef XrFlags64 XrOverlayMainSessionFlagsEXTX;
+
+// Flag bits for XrOverlayMainSessionFlagsEXTX
+static const XrOverlayMainSessionFlagsEXTX XR_OVERLAY_MAIN_SESSION_ENABLED_COMPOSITION_LAYER_INFO_DEPTH_BIT_EXTX = 0x00000001;
+
+// XrSessionCreateInfoOverlayEXTX extends XrSessionCreateInfo
+typedef struct XrSessionCreateInfoOverlayEXTX {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrOverlaySessionCreateFlagsEXTX createFlags;
+ uint32_t sessionLayersPlacement;
+} XrSessionCreateInfoOverlayEXTX;
+
+typedef struct XrEventDataMainSessionVisibilityChangedEXTX {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 visible;
+ XrOverlayMainSessionFlagsEXTX flags;
+} XrEventDataMainSessionVisibilityChangedEXTX;
+
+
+
+// XR_VARJO_quad_views is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_quad_views 1
+#define XR_VARJO_quad_views_SPEC_VERSION 2
+#define XR_VARJO_QUAD_VIEWS_EXTENSION_NAME "XR_VARJO_quad_views"
+
+
+// XR_MSFT_unbounded_reference_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_unbounded_reference_space 1
+#define XR_MSFT_unbounded_reference_space_SPEC_VERSION 1
+#define XR_MSFT_UNBOUNDED_REFERENCE_SPACE_EXTENSION_NAME "XR_MSFT_unbounded_reference_space"
+
+
+// XR_MSFT_spatial_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_spatial_anchor 1
+XR_DEFINE_HANDLE(XrSpatialAnchorMSFT)
+#define XR_MSFT_spatial_anchor_SPEC_VERSION 2
+#define XR_MSFT_SPATIAL_ANCHOR_EXTENSION_NAME "XR_MSFT_spatial_anchor"
+typedef struct XrSpatialAnchorCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrPosef pose;
+ XrTime time;
+} XrSpatialAnchorCreateInfoMSFT;
+
+typedef struct XrSpatialAnchorSpaceCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialAnchorMSFT anchor;
+ XrPosef poseInAnchorSpace;
+} XrSpatialAnchorSpaceCreateInfoMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorMSFT)(XrSession session, const XrSpatialAnchorCreateInfoMSFT* createInfo, XrSpatialAnchorMSFT* anchor);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorSpaceMSFT)(XrSession session, const XrSpatialAnchorSpaceCreateInfoMSFT* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialAnchorMSFT)(XrSpatialAnchorMSFT anchor);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorMSFT(
+ XrSession session,
+ const XrSpatialAnchorCreateInfoMSFT* createInfo,
+ XrSpatialAnchorMSFT* anchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorSpaceMSFT(
+ XrSession session,
+ const XrSpatialAnchorSpaceCreateInfoMSFT* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialAnchorMSFT(
+ XrSpatialAnchorMSFT anchor);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_composition_layer_image_layout is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_composition_layer_image_layout 1
+#define XR_FB_composition_layer_image_layout_SPEC_VERSION 1
+#define XR_FB_COMPOSITION_LAYER_IMAGE_LAYOUT_EXTENSION_NAME "XR_FB_composition_layer_image_layout"
+typedef XrFlags64 XrCompositionLayerImageLayoutFlagsFB;
+
+// Flag bits for XrCompositionLayerImageLayoutFlagsFB
+static const XrCompositionLayerImageLayoutFlagsFB XR_COMPOSITION_LAYER_IMAGE_LAYOUT_VERTICAL_FLIP_BIT_FB = 0x00000001;
+
+// XrCompositionLayerImageLayoutFB extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerImageLayoutFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrCompositionLayerImageLayoutFlagsFB flags;
+} XrCompositionLayerImageLayoutFB;
+
+
+
+// XR_FB_composition_layer_alpha_blend is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_composition_layer_alpha_blend 1
+#define XR_FB_composition_layer_alpha_blend_SPEC_VERSION 3
+#define XR_FB_COMPOSITION_LAYER_ALPHA_BLEND_EXTENSION_NAME "XR_FB_composition_layer_alpha_blend"
+
+typedef enum XrBlendFactorFB {
+ XR_BLEND_FACTOR_ZERO_FB = 0,
+ XR_BLEND_FACTOR_ONE_FB = 1,
+ XR_BLEND_FACTOR_SRC_ALPHA_FB = 2,
+ XR_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA_FB = 3,
+ XR_BLEND_FACTOR_DST_ALPHA_FB = 4,
+ XR_BLEND_FACTOR_ONE_MINUS_DST_ALPHA_FB = 5,
+ XR_BLEND_FACTOR_MAX_ENUM_FB = 0x7FFFFFFF
+} XrBlendFactorFB;
+typedef XrFlags64 XrCompositionLayerSecureContentFlagsFB;
+
+// Flag bits for XrCompositionLayerSecureContentFlagsFB
+static const XrCompositionLayerSecureContentFlagsFB XR_COMPOSITION_LAYER_SECURE_CONTENT_EXCLUDE_LAYER_BIT_FB = 0x00000001;
+static const XrCompositionLayerSecureContentFlagsFB XR_COMPOSITION_LAYER_SECURE_CONTENT_REPLACE_LAYER_BIT_FB = 0x00000002;
+
+// XrCompositionLayerAlphaBlendFB extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerAlphaBlendFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBlendFactorFB srcFactorColor;
+ XrBlendFactorFB dstFactorColor;
+ XrBlendFactorFB srcFactorAlpha;
+ XrBlendFactorFB dstFactorAlpha;
+} XrCompositionLayerAlphaBlendFB;
+
+
+
+// XR_MND_headless is a preprocessor guard. Do not pass it to API calls.
+#define XR_MND_headless 1
+#define XR_MND_headless_SPEC_VERSION 3
+#define XR_MND_HEADLESS_EXTENSION_NAME "XR_MND_headless"
+
+
+// XR_OCULUS_android_session_state_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_OCULUS_android_session_state_enable 1
+#define XR_OCULUS_android_session_state_enable_SPEC_VERSION 1
+#define XR_OCULUS_ANDROID_SESSION_STATE_ENABLE_EXTENSION_NAME "XR_OCULUS_android_session_state_enable"
+
+
+// XR_EXT_view_configuration_depth_range is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_view_configuration_depth_range 1
+#define XR_EXT_view_configuration_depth_range_SPEC_VERSION 1
+#define XR_EXT_VIEW_CONFIGURATION_DEPTH_RANGE_EXTENSION_NAME "XR_EXT_view_configuration_depth_range"
+// XrViewConfigurationDepthRangeEXT extends XrViewConfigurationView
+typedef struct XrViewConfigurationDepthRangeEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ float recommendedNearZ;
+ float minNearZ;
+ float recommendedFarZ;
+ float maxFarZ;
+} XrViewConfigurationDepthRangeEXT;
+
+
+
+// XR_EXT_conformance_automation is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_conformance_automation 1
+#define XR_EXT_conformance_automation_SPEC_VERSION 3
+#define XR_EXT_CONFORMANCE_AUTOMATION_EXTENSION_NAME "XR_EXT_conformance_automation"
+typedef XrResult (XRAPI_PTR *PFN_xrSetInputDeviceActiveEXT)(XrSession session, XrPath interactionProfile, XrPath topLevelPath, XrBool32 isActive);
+typedef XrResult (XRAPI_PTR *PFN_xrSetInputDeviceStateBoolEXT)(XrSession session, XrPath topLevelPath, XrPath inputSourcePath, XrBool32 state);
+typedef XrResult (XRAPI_PTR *PFN_xrSetInputDeviceStateFloatEXT)(XrSession session, XrPath topLevelPath, XrPath inputSourcePath, float state);
+typedef XrResult (XRAPI_PTR *PFN_xrSetInputDeviceStateVector2fEXT)(XrSession session, XrPath topLevelPath, XrPath inputSourcePath, XrVector2f state);
+typedef XrResult (XRAPI_PTR *PFN_xrSetInputDeviceLocationEXT)(XrSession session, XrPath topLevelPath, XrPath inputSourcePath, XrSpace space, XrPosef pose);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetInputDeviceActiveEXT(
+ XrSession session,
+ XrPath interactionProfile,
+ XrPath topLevelPath,
+ XrBool32 isActive);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetInputDeviceStateBoolEXT(
+ XrSession session,
+ XrPath topLevelPath,
+ XrPath inputSourcePath,
+ XrBool32 state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetInputDeviceStateFloatEXT(
+ XrSession session,
+ XrPath topLevelPath,
+ XrPath inputSourcePath,
+ float state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetInputDeviceStateVector2fEXT(
+ XrSession session,
+ XrPath topLevelPath,
+ XrPath inputSourcePath,
+ XrVector2f state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetInputDeviceLocationEXT(
+ XrSession session,
+ XrPath topLevelPath,
+ XrPath inputSourcePath,
+ XrSpace space,
+ XrPosef pose);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_spatial_graph_bridge is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_spatial_graph_bridge 1
+XR_DEFINE_HANDLE(XrSpatialGraphNodeBindingMSFT)
+#define XR_GUID_SIZE_MSFT 16
+#define XR_MSFT_spatial_graph_bridge_SPEC_VERSION 2
+#define XR_MSFT_SPATIAL_GRAPH_BRIDGE_EXTENSION_NAME "XR_MSFT_spatial_graph_bridge"
+
+typedef enum XrSpatialGraphNodeTypeMSFT {
+ XR_SPATIAL_GRAPH_NODE_TYPE_STATIC_MSFT = 1,
+ XR_SPATIAL_GRAPH_NODE_TYPE_DYNAMIC_MSFT = 2,
+ XR_SPATIAL_GRAPH_NODE_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSpatialGraphNodeTypeMSFT;
+typedef struct XrSpatialGraphNodeSpaceCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialGraphNodeTypeMSFT nodeType;
+ uint8_t nodeId[XR_GUID_SIZE_MSFT];
+ XrPosef pose;
+} XrSpatialGraphNodeSpaceCreateInfoMSFT;
+
+typedef struct XrSpatialGraphStaticNodeBindingCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrPosef poseInSpace;
+ XrTime time;
+} XrSpatialGraphStaticNodeBindingCreateInfoMSFT;
+
+typedef struct XrSpatialGraphNodeBindingPropertiesGetInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialGraphNodeBindingPropertiesGetInfoMSFT;
+
+typedef struct XrSpatialGraphNodeBindingPropertiesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint8_t nodeId[XR_GUID_SIZE_MSFT];
+ XrPosef poseInNodeSpace;
+} XrSpatialGraphNodeBindingPropertiesMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialGraphNodeSpaceMSFT)(XrSession session, const XrSpatialGraphNodeSpaceCreateInfoMSFT* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrTryCreateSpatialGraphStaticNodeBindingMSFT)(XrSession session, const XrSpatialGraphStaticNodeBindingCreateInfoMSFT* createInfo, XrSpatialGraphNodeBindingMSFT* nodeBinding);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialGraphNodeBindingMSFT)(XrSpatialGraphNodeBindingMSFT nodeBinding);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialGraphNodeBindingPropertiesMSFT)(XrSpatialGraphNodeBindingMSFT nodeBinding, const XrSpatialGraphNodeBindingPropertiesGetInfoMSFT* getInfo, XrSpatialGraphNodeBindingPropertiesMSFT* properties);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialGraphNodeSpaceMSFT(
+ XrSession session,
+ const XrSpatialGraphNodeSpaceCreateInfoMSFT* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTryCreateSpatialGraphStaticNodeBindingMSFT(
+ XrSession session,
+ const XrSpatialGraphStaticNodeBindingCreateInfoMSFT* createInfo,
+ XrSpatialGraphNodeBindingMSFT* nodeBinding);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialGraphNodeBindingMSFT(
+ XrSpatialGraphNodeBindingMSFT nodeBinding);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialGraphNodeBindingPropertiesMSFT(
+ XrSpatialGraphNodeBindingMSFT nodeBinding,
+ const XrSpatialGraphNodeBindingPropertiesGetInfoMSFT* getInfo,
+ XrSpatialGraphNodeBindingPropertiesMSFT* properties);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_hand_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_hand_interaction 1
+#define XR_MSFT_hand_interaction_SPEC_VERSION 1
+#define XR_MSFT_HAND_INTERACTION_EXTENSION_NAME "XR_MSFT_hand_interaction"
+
+
+// XR_EXT_hand_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_hand_tracking 1
+
+#define XR_HAND_JOINT_COUNT_EXT 26
+
+XR_DEFINE_HANDLE(XrHandTrackerEXT)
+#define XR_EXT_hand_tracking_SPEC_VERSION 4
+#define XR_EXT_HAND_TRACKING_EXTENSION_NAME "XR_EXT_hand_tracking"
+
+typedef enum XrHandEXT {
+ XR_HAND_LEFT_EXT = 1,
+ XR_HAND_RIGHT_EXT = 2,
+ XR_HAND_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHandEXT;
+
+typedef enum XrHandJointEXT {
+ XR_HAND_JOINT_PALM_EXT = 0,
+ XR_HAND_JOINT_WRIST_EXT = 1,
+ XR_HAND_JOINT_THUMB_METACARPAL_EXT = 2,
+ XR_HAND_JOINT_THUMB_PROXIMAL_EXT = 3,
+ XR_HAND_JOINT_THUMB_DISTAL_EXT = 4,
+ XR_HAND_JOINT_THUMB_TIP_EXT = 5,
+ XR_HAND_JOINT_INDEX_METACARPAL_EXT = 6,
+ XR_HAND_JOINT_INDEX_PROXIMAL_EXT = 7,
+ XR_HAND_JOINT_INDEX_INTERMEDIATE_EXT = 8,
+ XR_HAND_JOINT_INDEX_DISTAL_EXT = 9,
+ XR_HAND_JOINT_INDEX_TIP_EXT = 10,
+ XR_HAND_JOINT_MIDDLE_METACARPAL_EXT = 11,
+ XR_HAND_JOINT_MIDDLE_PROXIMAL_EXT = 12,
+ XR_HAND_JOINT_MIDDLE_INTERMEDIATE_EXT = 13,
+ XR_HAND_JOINT_MIDDLE_DISTAL_EXT = 14,
+ XR_HAND_JOINT_MIDDLE_TIP_EXT = 15,
+ XR_HAND_JOINT_RING_METACARPAL_EXT = 16,
+ XR_HAND_JOINT_RING_PROXIMAL_EXT = 17,
+ XR_HAND_JOINT_RING_INTERMEDIATE_EXT = 18,
+ XR_HAND_JOINT_RING_DISTAL_EXT = 19,
+ XR_HAND_JOINT_RING_TIP_EXT = 20,
+ XR_HAND_JOINT_LITTLE_METACARPAL_EXT = 21,
+ XR_HAND_JOINT_LITTLE_PROXIMAL_EXT = 22,
+ XR_HAND_JOINT_LITTLE_INTERMEDIATE_EXT = 23,
+ XR_HAND_JOINT_LITTLE_DISTAL_EXT = 24,
+ XR_HAND_JOINT_LITTLE_TIP_EXT = 25,
+ XR_HAND_JOINT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHandJointEXT;
+
+typedef enum XrHandJointSetEXT {
+ XR_HAND_JOINT_SET_DEFAULT_EXT = 0,
+ XR_HAND_JOINT_SET_HAND_WITH_FOREARM_ULTRALEAP = 1000149000,
+ XR_HAND_JOINT_SET_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHandJointSetEXT;
+// XrSystemHandTrackingPropertiesEXT extends XrSystemProperties
+typedef struct XrSystemHandTrackingPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsHandTracking;
+} XrSystemHandTrackingPropertiesEXT;
+
+typedef struct XrHandTrackerCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrHandEXT hand;
+ XrHandJointSetEXT handJointSet;
+} XrHandTrackerCreateInfoEXT;
+
+typedef struct XrHandJointsLocateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrHandJointsLocateInfoEXT;
+
+typedef struct XrHandJointLocationEXT {
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+ float radius;
+} XrHandJointLocationEXT;
+
+typedef struct XrHandJointVelocityEXT {
+ XrSpaceVelocityFlags velocityFlags;
+ XrVector3f linearVelocity;
+ XrVector3f angularVelocity;
+} XrHandJointVelocityEXT;
+
+typedef struct XrHandJointLocationsEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+ uint32_t jointCount;
+ XrHandJointLocationEXT* jointLocations;
+} XrHandJointLocationsEXT;
+
+// XrHandJointVelocitiesEXT extends XrHandJointLocationsEXT
+typedef struct XrHandJointVelocitiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t jointCount;
+ XrHandJointVelocityEXT* jointVelocities;
+} XrHandJointVelocitiesEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateHandTrackerEXT)(XrSession session, const XrHandTrackerCreateInfoEXT* createInfo, XrHandTrackerEXT* handTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyHandTrackerEXT)(XrHandTrackerEXT handTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateHandJointsEXT)(XrHandTrackerEXT handTracker, const XrHandJointsLocateInfoEXT* locateInfo, XrHandJointLocationsEXT* locations);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateHandTrackerEXT(
+ XrSession session,
+ const XrHandTrackerCreateInfoEXT* createInfo,
+ XrHandTrackerEXT* handTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyHandTrackerEXT(
+ XrHandTrackerEXT handTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateHandJointsEXT(
+ XrHandTrackerEXT handTracker,
+ const XrHandJointsLocateInfoEXT* locateInfo,
+ XrHandJointLocationsEXT* locations);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_hand_tracking_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_hand_tracking_mesh 1
+#define XR_MSFT_hand_tracking_mesh_SPEC_VERSION 4
+#define XR_MSFT_HAND_TRACKING_MESH_EXTENSION_NAME "XR_MSFT_hand_tracking_mesh"
+
+typedef enum XrHandPoseTypeMSFT {
+ XR_HAND_POSE_TYPE_TRACKED_MSFT = 0,
+ XR_HAND_POSE_TYPE_REFERENCE_OPEN_PALM_MSFT = 1,
+ XR_HAND_POSE_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrHandPoseTypeMSFT;
+// XrSystemHandTrackingMeshPropertiesMSFT extends XrSystemProperties
+typedef struct XrSystemHandTrackingMeshPropertiesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsHandTrackingMesh;
+ uint32_t maxHandMeshIndexCount;
+ uint32_t maxHandMeshVertexCount;
+} XrSystemHandTrackingMeshPropertiesMSFT;
+
+typedef struct XrHandMeshSpaceCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrHandPoseTypeMSFT handPoseType;
+ XrPosef poseInHandMeshSpace;
+} XrHandMeshSpaceCreateInfoMSFT;
+
+typedef struct XrHandMeshUpdateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+ XrHandPoseTypeMSFT handPoseType;
+} XrHandMeshUpdateInfoMSFT;
+
+typedef struct XrHandMeshIndexBufferMSFT {
+ uint32_t indexBufferKey;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrHandMeshIndexBufferMSFT;
+
+typedef struct XrHandMeshVertexMSFT {
+ XrVector3f position;
+ XrVector3f normal;
+} XrHandMeshVertexMSFT;
+
+typedef struct XrHandMeshVertexBufferMSFT {
+ XrTime vertexUpdateTime;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrHandMeshVertexMSFT* vertices;
+} XrHandMeshVertexBufferMSFT;
+
+typedef struct XrHandMeshMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+ XrBool32 indexBufferChanged;
+ XrBool32 vertexBufferChanged;
+ XrHandMeshIndexBufferMSFT indexBuffer;
+ XrHandMeshVertexBufferMSFT vertexBuffer;
+} XrHandMeshMSFT;
+
+// XrHandPoseTypeInfoMSFT extends XrHandTrackerCreateInfoEXT
+typedef struct XrHandPoseTypeInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrHandPoseTypeMSFT handPoseType;
+} XrHandPoseTypeInfoMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateHandMeshSpaceMSFT)(XrHandTrackerEXT handTracker, const XrHandMeshSpaceCreateInfoMSFT* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateHandMeshMSFT)(XrHandTrackerEXT handTracker, const XrHandMeshUpdateInfoMSFT* updateInfo, XrHandMeshMSFT* handMesh);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateHandMeshSpaceMSFT(
+ XrHandTrackerEXT handTracker,
+ const XrHandMeshSpaceCreateInfoMSFT* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateHandMeshMSFT(
+ XrHandTrackerEXT handTracker,
+ const XrHandMeshUpdateInfoMSFT* updateInfo,
+ XrHandMeshMSFT* handMesh);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_secondary_view_configuration is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_secondary_view_configuration 1
+#define XR_MSFT_secondary_view_configuration_SPEC_VERSION 1
+#define XR_MSFT_SECONDARY_VIEW_CONFIGURATION_EXTENSION_NAME "XR_MSFT_secondary_view_configuration"
+// XrSecondaryViewConfigurationSessionBeginInfoMSFT extends XrSessionBeginInfo
+typedef struct XrSecondaryViewConfigurationSessionBeginInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t viewConfigurationCount;
+ const XrViewConfigurationType* enabledViewConfigurationTypes;
+} XrSecondaryViewConfigurationSessionBeginInfoMSFT;
+
+typedef struct XrSecondaryViewConfigurationStateMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+ XrBool32 active;
+} XrSecondaryViewConfigurationStateMSFT;
+
+// XrSecondaryViewConfigurationFrameStateMSFT extends XrFrameState
+typedef struct XrSecondaryViewConfigurationFrameStateMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t viewConfigurationCount;
+ XrSecondaryViewConfigurationStateMSFT* viewConfigurationStates;
+} XrSecondaryViewConfigurationFrameStateMSFT;
+
+typedef struct XrSecondaryViewConfigurationLayerInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+ XrEnvironmentBlendMode environmentBlendMode;
+ uint32_t layerCount;
+ const XrCompositionLayerBaseHeader* const* layers;
+} XrSecondaryViewConfigurationLayerInfoMSFT;
+
+// XrSecondaryViewConfigurationFrameEndInfoMSFT extends XrFrameEndInfo
+typedef struct XrSecondaryViewConfigurationFrameEndInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t viewConfigurationCount;
+ const XrSecondaryViewConfigurationLayerInfoMSFT* viewConfigurationLayersInfo;
+} XrSecondaryViewConfigurationFrameEndInfoMSFT;
+
+// XrSecondaryViewConfigurationSwapchainCreateInfoMSFT extends XrSwapchainCreateInfo
+typedef struct XrSecondaryViewConfigurationSwapchainCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+} XrSecondaryViewConfigurationSwapchainCreateInfoMSFT;
+
+
+
+// XR_MSFT_first_person_observer is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_first_person_observer 1
+#define XR_MSFT_first_person_observer_SPEC_VERSION 1
+#define XR_MSFT_FIRST_PERSON_OBSERVER_EXTENSION_NAME "XR_MSFT_first_person_observer"
+
+
+// XR_MSFT_controller_model is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_controller_model 1
+
+#define XR_NULL_CONTROLLER_MODEL_KEY_MSFT 0
+
+XR_DEFINE_ATOM(XrControllerModelKeyMSFT)
+#define XR_MAX_CONTROLLER_MODEL_NODE_NAME_SIZE_MSFT 64
+#define XR_MSFT_controller_model_SPEC_VERSION 2
+#define XR_MSFT_CONTROLLER_MODEL_EXTENSION_NAME "XR_MSFT_controller_model"
+typedef struct XrControllerModelKeyStateMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrControllerModelKeyMSFT modelKey;
+} XrControllerModelKeyStateMSFT;
+
+typedef struct XrControllerModelNodePropertiesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ char parentNodeName[XR_MAX_CONTROLLER_MODEL_NODE_NAME_SIZE_MSFT];
+ char nodeName[XR_MAX_CONTROLLER_MODEL_NODE_NAME_SIZE_MSFT];
+} XrControllerModelNodePropertiesMSFT;
+
+typedef struct XrControllerModelPropertiesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t nodeCapacityInput;
+ uint32_t nodeCountOutput;
+ XrControllerModelNodePropertiesMSFT* nodeProperties;
+} XrControllerModelPropertiesMSFT;
+
+typedef struct XrControllerModelNodeStateMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPosef nodePose;
+} XrControllerModelNodeStateMSFT;
+
+typedef struct XrControllerModelStateMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t nodeCapacityInput;
+ uint32_t nodeCountOutput;
+ XrControllerModelNodeStateMSFT* nodeStates;
+} XrControllerModelStateMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetControllerModelKeyMSFT)(XrSession session, XrPath topLevelUserPath, XrControllerModelKeyStateMSFT* controllerModelKeyState);
+typedef XrResult (XRAPI_PTR *PFN_xrLoadControllerModelMSFT)(XrSession session, XrControllerModelKeyMSFT modelKey, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, uint8_t* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetControllerModelPropertiesMSFT)(XrSession session, XrControllerModelKeyMSFT modelKey, XrControllerModelPropertiesMSFT* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrGetControllerModelStateMSFT)(XrSession session, XrControllerModelKeyMSFT modelKey, XrControllerModelStateMSFT* state);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetControllerModelKeyMSFT(
+ XrSession session,
+ XrPath topLevelUserPath,
+ XrControllerModelKeyStateMSFT* controllerModelKeyState);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLoadControllerModelMSFT(
+ XrSession session,
+ XrControllerModelKeyMSFT modelKey,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ uint8_t* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetControllerModelPropertiesMSFT(
+ XrSession session,
+ XrControllerModelKeyMSFT modelKey,
+ XrControllerModelPropertiesMSFT* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetControllerModelStateMSFT(
+ XrSession session,
+ XrControllerModelKeyMSFT modelKey,
+ XrControllerModelStateMSFT* state);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_win32_appcontainer_compatible is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_win32_appcontainer_compatible 1
+#define XR_EXT_win32_appcontainer_compatible_SPEC_VERSION 1
+#define XR_EXT_WIN32_APPCONTAINER_COMPATIBLE_EXTENSION_NAME "XR_EXT_win32_appcontainer_compatible"
+
+
+// XR_EPIC_view_configuration_fov is a preprocessor guard. Do not pass it to API calls.
+#define XR_EPIC_view_configuration_fov 1
+#define XR_EPIC_view_configuration_fov_SPEC_VERSION 2
+#define XR_EPIC_VIEW_CONFIGURATION_FOV_EXTENSION_NAME "XR_EPIC_view_configuration_fov"
+// XrViewConfigurationViewFovEPIC extends XrViewConfigurationView
+typedef struct XrViewConfigurationViewFovEPIC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFovf recommendedFov;
+ XrFovf maxMutableFov;
+} XrViewConfigurationViewFovEPIC;
+
+
+
+// XR_MSFT_composition_layer_reprojection is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_composition_layer_reprojection 1
+#define XR_MSFT_composition_layer_reprojection_SPEC_VERSION 1
+#define XR_MSFT_COMPOSITION_LAYER_REPROJECTION_EXTENSION_NAME "XR_MSFT_composition_layer_reprojection"
+
+typedef enum XrReprojectionModeMSFT {
+ XR_REPROJECTION_MODE_DEPTH_MSFT = 1,
+ XR_REPROJECTION_MODE_PLANAR_FROM_DEPTH_MSFT = 2,
+ XR_REPROJECTION_MODE_PLANAR_MANUAL_MSFT = 3,
+ XR_REPROJECTION_MODE_ORIENTATION_ONLY_MSFT = 4,
+ XR_REPROJECTION_MODE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrReprojectionModeMSFT;
+// XrCompositionLayerReprojectionInfoMSFT extends XrCompositionLayerProjection
+typedef struct XrCompositionLayerReprojectionInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrReprojectionModeMSFT reprojectionMode;
+} XrCompositionLayerReprojectionInfoMSFT;
+
+// XrCompositionLayerReprojectionPlaneOverrideMSFT extends XrCompositionLayerProjection
+typedef struct XrCompositionLayerReprojectionPlaneOverrideMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVector3f position;
+ XrVector3f normal;
+ XrVector3f velocity;
+} XrCompositionLayerReprojectionPlaneOverrideMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateReprojectionModesMSFT)(XrInstance instance, XrSystemId systemId, XrViewConfigurationType viewConfigurationType, uint32_t modeCapacityInput, uint32_t* modeCountOutput, XrReprojectionModeMSFT* modes);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateReprojectionModesMSFT(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrViewConfigurationType viewConfigurationType,
+ uint32_t modeCapacityInput,
+ uint32_t* modeCountOutput,
+ XrReprojectionModeMSFT* modes);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HUAWEI_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HUAWEI_controller_interaction 1
+#define XR_HUAWEI_controller_interaction_SPEC_VERSION 1
+#define XR_HUAWEI_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_HUAWEI_controller_interaction"
+
+
+// XR_FB_swapchain_update_state is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_swapchain_update_state 1
+#define XR_FB_swapchain_update_state_SPEC_VERSION 3
+#define XR_FB_SWAPCHAIN_UPDATE_STATE_EXTENSION_NAME "XR_FB_swapchain_update_state"
+typedef struct XR_MAY_ALIAS XrSwapchainStateBaseHeaderFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrSwapchainStateBaseHeaderFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSwapchainFB)(XrSwapchain swapchain, const XrSwapchainStateBaseHeaderFB* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSwapchainStateFB)(XrSwapchain swapchain, XrSwapchainStateBaseHeaderFB* state);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSwapchainFB(
+ XrSwapchain swapchain,
+ const XrSwapchainStateBaseHeaderFB* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSwapchainStateFB(
+ XrSwapchain swapchain,
+ XrSwapchainStateBaseHeaderFB* state);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_composition_layer_secure_content is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_composition_layer_secure_content 1
+#define XR_FB_composition_layer_secure_content_SPEC_VERSION 1
+#define XR_FB_COMPOSITION_LAYER_SECURE_CONTENT_EXTENSION_NAME "XR_FB_composition_layer_secure_content"
+// XrCompositionLayerSecureContentFB extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerSecureContentFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerSecureContentFlagsFB flags;
+} XrCompositionLayerSecureContentFB;
+
+
+
+// XR_FB_body_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_body_tracking 1
+XR_DEFINE_HANDLE(XrBodyTrackerFB)
+#define XR_FB_body_tracking_SPEC_VERSION 1
+#define XR_FB_BODY_TRACKING_EXTENSION_NAME "XR_FB_body_tracking"
+
+typedef enum XrBodyJointFB {
+ XR_BODY_JOINT_ROOT_FB = 0,
+ XR_BODY_JOINT_HIPS_FB = 1,
+ XR_BODY_JOINT_SPINE_LOWER_FB = 2,
+ XR_BODY_JOINT_SPINE_MIDDLE_FB = 3,
+ XR_BODY_JOINT_SPINE_UPPER_FB = 4,
+ XR_BODY_JOINT_CHEST_FB = 5,
+ XR_BODY_JOINT_NECK_FB = 6,
+ XR_BODY_JOINT_HEAD_FB = 7,
+ XR_BODY_JOINT_LEFT_SHOULDER_FB = 8,
+ XR_BODY_JOINT_LEFT_SCAPULA_FB = 9,
+ XR_BODY_JOINT_LEFT_ARM_UPPER_FB = 10,
+ XR_BODY_JOINT_LEFT_ARM_LOWER_FB = 11,
+ XR_BODY_JOINT_LEFT_HAND_WRIST_TWIST_FB = 12,
+ XR_BODY_JOINT_RIGHT_SHOULDER_FB = 13,
+ XR_BODY_JOINT_RIGHT_SCAPULA_FB = 14,
+ XR_BODY_JOINT_RIGHT_ARM_UPPER_FB = 15,
+ XR_BODY_JOINT_RIGHT_ARM_LOWER_FB = 16,
+ XR_BODY_JOINT_RIGHT_HAND_WRIST_TWIST_FB = 17,
+ XR_BODY_JOINT_LEFT_HAND_PALM_FB = 18,
+ XR_BODY_JOINT_LEFT_HAND_WRIST_FB = 19,
+ XR_BODY_JOINT_LEFT_HAND_THUMB_METACARPAL_FB = 20,
+ XR_BODY_JOINT_LEFT_HAND_THUMB_PROXIMAL_FB = 21,
+ XR_BODY_JOINT_LEFT_HAND_THUMB_DISTAL_FB = 22,
+ XR_BODY_JOINT_LEFT_HAND_THUMB_TIP_FB = 23,
+ XR_BODY_JOINT_LEFT_HAND_INDEX_METACARPAL_FB = 24,
+ XR_BODY_JOINT_LEFT_HAND_INDEX_PROXIMAL_FB = 25,
+ XR_BODY_JOINT_LEFT_HAND_INDEX_INTERMEDIATE_FB = 26,
+ XR_BODY_JOINT_LEFT_HAND_INDEX_DISTAL_FB = 27,
+ XR_BODY_JOINT_LEFT_HAND_INDEX_TIP_FB = 28,
+ XR_BODY_JOINT_LEFT_HAND_MIDDLE_METACARPAL_FB = 29,
+ XR_BODY_JOINT_LEFT_HAND_MIDDLE_PROXIMAL_FB = 30,
+ XR_BODY_JOINT_LEFT_HAND_MIDDLE_INTERMEDIATE_FB = 31,
+ XR_BODY_JOINT_LEFT_HAND_MIDDLE_DISTAL_FB = 32,
+ XR_BODY_JOINT_LEFT_HAND_MIDDLE_TIP_FB = 33,
+ XR_BODY_JOINT_LEFT_HAND_RING_METACARPAL_FB = 34,
+ XR_BODY_JOINT_LEFT_HAND_RING_PROXIMAL_FB = 35,
+ XR_BODY_JOINT_LEFT_HAND_RING_INTERMEDIATE_FB = 36,
+ XR_BODY_JOINT_LEFT_HAND_RING_DISTAL_FB = 37,
+ XR_BODY_JOINT_LEFT_HAND_RING_TIP_FB = 38,
+ XR_BODY_JOINT_LEFT_HAND_LITTLE_METACARPAL_FB = 39,
+ XR_BODY_JOINT_LEFT_HAND_LITTLE_PROXIMAL_FB = 40,
+ XR_BODY_JOINT_LEFT_HAND_LITTLE_INTERMEDIATE_FB = 41,
+ XR_BODY_JOINT_LEFT_HAND_LITTLE_DISTAL_FB = 42,
+ XR_BODY_JOINT_LEFT_HAND_LITTLE_TIP_FB = 43,
+ XR_BODY_JOINT_RIGHT_HAND_PALM_FB = 44,
+ XR_BODY_JOINT_RIGHT_HAND_WRIST_FB = 45,
+ XR_BODY_JOINT_RIGHT_HAND_THUMB_METACARPAL_FB = 46,
+ XR_BODY_JOINT_RIGHT_HAND_THUMB_PROXIMAL_FB = 47,
+ XR_BODY_JOINT_RIGHT_HAND_THUMB_DISTAL_FB = 48,
+ XR_BODY_JOINT_RIGHT_HAND_THUMB_TIP_FB = 49,
+ XR_BODY_JOINT_RIGHT_HAND_INDEX_METACARPAL_FB = 50,
+ XR_BODY_JOINT_RIGHT_HAND_INDEX_PROXIMAL_FB = 51,
+ XR_BODY_JOINT_RIGHT_HAND_INDEX_INTERMEDIATE_FB = 52,
+ XR_BODY_JOINT_RIGHT_HAND_INDEX_DISTAL_FB = 53,
+ XR_BODY_JOINT_RIGHT_HAND_INDEX_TIP_FB = 54,
+ XR_BODY_JOINT_RIGHT_HAND_MIDDLE_METACARPAL_FB = 55,
+ XR_BODY_JOINT_RIGHT_HAND_MIDDLE_PROXIMAL_FB = 56,
+ XR_BODY_JOINT_RIGHT_HAND_MIDDLE_INTERMEDIATE_FB = 57,
+ XR_BODY_JOINT_RIGHT_HAND_MIDDLE_DISTAL_FB = 58,
+ XR_BODY_JOINT_RIGHT_HAND_MIDDLE_TIP_FB = 59,
+ XR_BODY_JOINT_RIGHT_HAND_RING_METACARPAL_FB = 60,
+ XR_BODY_JOINT_RIGHT_HAND_RING_PROXIMAL_FB = 61,
+ XR_BODY_JOINT_RIGHT_HAND_RING_INTERMEDIATE_FB = 62,
+ XR_BODY_JOINT_RIGHT_HAND_RING_DISTAL_FB = 63,
+ XR_BODY_JOINT_RIGHT_HAND_RING_TIP_FB = 64,
+ XR_BODY_JOINT_RIGHT_HAND_LITTLE_METACARPAL_FB = 65,
+ XR_BODY_JOINT_RIGHT_HAND_LITTLE_PROXIMAL_FB = 66,
+ XR_BODY_JOINT_RIGHT_HAND_LITTLE_INTERMEDIATE_FB = 67,
+ XR_BODY_JOINT_RIGHT_HAND_LITTLE_DISTAL_FB = 68,
+ XR_BODY_JOINT_RIGHT_HAND_LITTLE_TIP_FB = 69,
+ XR_BODY_JOINT_COUNT_FB = 70,
+ XR_BODY_JOINT_NONE_FB = -1,
+ XR_BODY_JOINT_MAX_ENUM_FB = 0x7FFFFFFF
+} XrBodyJointFB;
+
+typedef enum XrBodyJointSetFB {
+ XR_BODY_JOINT_SET_DEFAULT_FB = 0,
+ XR_BODY_JOINT_SET_FULL_BODY_META = 1000274000,
+ XR_BODY_JOINT_SET_MAX_ENUM_FB = 0x7FFFFFFF
+} XrBodyJointSetFB;
+typedef struct XrBodyJointLocationFB {
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+} XrBodyJointLocationFB;
+
+// XrSystemBodyTrackingPropertiesFB extends XrSystemProperties
+typedef struct XrSystemBodyTrackingPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsBodyTracking;
+} XrSystemBodyTrackingPropertiesFB;
+
+typedef struct XrBodyTrackerCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBodyJointSetFB bodyJointSet;
+} XrBodyTrackerCreateInfoFB;
+
+typedef struct XrBodySkeletonJointFB {
+ int32_t joint;
+ int32_t parentJoint;
+ XrPosef pose;
+} XrBodySkeletonJointFB;
+
+typedef struct XrBodySkeletonFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t jointCount;
+ XrBodySkeletonJointFB* joints;
+} XrBodySkeletonFB;
+
+typedef struct XrBodyJointsLocateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrBodyJointsLocateInfoFB;
+
+typedef struct XrBodyJointLocationsFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+ float confidence;
+ uint32_t jointCount;
+ XrBodyJointLocationFB* jointLocations;
+ uint32_t skeletonChangedCount;
+ XrTime time;
+} XrBodyJointLocationsFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateBodyTrackerFB)(XrSession session, const XrBodyTrackerCreateInfoFB* createInfo, XrBodyTrackerFB* bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyBodyTrackerFB)(XrBodyTrackerFB bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateBodyJointsFB)(XrBodyTrackerFB bodyTracker, const XrBodyJointsLocateInfoFB* locateInfo, XrBodyJointLocationsFB* locations);
+typedef XrResult (XRAPI_PTR *PFN_xrGetBodySkeletonFB)(XrBodyTrackerFB bodyTracker, XrBodySkeletonFB* skeleton);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateBodyTrackerFB(
+ XrSession session,
+ const XrBodyTrackerCreateInfoFB* createInfo,
+ XrBodyTrackerFB* bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyBodyTrackerFB(
+ XrBodyTrackerFB bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateBodyJointsFB(
+ XrBodyTrackerFB bodyTracker,
+ const XrBodyJointsLocateInfoFB* locateInfo,
+ XrBodyJointLocationsFB* locations);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetBodySkeletonFB(
+ XrBodyTrackerFB bodyTracker,
+ XrBodySkeletonFB* skeleton);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_dpad_binding is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_dpad_binding 1
+#define XR_EXT_dpad_binding_SPEC_VERSION 1
+#define XR_EXT_DPAD_BINDING_EXTENSION_NAME "XR_EXT_dpad_binding"
+typedef struct XrInteractionProfileDpadBindingEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPath binding;
+ XrActionSet actionSet;
+ float forceThreshold;
+ float forceThresholdReleased;
+ float centerRegion;
+ float wedgeAngle;
+ XrBool32 isSticky;
+ const XrHapticBaseHeader* onHaptic;
+ const XrHapticBaseHeader* offHaptic;
+} XrInteractionProfileDpadBindingEXT;
+
+
+
+// XR_VALVE_analog_threshold is a preprocessor guard. Do not pass it to API calls.
+#define XR_VALVE_analog_threshold 1
+#define XR_VALVE_analog_threshold_SPEC_VERSION 2
+#define XR_VALVE_ANALOG_THRESHOLD_EXTENSION_NAME "XR_VALVE_analog_threshold"
+typedef struct XrInteractionProfileAnalogThresholdVALVE {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAction action;
+ XrPath binding;
+ float onThreshold;
+ float offThreshold;
+ const XrHapticBaseHeader* onHaptic;
+ const XrHapticBaseHeader* offHaptic;
+} XrInteractionProfileAnalogThresholdVALVE;
+
+
+
+// XR_EXT_hand_joints_motion_range is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_hand_joints_motion_range 1
+#define XR_EXT_hand_joints_motion_range_SPEC_VERSION 1
+#define XR_EXT_HAND_JOINTS_MOTION_RANGE_EXTENSION_NAME "XR_EXT_hand_joints_motion_range"
+
+typedef enum XrHandJointsMotionRangeEXT {
+ XR_HAND_JOINTS_MOTION_RANGE_UNOBSTRUCTED_EXT = 1,
+ XR_HAND_JOINTS_MOTION_RANGE_CONFORMING_TO_CONTROLLER_EXT = 2,
+ XR_HAND_JOINTS_MOTION_RANGE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHandJointsMotionRangeEXT;
+// XrHandJointsMotionRangeInfoEXT extends XrHandJointsLocateInfoEXT
+typedef struct XrHandJointsMotionRangeInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrHandJointsMotionRangeEXT handJointsMotionRange;
+} XrHandJointsMotionRangeInfoEXT;
+
+
+
+// XR_EXT_samsung_odyssey_controller is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_samsung_odyssey_controller 1
+#define XR_EXT_samsung_odyssey_controller_SPEC_VERSION 1
+#define XR_EXT_SAMSUNG_ODYSSEY_CONTROLLER_EXTENSION_NAME "XR_EXT_samsung_odyssey_controller"
+
+
+// XR_EXT_hp_mixed_reality_controller is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_hp_mixed_reality_controller 1
+#define XR_EXT_hp_mixed_reality_controller_SPEC_VERSION 1
+#define XR_EXT_HP_MIXED_REALITY_CONTROLLER_EXTENSION_NAME "XR_EXT_hp_mixed_reality_controller"
+
+
+// XR_MND_swapchain_usage_input_attachment_bit is a preprocessor guard. Do not pass it to API calls.
+#define XR_MND_swapchain_usage_input_attachment_bit 1
+#define XR_MND_swapchain_usage_input_attachment_bit_SPEC_VERSION 2
+#define XR_MND_SWAPCHAIN_USAGE_INPUT_ATTACHMENT_BIT_EXTENSION_NAME "XR_MND_swapchain_usage_input_attachment_bit"
+
+
+// XR_MSFT_scene_understanding is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_scene_understanding 1
+
+ XR_DEFINE_HANDLE(XrSceneMSFT)
+
+
+ XR_DEFINE_HANDLE(XrSceneObserverMSFT)
+
+#define XR_MSFT_scene_understanding_SPEC_VERSION 2
+#define XR_MSFT_SCENE_UNDERSTANDING_EXTENSION_NAME "XR_MSFT_scene_understanding"
+
+typedef enum XrSceneComputeFeatureMSFT {
+ XR_SCENE_COMPUTE_FEATURE_PLANE_MSFT = 1,
+ XR_SCENE_COMPUTE_FEATURE_PLANE_MESH_MSFT = 2,
+ XR_SCENE_COMPUTE_FEATURE_VISUAL_MESH_MSFT = 3,
+ XR_SCENE_COMPUTE_FEATURE_COLLIDER_MESH_MSFT = 4,
+ XR_SCENE_COMPUTE_FEATURE_SERIALIZE_SCENE_MSFT = 1000098000,
+ XR_SCENE_COMPUTE_FEATURE_MARKER_MSFT = 1000147000,
+ XR_SCENE_COMPUTE_FEATURE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneComputeFeatureMSFT;
+
+typedef enum XrSceneComputeConsistencyMSFT {
+ XR_SCENE_COMPUTE_CONSISTENCY_SNAPSHOT_COMPLETE_MSFT = 1,
+ XR_SCENE_COMPUTE_CONSISTENCY_SNAPSHOT_INCOMPLETE_FAST_MSFT = 2,
+ XR_SCENE_COMPUTE_CONSISTENCY_OCCLUSION_OPTIMIZED_MSFT = 3,
+ XR_SCENE_COMPUTE_CONSISTENCY_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneComputeConsistencyMSFT;
+
+typedef enum XrMeshComputeLodMSFT {
+ XR_MESH_COMPUTE_LOD_COARSE_MSFT = 1,
+ XR_MESH_COMPUTE_LOD_MEDIUM_MSFT = 2,
+ XR_MESH_COMPUTE_LOD_FINE_MSFT = 3,
+ XR_MESH_COMPUTE_LOD_UNLIMITED_MSFT = 4,
+ XR_MESH_COMPUTE_LOD_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrMeshComputeLodMSFT;
+
+typedef enum XrSceneComponentTypeMSFT {
+ XR_SCENE_COMPONENT_TYPE_INVALID_MSFT = -1,
+ XR_SCENE_COMPONENT_TYPE_OBJECT_MSFT = 1,
+ XR_SCENE_COMPONENT_TYPE_PLANE_MSFT = 2,
+ XR_SCENE_COMPONENT_TYPE_VISUAL_MESH_MSFT = 3,
+ XR_SCENE_COMPONENT_TYPE_COLLIDER_MESH_MSFT = 4,
+ XR_SCENE_COMPONENT_TYPE_SERIALIZED_SCENE_FRAGMENT_MSFT = 1000098000,
+ XR_SCENE_COMPONENT_TYPE_MARKER_MSFT = 1000147000,
+ XR_SCENE_COMPONENT_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneComponentTypeMSFT;
+
+typedef enum XrSceneObjectTypeMSFT {
+ XR_SCENE_OBJECT_TYPE_UNCATEGORIZED_MSFT = -1,
+ XR_SCENE_OBJECT_TYPE_BACKGROUND_MSFT = 1,
+ XR_SCENE_OBJECT_TYPE_WALL_MSFT = 2,
+ XR_SCENE_OBJECT_TYPE_FLOOR_MSFT = 3,
+ XR_SCENE_OBJECT_TYPE_CEILING_MSFT = 4,
+ XR_SCENE_OBJECT_TYPE_PLATFORM_MSFT = 5,
+ XR_SCENE_OBJECT_TYPE_INFERRED_MSFT = 6,
+ XR_SCENE_OBJECT_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneObjectTypeMSFT;
+
+typedef enum XrScenePlaneAlignmentTypeMSFT {
+ XR_SCENE_PLANE_ALIGNMENT_TYPE_NON_ORTHOGONAL_MSFT = 0,
+ XR_SCENE_PLANE_ALIGNMENT_TYPE_HORIZONTAL_MSFT = 1,
+ XR_SCENE_PLANE_ALIGNMENT_TYPE_VERTICAL_MSFT = 2,
+ XR_SCENE_PLANE_ALIGNMENT_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrScenePlaneAlignmentTypeMSFT;
+
+typedef enum XrSceneComputeStateMSFT {
+ XR_SCENE_COMPUTE_STATE_NONE_MSFT = 0,
+ XR_SCENE_COMPUTE_STATE_UPDATING_MSFT = 1,
+ XR_SCENE_COMPUTE_STATE_COMPLETED_MSFT = 2,
+ XR_SCENE_COMPUTE_STATE_COMPLETED_WITH_ERROR_MSFT = 3,
+ XR_SCENE_COMPUTE_STATE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneComputeStateMSFT;
+typedef struct XrUuidMSFT {
+ uint8_t bytes[16];
+} XrUuidMSFT;
+
+typedef struct XrSceneObserverCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSceneObserverCreateInfoMSFT;
+
+typedef struct XrSceneCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSceneCreateInfoMSFT;
+
+typedef struct XrSceneSphereBoundMSFT {
+ XrVector3f center;
+ float radius;
+} XrSceneSphereBoundMSFT;
+
+typedef struct XrSceneOrientedBoxBoundMSFT {
+ XrPosef pose;
+ XrVector3f extents;
+} XrSceneOrientedBoxBoundMSFT;
+
+typedef struct XrSceneFrustumBoundMSFT {
+ XrPosef pose;
+ XrFovf fov;
+ float farDistance;
+} XrSceneFrustumBoundMSFT;
+
+typedef struct XrSceneBoundsMSFT {
+ XrSpace space;
+ XrTime time;
+ uint32_t sphereCount;
+ const XrSceneSphereBoundMSFT* spheres;
+ uint32_t boxCount;
+ const XrSceneOrientedBoxBoundMSFT* boxes;
+ uint32_t frustumCount;
+ const XrSceneFrustumBoundMSFT* frustums;
+} XrSceneBoundsMSFT;
+
+typedef struct XrNewSceneComputeInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t requestedFeatureCount;
+ const XrSceneComputeFeatureMSFT* requestedFeatures;
+ XrSceneComputeConsistencyMSFT consistency;
+ XrSceneBoundsMSFT bounds;
+} XrNewSceneComputeInfoMSFT;
+
+// XrVisualMeshComputeLodInfoMSFT extends XrNewSceneComputeInfoMSFT
+typedef struct XrVisualMeshComputeLodInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMeshComputeLodMSFT lod;
+} XrVisualMeshComputeLodInfoMSFT;
+
+typedef struct XrSceneComponentMSFT {
+ XrSceneComponentTypeMSFT componentType;
+ XrUuidMSFT id;
+ XrUuidMSFT parentId;
+ XrTime updateTime;
+} XrSceneComponentMSFT;
+
+typedef struct XrSceneComponentsMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t componentCapacityInput;
+ uint32_t componentCountOutput;
+ XrSceneComponentMSFT* components;
+} XrSceneComponentsMSFT;
+
+typedef struct XrSceneComponentsGetInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSceneComponentTypeMSFT componentType;
+} XrSceneComponentsGetInfoMSFT;
+
+typedef struct XrSceneComponentLocationMSFT {
+ XrSpaceLocationFlags flags;
+ XrPosef pose;
+} XrSceneComponentLocationMSFT;
+
+typedef struct XrSceneComponentLocationsMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t locationCount;
+ XrSceneComponentLocationMSFT* locations;
+} XrSceneComponentLocationsMSFT;
+
+typedef struct XrSceneComponentsLocateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ uint32_t componentIdCount;
+ const XrUuidMSFT* componentIds;
+} XrSceneComponentsLocateInfoMSFT;
+
+typedef struct XrSceneObjectMSFT {
+ XrSceneObjectTypeMSFT objectType;
+} XrSceneObjectMSFT;
+
+// XrSceneObjectsMSFT extends XrSceneComponentsMSFT
+typedef struct XrSceneObjectsMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t sceneObjectCount;
+ XrSceneObjectMSFT* sceneObjects;
+} XrSceneObjectsMSFT;
+
+// XrSceneComponentParentFilterInfoMSFT extends XrSceneComponentsGetInfoMSFT
+typedef struct XrSceneComponentParentFilterInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidMSFT parentId;
+} XrSceneComponentParentFilterInfoMSFT;
+
+// XrSceneObjectTypesFilterInfoMSFT extends XrSceneComponentsGetInfoMSFT
+typedef struct XrSceneObjectTypesFilterInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t objectTypeCount;
+ const XrSceneObjectTypeMSFT* objectTypes;
+} XrSceneObjectTypesFilterInfoMSFT;
+
+typedef struct XrScenePlaneMSFT {
+ XrScenePlaneAlignmentTypeMSFT alignment;
+ XrExtent2Df size;
+ uint64_t meshBufferId;
+ XrBool32 supportsIndicesUint16;
+} XrScenePlaneMSFT;
+
+// XrScenePlanesMSFT extends XrSceneComponentsMSFT
+typedef struct XrScenePlanesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t scenePlaneCount;
+ XrScenePlaneMSFT* scenePlanes;
+} XrScenePlanesMSFT;
+
+// XrScenePlaneAlignmentFilterInfoMSFT extends XrSceneComponentsGetInfoMSFT
+typedef struct XrScenePlaneAlignmentFilterInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t alignmentCount;
+ const XrScenePlaneAlignmentTypeMSFT* alignments;
+} XrScenePlaneAlignmentFilterInfoMSFT;
+
+typedef struct XrSceneMeshMSFT {
+ uint64_t meshBufferId;
+ XrBool32 supportsIndicesUint16;
+} XrSceneMeshMSFT;
+
+// XrSceneMeshesMSFT extends XrSceneComponentsMSFT
+typedef struct XrSceneMeshesMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t sceneMeshCount;
+ XrSceneMeshMSFT* sceneMeshes;
+} XrSceneMeshesMSFT;
+
+typedef struct XrSceneMeshBuffersGetInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint64_t meshBufferId;
+} XrSceneMeshBuffersGetInfoMSFT;
+
+typedef struct XrSceneMeshBuffersMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrSceneMeshBuffersMSFT;
+
+// XrSceneMeshVertexBufferMSFT extends XrSceneMeshBuffersMSFT
+typedef struct XrSceneMeshVertexBufferMSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertices;
+} XrSceneMeshVertexBufferMSFT;
+
+// XrSceneMeshIndicesUint32MSFT extends XrSceneMeshBuffersMSFT
+typedef struct XrSceneMeshIndicesUint32MSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrSceneMeshIndicesUint32MSFT;
+
+// XrSceneMeshIndicesUint16MSFT extends XrSceneMeshBuffersMSFT
+typedef struct XrSceneMeshIndicesUint16MSFT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint16_t* indices;
+} XrSceneMeshIndicesUint16MSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSceneComputeFeaturesMSFT)(XrInstance instance, XrSystemId systemId, uint32_t featureCapacityInput, uint32_t* featureCountOutput, XrSceneComputeFeatureMSFT* features);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSceneObserverMSFT)(XrSession session, const XrSceneObserverCreateInfoMSFT* createInfo, XrSceneObserverMSFT* sceneObserver);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySceneObserverMSFT)(XrSceneObserverMSFT sceneObserver);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSceneMSFT)(XrSceneObserverMSFT sceneObserver, const XrSceneCreateInfoMSFT* createInfo, XrSceneMSFT* scene);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySceneMSFT)(XrSceneMSFT scene);
+typedef XrResult (XRAPI_PTR *PFN_xrComputeNewSceneMSFT)(XrSceneObserverMSFT sceneObserver, const XrNewSceneComputeInfoMSFT* computeInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSceneComputeStateMSFT)(XrSceneObserverMSFT sceneObserver, XrSceneComputeStateMSFT* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSceneComponentsMSFT)(XrSceneMSFT scene, const XrSceneComponentsGetInfoMSFT* getInfo, XrSceneComponentsMSFT* components);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateSceneComponentsMSFT)(XrSceneMSFT scene, const XrSceneComponentsLocateInfoMSFT* locateInfo, XrSceneComponentLocationsMSFT* locations);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSceneMeshBuffersMSFT)(XrSceneMSFT scene, const XrSceneMeshBuffersGetInfoMSFT* getInfo, XrSceneMeshBuffersMSFT* buffers);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSceneComputeFeaturesMSFT(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t featureCapacityInput,
+ uint32_t* featureCountOutput,
+ XrSceneComputeFeatureMSFT* features);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSceneObserverMSFT(
+ XrSession session,
+ const XrSceneObserverCreateInfoMSFT* createInfo,
+ XrSceneObserverMSFT* sceneObserver);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySceneObserverMSFT(
+ XrSceneObserverMSFT sceneObserver);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSceneMSFT(
+ XrSceneObserverMSFT sceneObserver,
+ const XrSceneCreateInfoMSFT* createInfo,
+ XrSceneMSFT* scene);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySceneMSFT(
+ XrSceneMSFT scene);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrComputeNewSceneMSFT(
+ XrSceneObserverMSFT sceneObserver,
+ const XrNewSceneComputeInfoMSFT* computeInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSceneComputeStateMSFT(
+ XrSceneObserverMSFT sceneObserver,
+ XrSceneComputeStateMSFT* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSceneComponentsMSFT(
+ XrSceneMSFT scene,
+ const XrSceneComponentsGetInfoMSFT* getInfo,
+ XrSceneComponentsMSFT* components);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateSceneComponentsMSFT(
+ XrSceneMSFT scene,
+ const XrSceneComponentsLocateInfoMSFT* locateInfo,
+ XrSceneComponentLocationsMSFT* locations);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSceneMeshBuffersMSFT(
+ XrSceneMSFT scene,
+ const XrSceneMeshBuffersGetInfoMSFT* getInfo,
+ XrSceneMeshBuffersMSFT* buffers);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_scene_understanding_serialization is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_scene_understanding_serialization 1
+#define XR_MSFT_scene_understanding_serialization_SPEC_VERSION 2
+#define XR_MSFT_SCENE_UNDERSTANDING_SERIALIZATION_EXTENSION_NAME "XR_MSFT_scene_understanding_serialization"
+typedef struct XrSerializedSceneFragmentDataGetInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidMSFT sceneFragmentId;
+} XrSerializedSceneFragmentDataGetInfoMSFT;
+
+typedef struct XrDeserializeSceneFragmentMSFT {
+ uint32_t bufferSize;
+ const uint8_t* buffer;
+} XrDeserializeSceneFragmentMSFT;
+
+typedef struct XrSceneDeserializeInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t fragmentCount;
+ const XrDeserializeSceneFragmentMSFT* fragments;
+} XrSceneDeserializeInfoMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrDeserializeSceneMSFT)(XrSceneObserverMSFT sceneObserver, const XrSceneDeserializeInfoMSFT* deserializeInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSerializedSceneFragmentDataMSFT)(XrSceneMSFT scene, const XrSerializedSceneFragmentDataGetInfoMSFT* getInfo, uint32_t countInput, uint32_t* readOutput, uint8_t* buffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrDeserializeSceneMSFT(
+ XrSceneObserverMSFT sceneObserver,
+ const XrSceneDeserializeInfoMSFT* deserializeInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSerializedSceneFragmentDataMSFT(
+ XrSceneMSFT scene,
+ const XrSerializedSceneFragmentDataGetInfoMSFT* getInfo,
+ uint32_t countInput,
+ uint32_t* readOutput,
+ uint8_t* buffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_display_refresh_rate is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_display_refresh_rate 1
+#define XR_FB_display_refresh_rate_SPEC_VERSION 1
+#define XR_FB_DISPLAY_REFRESH_RATE_EXTENSION_NAME "XR_FB_display_refresh_rate"
+typedef struct XrEventDataDisplayRefreshRateChangedFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float fromDisplayRefreshRate;
+ float toDisplayRefreshRate;
+} XrEventDataDisplayRefreshRateChangedFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateDisplayRefreshRatesFB)(XrSession session, uint32_t displayRefreshRateCapacityInput, uint32_t* displayRefreshRateCountOutput, float* displayRefreshRates);
+typedef XrResult (XRAPI_PTR *PFN_xrGetDisplayRefreshRateFB)(XrSession session, float* displayRefreshRate);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestDisplayRefreshRateFB)(XrSession session, float displayRefreshRate);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateDisplayRefreshRatesFB(
+ XrSession session,
+ uint32_t displayRefreshRateCapacityInput,
+ uint32_t* displayRefreshRateCountOutput,
+ float* displayRefreshRates);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetDisplayRefreshRateFB(
+ XrSession session,
+ float* displayRefreshRate);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestDisplayRefreshRateFB(
+ XrSession session,
+ float displayRefreshRate);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_vive_cosmos_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_vive_cosmos_controller_interaction 1
+#define XR_HTC_vive_cosmos_controller_interaction_SPEC_VERSION 1
+#define XR_HTC_VIVE_COSMOS_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_HTC_vive_cosmos_controller_interaction"
+
+
+// XR_HTCX_vive_tracker_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTCX_vive_tracker_interaction 1
+#define XR_HTCX_vive_tracker_interaction_SPEC_VERSION 3
+#define XR_HTCX_VIVE_TRACKER_INTERACTION_EXTENSION_NAME "XR_HTCX_vive_tracker_interaction"
+typedef struct XrViveTrackerPathsHTCX {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPath persistentPath;
+ XrPath rolePath;
+} XrViveTrackerPathsHTCX;
+
+typedef struct XrEventDataViveTrackerConnectedHTCX {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViveTrackerPathsHTCX* paths;
+} XrEventDataViveTrackerConnectedHTCX;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateViveTrackerPathsHTCX)(XrInstance instance, uint32_t pathCapacityInput, uint32_t* pathCountOutput, XrViveTrackerPathsHTCX* paths);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateViveTrackerPathsHTCX(
+ XrInstance instance,
+ uint32_t pathCapacityInput,
+ uint32_t* pathCountOutput,
+ XrViveTrackerPathsHTCX* paths);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_facial_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_facial_tracking 1
+
+#define XR_FACIAL_EXPRESSION_EYE_COUNT_HTC 14
+
+
+#define XR_FACIAL_EXPRESSION_LIP_COUNT_HTC 37
+
+XR_DEFINE_HANDLE(XrFacialTrackerHTC)
+#define XR_HTC_facial_tracking_SPEC_VERSION 3
+#define XR_HTC_FACIAL_TRACKING_EXTENSION_NAME "XR_HTC_facial_tracking"
+
+typedef enum XrEyeExpressionHTC {
+ XR_EYE_EXPRESSION_LEFT_BLINK_HTC = 0,
+ XR_EYE_EXPRESSION_LEFT_WIDE_HTC = 1,
+ XR_EYE_EXPRESSION_RIGHT_BLINK_HTC = 2,
+ XR_EYE_EXPRESSION_RIGHT_WIDE_HTC = 3,
+ XR_EYE_EXPRESSION_LEFT_SQUEEZE_HTC = 4,
+ XR_EYE_EXPRESSION_RIGHT_SQUEEZE_HTC = 5,
+ XR_EYE_EXPRESSION_LEFT_DOWN_HTC = 6,
+ XR_EYE_EXPRESSION_RIGHT_DOWN_HTC = 7,
+ XR_EYE_EXPRESSION_LEFT_OUT_HTC = 8,
+ XR_EYE_EXPRESSION_RIGHT_IN_HTC = 9,
+ XR_EYE_EXPRESSION_LEFT_IN_HTC = 10,
+ XR_EYE_EXPRESSION_RIGHT_OUT_HTC = 11,
+ XR_EYE_EXPRESSION_LEFT_UP_HTC = 12,
+ XR_EYE_EXPRESSION_RIGHT_UP_HTC = 13,
+ XR_EYE_EXPRESSION_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrEyeExpressionHTC;
+
+typedef enum XrLipExpressionHTC {
+ XR_LIP_EXPRESSION_JAW_RIGHT_HTC = 0,
+ XR_LIP_EXPRESSION_JAW_LEFT_HTC = 1,
+ XR_LIP_EXPRESSION_JAW_FORWARD_HTC = 2,
+ XR_LIP_EXPRESSION_JAW_OPEN_HTC = 3,
+ XR_LIP_EXPRESSION_MOUTH_APE_SHAPE_HTC = 4,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_RIGHT_HTC = 5,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_LEFT_HTC = 6,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_RIGHT_HTC = 7,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_LEFT_HTC = 8,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_OVERTURN_HTC = 9,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_OVERTURN_HTC = 10,
+ XR_LIP_EXPRESSION_MOUTH_POUT_HTC = 11,
+ XR_LIP_EXPRESSION_MOUTH_RAISER_RIGHT_HTC = 12,
+ XR_LIP_EXPRESSION_MOUTH_RAISER_LEFT_HTC = 13,
+ XR_LIP_EXPRESSION_MOUTH_STRETCHER_RIGHT_HTC = 14,
+ XR_LIP_EXPRESSION_MOUTH_STRETCHER_LEFT_HTC = 15,
+ XR_LIP_EXPRESSION_CHEEK_PUFF_RIGHT_HTC = 16,
+ XR_LIP_EXPRESSION_CHEEK_PUFF_LEFT_HTC = 17,
+ XR_LIP_EXPRESSION_CHEEK_SUCK_HTC = 18,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_UPRIGHT_HTC = 19,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_UPLEFT_HTC = 20,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_DOWNRIGHT_HTC = 21,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_DOWNLEFT_HTC = 22,
+ XR_LIP_EXPRESSION_MOUTH_UPPER_INSIDE_HTC = 23,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_INSIDE_HTC = 24,
+ XR_LIP_EXPRESSION_MOUTH_LOWER_OVERLAY_HTC = 25,
+ XR_LIP_EXPRESSION_TONGUE_LONGSTEP1_HTC = 26,
+ XR_LIP_EXPRESSION_TONGUE_LEFT_HTC = 27,
+ XR_LIP_EXPRESSION_TONGUE_RIGHT_HTC = 28,
+ XR_LIP_EXPRESSION_TONGUE_UP_HTC = 29,
+ XR_LIP_EXPRESSION_TONGUE_DOWN_HTC = 30,
+ XR_LIP_EXPRESSION_TONGUE_ROLL_HTC = 31,
+ XR_LIP_EXPRESSION_TONGUE_LONGSTEP2_HTC = 32,
+ XR_LIP_EXPRESSION_TONGUE_UPRIGHT_MORPH_HTC = 33,
+ XR_LIP_EXPRESSION_TONGUE_UPLEFT_MORPH_HTC = 34,
+ XR_LIP_EXPRESSION_TONGUE_DOWNRIGHT_MORPH_HTC = 35,
+ XR_LIP_EXPRESSION_TONGUE_DOWNLEFT_MORPH_HTC = 36,
+ XR_LIP_EXPRESSION_MOUTH_SMILE_RIGHT_HTC = XR_LIP_EXPRESSION_MOUTH_RAISER_RIGHT_HTC,
+ XR_LIP_EXPRESSION_MOUTH_SMILE_LEFT_HTC = XR_LIP_EXPRESSION_MOUTH_RAISER_LEFT_HTC,
+ XR_LIP_EXPRESSION_MOUTH_SAD_RIGHT_HTC = XR_LIP_EXPRESSION_MOUTH_STRETCHER_RIGHT_HTC,
+ XR_LIP_EXPRESSION_MOUTH_SAD_LEFT_HTC = XR_LIP_EXPRESSION_MOUTH_STRETCHER_LEFT_HTC,
+ XR_LIP_EXPRESSION_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrLipExpressionHTC;
+
+typedef enum XrFacialTrackingTypeHTC {
+ XR_FACIAL_TRACKING_TYPE_EYE_DEFAULT_HTC = 1,
+ XR_FACIAL_TRACKING_TYPE_LIP_DEFAULT_HTC = 2,
+ XR_FACIAL_TRACKING_TYPE_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrFacialTrackingTypeHTC;
+// XrSystemFacialTrackingPropertiesHTC extends XrSystemProperties
+typedef struct XrSystemFacialTrackingPropertiesHTC {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportEyeFacialTracking;
+ XrBool32 supportLipFacialTracking;
+} XrSystemFacialTrackingPropertiesHTC;
+
+typedef struct XrFacialExpressionsHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+ XrTime sampleTime;
+ uint32_t expressionCount;
+ float* expressionWeightings;
+} XrFacialExpressionsHTC;
+
+typedef struct XrFacialTrackerCreateInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFacialTrackingTypeHTC facialTrackingType;
+} XrFacialTrackerCreateInfoHTC;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFacialTrackerHTC)(XrSession session, const XrFacialTrackerCreateInfoHTC* createInfo, XrFacialTrackerHTC* facialTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFacialTrackerHTC)(XrFacialTrackerHTC facialTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFacialExpressionsHTC)(XrFacialTrackerHTC facialTracker, XrFacialExpressionsHTC* facialExpressions);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFacialTrackerHTC(
+ XrSession session,
+ const XrFacialTrackerCreateInfoHTC* createInfo,
+ XrFacialTrackerHTC* facialTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFacialTrackerHTC(
+ XrFacialTrackerHTC facialTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFacialExpressionsHTC(
+ XrFacialTrackerHTC facialTracker,
+ XrFacialExpressionsHTC* facialExpressions);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_vive_focus3_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_vive_focus3_controller_interaction 1
+#define XR_HTC_vive_focus3_controller_interaction_SPEC_VERSION 2
+#define XR_HTC_VIVE_FOCUS3_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_HTC_vive_focus3_controller_interaction"
+
+
+// XR_HTC_hand_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_hand_interaction 1
+#define XR_HTC_hand_interaction_SPEC_VERSION 1
+#define XR_HTC_HAND_INTERACTION_EXTENSION_NAME "XR_HTC_hand_interaction"
+
+
+// XR_HTC_vive_wrist_tracker_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_vive_wrist_tracker_interaction 1
+#define XR_HTC_vive_wrist_tracker_interaction_SPEC_VERSION 1
+#define XR_HTC_VIVE_WRIST_TRACKER_INTERACTION_EXTENSION_NAME "XR_HTC_vive_wrist_tracker_interaction"
+
+
+// XR_FB_color_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_color_space 1
+#define XR_FB_color_space_SPEC_VERSION 3
+#define XR_FB_COLOR_SPACE_EXTENSION_NAME "XR_FB_color_space"
+
+typedef enum XrColorSpaceFB {
+ XR_COLOR_SPACE_UNMANAGED_FB = 0,
+ XR_COLOR_SPACE_REC2020_FB = 1,
+ XR_COLOR_SPACE_REC709_FB = 2,
+ XR_COLOR_SPACE_RIFT_CV1_FB = 3,
+ XR_COLOR_SPACE_RIFT_S_FB = 4,
+ XR_COLOR_SPACE_QUEST_FB = 5,
+ XR_COLOR_SPACE_P3_FB = 6,
+ XR_COLOR_SPACE_ADOBE_RGB_FB = 7,
+ XR_COLOR_SPACE_MAX_ENUM_FB = 0x7FFFFFFF
+} XrColorSpaceFB;
+// XrSystemColorSpacePropertiesFB extends XrSystemProperties
+typedef struct XrSystemColorSpacePropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrColorSpaceFB colorSpace;
+} XrSystemColorSpacePropertiesFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateColorSpacesFB)(XrSession session, uint32_t colorSpaceCapacityInput, uint32_t* colorSpaceCountOutput, XrColorSpaceFB* colorSpaces);
+typedef XrResult (XRAPI_PTR *PFN_xrSetColorSpaceFB)(XrSession session, const XrColorSpaceFB colorSpace);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateColorSpacesFB(
+ XrSession session,
+ uint32_t colorSpaceCapacityInput,
+ uint32_t* colorSpaceCountOutput,
+ XrColorSpaceFB* colorSpaces);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetColorSpaceFB(
+ XrSession session,
+ const XrColorSpaceFB colorSpace);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_hand_tracking_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_hand_tracking_mesh 1
+#define XR_FB_hand_tracking_mesh_SPEC_VERSION 3
+#define XR_FB_HAND_TRACKING_MESH_EXTENSION_NAME "XR_FB_hand_tracking_mesh"
+typedef struct XrVector4sFB {
+ int16_t x;
+ int16_t y;
+ int16_t z;
+ int16_t w;
+} XrVector4sFB;
+
+typedef struct XrHandTrackingMeshFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t jointCapacityInput;
+ uint32_t jointCountOutput;
+ XrPosef* jointBindPoses;
+ float* jointRadii;
+ XrHandJointEXT* jointParents;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertexPositions;
+ XrVector3f* vertexNormals;
+ XrVector2f* vertexUVs;
+ XrVector4sFB* vertexBlendIndices;
+ XrVector4f* vertexBlendWeights;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ int16_t* indices;
+} XrHandTrackingMeshFB;
+
+// XrHandTrackingScaleFB extends XrHandJointLocationsEXT
+typedef struct XrHandTrackingScaleFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ float sensorOutput;
+ float currentOutput;
+ XrBool32 overrideHandScale;
+ float overrideValueInput;
+} XrHandTrackingScaleFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetHandMeshFB)(XrHandTrackerEXT handTracker, XrHandTrackingMeshFB* mesh);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetHandMeshFB(
+ XrHandTrackerEXT handTracker,
+ XrHandTrackingMeshFB* mesh);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_hand_tracking_aim is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_hand_tracking_aim 1
+#define XR_FB_hand_tracking_aim_SPEC_VERSION 2
+#define XR_FB_HAND_TRACKING_AIM_EXTENSION_NAME "XR_FB_hand_tracking_aim"
+typedef XrFlags64 XrHandTrackingAimFlagsFB;
+
+// Flag bits for XrHandTrackingAimFlagsFB
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_COMPUTED_BIT_FB = 0x00000001;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_VALID_BIT_FB = 0x00000002;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_INDEX_PINCHING_BIT_FB = 0x00000004;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_MIDDLE_PINCHING_BIT_FB = 0x00000008;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_RING_PINCHING_BIT_FB = 0x00000010;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_LITTLE_PINCHING_BIT_FB = 0x00000020;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_SYSTEM_GESTURE_BIT_FB = 0x00000040;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_DOMINANT_HAND_BIT_FB = 0x00000080;
+static const XrHandTrackingAimFlagsFB XR_HAND_TRACKING_AIM_MENU_PRESSED_BIT_FB = 0x00000100;
+
+// XrHandTrackingAimStateFB extends XrHandJointLocationsEXT
+typedef struct XrHandTrackingAimStateFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrHandTrackingAimFlagsFB status;
+ XrPosef aimPose;
+ float pinchStrengthIndex;
+ float pinchStrengthMiddle;
+ float pinchStrengthRing;
+ float pinchStrengthLittle;
+} XrHandTrackingAimStateFB;
+
+
+
+// XR_FB_hand_tracking_capsules is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_hand_tracking_capsules 1
+#define XR_HAND_TRACKING_CAPSULE_POINT_COUNT_FB 2
+#define XR_HAND_TRACKING_CAPSULE_COUNT_FB 19
+#define XR_FB_hand_tracking_capsules_SPEC_VERSION 3
+#define XR_FB_HAND_TRACKING_CAPSULES_EXTENSION_NAME "XR_FB_hand_tracking_capsules"
+#define XR_FB_HAND_TRACKING_CAPSULE_POINT_COUNT XR_HAND_TRACKING_CAPSULE_POINT_COUNT_FB
+#define XR_FB_HAND_TRACKING_CAPSULE_COUNT XR_HAND_TRACKING_CAPSULE_COUNT_FB
+typedef struct XrHandCapsuleFB {
+ XrVector3f points[XR_HAND_TRACKING_CAPSULE_POINT_COUNT_FB];
+ float radius;
+ XrHandJointEXT joint;
+} XrHandCapsuleFB;
+
+// XrHandTrackingCapsulesStateFB extends XrHandJointLocationsEXT
+typedef struct XrHandTrackingCapsulesStateFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrHandCapsuleFB capsules[XR_HAND_TRACKING_CAPSULE_COUNT_FB];
+} XrHandTrackingCapsulesStateFB;
+
+
+
+// XR_FB_spatial_entity is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity 1
+XR_DEFINE_ATOM(XrAsyncRequestIdFB)
+#define XR_FB_spatial_entity_SPEC_VERSION 3
+#define XR_FB_SPATIAL_ENTITY_EXTENSION_NAME "XR_FB_spatial_entity"
+
+typedef enum XrSpaceComponentTypeFB {
+ XR_SPACE_COMPONENT_TYPE_LOCATABLE_FB = 0,
+ XR_SPACE_COMPONENT_TYPE_STORABLE_FB = 1,
+ XR_SPACE_COMPONENT_TYPE_SHARABLE_FB = 2,
+ XR_SPACE_COMPONENT_TYPE_BOUNDED_2D_FB = 3,
+ XR_SPACE_COMPONENT_TYPE_BOUNDED_3D_FB = 4,
+ XR_SPACE_COMPONENT_TYPE_SEMANTIC_LABELS_FB = 5,
+ XR_SPACE_COMPONENT_TYPE_ROOM_LAYOUT_FB = 6,
+ XR_SPACE_COMPONENT_TYPE_SPACE_CONTAINER_FB = 7,
+ XR_SPACE_COMPONENT_TYPE_TRIANGLE_MESH_META = 1000269000,
+ XR_SPACE_COMPONENT_TYPE_ROOM_MESH_META = 1000553000,
+ XR_SPACE_COMPONENT_TYPE_MAX_ENUM_FB = 0x7FFFFFFF
+} XrSpaceComponentTypeFB;
+// XrSystemSpatialEntityPropertiesFB extends XrSystemProperties
+typedef struct XrSystemSpatialEntityPropertiesFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialEntity;
+} XrSystemSpatialEntityPropertiesFB;
+
+typedef struct XrSpatialAnchorCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrPosef poseInSpace;
+ XrTime time;
+} XrSpatialAnchorCreateInfoFB;
+
+typedef struct XrSpaceComponentStatusSetInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceComponentTypeFB componentType;
+ XrBool32 enabled;
+ XrDuration timeout;
+} XrSpaceComponentStatusSetInfoFB;
+
+typedef struct XrSpaceComponentStatusFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+ XrBool32 changePending;
+} XrSpaceComponentStatusFB;
+
+typedef XrUuid XrUuidEXT;
+
+typedef struct XrEventDataSpatialAnchorCreateCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+ XrSpace space;
+ XrUuidEXT uuid;
+} XrEventDataSpatialAnchorCreateCompleteFB;
+
+typedef struct XrEventDataSpaceSetStatusCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+ XrSpace space;
+ XrUuidEXT uuid;
+ XrSpaceComponentTypeFB componentType;
+ XrBool32 enabled;
+} XrEventDataSpaceSetStatusCompleteFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorFB)(XrSession session, const XrSpatialAnchorCreateInfoFB* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceUuidFB)(XrSpace space, XrUuidEXT* uuid);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpaceSupportedComponentsFB)(XrSpace space, uint32_t componentTypeCapacityInput, uint32_t* componentTypeCountOutput, XrSpaceComponentTypeFB* componentTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrSetSpaceComponentStatusFB)(XrSpace space, const XrSpaceComponentStatusSetInfoFB* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceComponentStatusFB)(XrSpace space, XrSpaceComponentTypeFB componentType, XrSpaceComponentStatusFB* status);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorFB(
+ XrSession session,
+ const XrSpatialAnchorCreateInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceUuidFB(
+ XrSpace space,
+ XrUuidEXT* uuid);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpaceSupportedComponentsFB(
+ XrSpace space,
+ uint32_t componentTypeCapacityInput,
+ uint32_t* componentTypeCountOutput,
+ XrSpaceComponentTypeFB* componentTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetSpaceComponentStatusFB(
+ XrSpace space,
+ const XrSpaceComponentStatusSetInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceComponentStatusFB(
+ XrSpace space,
+ XrSpaceComponentTypeFB componentType,
+ XrSpaceComponentStatusFB* status);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_foveation is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_foveation 1
+XR_DEFINE_HANDLE(XrFoveationProfileFB)
+#define XR_FB_foveation_SPEC_VERSION 1
+#define XR_FB_FOVEATION_EXTENSION_NAME "XR_FB_foveation"
+typedef XrFlags64 XrSwapchainCreateFoveationFlagsFB;
+
+// Flag bits for XrSwapchainCreateFoveationFlagsFB
+static const XrSwapchainCreateFoveationFlagsFB XR_SWAPCHAIN_CREATE_FOVEATION_SCALED_BIN_BIT_FB = 0x00000001;
+static const XrSwapchainCreateFoveationFlagsFB XR_SWAPCHAIN_CREATE_FOVEATION_FRAGMENT_DENSITY_MAP_BIT_FB = 0x00000002;
+
+typedef XrFlags64 XrSwapchainStateFoveationFlagsFB;
+
+// Flag bits for XrSwapchainStateFoveationFlagsFB
+
+typedef struct XrFoveationProfileCreateInfoFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrFoveationProfileCreateInfoFB;
+
+// XrSwapchainCreateInfoFoveationFB extends XrSwapchainCreateInfo
+typedef struct XrSwapchainCreateInfoFoveationFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSwapchainCreateFoveationFlagsFB flags;
+} XrSwapchainCreateInfoFoveationFB;
+
+typedef struct XrSwapchainStateFoveationFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSwapchainStateFoveationFlagsFB flags;
+ XrFoveationProfileFB profile;
+} XrSwapchainStateFoveationFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFoveationProfileFB)(XrSession session, const XrFoveationProfileCreateInfoFB* createInfo, XrFoveationProfileFB* profile);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFoveationProfileFB)(XrFoveationProfileFB profile);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFoveationProfileFB(
+ XrSession session,
+ const XrFoveationProfileCreateInfoFB* createInfo,
+ XrFoveationProfileFB* profile);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFoveationProfileFB(
+ XrFoveationProfileFB profile);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_foveation_configuration is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_foveation_configuration 1
+#define XR_FB_foveation_configuration_SPEC_VERSION 1
+#define XR_FB_FOVEATION_CONFIGURATION_EXTENSION_NAME "XR_FB_foveation_configuration"
+
+typedef enum XrFoveationLevelFB {
+ XR_FOVEATION_LEVEL_NONE_FB = 0,
+ XR_FOVEATION_LEVEL_LOW_FB = 1,
+ XR_FOVEATION_LEVEL_MEDIUM_FB = 2,
+ XR_FOVEATION_LEVEL_HIGH_FB = 3,
+ XR_FOVEATION_LEVEL_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFoveationLevelFB;
+
+typedef enum XrFoveationDynamicFB {
+ XR_FOVEATION_DYNAMIC_DISABLED_FB = 0,
+ XR_FOVEATION_DYNAMIC_LEVEL_ENABLED_FB = 1,
+ XR_FOVEATION_DYNAMIC_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFoveationDynamicFB;
+// XrFoveationLevelProfileCreateInfoFB extends XrFoveationProfileCreateInfoFB
+typedef struct XrFoveationLevelProfileCreateInfoFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrFoveationLevelFB level;
+ float verticalOffset;
+ XrFoveationDynamicFB dynamic;
+} XrFoveationLevelProfileCreateInfoFB;
+
+
+
+// XR_FB_keyboard_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_keyboard_tracking 1
+#define XR_MAX_KEYBOARD_TRACKING_NAME_SIZE_FB 128
+#define XR_FB_keyboard_tracking_SPEC_VERSION 1
+#define XR_FB_KEYBOARD_TRACKING_EXTENSION_NAME "XR_FB_keyboard_tracking"
+typedef XrFlags64 XrKeyboardTrackingFlagsFB;
+
+// Flag bits for XrKeyboardTrackingFlagsFB
+static const XrKeyboardTrackingFlagsFB XR_KEYBOARD_TRACKING_EXISTS_BIT_FB = 0x00000001;
+static const XrKeyboardTrackingFlagsFB XR_KEYBOARD_TRACKING_LOCAL_BIT_FB = 0x00000002;
+static const XrKeyboardTrackingFlagsFB XR_KEYBOARD_TRACKING_REMOTE_BIT_FB = 0x00000004;
+static const XrKeyboardTrackingFlagsFB XR_KEYBOARD_TRACKING_CONNECTED_BIT_FB = 0x00000008;
+
+typedef XrFlags64 XrKeyboardTrackingQueryFlagsFB;
+
+// Flag bits for XrKeyboardTrackingQueryFlagsFB
+static const XrKeyboardTrackingQueryFlagsFB XR_KEYBOARD_TRACKING_QUERY_LOCAL_BIT_FB = 0x00000002;
+static const XrKeyboardTrackingQueryFlagsFB XR_KEYBOARD_TRACKING_QUERY_REMOTE_BIT_FB = 0x00000004;
+
+// XrSystemKeyboardTrackingPropertiesFB extends XrSystemProperties
+typedef struct XrSystemKeyboardTrackingPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsKeyboardTracking;
+} XrSystemKeyboardTrackingPropertiesFB;
+
+typedef struct XrKeyboardTrackingDescriptionFB {
+ uint64_t trackedKeyboardId;
+ XrVector3f size;
+ XrKeyboardTrackingFlagsFB flags;
+ char name[XR_MAX_KEYBOARD_TRACKING_NAME_SIZE_FB];
+} XrKeyboardTrackingDescriptionFB;
+
+typedef struct XrKeyboardSpaceCreateInfoFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint64_t trackedKeyboardId;
+} XrKeyboardSpaceCreateInfoFB;
+
+typedef struct XrKeyboardTrackingQueryFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrKeyboardTrackingQueryFlagsFB flags;
+} XrKeyboardTrackingQueryFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySystemTrackedKeyboardFB)(XrSession session, const XrKeyboardTrackingQueryFB* queryInfo, XrKeyboardTrackingDescriptionFB* keyboard);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateKeyboardSpaceFB)(XrSession session, const XrKeyboardSpaceCreateInfoFB* createInfo, XrSpace* keyboardSpace);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySystemTrackedKeyboardFB(
+ XrSession session,
+ const XrKeyboardTrackingQueryFB* queryInfo,
+ XrKeyboardTrackingDescriptionFB* keyboard);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateKeyboardSpaceFB(
+ XrSession session,
+ const XrKeyboardSpaceCreateInfoFB* createInfo,
+ XrSpace* keyboardSpace);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_triangle_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_triangle_mesh 1
+XR_DEFINE_HANDLE(XrTriangleMeshFB)
+#define XR_FB_triangle_mesh_SPEC_VERSION 2
+#define XR_FB_TRIANGLE_MESH_EXTENSION_NAME "XR_FB_triangle_mesh"
+
+typedef enum XrWindingOrderFB {
+ XR_WINDING_ORDER_UNKNOWN_FB = 0,
+ XR_WINDING_ORDER_CW_FB = 1,
+ XR_WINDING_ORDER_CCW_FB = 2,
+ XR_WINDING_ORDER_MAX_ENUM_FB = 0x7FFFFFFF
+} XrWindingOrderFB;
+typedef XrFlags64 XrTriangleMeshFlagsFB;
+
+// Flag bits for XrTriangleMeshFlagsFB
+static const XrTriangleMeshFlagsFB XR_TRIANGLE_MESH_MUTABLE_BIT_FB = 0x00000001;
+
+typedef struct XrTriangleMeshCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTriangleMeshFlagsFB flags;
+ XrWindingOrderFB windingOrder;
+ uint32_t vertexCount;
+ const XrVector3f* vertexBuffer;
+ uint32_t triangleCount;
+ const uint32_t* indexBuffer;
+} XrTriangleMeshCreateInfoFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateTriangleMeshFB)(XrSession session, const XrTriangleMeshCreateInfoFB* createInfo, XrTriangleMeshFB* outTriangleMesh);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyTriangleMeshFB)(XrTriangleMeshFB mesh);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshGetVertexBufferFB)(XrTriangleMeshFB mesh, XrVector3f** outVertexBuffer);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshGetIndexBufferFB)(XrTriangleMeshFB mesh, uint32_t** outIndexBuffer);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshBeginUpdateFB)(XrTriangleMeshFB mesh);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshEndUpdateFB)(XrTriangleMeshFB mesh, uint32_t vertexCount, uint32_t triangleCount);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshBeginVertexBufferUpdateFB)(XrTriangleMeshFB mesh, uint32_t* outVertexCount);
+typedef XrResult (XRAPI_PTR *PFN_xrTriangleMeshEndVertexBufferUpdateFB)(XrTriangleMeshFB mesh);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateTriangleMeshFB(
+ XrSession session,
+ const XrTriangleMeshCreateInfoFB* createInfo,
+ XrTriangleMeshFB* outTriangleMesh);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyTriangleMeshFB(
+ XrTriangleMeshFB mesh);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshGetVertexBufferFB(
+ XrTriangleMeshFB mesh,
+ XrVector3f** outVertexBuffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshGetIndexBufferFB(
+ XrTriangleMeshFB mesh,
+ uint32_t** outIndexBuffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshBeginUpdateFB(
+ XrTriangleMeshFB mesh);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshEndUpdateFB(
+ XrTriangleMeshFB mesh,
+ uint32_t vertexCount,
+ uint32_t triangleCount);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshBeginVertexBufferUpdateFB(
+ XrTriangleMeshFB mesh,
+ uint32_t* outVertexCount);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTriangleMeshEndVertexBufferUpdateFB(
+ XrTriangleMeshFB mesh);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_passthrough is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_passthrough 1
+XR_DEFINE_HANDLE(XrPassthroughFB)
+XR_DEFINE_HANDLE(XrPassthroughLayerFB)
+XR_DEFINE_HANDLE(XrGeometryInstanceFB)
+#define XR_PASSTHROUGH_COLOR_MAP_MONO_SIZE_FB 256
+#define XR_FB_passthrough_SPEC_VERSION 5
+#define XR_FB_PASSTHROUGH_EXTENSION_NAME "XR_FB_passthrough"
+
+typedef enum XrPassthroughLayerPurposeFB {
+ XR_PASSTHROUGH_LAYER_PURPOSE_RECONSTRUCTION_FB = 0,
+ XR_PASSTHROUGH_LAYER_PURPOSE_PROJECTED_FB = 1,
+ XR_PASSTHROUGH_LAYER_PURPOSE_TRACKED_KEYBOARD_HANDS_FB = 1000203001,
+ XR_PASSTHROUGH_LAYER_PURPOSE_TRACKED_KEYBOARD_MASKED_HANDS_FB = 1000203002,
+ XR_PASSTHROUGH_LAYER_PURPOSE_MAX_ENUM_FB = 0x7FFFFFFF
+} XrPassthroughLayerPurposeFB;
+typedef XrFlags64 XrPassthroughCapabilityFlagsFB;
+
+// Flag bits for XrPassthroughCapabilityFlagsFB
+static const XrPassthroughCapabilityFlagsFB XR_PASSTHROUGH_CAPABILITY_BIT_FB = 0x00000001;
+static const XrPassthroughCapabilityFlagsFB XR_PASSTHROUGH_CAPABILITY_COLOR_BIT_FB = 0x00000002;
+static const XrPassthroughCapabilityFlagsFB XR_PASSTHROUGH_CAPABILITY_LAYER_DEPTH_BIT_FB = 0x00000004;
+
+typedef XrFlags64 XrPassthroughFlagsFB;
+
+// Flag bits for XrPassthroughFlagsFB
+static const XrPassthroughFlagsFB XR_PASSTHROUGH_IS_RUNNING_AT_CREATION_BIT_FB = 0x00000001;
+static const XrPassthroughFlagsFB XR_PASSTHROUGH_LAYER_DEPTH_BIT_FB = 0x00000002;
+
+typedef XrFlags64 XrPassthroughStateChangedFlagsFB;
+
+// Flag bits for XrPassthroughStateChangedFlagsFB
+static const XrPassthroughStateChangedFlagsFB XR_PASSTHROUGH_STATE_CHANGED_REINIT_REQUIRED_BIT_FB = 0x00000001;
+static const XrPassthroughStateChangedFlagsFB XR_PASSTHROUGH_STATE_CHANGED_NON_RECOVERABLE_ERROR_BIT_FB = 0x00000002;
+static const XrPassthroughStateChangedFlagsFB XR_PASSTHROUGH_STATE_CHANGED_RECOVERABLE_ERROR_BIT_FB = 0x00000004;
+static const XrPassthroughStateChangedFlagsFB XR_PASSTHROUGH_STATE_CHANGED_RESTORED_ERROR_BIT_FB = 0x00000008;
+
+// XrSystemPassthroughPropertiesFB extends XrSystemProperties
+typedef struct XrSystemPassthroughPropertiesFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 supportsPassthrough;
+} XrSystemPassthroughPropertiesFB;
+
+// XrSystemPassthroughProperties2FB extends XrSystemProperties
+typedef struct XrSystemPassthroughProperties2FB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughCapabilityFlagsFB capabilities;
+} XrSystemPassthroughProperties2FB;
+
+typedef struct XrPassthroughCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughFlagsFB flags;
+} XrPassthroughCreateInfoFB;
+
+typedef struct XrPassthroughLayerCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughFB passthrough;
+ XrPassthroughFlagsFB flags;
+ XrPassthroughLayerPurposeFB purpose;
+} XrPassthroughLayerCreateInfoFB;
+
+typedef struct XrCompositionLayerPassthroughFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags flags;
+ XrSpace space;
+ XrPassthroughLayerFB layerHandle;
+} XrCompositionLayerPassthroughFB;
+
+typedef struct XrGeometryInstanceCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughLayerFB layer;
+ XrTriangleMeshFB mesh;
+ XrSpace baseSpace;
+ XrPosef pose;
+ XrVector3f scale;
+} XrGeometryInstanceCreateInfoFB;
+
+typedef struct XrGeometryInstanceTransformFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrPosef pose;
+ XrVector3f scale;
+} XrGeometryInstanceTransformFB;
+
+typedef struct XrPassthroughStyleFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float textureOpacityFactor;
+ XrColor4f edgeColor;
+} XrPassthroughStyleFB;
+
+// XrPassthroughColorMapMonoToRgbaFB extends XrPassthroughStyleFB
+typedef struct XrPassthroughColorMapMonoToRgbaFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrColor4f textureColorMap[XR_PASSTHROUGH_COLOR_MAP_MONO_SIZE_FB];
+} XrPassthroughColorMapMonoToRgbaFB;
+
+// XrPassthroughColorMapMonoToMonoFB extends XrPassthroughStyleFB
+typedef struct XrPassthroughColorMapMonoToMonoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint8_t textureColorMap[XR_PASSTHROUGH_COLOR_MAP_MONO_SIZE_FB];
+} XrPassthroughColorMapMonoToMonoFB;
+
+// XrPassthroughBrightnessContrastSaturationFB extends XrPassthroughStyleFB
+typedef struct XrPassthroughBrightnessContrastSaturationFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float brightness;
+ float contrast;
+ float saturation;
+} XrPassthroughBrightnessContrastSaturationFB;
+
+typedef struct XrEventDataPassthroughStateChangedFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughStateChangedFlagsFB flags;
+} XrEventDataPassthroughStateChangedFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePassthroughFB)(XrSession session, const XrPassthroughCreateInfoFB* createInfo, XrPassthroughFB* outPassthrough);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPassthroughFB)(XrPassthroughFB passthrough);
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughStartFB)(XrPassthroughFB passthrough);
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughPauseFB)(XrPassthroughFB passthrough);
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePassthroughLayerFB)(XrSession session, const XrPassthroughLayerCreateInfoFB* createInfo, XrPassthroughLayerFB* outLayer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPassthroughLayerFB)(XrPassthroughLayerFB layer);
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughLayerPauseFB)(XrPassthroughLayerFB layer);
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughLayerResumeFB)(XrPassthroughLayerFB layer);
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughLayerSetStyleFB)(XrPassthroughLayerFB layer, const XrPassthroughStyleFB* style);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateGeometryInstanceFB)(XrSession session, const XrGeometryInstanceCreateInfoFB* createInfo, XrGeometryInstanceFB* outGeometryInstance);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyGeometryInstanceFB)(XrGeometryInstanceFB instance);
+typedef XrResult (XRAPI_PTR *PFN_xrGeometryInstanceSetTransformFB)(XrGeometryInstanceFB instance, const XrGeometryInstanceTransformFB* transformation);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePassthroughFB(
+ XrSession session,
+ const XrPassthroughCreateInfoFB* createInfo,
+ XrPassthroughFB* outPassthrough);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPassthroughFB(
+ XrPassthroughFB passthrough);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughStartFB(
+ XrPassthroughFB passthrough);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughPauseFB(
+ XrPassthroughFB passthrough);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePassthroughLayerFB(
+ XrSession session,
+ const XrPassthroughLayerCreateInfoFB* createInfo,
+ XrPassthroughLayerFB* outLayer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPassthroughLayerFB(
+ XrPassthroughLayerFB layer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughLayerPauseFB(
+ XrPassthroughLayerFB layer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughLayerResumeFB(
+ XrPassthroughLayerFB layer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughLayerSetStyleFB(
+ XrPassthroughLayerFB layer,
+ const XrPassthroughStyleFB* style);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateGeometryInstanceFB(
+ XrSession session,
+ const XrGeometryInstanceCreateInfoFB* createInfo,
+ XrGeometryInstanceFB* outGeometryInstance);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyGeometryInstanceFB(
+ XrGeometryInstanceFB instance);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGeometryInstanceSetTransformFB(
+ XrGeometryInstanceFB instance,
+ const XrGeometryInstanceTransformFB* transformation);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_render_model is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_render_model 1
+
+#define XR_NULL_RENDER_MODEL_KEY_FB 0
+
+XR_DEFINE_ATOM(XrRenderModelKeyFB)
+#define XR_MAX_RENDER_MODEL_NAME_SIZE_FB 64
+#define XR_FB_render_model_SPEC_VERSION 4
+#define XR_FB_RENDER_MODEL_EXTENSION_NAME "XR_FB_render_model"
+typedef XrFlags64 XrRenderModelFlagsFB;
+
+// Flag bits for XrRenderModelFlagsFB
+static const XrRenderModelFlagsFB XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_1_BIT_FB = 0x00000001;
+static const XrRenderModelFlagsFB XR_RENDER_MODEL_SUPPORTS_GLTF_2_0_SUBSET_2_BIT_FB = 0x00000002;
+
+typedef struct XrRenderModelPathInfoFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPath path;
+} XrRenderModelPathInfoFB;
+
+typedef struct XrRenderModelPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vendorId;
+ char modelName[XR_MAX_RENDER_MODEL_NAME_SIZE_FB];
+ XrRenderModelKeyFB modelKey;
+ uint32_t modelVersion;
+ XrRenderModelFlagsFB flags;
+} XrRenderModelPropertiesFB;
+
+typedef struct XrRenderModelBufferFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t bufferCapacityInput;
+ uint32_t bufferCountOutput;
+ uint8_t* buffer;
+} XrRenderModelBufferFB;
+
+typedef struct XrRenderModelLoadInfoFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrRenderModelKeyFB modelKey;
+} XrRenderModelLoadInfoFB;
+
+// XrSystemRenderModelPropertiesFB extends XrSystemProperties
+typedef struct XrSystemRenderModelPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsRenderModelLoading;
+} XrSystemRenderModelPropertiesFB;
+
+// XrRenderModelCapabilitiesRequestFB extends XrRenderModelPropertiesFB
+typedef struct XrRenderModelCapabilitiesRequestFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrRenderModelFlagsFB flags;
+} XrRenderModelCapabilitiesRequestFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateRenderModelPathsFB)(XrSession session, uint32_t pathCapacityInput, uint32_t* pathCountOutput, XrRenderModelPathInfoFB* paths);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelPropertiesFB)(XrSession session, XrPath path, XrRenderModelPropertiesFB* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrLoadRenderModelFB)(XrSession session, const XrRenderModelLoadInfoFB* info, XrRenderModelBufferFB* buffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateRenderModelPathsFB(
+ XrSession session,
+ uint32_t pathCapacityInput,
+ uint32_t* pathCountOutput,
+ XrRenderModelPathInfoFB* paths);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelPropertiesFB(
+ XrSession session,
+ XrPath path,
+ XrRenderModelPropertiesFB* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLoadRenderModelFB(
+ XrSession session,
+ const XrRenderModelLoadInfoFB* info,
+ XrRenderModelBufferFB* buffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_VARJO_foveated_rendering is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_foveated_rendering 1
+#define XR_VARJO_foveated_rendering_SPEC_VERSION 3
+#define XR_VARJO_FOVEATED_RENDERING_EXTENSION_NAME "XR_VARJO_foveated_rendering"
+// XrViewLocateFoveatedRenderingVARJO extends XrViewLocateInfo
+typedef struct XrViewLocateFoveatedRenderingVARJO {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 foveatedRenderingActive;
+} XrViewLocateFoveatedRenderingVARJO;
+
+// XrFoveatedViewConfigurationViewVARJO extends XrViewConfigurationView
+typedef struct XrFoveatedViewConfigurationViewVARJO {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 foveatedRenderingActive;
+} XrFoveatedViewConfigurationViewVARJO;
+
+// XrSystemFoveatedRenderingPropertiesVARJO extends XrSystemProperties
+typedef struct XrSystemFoveatedRenderingPropertiesVARJO {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFoveatedRendering;
+} XrSystemFoveatedRenderingPropertiesVARJO;
+
+
+
+// XR_VARJO_composition_layer_depth_test is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_composition_layer_depth_test 1
+#define XR_VARJO_composition_layer_depth_test_SPEC_VERSION 2
+#define XR_VARJO_COMPOSITION_LAYER_DEPTH_TEST_EXTENSION_NAME "XR_VARJO_composition_layer_depth_test"
+// XrCompositionLayerDepthTestVARJO extends XrCompositionLayerProjection
+typedef struct XrCompositionLayerDepthTestVARJO {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float depthTestRangeNearZ;
+ float depthTestRangeFarZ;
+} XrCompositionLayerDepthTestVARJO;
+
+
+
+// XR_VARJO_environment_depth_estimation is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_environment_depth_estimation 1
+#define XR_VARJO_environment_depth_estimation_SPEC_VERSION 1
+#define XR_VARJO_ENVIRONMENT_DEPTH_ESTIMATION_EXTENSION_NAME "XR_VARJO_environment_depth_estimation"
+typedef XrResult (XRAPI_PTR *PFN_xrSetEnvironmentDepthEstimationVARJO)(XrSession session, XrBool32 enabled);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetEnvironmentDepthEstimationVARJO(
+ XrSession session,
+ XrBool32 enabled);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_VARJO_marker_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_marker_tracking 1
+#define XR_VARJO_marker_tracking_SPEC_VERSION 1
+#define XR_VARJO_MARKER_TRACKING_EXTENSION_NAME "XR_VARJO_marker_tracking"
+// XrSystemMarkerTrackingPropertiesVARJO extends XrSystemProperties
+typedef struct XrSystemMarkerTrackingPropertiesVARJO {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsMarkerTracking;
+} XrSystemMarkerTrackingPropertiesVARJO;
+
+typedef struct XrEventDataMarkerTrackingUpdateVARJO {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint64_t markerId;
+ XrBool32 isActive;
+ XrBool32 isPredicted;
+ XrTime time;
+} XrEventDataMarkerTrackingUpdateVARJO;
+
+typedef struct XrMarkerSpaceCreateInfoVARJO {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint64_t markerId;
+ XrPosef poseInMarkerSpace;
+} XrMarkerSpaceCreateInfoVARJO;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetMarkerTrackingVARJO)(XrSession session, XrBool32 enabled);
+typedef XrResult (XRAPI_PTR *PFN_xrSetMarkerTrackingTimeoutVARJO)(XrSession session, uint64_t markerId, XrDuration timeout);
+typedef XrResult (XRAPI_PTR *PFN_xrSetMarkerTrackingPredictionVARJO)(XrSession session, uint64_t markerId, XrBool32 enable);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerSizeVARJO)(XrSession session, uint64_t markerId, XrExtent2Df* size);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateMarkerSpaceVARJO)(XrSession session, const XrMarkerSpaceCreateInfoVARJO* createInfo, XrSpace* space);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetMarkerTrackingVARJO(
+ XrSession session,
+ XrBool32 enabled);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetMarkerTrackingTimeoutVARJO(
+ XrSession session,
+ uint64_t markerId,
+ XrDuration timeout);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetMarkerTrackingPredictionVARJO(
+ XrSession session,
+ uint64_t markerId,
+ XrBool32 enable);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerSizeVARJO(
+ XrSession session,
+ uint64_t markerId,
+ XrExtent2Df* size);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateMarkerSpaceVARJO(
+ XrSession session,
+ const XrMarkerSpaceCreateInfoVARJO* createInfo,
+ XrSpace* space);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_VARJO_view_offset is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_view_offset 1
+#define XR_VARJO_view_offset_SPEC_VERSION 1
+#define XR_VARJO_VIEW_OFFSET_EXTENSION_NAME "XR_VARJO_view_offset"
+typedef XrResult (XRAPI_PTR *PFN_xrSetViewOffsetVARJO)(XrSession session, float offset);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetViewOffsetVARJO(
+ XrSession session,
+ float offset);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_VARJO_xr4_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_VARJO_xr4_controller_interaction 1
+#define XR_VARJO_xr4_controller_interaction_SPEC_VERSION 2
+#define XR_VARJO_XR4_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_VARJO_xr4_controller_interaction"
+
+
+// XR_ML_ml2_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_ml2_controller_interaction 1
+#define XR_ML_ml2_controller_interaction_SPEC_VERSION 1
+#define XR_ML_ML2_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_ML_ml2_controller_interaction"
+
+
+// XR_ML_frame_end_info is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_frame_end_info 1
+#define XR_ML_frame_end_info_SPEC_VERSION 1
+#define XR_ML_FRAME_END_INFO_EXTENSION_NAME "XR_ML_frame_end_info"
+typedef XrFlags64 XrFrameEndInfoFlagsML;
+
+// Flag bits for XrFrameEndInfoFlagsML
+static const XrFrameEndInfoFlagsML XR_FRAME_END_INFO_PROTECTED_BIT_ML = 0x00000001;
+static const XrFrameEndInfoFlagsML XR_FRAME_END_INFO_VIGNETTE_BIT_ML = 0x00000002;
+
+// XrFrameEndInfoML extends XrFrameEndInfo
+typedef struct XrFrameEndInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float focusDistance;
+ XrFrameEndInfoFlagsML flags;
+} XrFrameEndInfoML;
+
+
+
+// XR_ML_global_dimmer is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_global_dimmer 1
+#define XR_ML_global_dimmer_SPEC_VERSION 1
+#define XR_ML_GLOBAL_DIMMER_EXTENSION_NAME "XR_ML_global_dimmer"
+typedef XrFlags64 XrGlobalDimmerFrameEndInfoFlagsML;
+
+// Flag bits for XrGlobalDimmerFrameEndInfoFlagsML
+static const XrGlobalDimmerFrameEndInfoFlagsML XR_GLOBAL_DIMMER_FRAME_END_INFO_ENABLED_BIT_ML = 0x00000001;
+
+// XrGlobalDimmerFrameEndInfoML extends XrFrameEndInfo
+typedef struct XrGlobalDimmerFrameEndInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float dimmerValue;
+ XrGlobalDimmerFrameEndInfoFlagsML flags;
+} XrGlobalDimmerFrameEndInfoML;
+
+
+
+// XR_ML_marker_understanding is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_marker_understanding 1
+XR_DEFINE_ATOM(XrMarkerML)
+XR_DEFINE_HANDLE(XrMarkerDetectorML)
+#define XR_ML_marker_understanding_SPEC_VERSION 1
+#define XR_ML_MARKER_UNDERSTANDING_EXTENSION_NAME "XR_ML_marker_understanding"
+
+typedef enum XrMarkerDetectorProfileML {
+ XR_MARKER_DETECTOR_PROFILE_DEFAULT_ML = 0,
+ XR_MARKER_DETECTOR_PROFILE_SPEED_ML = 1,
+ XR_MARKER_DETECTOR_PROFILE_ACCURACY_ML = 2,
+ XR_MARKER_DETECTOR_PROFILE_SMALL_TARGETS_ML = 3,
+ XR_MARKER_DETECTOR_PROFILE_LARGE_FOV_ML = 4,
+ XR_MARKER_DETECTOR_PROFILE_CUSTOM_ML = 5,
+ XR_MARKER_DETECTOR_PROFILE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorProfileML;
+
+typedef enum XrMarkerTypeML {
+ XR_MARKER_TYPE_ARUCO_ML = 0,
+ XR_MARKER_TYPE_APRIL_TAG_ML = 1,
+ XR_MARKER_TYPE_QR_ML = 2,
+ XR_MARKER_TYPE_EAN_13_ML = 3,
+ XR_MARKER_TYPE_UPC_A_ML = 4,
+ XR_MARKER_TYPE_CODE_128_ML = 5,
+ XR_MARKER_TYPE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerTypeML;
+
+typedef enum XrMarkerArucoDictML {
+ XR_MARKER_ARUCO_DICT_4X4_50_ML = 0,
+ XR_MARKER_ARUCO_DICT_4X4_100_ML = 1,
+ XR_MARKER_ARUCO_DICT_4X4_250_ML = 2,
+ XR_MARKER_ARUCO_DICT_4X4_1000_ML = 3,
+ XR_MARKER_ARUCO_DICT_5X5_50_ML = 4,
+ XR_MARKER_ARUCO_DICT_5X5_100_ML = 5,
+ XR_MARKER_ARUCO_DICT_5X5_250_ML = 6,
+ XR_MARKER_ARUCO_DICT_5X5_1000_ML = 7,
+ XR_MARKER_ARUCO_DICT_6X6_50_ML = 8,
+ XR_MARKER_ARUCO_DICT_6X6_100_ML = 9,
+ XR_MARKER_ARUCO_DICT_6X6_250_ML = 10,
+ XR_MARKER_ARUCO_DICT_6X6_1000_ML = 11,
+ XR_MARKER_ARUCO_DICT_7X7_50_ML = 12,
+ XR_MARKER_ARUCO_DICT_7X7_100_ML = 13,
+ XR_MARKER_ARUCO_DICT_7X7_250_ML = 14,
+ XR_MARKER_ARUCO_DICT_7X7_1000_ML = 15,
+ XR_MARKER_ARUCO_DICT_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerArucoDictML;
+
+typedef enum XrMarkerAprilTagDictML {
+ XR_MARKER_APRIL_TAG_DICT_16H5_ML = 0,
+ XR_MARKER_APRIL_TAG_DICT_25H9_ML = 1,
+ XR_MARKER_APRIL_TAG_DICT_36H10_ML = 2,
+ XR_MARKER_APRIL_TAG_DICT_36H11_ML = 3,
+ XR_MARKER_APRIL_TAG_DICT_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerAprilTagDictML;
+
+typedef enum XrMarkerDetectorFpsML {
+ XR_MARKER_DETECTOR_FPS_LOW_ML = 0,
+ XR_MARKER_DETECTOR_FPS_MEDIUM_ML = 1,
+ XR_MARKER_DETECTOR_FPS_HIGH_ML = 2,
+ XR_MARKER_DETECTOR_FPS_MAX_ML = 3,
+ XR_MARKER_DETECTOR_FPS_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorFpsML;
+
+typedef enum XrMarkerDetectorResolutionML {
+ XR_MARKER_DETECTOR_RESOLUTION_LOW_ML = 0,
+ XR_MARKER_DETECTOR_RESOLUTION_MEDIUM_ML = 1,
+ XR_MARKER_DETECTOR_RESOLUTION_HIGH_ML = 2,
+ XR_MARKER_DETECTOR_RESOLUTION_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorResolutionML;
+
+typedef enum XrMarkerDetectorCameraML {
+ XR_MARKER_DETECTOR_CAMERA_RGB_CAMERA_ML = 0,
+ XR_MARKER_DETECTOR_CAMERA_WORLD_CAMERAS_ML = 1,
+ XR_MARKER_DETECTOR_CAMERA_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorCameraML;
+
+typedef enum XrMarkerDetectorCornerRefineMethodML {
+ XR_MARKER_DETECTOR_CORNER_REFINE_METHOD_NONE_ML = 0,
+ XR_MARKER_DETECTOR_CORNER_REFINE_METHOD_SUBPIX_ML = 1,
+ XR_MARKER_DETECTOR_CORNER_REFINE_METHOD_CONTOUR_ML = 2,
+ XR_MARKER_DETECTOR_CORNER_REFINE_METHOD_APRIL_TAG_ML = 3,
+ XR_MARKER_DETECTOR_CORNER_REFINE_METHOD_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorCornerRefineMethodML;
+
+typedef enum XrMarkerDetectorFullAnalysisIntervalML {
+ XR_MARKER_DETECTOR_FULL_ANALYSIS_INTERVAL_MAX_ML = 0,
+ XR_MARKER_DETECTOR_FULL_ANALYSIS_INTERVAL_FAST_ML = 1,
+ XR_MARKER_DETECTOR_FULL_ANALYSIS_INTERVAL_MEDIUM_ML = 2,
+ XR_MARKER_DETECTOR_FULL_ANALYSIS_INTERVAL_SLOW_ML = 3,
+ XR_MARKER_DETECTOR_FULL_ANALYSIS_INTERVAL_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorFullAnalysisIntervalML;
+
+typedef enum XrMarkerDetectorStatusML {
+ XR_MARKER_DETECTOR_STATUS_PENDING_ML = 0,
+ XR_MARKER_DETECTOR_STATUS_READY_ML = 1,
+ XR_MARKER_DETECTOR_STATUS_ERROR_ML = 2,
+ XR_MARKER_DETECTOR_STATUS_MAX_ENUM_ML = 0x7FFFFFFF
+} XrMarkerDetectorStatusML;
+// XrSystemMarkerUnderstandingPropertiesML extends XrSystemProperties
+typedef struct XrSystemMarkerUnderstandingPropertiesML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsMarkerUnderstanding;
+} XrSystemMarkerUnderstandingPropertiesML;
+
+typedef struct XrMarkerDetectorCreateInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMarkerDetectorProfileML profile;
+ XrMarkerTypeML markerType;
+} XrMarkerDetectorCreateInfoML;
+
+// XrMarkerDetectorArucoInfoML extends XrMarkerDetectorCreateInfoML
+typedef struct XrMarkerDetectorArucoInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMarkerArucoDictML arucoDict;
+} XrMarkerDetectorArucoInfoML;
+
+// XrMarkerDetectorSizeInfoML extends XrMarkerDetectorCreateInfoML
+typedef struct XrMarkerDetectorSizeInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float markerLength;
+} XrMarkerDetectorSizeInfoML;
+
+// XrMarkerDetectorAprilTagInfoML extends XrMarkerDetectorCreateInfoML
+typedef struct XrMarkerDetectorAprilTagInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMarkerAprilTagDictML aprilTagDict;
+} XrMarkerDetectorAprilTagInfoML;
+
+// XrMarkerDetectorCustomProfileInfoML extends XrMarkerDetectorCreateInfoML
+typedef struct XrMarkerDetectorCustomProfileInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMarkerDetectorFpsML fpsHint;
+ XrMarkerDetectorResolutionML resolutionHint;
+ XrMarkerDetectorCameraML cameraHint;
+ XrMarkerDetectorCornerRefineMethodML cornerRefineMethod;
+ XrBool32 useEdgeRefinement;
+ XrMarkerDetectorFullAnalysisIntervalML fullAnalysisIntervalHint;
+} XrMarkerDetectorCustomProfileInfoML;
+
+typedef struct XrMarkerDetectorSnapshotInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrMarkerDetectorSnapshotInfoML;
+
+typedef struct XrMarkerDetectorStateML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrMarkerDetectorStatusML state;
+} XrMarkerDetectorStateML;
+
+typedef struct XrMarkerSpaceCreateInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrMarkerDetectorML markerDetector;
+ XrMarkerML marker;
+ XrPosef poseInMarkerSpace;
+} XrMarkerSpaceCreateInfoML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateMarkerDetectorML)(XrSession session, const XrMarkerDetectorCreateInfoML* createInfo, XrMarkerDetectorML* markerDetector);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyMarkerDetectorML)(XrMarkerDetectorML markerDetector);
+typedef XrResult (XRAPI_PTR *PFN_xrSnapshotMarkerDetectorML)(XrMarkerDetectorML markerDetector, XrMarkerDetectorSnapshotInfoML* snapshotInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerDetectorStateML)(XrMarkerDetectorML markerDetector, XrMarkerDetectorStateML* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkersML)(XrMarkerDetectorML markerDetector, uint32_t markerCapacityInput, uint32_t* markerCountOutput, XrMarkerML* markers);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerReprojectionErrorML)(XrMarkerDetectorML markerDetector, XrMarkerML marker, float* reprojectionErrorMeters);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerLengthML)(XrMarkerDetectorML markerDetector, XrMarkerML marker, float* meters);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerNumberML)(XrMarkerDetectorML markerDetector, XrMarkerML marker, uint64_t* number);
+typedef XrResult (XRAPI_PTR *PFN_xrGetMarkerStringML)(XrMarkerDetectorML markerDetector, XrMarkerML marker, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateMarkerSpaceML)(XrSession session, const XrMarkerSpaceCreateInfoML* createInfo, XrSpace* space);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateMarkerDetectorML(
+ XrSession session,
+ const XrMarkerDetectorCreateInfoML* createInfo,
+ XrMarkerDetectorML* markerDetector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyMarkerDetectorML(
+ XrMarkerDetectorML markerDetector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSnapshotMarkerDetectorML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerDetectorSnapshotInfoML* snapshotInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerDetectorStateML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerDetectorStateML* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkersML(
+ XrMarkerDetectorML markerDetector,
+ uint32_t markerCapacityInput,
+ uint32_t* markerCountOutput,
+ XrMarkerML* markers);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerReprojectionErrorML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerML marker,
+ float* reprojectionErrorMeters);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerLengthML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerML marker,
+ float* meters);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerNumberML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerML marker,
+ uint64_t* number);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMarkerStringML(
+ XrMarkerDetectorML markerDetector,
+ XrMarkerML marker,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateMarkerSpaceML(
+ XrSession session,
+ const XrMarkerSpaceCreateInfoML* createInfo,
+ XrSpace* space);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_localization_map is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_localization_map 1
+XR_DEFINE_HANDLE(XrExportedLocalizationMapML)
+#define XR_MAX_LOCALIZATION_MAP_NAME_LENGTH_ML 64
+#define XR_ML_localization_map_SPEC_VERSION 1
+#define XR_ML_LOCALIZATION_MAP_EXTENSION_NAME "XR_ML_localization_map"
+
+typedef enum XrLocalizationMapStateML {
+ XR_LOCALIZATION_MAP_STATE_NOT_LOCALIZED_ML = 0,
+ XR_LOCALIZATION_MAP_STATE_LOCALIZED_ML = 1,
+ XR_LOCALIZATION_MAP_STATE_LOCALIZATION_PENDING_ML = 2,
+ XR_LOCALIZATION_MAP_STATE_LOCALIZATION_SLEEPING_BEFORE_RETRY_ML = 3,
+ XR_LOCALIZATION_MAP_STATE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrLocalizationMapStateML;
+
+typedef enum XrLocalizationMapTypeML {
+ XR_LOCALIZATION_MAP_TYPE_ON_DEVICE_ML = 0,
+ XR_LOCALIZATION_MAP_TYPE_CLOUD_ML = 1,
+ XR_LOCALIZATION_MAP_TYPE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrLocalizationMapTypeML;
+
+typedef enum XrLocalizationMapConfidenceML {
+ XR_LOCALIZATION_MAP_CONFIDENCE_POOR_ML = 0,
+ XR_LOCALIZATION_MAP_CONFIDENCE_FAIR_ML = 1,
+ XR_LOCALIZATION_MAP_CONFIDENCE_GOOD_ML = 2,
+ XR_LOCALIZATION_MAP_CONFIDENCE_EXCELLENT_ML = 3,
+ XR_LOCALIZATION_MAP_CONFIDENCE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrLocalizationMapConfidenceML;
+typedef XrFlags64 XrLocalizationMapErrorFlagsML;
+
+// Flag bits for XrLocalizationMapErrorFlagsML
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_UNKNOWN_BIT_ML = 0x00000001;
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_OUT_OF_MAPPED_AREA_BIT_ML = 0x00000002;
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_LOW_FEATURE_COUNT_BIT_ML = 0x00000004;
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_EXCESSIVE_MOTION_BIT_ML = 0x00000008;
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_LOW_LIGHT_BIT_ML = 0x00000010;
+static const XrLocalizationMapErrorFlagsML XR_LOCALIZATION_MAP_ERROR_HEADPOSE_BIT_ML = 0x00000020;
+
+typedef struct XrLocalizationMapML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ char name[XR_MAX_LOCALIZATION_MAP_NAME_LENGTH_ML];
+ XrUuidEXT mapUuid;
+ XrLocalizationMapTypeML mapType;
+} XrLocalizationMapML;
+
+typedef struct XrEventDataLocalizationChangedML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+ XrLocalizationMapStateML state;
+ XrLocalizationMapML map;
+ XrLocalizationMapConfidenceML confidence;
+ XrLocalizationMapErrorFlagsML errorFlags;
+} XrEventDataLocalizationChangedML;
+
+typedef struct XrLocalizationMapQueryInfoBaseHeaderML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrLocalizationMapQueryInfoBaseHeaderML;
+
+typedef struct XrMapLocalizationRequestInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidEXT mapUuid;
+} XrMapLocalizationRequestInfoML;
+
+typedef struct XrLocalizationMapImportInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t size;
+ char* data;
+} XrLocalizationMapImportInfoML;
+
+typedef struct XrLocalizationEnableEventsInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+} XrLocalizationEnableEventsInfoML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnableLocalizationEventsML)(XrSession session, const XrLocalizationEnableEventsInfoML * info);
+typedef XrResult (XRAPI_PTR *PFN_xrQueryLocalizationMapsML)(XrSession session, const XrLocalizationMapQueryInfoBaseHeaderML* queryInfo, uint32_t mapCapacityInput, uint32_t * mapCountOutput, XrLocalizationMapML* maps);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestMapLocalizationML)(XrSession session, const XrMapLocalizationRequestInfoML* requestInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrImportLocalizationMapML)(XrSession session, const XrLocalizationMapImportInfoML* importInfo, XrUuidEXT* mapUuid);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateExportedLocalizationMapML)(XrSession session, const XrUuidEXT* mapUuid, XrExportedLocalizationMapML* map);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyExportedLocalizationMapML)(XrExportedLocalizationMapML map);
+typedef XrResult (XRAPI_PTR *PFN_xrGetExportedLocalizationMapDataML)(XrExportedLocalizationMapML map, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnableLocalizationEventsML(
+ XrSession session,
+ const XrLocalizationEnableEventsInfoML * info);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQueryLocalizationMapsML(
+ XrSession session,
+ const XrLocalizationMapQueryInfoBaseHeaderML* queryInfo,
+ uint32_t mapCapacityInput,
+ uint32_t * mapCountOutput,
+ XrLocalizationMapML* maps);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestMapLocalizationML(
+ XrSession session,
+ const XrMapLocalizationRequestInfoML* requestInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrImportLocalizationMapML(
+ XrSession session,
+ const XrLocalizationMapImportInfoML* importInfo,
+ XrUuidEXT* mapUuid);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateExportedLocalizationMapML(
+ XrSession session,
+ const XrUuidEXT* mapUuid,
+ XrExportedLocalizationMapML* map);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyExportedLocalizationMapML(
+ XrExportedLocalizationMapML map);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetExportedLocalizationMapDataML(
+ XrExportedLocalizationMapML map,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_spatial_anchors is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_spatial_anchors 1
+XR_DEFINE_OPAQUE_64(XrFutureEXT)
+#define XR_ML_spatial_anchors_SPEC_VERSION 1
+#define XR_ML_SPATIAL_ANCHORS_EXTENSION_NAME "XR_ML_spatial_anchors"
+
+typedef enum XrSpatialAnchorConfidenceML {
+ XR_SPATIAL_ANCHOR_CONFIDENCE_LOW_ML = 0,
+ XR_SPATIAL_ANCHOR_CONFIDENCE_MEDIUM_ML = 1,
+ XR_SPATIAL_ANCHOR_CONFIDENCE_HIGH_ML = 2,
+ XR_SPATIAL_ANCHOR_CONFIDENCE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrSpatialAnchorConfidenceML;
+typedef struct XR_MAY_ALIAS XrSpatialAnchorsCreateInfoBaseHeaderML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialAnchorsCreateInfoBaseHeaderML;
+
+typedef struct XrSpatialAnchorsCreateInfoFromPoseML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrPosef poseInBaseSpace;
+ XrTime time;
+} XrSpatialAnchorsCreateInfoFromPoseML;
+
+typedef struct XrCreateSpatialAnchorsCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+} XrCreateSpatialAnchorsCompletionML;
+
+typedef struct XrSpatialAnchorStateML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpatialAnchorConfidenceML confidence;
+} XrSpatialAnchorStateML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorsAsyncML)(XrSession session, const XrSpatialAnchorsCreateInfoBaseHeaderML* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorsCompleteML)(XrSession session, XrFutureEXT future, XrCreateSpatialAnchorsCompletionML* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialAnchorStateML)(XrSpace anchor, XrSpatialAnchorStateML* state);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorsAsyncML(
+ XrSession session,
+ const XrSpatialAnchorsCreateInfoBaseHeaderML* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorsCompleteML(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateSpatialAnchorsCompletionML* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialAnchorStateML(
+ XrSpace anchor,
+ XrSpatialAnchorStateML* state);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_spatial_anchors_storage is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_spatial_anchors_storage 1
+XR_DEFINE_HANDLE(XrSpatialAnchorsStorageML)
+#define XR_ML_spatial_anchors_storage_SPEC_VERSION 1
+#define XR_ML_SPATIAL_ANCHORS_STORAGE_EXTENSION_NAME "XR_ML_spatial_anchors_storage"
+typedef struct XrSpatialAnchorsCreateStorageInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialAnchorsCreateStorageInfoML;
+
+typedef struct XR_MAY_ALIAS XrSpatialAnchorsQueryInfoBaseHeaderML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialAnchorsQueryInfoBaseHeaderML;
+
+typedef struct XrSpatialAnchorsQueryInfoRadiusML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrVector3f center;
+ XrTime time;
+ float radius;
+} XrSpatialAnchorsQueryInfoRadiusML;
+
+typedef struct XrSpatialAnchorsQueryCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ uint32_t uuidCapacityInput;
+ uint32_t uuidCountOutput;
+ XrUuidEXT* uuids;
+} XrSpatialAnchorsQueryCompletionML;
+
+typedef struct XrSpatialAnchorsCreateInfoFromUuidsML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialAnchorsStorageML storage;
+ uint32_t uuidCount;
+ const XrUuidEXT* uuids;
+} XrSpatialAnchorsCreateInfoFromUuidsML;
+
+typedef struct XrSpatialAnchorsPublishInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t anchorCount;
+ const XrSpace* anchors;
+ uint64_t expiration;
+} XrSpatialAnchorsPublishInfoML;
+
+typedef struct XrSpatialAnchorsPublishCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ uint32_t uuidCount;
+ XrUuidEXT* uuids;
+} XrSpatialAnchorsPublishCompletionML;
+
+typedef struct XrSpatialAnchorsDeleteInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCount;
+ const XrUuidEXT* uuids;
+} XrSpatialAnchorsDeleteInfoML;
+
+typedef struct XrSpatialAnchorsDeleteCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+} XrSpatialAnchorsDeleteCompletionML;
+
+typedef struct XrSpatialAnchorsUpdateExpirationInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCount;
+ const XrUuidEXT* uuids;
+ uint64_t expiration;
+} XrSpatialAnchorsUpdateExpirationInfoML;
+
+typedef struct XrSpatialAnchorsUpdateExpirationCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+} XrSpatialAnchorsUpdateExpirationCompletionML;
+
+typedef struct XrSpatialAnchorCompletionResultML {
+ XrUuidEXT uuid;
+ XrResult result;
+} XrSpatialAnchorCompletionResultML;
+
+// XrSpatialAnchorsPublishCompletionDetailsML extends XrSpatialAnchorsPublishCompletionML
+typedef struct XrSpatialAnchorsPublishCompletionDetailsML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resultCount;
+ XrSpatialAnchorCompletionResultML* results;
+} XrSpatialAnchorsPublishCompletionDetailsML;
+
+// XrSpatialAnchorsDeleteCompletionDetailsML extends XrSpatialAnchorsDeleteCompletionML
+typedef struct XrSpatialAnchorsDeleteCompletionDetailsML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resultCount;
+ XrSpatialAnchorCompletionResultML* results;
+} XrSpatialAnchorsDeleteCompletionDetailsML;
+
+// XrSpatialAnchorsUpdateExpirationCompletionDetailsML extends XrSpatialAnchorsUpdateExpirationCompletionML
+typedef struct XrSpatialAnchorsUpdateExpirationCompletionDetailsML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resultCount;
+ XrSpatialAnchorCompletionResultML* results;
+} XrSpatialAnchorsUpdateExpirationCompletionDetailsML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorsStorageML)(XrSession session, const XrSpatialAnchorsCreateStorageInfoML* createInfo, XrSpatialAnchorsStorageML* storage);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialAnchorsStorageML)(XrSpatialAnchorsStorageML storage);
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySpatialAnchorsAsyncML)(XrSpatialAnchorsStorageML storage, const XrSpatialAnchorsQueryInfoBaseHeaderML* queryInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySpatialAnchorsCompleteML)(XrSpatialAnchorsStorageML storage, XrFutureEXT future, XrSpatialAnchorsQueryCompletionML* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrPublishSpatialAnchorsAsyncML)(XrSpatialAnchorsStorageML storage, const XrSpatialAnchorsPublishInfoML* publishInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrPublishSpatialAnchorsCompleteML)(XrSpatialAnchorsStorageML storage, XrFutureEXT future, XrSpatialAnchorsPublishCompletionML* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDeleteSpatialAnchorsAsyncML)(XrSpatialAnchorsStorageML storage, const XrSpatialAnchorsDeleteInfoML* deleteInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrDeleteSpatialAnchorsCompleteML)(XrSpatialAnchorsStorageML storage, XrFutureEXT future, XrSpatialAnchorsDeleteCompletionML* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSpatialAnchorsExpirationAsyncML)(XrSpatialAnchorsStorageML storage, const XrSpatialAnchorsUpdateExpirationInfoML* updateInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSpatialAnchorsExpirationCompleteML)(XrSpatialAnchorsStorageML storage, XrFutureEXT future, XrSpatialAnchorsUpdateExpirationCompletionML* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorsStorageML(
+ XrSession session,
+ const XrSpatialAnchorsCreateStorageInfoML* createInfo,
+ XrSpatialAnchorsStorageML* storage);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialAnchorsStorageML(
+ XrSpatialAnchorsStorageML storage);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySpatialAnchorsAsyncML(
+ XrSpatialAnchorsStorageML storage,
+ const XrSpatialAnchorsQueryInfoBaseHeaderML* queryInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySpatialAnchorsCompleteML(
+ XrSpatialAnchorsStorageML storage,
+ XrFutureEXT future,
+ XrSpatialAnchorsQueryCompletionML* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPublishSpatialAnchorsAsyncML(
+ XrSpatialAnchorsStorageML storage,
+ const XrSpatialAnchorsPublishInfoML* publishInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPublishSpatialAnchorsCompleteML(
+ XrSpatialAnchorsStorageML storage,
+ XrFutureEXT future,
+ XrSpatialAnchorsPublishCompletionML* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDeleteSpatialAnchorsAsyncML(
+ XrSpatialAnchorsStorageML storage,
+ const XrSpatialAnchorsDeleteInfoML* deleteInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDeleteSpatialAnchorsCompleteML(
+ XrSpatialAnchorsStorageML storage,
+ XrFutureEXT future,
+ XrSpatialAnchorsDeleteCompletionML* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSpatialAnchorsExpirationAsyncML(
+ XrSpatialAnchorsStorageML storage,
+ const XrSpatialAnchorsUpdateExpirationInfoML* updateInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSpatialAnchorsExpirationCompleteML(
+ XrSpatialAnchorsStorageML storage,
+ XrFutureEXT future,
+ XrSpatialAnchorsUpdateExpirationCompletionML* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_spatial_anchor_persistence is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_spatial_anchor_persistence 1
+XR_DEFINE_HANDLE(XrSpatialAnchorStoreConnectionMSFT)
+#define XR_MAX_SPATIAL_ANCHOR_NAME_SIZE_MSFT 256
+#define XR_MSFT_spatial_anchor_persistence_SPEC_VERSION 2
+#define XR_MSFT_SPATIAL_ANCHOR_PERSISTENCE_EXTENSION_NAME "XR_MSFT_spatial_anchor_persistence"
+typedef struct XrSpatialAnchorPersistenceNameMSFT {
+ char name[XR_MAX_SPATIAL_ANCHOR_NAME_SIZE_MSFT];
+} XrSpatialAnchorPersistenceNameMSFT;
+
+typedef struct XrSpatialAnchorPersistenceInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialAnchorPersistenceNameMSFT spatialAnchorPersistenceName;
+ XrSpatialAnchorMSFT spatialAnchor;
+} XrSpatialAnchorPersistenceInfoMSFT;
+
+typedef struct XrSpatialAnchorFromPersistedAnchorCreateInfoMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore;
+ XrSpatialAnchorPersistenceNameMSFT spatialAnchorPersistenceName;
+} XrSpatialAnchorFromPersistedAnchorCreateInfoMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorStoreConnectionMSFT)(XrSession session, XrSpatialAnchorStoreConnectionMSFT* spatialAnchorStore);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialAnchorStoreConnectionMSFT)(XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore);
+typedef XrResult (XRAPI_PTR *PFN_xrPersistSpatialAnchorMSFT)(XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore, const XrSpatialAnchorPersistenceInfoMSFT* spatialAnchorPersistenceInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumeratePersistedSpatialAnchorNamesMSFT)(XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore, uint32_t spatialAnchorNameCapacityInput, uint32_t* spatialAnchorNameCountOutput, XrSpatialAnchorPersistenceNameMSFT* spatialAnchorNames);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorFromPersistedNameMSFT)(XrSession session, const XrSpatialAnchorFromPersistedAnchorCreateInfoMSFT* spatialAnchorCreateInfo, XrSpatialAnchorMSFT* spatialAnchor);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistSpatialAnchorMSFT)(XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore, const XrSpatialAnchorPersistenceNameMSFT* spatialAnchorPersistenceName);
+typedef XrResult (XRAPI_PTR *PFN_xrClearSpatialAnchorStoreMSFT)(XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorStoreConnectionMSFT(
+ XrSession session,
+ XrSpatialAnchorStoreConnectionMSFT* spatialAnchorStore);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialAnchorStoreConnectionMSFT(
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistSpatialAnchorMSFT(
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore,
+ const XrSpatialAnchorPersistenceInfoMSFT* spatialAnchorPersistenceInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumeratePersistedSpatialAnchorNamesMSFT(
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore,
+ uint32_t spatialAnchorNameCapacityInput,
+ uint32_t* spatialAnchorNameCountOutput,
+ XrSpatialAnchorPersistenceNameMSFT* spatialAnchorNames);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorFromPersistedNameMSFT(
+ XrSession session,
+ const XrSpatialAnchorFromPersistedAnchorCreateInfoMSFT* spatialAnchorCreateInfo,
+ XrSpatialAnchorMSFT* spatialAnchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistSpatialAnchorMSFT(
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore,
+ const XrSpatialAnchorPersistenceNameMSFT* spatialAnchorPersistenceName);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrClearSpatialAnchorStoreMSFT(
+ XrSpatialAnchorStoreConnectionMSFT spatialAnchorStore);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_MSFT_scene_marker is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_scene_marker 1
+#define XR_MSFT_scene_marker_SPEC_VERSION 1
+#define XR_MSFT_SCENE_MARKER_EXTENSION_NAME "XR_MSFT_scene_marker"
+
+typedef enum XrSceneMarkerTypeMSFT {
+ XR_SCENE_MARKER_TYPE_QR_CODE_MSFT = 1,
+ XR_SCENE_MARKER_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneMarkerTypeMSFT;
+
+typedef enum XrSceneMarkerQRCodeSymbolTypeMSFT {
+ XR_SCENE_MARKER_QR_CODE_SYMBOL_TYPE_QR_CODE_MSFT = 1,
+ XR_SCENE_MARKER_QR_CODE_SYMBOL_TYPE_MICRO_QR_CODE_MSFT = 2,
+ XR_SCENE_MARKER_QRCODE_SYMBOL_TYPE_MAX_ENUM_MSFT = 0x7FFFFFFF
+} XrSceneMarkerQRCodeSymbolTypeMSFT;
+typedef struct XrSceneMarkerMSFT {
+ XrSceneMarkerTypeMSFT markerType;
+ XrTime lastSeenTime;
+ XrOffset2Df center;
+ XrExtent2Df size;
+} XrSceneMarkerMSFT;
+
+// XrSceneMarkersMSFT extends XrSceneComponentsMSFT
+typedef struct XrSceneMarkersMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t sceneMarkerCapacityInput;
+ XrSceneMarkerMSFT* sceneMarkers;
+} XrSceneMarkersMSFT;
+
+// XrSceneMarkerTypeFilterMSFT extends XrSceneComponentsGetInfoMSFT
+typedef struct XrSceneMarkerTypeFilterMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t markerTypeCount;
+ XrSceneMarkerTypeMSFT* markerTypes;
+} XrSceneMarkerTypeFilterMSFT;
+
+typedef struct XrSceneMarkerQRCodeMSFT {
+ XrSceneMarkerQRCodeSymbolTypeMSFT symbolType;
+ uint8_t version;
+} XrSceneMarkerQRCodeMSFT;
+
+// XrSceneMarkerQRCodesMSFT extends XrSceneComponentsMSFT
+typedef struct XrSceneMarkerQRCodesMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t qrCodeCapacityInput;
+ XrSceneMarkerQRCodeMSFT* qrCodes;
+} XrSceneMarkerQRCodesMSFT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetSceneMarkerRawDataMSFT)(XrSceneMSFT scene, const XrUuidMSFT* markerId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, uint8_t* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSceneMarkerDecodedStringMSFT)(XrSceneMSFT scene, const XrUuidMSFT* markerId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSceneMarkerRawDataMSFT(
+ XrSceneMSFT scene,
+ const XrUuidMSFT* markerId,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ uint8_t* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSceneMarkerDecodedStringMSFT(
+ XrSceneMSFT scene,
+ const XrUuidMSFT* markerId,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ULTRALEAP_hand_tracking_forearm is a preprocessor guard. Do not pass it to API calls.
+#define XR_ULTRALEAP_hand_tracking_forearm 1
+
+#define XR_HAND_FOREARM_JOINT_COUNT_ULTRALEAP 27
+
+#define XR_ULTRALEAP_hand_tracking_forearm_SPEC_VERSION 1
+#define XR_ULTRALEAP_HAND_TRACKING_FOREARM_EXTENSION_NAME "XR_ULTRALEAP_hand_tracking_forearm"
+
+typedef enum XrHandForearmJointULTRALEAP {
+ XR_HAND_FOREARM_JOINT_PALM_ULTRALEAP = 0,
+ XR_HAND_FOREARM_JOINT_WRIST_ULTRALEAP = 1,
+ XR_HAND_FOREARM_JOINT_THUMB_METACARPAL_ULTRALEAP = 2,
+ XR_HAND_FOREARM_JOINT_THUMB_PROXIMAL_ULTRALEAP = 3,
+ XR_HAND_FOREARM_JOINT_THUMB_DISTAL_ULTRALEAP = 4,
+ XR_HAND_FOREARM_JOINT_THUMB_TIP_ULTRALEAP = 5,
+ XR_HAND_FOREARM_JOINT_INDEX_METACARPAL_ULTRALEAP = 6,
+ XR_HAND_FOREARM_JOINT_INDEX_PROXIMAL_ULTRALEAP = 7,
+ XR_HAND_FOREARM_JOINT_INDEX_INTERMEDIATE_ULTRALEAP = 8,
+ XR_HAND_FOREARM_JOINT_INDEX_DISTAL_ULTRALEAP = 9,
+ XR_HAND_FOREARM_JOINT_INDEX_TIP_ULTRALEAP = 10,
+ XR_HAND_FOREARM_JOINT_MIDDLE_METACARPAL_ULTRALEAP = 11,
+ XR_HAND_FOREARM_JOINT_MIDDLE_PROXIMAL_ULTRALEAP = 12,
+ XR_HAND_FOREARM_JOINT_MIDDLE_INTERMEDIATE_ULTRALEAP = 13,
+ XR_HAND_FOREARM_JOINT_MIDDLE_DISTAL_ULTRALEAP = 14,
+ XR_HAND_FOREARM_JOINT_MIDDLE_TIP_ULTRALEAP = 15,
+ XR_HAND_FOREARM_JOINT_RING_METACARPAL_ULTRALEAP = 16,
+ XR_HAND_FOREARM_JOINT_RING_PROXIMAL_ULTRALEAP = 17,
+ XR_HAND_FOREARM_JOINT_RING_INTERMEDIATE_ULTRALEAP = 18,
+ XR_HAND_FOREARM_JOINT_RING_DISTAL_ULTRALEAP = 19,
+ XR_HAND_FOREARM_JOINT_RING_TIP_ULTRALEAP = 20,
+ XR_HAND_FOREARM_JOINT_LITTLE_METACARPAL_ULTRALEAP = 21,
+ XR_HAND_FOREARM_JOINT_LITTLE_PROXIMAL_ULTRALEAP = 22,
+ XR_HAND_FOREARM_JOINT_LITTLE_INTERMEDIATE_ULTRALEAP = 23,
+ XR_HAND_FOREARM_JOINT_LITTLE_DISTAL_ULTRALEAP = 24,
+ XR_HAND_FOREARM_JOINT_LITTLE_TIP_ULTRALEAP = 25,
+ XR_HAND_FOREARM_JOINT_ELBOW_ULTRALEAP = 26,
+ XR_HAND_FOREARM_JOINT_MAX_ENUM_ULTRALEAP = 0x7FFFFFFF
+} XrHandForearmJointULTRALEAP;
+
+
+// XR_FB_spatial_entity_query is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_query 1
+#define XR_FB_spatial_entity_query_SPEC_VERSION 1
+#define XR_FB_SPATIAL_ENTITY_QUERY_EXTENSION_NAME "XR_FB_spatial_entity_query"
+
+typedef enum XrSpaceQueryActionFB {
+ XR_SPACE_QUERY_ACTION_LOAD_FB = 0,
+ XR_SPACE_QUERY_ACTION_MAX_ENUM_FB = 0x7FFFFFFF
+} XrSpaceQueryActionFB;
+
+typedef enum XrSpaceStorageLocationFB {
+ XR_SPACE_STORAGE_LOCATION_INVALID_FB = 0,
+ XR_SPACE_STORAGE_LOCATION_LOCAL_FB = 1,
+ XR_SPACE_STORAGE_LOCATION_CLOUD_FB = 2,
+ XR_SPACE_STORAGE_LOCATION_MAX_ENUM_FB = 0x7FFFFFFF
+} XrSpaceStorageLocationFB;
+typedef struct XR_MAY_ALIAS XrSpaceQueryInfoBaseHeaderFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpaceQueryInfoBaseHeaderFB;
+
+typedef struct XR_MAY_ALIAS XrSpaceFilterInfoBaseHeaderFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpaceFilterInfoBaseHeaderFB;
+
+typedef struct XrSpaceQueryInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceQueryActionFB queryAction;
+ uint32_t maxResultCount;
+ XrDuration timeout;
+ const XrSpaceFilterInfoBaseHeaderFB* filter;
+ const XrSpaceFilterInfoBaseHeaderFB* excludeFilter;
+} XrSpaceQueryInfoFB;
+
+// XrSpaceStorageLocationFilterInfoFB extends XrSpaceFilterInfoBaseHeaderFB
+typedef struct XrSpaceStorageLocationFilterInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceStorageLocationFB location;
+} XrSpaceStorageLocationFilterInfoFB;
+
+typedef struct XrSpaceUuidFilterInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCount;
+ XrUuidEXT* uuids;
+} XrSpaceUuidFilterInfoFB;
+
+typedef struct XrSpaceComponentFilterInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceComponentTypeFB componentType;
+} XrSpaceComponentFilterInfoFB;
+
+typedef struct XrSpaceQueryResultFB {
+ XrSpace space;
+ XrUuidEXT uuid;
+} XrSpaceQueryResultFB;
+
+typedef struct XrSpaceQueryResultsFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resultCapacityInput;
+ uint32_t resultCountOutput;
+ XrSpaceQueryResultFB* results;
+} XrSpaceQueryResultsFB;
+
+typedef struct XrEventDataSpaceQueryResultsAvailableFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+} XrEventDataSpaceQueryResultsAvailableFB;
+
+typedef struct XrEventDataSpaceQueryCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpaceQueryCompleteFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySpacesFB)(XrSession session, const XrSpaceQueryInfoBaseHeaderFB* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrRetrieveSpaceQueryResultsFB)(XrSession session, XrAsyncRequestIdFB requestId, XrSpaceQueryResultsFB* results);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySpacesFB(
+ XrSession session,
+ const XrSpaceQueryInfoBaseHeaderFB* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRetrieveSpaceQueryResultsFB(
+ XrSession session,
+ XrAsyncRequestIdFB requestId,
+ XrSpaceQueryResultsFB* results);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_spatial_entity_storage is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_storage 1
+#define XR_FB_spatial_entity_storage_SPEC_VERSION 1
+#define XR_FB_SPATIAL_ENTITY_STORAGE_EXTENSION_NAME "XR_FB_spatial_entity_storage"
+
+typedef enum XrSpacePersistenceModeFB {
+ XR_SPACE_PERSISTENCE_MODE_INVALID_FB = 0,
+ XR_SPACE_PERSISTENCE_MODE_INDEFINITE_FB = 1,
+ XR_SPACE_PERSISTENCE_MODE_MAX_ENUM_FB = 0x7FFFFFFF
+} XrSpacePersistenceModeFB;
+typedef struct XrSpaceSaveInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrSpaceStorageLocationFB location;
+ XrSpacePersistenceModeFB persistenceMode;
+} XrSpaceSaveInfoFB;
+
+typedef struct XrSpaceEraseInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrSpaceStorageLocationFB location;
+} XrSpaceEraseInfoFB;
+
+typedef struct XrEventDataSpaceSaveCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+ XrSpace space;
+ XrUuidEXT uuid;
+ XrSpaceStorageLocationFB location;
+} XrEventDataSpaceSaveCompleteFB;
+
+typedef struct XrEventDataSpaceEraseCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+ XrSpace space;
+ XrUuidEXT uuid;
+ XrSpaceStorageLocationFB location;
+} XrEventDataSpaceEraseCompleteFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSaveSpaceFB)(XrSession session, const XrSpaceSaveInfoFB* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrEraseSpaceFB)(XrSession session, const XrSpaceEraseInfoFB* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSaveSpaceFB(
+ XrSession session,
+ const XrSpaceSaveInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEraseSpaceFB(
+ XrSession session,
+ const XrSpaceEraseInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_touch_controller_pro is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_touch_controller_pro 1
+#define XR_FB_touch_controller_pro_SPEC_VERSION 1
+#define XR_FB_TOUCH_CONTROLLER_PRO_EXTENSION_NAME "XR_FB_touch_controller_pro"
+
+
+// XR_FB_spatial_entity_sharing is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_sharing 1
+XR_DEFINE_HANDLE(XrSpaceUserFB)
+#define XR_FB_spatial_entity_sharing_SPEC_VERSION 1
+#define XR_FB_SPATIAL_ENTITY_SHARING_EXTENSION_NAME "XR_FB_spatial_entity_sharing"
+typedef struct XrSpaceShareInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+ uint32_t userCount;
+ XrSpaceUserFB* users;
+} XrSpaceShareInfoFB;
+
+typedef struct XrEventDataSpaceShareCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpaceShareCompleteFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrShareSpacesFB)(XrSession session, const XrSpaceShareInfoFB* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrShareSpacesFB(
+ XrSession session,
+ const XrSpaceShareInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_space_warp is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_space_warp 1
+#define XR_FB_space_warp_SPEC_VERSION 2
+#define XR_FB_SPACE_WARP_EXTENSION_NAME "XR_FB_space_warp"
+typedef XrFlags64 XrCompositionLayerSpaceWarpInfoFlagsFB;
+
+// Flag bits for XrCompositionLayerSpaceWarpInfoFlagsFB
+static const XrCompositionLayerSpaceWarpInfoFlagsFB XR_COMPOSITION_LAYER_SPACE_WARP_INFO_FRAME_SKIP_BIT_FB = 0x00000001;
+
+// XrCompositionLayerSpaceWarpInfoFB extends XrCompositionLayerProjectionView
+typedef struct XrCompositionLayerSpaceWarpInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerSpaceWarpInfoFlagsFB layerFlags;
+ XrSwapchainSubImage motionVectorSubImage;
+ XrPosef appSpaceDeltaPose;
+ XrSwapchainSubImage depthSubImage;
+ float minDepth;
+ float maxDepth;
+ float nearZ;
+ float farZ;
+} XrCompositionLayerSpaceWarpInfoFB;
+
+// XrSystemSpaceWarpPropertiesFB extends XrSystemProperties
+typedef struct XrSystemSpaceWarpPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t recommendedMotionVectorImageRectWidth;
+ uint32_t recommendedMotionVectorImageRectHeight;
+} XrSystemSpaceWarpPropertiesFB;
+
+
+
+// XR_FB_haptic_amplitude_envelope is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_haptic_amplitude_envelope 1
+
+#define XR_MAX_HAPTIC_AMPLITUDE_ENVELOPE_SAMPLES_FB 4000u
+
+#define XR_FB_haptic_amplitude_envelope_SPEC_VERSION 1
+#define XR_FB_HAPTIC_AMPLITUDE_ENVELOPE_EXTENSION_NAME "XR_FB_haptic_amplitude_envelope"
+typedef struct XrHapticAmplitudeEnvelopeVibrationFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrDuration duration;
+ uint32_t amplitudeCount;
+ const float* amplitudes;
+} XrHapticAmplitudeEnvelopeVibrationFB;
+
+
+
+// XR_FB_scene is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_scene 1
+#define XR_FB_scene_SPEC_VERSION 4
+#define XR_FB_SCENE_EXTENSION_NAME "XR_FB_scene"
+typedef XrFlags64 XrSemanticLabelsSupportFlagsFB;
+
+// Flag bits for XrSemanticLabelsSupportFlagsFB
+static const XrSemanticLabelsSupportFlagsFB XR_SEMANTIC_LABELS_SUPPORT_MULTIPLE_SEMANTIC_LABELS_BIT_FB = 0x00000001;
+static const XrSemanticLabelsSupportFlagsFB XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_DESK_TO_TABLE_MIGRATION_BIT_FB = 0x00000002;
+static const XrSemanticLabelsSupportFlagsFB XR_SEMANTIC_LABELS_SUPPORT_ACCEPT_INVISIBLE_WALL_FACE_BIT_FB = 0x00000004;
+
+typedef XrExtent3Df XrExtent3DfFB;
+
+typedef struct XrOffset3DfFB {
+ float x;
+ float y;
+ float z;
+} XrOffset3DfFB;
+
+typedef struct XrRect3DfFB {
+ XrOffset3DfFB offset;
+ XrExtent3DfFB extent;
+} XrRect3DfFB;
+
+typedef struct XrSemanticLabelsFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t bufferCapacityInput;
+ uint32_t bufferCountOutput;
+ char* buffer;
+} XrSemanticLabelsFB;
+
+typedef struct XrRoomLayoutFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidEXT floorUuid;
+ XrUuidEXT ceilingUuid;
+ uint32_t wallUuidCapacityInput;
+ uint32_t wallUuidCountOutput;
+ XrUuidEXT* wallUuids;
+} XrRoomLayoutFB;
+
+typedef struct XrBoundary2DFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector2f* vertices;
+} XrBoundary2DFB;
+
+// XrSemanticLabelsSupportInfoFB extends XrSemanticLabelsFB
+typedef struct XrSemanticLabelsSupportInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSemanticLabelsSupportFlagsFB flags;
+ const char* recognizedLabels;
+} XrSemanticLabelsSupportInfoFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceBoundingBox2DFB)(XrSession session, XrSpace space, XrRect2Df* boundingBox2DOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceBoundingBox3DFB)(XrSession session, XrSpace space, XrRect3DfFB* boundingBox3DOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceSemanticLabelsFB)(XrSession session, XrSpace space, XrSemanticLabelsFB* semanticLabelsOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceBoundary2DFB)(XrSession session, XrSpace space, XrBoundary2DFB* boundary2DOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceRoomLayoutFB)(XrSession session, XrSpace space, XrRoomLayoutFB* roomLayoutOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceBoundingBox2DFB(
+ XrSession session,
+ XrSpace space,
+ XrRect2Df* boundingBox2DOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceBoundingBox3DFB(
+ XrSession session,
+ XrSpace space,
+ XrRect3DfFB* boundingBox3DOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceSemanticLabelsFB(
+ XrSession session,
+ XrSpace space,
+ XrSemanticLabelsFB* semanticLabelsOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceBoundary2DFB(
+ XrSession session,
+ XrSpace space,
+ XrBoundary2DFB* boundary2DOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceRoomLayoutFB(
+ XrSession session,
+ XrSpace space,
+ XrRoomLayoutFB* roomLayoutOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_palm_pose is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_palm_pose 1
+#define XR_EXT_palm_pose_SPEC_VERSION 3
+#define XR_EXT_PALM_POSE_EXTENSION_NAME "XR_EXT_palm_pose"
+
+
+// XR_ALMALENCE_digital_lens_control is a preprocessor guard. Do not pass it to API calls.
+#define XR_ALMALENCE_digital_lens_control 1
+#define XR_ALMALENCE_digital_lens_control_SPEC_VERSION 1
+#define XR_ALMALENCE_DIGITAL_LENS_CONTROL_EXTENSION_NAME "XR_ALMALENCE_digital_lens_control"
+typedef XrFlags64 XrDigitalLensControlFlagsALMALENCE;
+
+// Flag bits for XrDigitalLensControlFlagsALMALENCE
+static const XrDigitalLensControlFlagsALMALENCE XR_DIGITAL_LENS_CONTROL_PROCESSING_DISABLE_BIT_ALMALENCE = 0x00000001;
+
+typedef struct XrDigitalLensControlALMALENCE {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrDigitalLensControlFlagsALMALENCE flags;
+} XrDigitalLensControlALMALENCE;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetDigitalLensControlALMALENCE)(XrSession session, const XrDigitalLensControlALMALENCE* digitalLensControl);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetDigitalLensControlALMALENCE(
+ XrSession session,
+ const XrDigitalLensControlALMALENCE* digitalLensControl);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_scene_capture is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_scene_capture 1
+#define XR_FB_scene_capture_SPEC_VERSION 1
+#define XR_FB_SCENE_CAPTURE_EXTENSION_NAME "XR_FB_scene_capture"
+typedef struct XrEventDataSceneCaptureCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSceneCaptureCompleteFB;
+
+typedef struct XrSceneCaptureRequestInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t requestByteCount;
+ const char* request;
+} XrSceneCaptureRequestInfoFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrRequestSceneCaptureFB)(XrSession session, const XrSceneCaptureRequestInfoFB* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestSceneCaptureFB(
+ XrSession session,
+ const XrSceneCaptureRequestInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_spatial_entity_container is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_container 1
+#define XR_FB_spatial_entity_container_SPEC_VERSION 2
+#define XR_FB_SPATIAL_ENTITY_CONTAINER_EXTENSION_NAME "XR_FB_spatial_entity_container"
+typedef struct XrSpaceContainerFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCapacityInput;
+ uint32_t uuidCountOutput;
+ XrUuidEXT* uuids;
+} XrSpaceContainerFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceContainerFB)(XrSession session, XrSpace space, XrSpaceContainerFB* spaceContainerOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceContainerFB(
+ XrSession session,
+ XrSpace space,
+ XrSpaceContainerFB* spaceContainerOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_foveation_eye_tracked is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_foveation_eye_tracked 1
+#define XR_FOVEATION_CENTER_SIZE_META 2
+#define XR_META_foveation_eye_tracked_SPEC_VERSION 1
+#define XR_META_FOVEATION_EYE_TRACKED_EXTENSION_NAME "XR_META_foveation_eye_tracked"
+typedef XrFlags64 XrFoveationEyeTrackedProfileCreateFlagsMETA;
+
+// Flag bits for XrFoveationEyeTrackedProfileCreateFlagsMETA
+
+typedef XrFlags64 XrFoveationEyeTrackedStateFlagsMETA;
+
+// Flag bits for XrFoveationEyeTrackedStateFlagsMETA
+static const XrFoveationEyeTrackedStateFlagsMETA XR_FOVEATION_EYE_TRACKED_STATE_VALID_BIT_META = 0x00000001;
+
+// XrFoveationEyeTrackedProfileCreateInfoMETA extends XrFoveationLevelProfileCreateInfoFB
+typedef struct XrFoveationEyeTrackedProfileCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFoveationEyeTrackedProfileCreateFlagsMETA flags;
+} XrFoveationEyeTrackedProfileCreateInfoMETA;
+
+typedef struct XrFoveationEyeTrackedStateMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVector2f foveationCenter[XR_FOVEATION_CENTER_SIZE_META];
+ XrFoveationEyeTrackedStateFlagsMETA flags;
+} XrFoveationEyeTrackedStateMETA;
+
+// XrSystemFoveationEyeTrackedPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemFoveationEyeTrackedPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFoveationEyeTracked;
+} XrSystemFoveationEyeTrackedPropertiesMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetFoveationEyeTrackedStateMETA)(XrSession session, XrFoveationEyeTrackedStateMETA* foveationState);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFoveationEyeTrackedStateMETA(
+ XrSession session,
+ XrFoveationEyeTrackedStateMETA* foveationState);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_face_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_face_tracking 1
+
+#define XR_FACE_EXPRESSSION_SET_DEFAULT_FB XR_FACE_EXPRESSION_SET_DEFAULT_FB
+
+XR_DEFINE_HANDLE(XrFaceTrackerFB)
+#define XR_FB_face_tracking_SPEC_VERSION 1
+#define XR_FB_FACE_TRACKING_EXTENSION_NAME "XR_FB_face_tracking"
+
+typedef enum XrFaceExpressionFB {
+ XR_FACE_EXPRESSION_BROW_LOWERER_L_FB = 0,
+ XR_FACE_EXPRESSION_BROW_LOWERER_R_FB = 1,
+ XR_FACE_EXPRESSION_CHEEK_PUFF_L_FB = 2,
+ XR_FACE_EXPRESSION_CHEEK_PUFF_R_FB = 3,
+ XR_FACE_EXPRESSION_CHEEK_RAISER_L_FB = 4,
+ XR_FACE_EXPRESSION_CHEEK_RAISER_R_FB = 5,
+ XR_FACE_EXPRESSION_CHEEK_SUCK_L_FB = 6,
+ XR_FACE_EXPRESSION_CHEEK_SUCK_R_FB = 7,
+ XR_FACE_EXPRESSION_CHIN_RAISER_B_FB = 8,
+ XR_FACE_EXPRESSION_CHIN_RAISER_T_FB = 9,
+ XR_FACE_EXPRESSION_DIMPLER_L_FB = 10,
+ XR_FACE_EXPRESSION_DIMPLER_R_FB = 11,
+ XR_FACE_EXPRESSION_EYES_CLOSED_L_FB = 12,
+ XR_FACE_EXPRESSION_EYES_CLOSED_R_FB = 13,
+ XR_FACE_EXPRESSION_EYES_LOOK_DOWN_L_FB = 14,
+ XR_FACE_EXPRESSION_EYES_LOOK_DOWN_R_FB = 15,
+ XR_FACE_EXPRESSION_EYES_LOOK_LEFT_L_FB = 16,
+ XR_FACE_EXPRESSION_EYES_LOOK_LEFT_R_FB = 17,
+ XR_FACE_EXPRESSION_EYES_LOOK_RIGHT_L_FB = 18,
+ XR_FACE_EXPRESSION_EYES_LOOK_RIGHT_R_FB = 19,
+ XR_FACE_EXPRESSION_EYES_LOOK_UP_L_FB = 20,
+ XR_FACE_EXPRESSION_EYES_LOOK_UP_R_FB = 21,
+ XR_FACE_EXPRESSION_INNER_BROW_RAISER_L_FB = 22,
+ XR_FACE_EXPRESSION_INNER_BROW_RAISER_R_FB = 23,
+ XR_FACE_EXPRESSION_JAW_DROP_FB = 24,
+ XR_FACE_EXPRESSION_JAW_SIDEWAYS_LEFT_FB = 25,
+ XR_FACE_EXPRESSION_JAW_SIDEWAYS_RIGHT_FB = 26,
+ XR_FACE_EXPRESSION_JAW_THRUST_FB = 27,
+ XR_FACE_EXPRESSION_LID_TIGHTENER_L_FB = 28,
+ XR_FACE_EXPRESSION_LID_TIGHTENER_R_FB = 29,
+ XR_FACE_EXPRESSION_LIP_CORNER_DEPRESSOR_L_FB = 30,
+ XR_FACE_EXPRESSION_LIP_CORNER_DEPRESSOR_R_FB = 31,
+ XR_FACE_EXPRESSION_LIP_CORNER_PULLER_L_FB = 32,
+ XR_FACE_EXPRESSION_LIP_CORNER_PULLER_R_FB = 33,
+ XR_FACE_EXPRESSION_LIP_FUNNELER_LB_FB = 34,
+ XR_FACE_EXPRESSION_LIP_FUNNELER_LT_FB = 35,
+ XR_FACE_EXPRESSION_LIP_FUNNELER_RB_FB = 36,
+ XR_FACE_EXPRESSION_LIP_FUNNELER_RT_FB = 37,
+ XR_FACE_EXPRESSION_LIP_PRESSOR_L_FB = 38,
+ XR_FACE_EXPRESSION_LIP_PRESSOR_R_FB = 39,
+ XR_FACE_EXPRESSION_LIP_PUCKER_L_FB = 40,
+ XR_FACE_EXPRESSION_LIP_PUCKER_R_FB = 41,
+ XR_FACE_EXPRESSION_LIP_STRETCHER_L_FB = 42,
+ XR_FACE_EXPRESSION_LIP_STRETCHER_R_FB = 43,
+ XR_FACE_EXPRESSION_LIP_SUCK_LB_FB = 44,
+ XR_FACE_EXPRESSION_LIP_SUCK_LT_FB = 45,
+ XR_FACE_EXPRESSION_LIP_SUCK_RB_FB = 46,
+ XR_FACE_EXPRESSION_LIP_SUCK_RT_FB = 47,
+ XR_FACE_EXPRESSION_LIP_TIGHTENER_L_FB = 48,
+ XR_FACE_EXPRESSION_LIP_TIGHTENER_R_FB = 49,
+ XR_FACE_EXPRESSION_LIPS_TOWARD_FB = 50,
+ XR_FACE_EXPRESSION_LOWER_LIP_DEPRESSOR_L_FB = 51,
+ XR_FACE_EXPRESSION_LOWER_LIP_DEPRESSOR_R_FB = 52,
+ XR_FACE_EXPRESSION_MOUTH_LEFT_FB = 53,
+ XR_FACE_EXPRESSION_MOUTH_RIGHT_FB = 54,
+ XR_FACE_EXPRESSION_NOSE_WRINKLER_L_FB = 55,
+ XR_FACE_EXPRESSION_NOSE_WRINKLER_R_FB = 56,
+ XR_FACE_EXPRESSION_OUTER_BROW_RAISER_L_FB = 57,
+ XR_FACE_EXPRESSION_OUTER_BROW_RAISER_R_FB = 58,
+ XR_FACE_EXPRESSION_UPPER_LID_RAISER_L_FB = 59,
+ XR_FACE_EXPRESSION_UPPER_LID_RAISER_R_FB = 60,
+ XR_FACE_EXPRESSION_UPPER_LIP_RAISER_L_FB = 61,
+ XR_FACE_EXPRESSION_UPPER_LIP_RAISER_R_FB = 62,
+ XR_FACE_EXPRESSION_COUNT_FB = 63,
+ XR_FACE_EXPRESSION_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceExpressionFB;
+
+typedef enum XrFaceExpressionSetFB {
+ XR_FACE_EXPRESSION_SET_DEFAULT_FB = 0,
+ XR_FACE_EXPRESSION_SET_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceExpressionSetFB;
+
+typedef enum XrFaceConfidenceFB {
+ XR_FACE_CONFIDENCE_LOWER_FACE_FB = 0,
+ XR_FACE_CONFIDENCE_UPPER_FACE_FB = 1,
+ XR_FACE_CONFIDENCE_COUNT_FB = 2,
+ XR_FACE_CONFIDENCE_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceConfidenceFB;
+// XrSystemFaceTrackingPropertiesFB extends XrSystemProperties
+typedef struct XrSystemFaceTrackingPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFaceTracking;
+} XrSystemFaceTrackingPropertiesFB;
+
+typedef struct XrFaceTrackerCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFaceExpressionSetFB faceExpressionSet;
+} XrFaceTrackerCreateInfoFB;
+
+typedef struct XrFaceExpressionInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrFaceExpressionInfoFB;
+
+typedef struct XrFaceExpressionStatusFB {
+ XrBool32 isValid;
+ XrBool32 isEyeFollowingBlendshapesValid;
+} XrFaceExpressionStatusFB;
+
+typedef struct XrFaceExpressionWeightsFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t weightCount;
+ float* weights;
+ uint32_t confidenceCount;
+ float* confidences;
+ XrFaceExpressionStatusFB status;
+ XrTime time;
+} XrFaceExpressionWeightsFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFaceTrackerFB)(XrSession session, const XrFaceTrackerCreateInfoFB* createInfo, XrFaceTrackerFB* faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFaceTrackerFB)(XrFaceTrackerFB faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFaceExpressionWeightsFB)(XrFaceTrackerFB faceTracker, const XrFaceExpressionInfoFB* expressionInfo, XrFaceExpressionWeightsFB* expressionWeights);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFaceTrackerFB(
+ XrSession session,
+ const XrFaceTrackerCreateInfoFB* createInfo,
+ XrFaceTrackerFB* faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFaceTrackerFB(
+ XrFaceTrackerFB faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFaceExpressionWeightsFB(
+ XrFaceTrackerFB faceTracker,
+ const XrFaceExpressionInfoFB* expressionInfo,
+ XrFaceExpressionWeightsFB* expressionWeights);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_eye_tracking_social is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_eye_tracking_social 1
+XR_DEFINE_HANDLE(XrEyeTrackerFB)
+#define XR_FB_eye_tracking_social_SPEC_VERSION 1
+#define XR_FB_EYE_TRACKING_SOCIAL_EXTENSION_NAME "XR_FB_eye_tracking_social"
+
+typedef enum XrEyePositionFB {
+ XR_EYE_POSITION_LEFT_FB = 0,
+ XR_EYE_POSITION_RIGHT_FB = 1,
+ XR_EYE_POSITION_COUNT_FB = 2,
+ XR_EYE_POSITION_MAX_ENUM_FB = 0x7FFFFFFF
+} XrEyePositionFB;
+typedef struct XrEyeGazeFB {
+ XrBool32 isValid;
+ XrPosef gazePose;
+ float gazeConfidence;
+} XrEyeGazeFB;
+
+typedef struct XrEyeTrackerCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEyeTrackerCreateInfoFB;
+
+typedef struct XrEyeGazesInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrEyeGazesInfoFB;
+
+// XrSystemEyeTrackingPropertiesFB extends XrSystemProperties
+typedef struct XrSystemEyeTrackingPropertiesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsEyeTracking;
+} XrSystemEyeTrackingPropertiesFB;
+
+typedef struct XrEyeGazesFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrEyeGazeFB gaze[XR_EYE_POSITION_COUNT_FB];
+ XrTime time;
+} XrEyeGazesFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEyeTrackerFB)(XrSession session, const XrEyeTrackerCreateInfoFB* createInfo, XrEyeTrackerFB* eyeTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyEyeTrackerFB)(XrEyeTrackerFB eyeTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetEyeGazesFB)(XrEyeTrackerFB eyeTracker, const XrEyeGazesInfoFB* gazeInfo, XrEyeGazesFB* eyeGazes);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEyeTrackerFB(
+ XrSession session,
+ const XrEyeTrackerCreateInfoFB* createInfo,
+ XrEyeTrackerFB* eyeTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyEyeTrackerFB(
+ XrEyeTrackerFB eyeTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetEyeGazesFB(
+ XrEyeTrackerFB eyeTracker,
+ const XrEyeGazesInfoFB* gazeInfo,
+ XrEyeGazesFB* eyeGazes);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_passthrough_keyboard_hands is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_passthrough_keyboard_hands 1
+#define XR_FB_passthrough_keyboard_hands_SPEC_VERSION 2
+#define XR_FB_PASSTHROUGH_KEYBOARD_HANDS_EXTENSION_NAME "XR_FB_passthrough_keyboard_hands"
+typedef struct XrPassthroughKeyboardHandsIntensityFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float leftHandIntensity;
+ float rightHandIntensity;
+} XrPassthroughKeyboardHandsIntensityFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrPassthroughLayerSetKeyboardHandsIntensityFB)(XrPassthroughLayerFB layer, const XrPassthroughKeyboardHandsIntensityFB* intensity);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrPassthroughLayerSetKeyboardHandsIntensityFB(
+ XrPassthroughLayerFB layer,
+ const XrPassthroughKeyboardHandsIntensityFB* intensity);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_composition_layer_settings is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_composition_layer_settings 1
+#define XR_FB_composition_layer_settings_SPEC_VERSION 1
+#define XR_FB_COMPOSITION_LAYER_SETTINGS_EXTENSION_NAME "XR_FB_composition_layer_settings"
+typedef XrFlags64 XrCompositionLayerSettingsFlagsFB;
+
+// Flag bits for XrCompositionLayerSettingsFlagsFB
+static const XrCompositionLayerSettingsFlagsFB XR_COMPOSITION_LAYER_SETTINGS_NORMAL_SUPER_SAMPLING_BIT_FB = 0x00000001;
+static const XrCompositionLayerSettingsFlagsFB XR_COMPOSITION_LAYER_SETTINGS_QUALITY_SUPER_SAMPLING_BIT_FB = 0x00000002;
+static const XrCompositionLayerSettingsFlagsFB XR_COMPOSITION_LAYER_SETTINGS_NORMAL_SHARPENING_BIT_FB = 0x00000004;
+static const XrCompositionLayerSettingsFlagsFB XR_COMPOSITION_LAYER_SETTINGS_QUALITY_SHARPENING_BIT_FB = 0x00000008;
+static const XrCompositionLayerSettingsFlagsFB XR_COMPOSITION_LAYER_SETTINGS_AUTO_LAYER_FILTER_BIT_META = 0x00000020;
+
+// XrCompositionLayerSettingsFB extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerSettingsFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerSettingsFlagsFB layerFlags;
+} XrCompositionLayerSettingsFB;
+
+
+
+// XR_FB_touch_controller_proximity is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_touch_controller_proximity 1
+#define XR_FB_touch_controller_proximity_SPEC_VERSION 1
+#define XR_FB_TOUCH_CONTROLLER_PROXIMITY_EXTENSION_NAME "XR_FB_touch_controller_proximity"
+
+
+// XR_FB_haptic_pcm is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_haptic_pcm 1
+
+#define XR_MAX_HAPTIC_PCM_BUFFER_SIZE_FB 4000
+
+#define XR_FB_haptic_pcm_SPEC_VERSION 1
+#define XR_FB_HAPTIC_PCM_EXTENSION_NAME "XR_FB_haptic_pcm"
+typedef struct XrHapticPcmVibrationFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t bufferSize;
+ const float* buffer;
+ float sampleRate;
+ XrBool32 append;
+ uint32_t* samplesConsumed;
+} XrHapticPcmVibrationFB;
+
+typedef struct XrDevicePcmSampleRateStateFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ float sampleRate;
+} XrDevicePcmSampleRateStateFB;
+
+typedef XrDevicePcmSampleRateStateFB XrDevicePcmSampleRateGetInfoFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetDeviceSampleRateFB)(XrSession session, const XrHapticActionInfo* hapticActionInfo, XrDevicePcmSampleRateGetInfoFB* deviceSampleRate);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetDeviceSampleRateFB(
+ XrSession session,
+ const XrHapticActionInfo* hapticActionInfo,
+ XrDevicePcmSampleRateGetInfoFB* deviceSampleRate);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_frame_synthesis is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_frame_synthesis 1
+#define XR_EXT_frame_synthesis_SPEC_VERSION 1
+#define XR_EXT_FRAME_SYNTHESIS_EXTENSION_NAME "XR_EXT_frame_synthesis"
+typedef XrFlags64 XrFrameSynthesisInfoFlagsEXT;
+
+// Flag bits for XrFrameSynthesisInfoFlagsEXT
+static const XrFrameSynthesisInfoFlagsEXT XR_FRAME_SYNTHESIS_INFO_USE_2D_MOTION_VECTOR_BIT_EXT = 0x00000001;
+static const XrFrameSynthesisInfoFlagsEXT XR_FRAME_SYNTHESIS_INFO_REQUEST_RELAXED_FRAME_INTERVAL_BIT_EXT = 0x00000002;
+
+// XrFrameSynthesisInfoEXT extends XrCompositionLayerProjectionView
+typedef struct XrFrameSynthesisInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFrameSynthesisInfoFlagsEXT layerFlags;
+ XrSwapchainSubImage motionVectorSubImage;
+ XrVector4f motionVectorScale;
+ XrVector4f motionVectorOffset;
+ XrPosef appSpaceDeltaPose;
+ XrSwapchainSubImage depthSubImage;
+ float minDepth;
+ float maxDepth;
+ float nearZ;
+ float farZ;
+} XrFrameSynthesisInfoEXT;
+
+// XrFrameSynthesisConfigViewEXT extends XrViewConfigurationView
+typedef struct XrFrameSynthesisConfigViewEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t recommendedMotionVectorImageRectWidth;
+ uint32_t recommendedMotionVectorImageRectHeight;
+} XrFrameSynthesisConfigViewEXT;
+
+
+
+// XR_FB_composition_layer_depth_test is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_composition_layer_depth_test 1
+#define XR_FB_composition_layer_depth_test_SPEC_VERSION 1
+#define XR_FB_COMPOSITION_LAYER_DEPTH_TEST_EXTENSION_NAME "XR_FB_composition_layer_depth_test"
+
+typedef enum XrCompareOpFB {
+ XR_COMPARE_OP_NEVER_FB = 0,
+ XR_COMPARE_OP_LESS_FB = 1,
+ XR_COMPARE_OP_EQUAL_FB = 2,
+ XR_COMPARE_OP_LESS_OR_EQUAL_FB = 3,
+ XR_COMPARE_OP_GREATER_FB = 4,
+ XR_COMPARE_OP_NOT_EQUAL_FB = 5,
+ XR_COMPARE_OP_GREATER_OR_EQUAL_FB = 6,
+ XR_COMPARE_OP_ALWAYS_FB = 7,
+ XR_COMPARE_OP_MAX_ENUM_FB = 0x7FFFFFFF
+} XrCompareOpFB;
+// XrCompositionLayerDepthTestFB extends XrCompositionLayerBaseHeader
+typedef struct XrCompositionLayerDepthTestFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 depthMask;
+ XrCompareOpFB compareOp;
+} XrCompositionLayerDepthTestFB;
+
+
+
+// XR_META_local_dimming is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_local_dimming 1
+#define XR_META_local_dimming_SPEC_VERSION 1
+#define XR_META_LOCAL_DIMMING_EXTENSION_NAME "XR_META_local_dimming"
+
+typedef enum XrLocalDimmingModeMETA {
+ XR_LOCAL_DIMMING_MODE_OFF_META = 0,
+ XR_LOCAL_DIMMING_MODE_ON_META = 1,
+ XR_LOCAL_DIMMING_MODE_MAX_ENUM_META = 0x7FFFFFFF
+} XrLocalDimmingModeMETA;
+// XrLocalDimmingFrameEndInfoMETA extends XrFrameEndInfo
+typedef struct XrLocalDimmingFrameEndInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrLocalDimmingModeMETA localDimmingMode;
+} XrLocalDimmingFrameEndInfoMETA;
+
+
+
+// XR_META_passthrough_preferences is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_passthrough_preferences 1
+#define XR_META_passthrough_preferences_SPEC_VERSION 1
+#define XR_META_PASSTHROUGH_PREFERENCES_EXTENSION_NAME "XR_META_passthrough_preferences"
+typedef XrFlags64 XrPassthroughPreferenceFlagsMETA;
+
+// Flag bits for XrPassthroughPreferenceFlagsMETA
+static const XrPassthroughPreferenceFlagsMETA XR_PASSTHROUGH_PREFERENCE_DEFAULT_TO_ACTIVE_BIT_META = 0x00000001;
+
+typedef struct XrPassthroughPreferencesMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughPreferenceFlagsMETA flags;
+} XrPassthroughPreferencesMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetPassthroughPreferencesMETA)(XrSession session, XrPassthroughPreferencesMETA* preferences);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPassthroughPreferencesMETA(
+ XrSession session,
+ XrPassthroughPreferencesMETA* preferences);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_virtual_keyboard is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_virtual_keyboard 1
+XR_DEFINE_HANDLE(XrVirtualKeyboardMETA)
+#define XR_MAX_VIRTUAL_KEYBOARD_COMMIT_TEXT_SIZE_META 3992
+#define XR_META_virtual_keyboard_SPEC_VERSION 1
+#define XR_META_VIRTUAL_KEYBOARD_EXTENSION_NAME "XR_META_virtual_keyboard"
+
+typedef enum XrVirtualKeyboardLocationTypeMETA {
+ XR_VIRTUAL_KEYBOARD_LOCATION_TYPE_CUSTOM_META = 0,
+ XR_VIRTUAL_KEYBOARD_LOCATION_TYPE_FAR_META = 1,
+ XR_VIRTUAL_KEYBOARD_LOCATION_TYPE_DIRECT_META = 2,
+ XR_VIRTUAL_KEYBOARD_LOCATION_TYPE_MAX_ENUM_META = 0x7FFFFFFF
+} XrVirtualKeyboardLocationTypeMETA;
+
+typedef enum XrVirtualKeyboardInputSourceMETA {
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_CONTROLLER_RAY_LEFT_META = 1,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_CONTROLLER_RAY_RIGHT_META = 2,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_HAND_RAY_LEFT_META = 3,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_HAND_RAY_RIGHT_META = 4,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_CONTROLLER_DIRECT_LEFT_META = 5,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_CONTROLLER_DIRECT_RIGHT_META = 6,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_HAND_DIRECT_INDEX_TIP_LEFT_META = 7,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_HAND_DIRECT_INDEX_TIP_RIGHT_META = 8,
+ XR_VIRTUAL_KEYBOARD_INPUT_SOURCE_MAX_ENUM_META = 0x7FFFFFFF
+} XrVirtualKeyboardInputSourceMETA;
+typedef XrFlags64 XrVirtualKeyboardInputStateFlagsMETA;
+
+// Flag bits for XrVirtualKeyboardInputStateFlagsMETA
+static const XrVirtualKeyboardInputStateFlagsMETA XR_VIRTUAL_KEYBOARD_INPUT_STATE_PRESSED_BIT_META = 0x00000001;
+
+// XrSystemVirtualKeyboardPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemVirtualKeyboardPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsVirtualKeyboard;
+} XrSystemVirtualKeyboardPropertiesMETA;
+
+typedef struct XrVirtualKeyboardCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrVirtualKeyboardCreateInfoMETA;
+
+typedef struct XrVirtualKeyboardSpaceCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardLocationTypeMETA locationType;
+ XrSpace space;
+ XrPosef poseInSpace;
+} XrVirtualKeyboardSpaceCreateInfoMETA;
+
+typedef struct XrVirtualKeyboardLocationInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardLocationTypeMETA locationType;
+ XrSpace space;
+ XrPosef poseInSpace;
+ float scale;
+} XrVirtualKeyboardLocationInfoMETA;
+
+typedef struct XrVirtualKeyboardModelVisibilitySetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 visible;
+} XrVirtualKeyboardModelVisibilitySetInfoMETA;
+
+typedef struct XrVirtualKeyboardAnimationStateMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ int32_t animationIndex;
+ float fraction;
+} XrVirtualKeyboardAnimationStateMETA;
+
+typedef struct XrVirtualKeyboardModelAnimationStatesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t stateCapacityInput;
+ uint32_t stateCountOutput;
+ XrVirtualKeyboardAnimationStateMETA* states;
+} XrVirtualKeyboardModelAnimationStatesMETA;
+
+typedef struct XrVirtualKeyboardTextureDataMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t textureWidth;
+ uint32_t textureHeight;
+ uint32_t bufferCapacityInput;
+ uint32_t bufferCountOutput;
+ uint8_t* buffer;
+} XrVirtualKeyboardTextureDataMETA;
+
+typedef struct XrVirtualKeyboardInputInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardInputSourceMETA inputSource;
+ XrSpace inputSpace;
+ XrPosef inputPoseInSpace;
+ XrVirtualKeyboardInputStateFlagsMETA inputState;
+} XrVirtualKeyboardInputInfoMETA;
+
+typedef struct XrVirtualKeyboardTextContextChangeInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const char* textContext;
+} XrVirtualKeyboardTextContextChangeInfoMETA;
+
+typedef struct XrEventDataVirtualKeyboardCommitTextMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardMETA keyboard;
+ char text[XR_MAX_VIRTUAL_KEYBOARD_COMMIT_TEXT_SIZE_META];
+} XrEventDataVirtualKeyboardCommitTextMETA;
+
+typedef struct XrEventDataVirtualKeyboardBackspaceMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardMETA keyboard;
+} XrEventDataVirtualKeyboardBackspaceMETA;
+
+typedef struct XrEventDataVirtualKeyboardEnterMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardMETA keyboard;
+} XrEventDataVirtualKeyboardEnterMETA;
+
+typedef struct XrEventDataVirtualKeyboardShownMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardMETA keyboard;
+} XrEventDataVirtualKeyboardShownMETA;
+
+typedef struct XrEventDataVirtualKeyboardHiddenMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrVirtualKeyboardMETA keyboard;
+} XrEventDataVirtualKeyboardHiddenMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateVirtualKeyboardMETA)(XrSession session, const XrVirtualKeyboardCreateInfoMETA* createInfo, XrVirtualKeyboardMETA* keyboard);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyVirtualKeyboardMETA)(XrVirtualKeyboardMETA keyboard);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateVirtualKeyboardSpaceMETA)(XrSession session, XrVirtualKeyboardMETA keyboard, const XrVirtualKeyboardSpaceCreateInfoMETA* createInfo, XrSpace* keyboardSpace);
+typedef XrResult (XRAPI_PTR *PFN_xrSuggestVirtualKeyboardLocationMETA)(XrVirtualKeyboardMETA keyboard, const XrVirtualKeyboardLocationInfoMETA* locationInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVirtualKeyboardScaleMETA)(XrVirtualKeyboardMETA keyboard, float* scale);
+typedef XrResult (XRAPI_PTR *PFN_xrSetVirtualKeyboardModelVisibilityMETA)(XrVirtualKeyboardMETA keyboard, const XrVirtualKeyboardModelVisibilitySetInfoMETA* modelVisibility);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVirtualKeyboardModelAnimationStatesMETA)(XrVirtualKeyboardMETA keyboard, XrVirtualKeyboardModelAnimationStatesMETA* animationStates);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVirtualKeyboardDirtyTexturesMETA)(XrVirtualKeyboardMETA keyboard, uint32_t textureIdCapacityInput, uint32_t* textureIdCountOutput, uint64_t* textureIds);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVirtualKeyboardTextureDataMETA)(XrVirtualKeyboardMETA keyboard, uint64_t textureId, XrVirtualKeyboardTextureDataMETA* textureData);
+typedef XrResult (XRAPI_PTR *PFN_xrSendVirtualKeyboardInputMETA)(XrVirtualKeyboardMETA keyboard, const XrVirtualKeyboardInputInfoMETA* info, XrPosef* interactorRootPose);
+typedef XrResult (XRAPI_PTR *PFN_xrChangeVirtualKeyboardTextContextMETA)(XrVirtualKeyboardMETA keyboard, const XrVirtualKeyboardTextContextChangeInfoMETA* changeInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateVirtualKeyboardMETA(
+ XrSession session,
+ const XrVirtualKeyboardCreateInfoMETA* createInfo,
+ XrVirtualKeyboardMETA* keyboard);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyVirtualKeyboardMETA(
+ XrVirtualKeyboardMETA keyboard);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateVirtualKeyboardSpaceMETA(
+ XrSession session,
+ XrVirtualKeyboardMETA keyboard,
+ const XrVirtualKeyboardSpaceCreateInfoMETA* createInfo,
+ XrSpace* keyboardSpace);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSuggestVirtualKeyboardLocationMETA(
+ XrVirtualKeyboardMETA keyboard,
+ const XrVirtualKeyboardLocationInfoMETA* locationInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVirtualKeyboardScaleMETA(
+ XrVirtualKeyboardMETA keyboard,
+ float* scale);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetVirtualKeyboardModelVisibilityMETA(
+ XrVirtualKeyboardMETA keyboard,
+ const XrVirtualKeyboardModelVisibilitySetInfoMETA* modelVisibility);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVirtualKeyboardModelAnimationStatesMETA(
+ XrVirtualKeyboardMETA keyboard,
+ XrVirtualKeyboardModelAnimationStatesMETA* animationStates);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVirtualKeyboardDirtyTexturesMETA(
+ XrVirtualKeyboardMETA keyboard,
+ uint32_t textureIdCapacityInput,
+ uint32_t* textureIdCountOutput,
+ uint64_t* textureIds);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVirtualKeyboardTextureDataMETA(
+ XrVirtualKeyboardMETA keyboard,
+ uint64_t textureId,
+ XrVirtualKeyboardTextureDataMETA* textureData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSendVirtualKeyboardInputMETA(
+ XrVirtualKeyboardMETA keyboard,
+ const XrVirtualKeyboardInputInfoMETA* info,
+ XrPosef* interactorRootPose);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrChangeVirtualKeyboardTextContextMETA(
+ XrVirtualKeyboardMETA keyboard,
+ const XrVirtualKeyboardTextContextChangeInfoMETA* changeInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_OCULUS_external_camera is a preprocessor guard. Do not pass it to API calls.
+#define XR_OCULUS_external_camera 1
+#define XR_MAX_EXTERNAL_CAMERA_NAME_SIZE_OCULUS 32
+#define XR_OCULUS_external_camera_SPEC_VERSION 1
+#define XR_OCULUS_EXTERNAL_CAMERA_EXTENSION_NAME "XR_OCULUS_external_camera"
+
+typedef enum XrExternalCameraAttachedToDeviceOCULUS {
+ XR_EXTERNAL_CAMERA_ATTACHED_TO_DEVICE_NONE_OCULUS = 0,
+ XR_EXTERNAL_CAMERA_ATTACHED_TO_DEVICE_HMD_OCULUS = 1,
+ XR_EXTERNAL_CAMERA_ATTACHED_TO_DEVICE_LTOUCH_OCULUS = 2,
+ XR_EXTERNAL_CAMERA_ATTACHED_TO_DEVICE_RTOUCH_OCULUS = 3,
+ XR_EXTERNAL_CAMERA_ATTACHED_TO_DEVICE_MAX_ENUM_OCULUS = 0x7FFFFFFF
+} XrExternalCameraAttachedToDeviceOCULUS;
+typedef XrFlags64 XrExternalCameraStatusFlagsOCULUS;
+
+// Flag bits for XrExternalCameraStatusFlagsOCULUS
+static const XrExternalCameraStatusFlagsOCULUS XR_EXTERNAL_CAMERA_STATUS_CONNECTED_BIT_OCULUS = 0x00000001;
+static const XrExternalCameraStatusFlagsOCULUS XR_EXTERNAL_CAMERA_STATUS_CALIBRATING_BIT_OCULUS = 0x00000002;
+static const XrExternalCameraStatusFlagsOCULUS XR_EXTERNAL_CAMERA_STATUS_CALIBRATION_FAILED_BIT_OCULUS = 0x00000004;
+static const XrExternalCameraStatusFlagsOCULUS XR_EXTERNAL_CAMERA_STATUS_CALIBRATED_BIT_OCULUS = 0x00000008;
+static const XrExternalCameraStatusFlagsOCULUS XR_EXTERNAL_CAMERA_STATUS_CAPTURING_BIT_OCULUS = 0x00000010;
+
+typedef struct XrExternalCameraIntrinsicsOCULUS {
+ XrTime lastChangeTime;
+ XrFovf fov;
+ float virtualNearPlaneDistance;
+ float virtualFarPlaneDistance;
+ XrExtent2Di imageSensorPixelResolution;
+} XrExternalCameraIntrinsicsOCULUS;
+
+typedef struct XrExternalCameraExtrinsicsOCULUS {
+ XrTime lastChangeTime;
+ XrExternalCameraStatusFlagsOCULUS cameraStatusFlags;
+ XrExternalCameraAttachedToDeviceOCULUS attachedToDevice;
+ XrPosef relativePose;
+} XrExternalCameraExtrinsicsOCULUS;
+
+typedef struct XrExternalCameraOCULUS {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ char name[XR_MAX_EXTERNAL_CAMERA_NAME_SIZE_OCULUS];
+ XrExternalCameraIntrinsicsOCULUS intrinsics;
+ XrExternalCameraExtrinsicsOCULUS extrinsics;
+} XrExternalCameraOCULUS;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateExternalCamerasOCULUS)(XrSession session, uint32_t cameraCapacityInput, uint32_t* cameraCountOutput, XrExternalCameraOCULUS* cameras);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateExternalCamerasOCULUS(
+ XrSession session,
+ uint32_t cameraCapacityInput,
+ uint32_t* cameraCountOutput,
+ XrExternalCameraOCULUS* cameras);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_performance_metrics is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_performance_metrics 1
+#define XR_META_performance_metrics_SPEC_VERSION 2
+#define XR_META_PERFORMANCE_METRICS_EXTENSION_NAME "XR_META_performance_metrics"
+
+typedef enum XrPerformanceMetricsCounterUnitMETA {
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_GENERIC_META = 0,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_PERCENTAGE_META = 1,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_MILLISECONDS_META = 2,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_BYTES_META = 3,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_HERTZ_META = 4,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_MAX_ENUM_META = 0x7FFFFFFF
+} XrPerformanceMetricsCounterUnitMETA;
+typedef XrFlags64 XrPerformanceMetricsCounterFlagsMETA;
+
+// Flag bits for XrPerformanceMetricsCounterFlagsMETA
+static const XrPerformanceMetricsCounterFlagsMETA XR_PERFORMANCE_METRICS_COUNTER_ANY_VALUE_VALID_BIT_META = 0x00000001;
+static const XrPerformanceMetricsCounterFlagsMETA XR_PERFORMANCE_METRICS_COUNTER_UINT_VALUE_VALID_BIT_META = 0x00000002;
+static const XrPerformanceMetricsCounterFlagsMETA XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_META = 0x00000004;
+
+typedef struct XrPerformanceMetricsStateMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+} XrPerformanceMetricsStateMETA;
+
+typedef struct XrPerformanceMetricsCounterMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPerformanceMetricsCounterFlagsMETA counterFlags;
+ XrPerformanceMetricsCounterUnitMETA counterUnit;
+ uint32_t uintValue;
+ float floatValue;
+} XrPerformanceMetricsCounterMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumeratePerformanceMetricsCounterPathsMETA)(XrInstance instance, uint32_t counterPathCapacityInput, uint32_t* counterPathCountOutput, XrPath* counterPaths);
+typedef XrResult (XRAPI_PTR *PFN_xrSetPerformanceMetricsStateMETA)(XrSession session, const XrPerformanceMetricsStateMETA* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetPerformanceMetricsStateMETA)(XrSession session, XrPerformanceMetricsStateMETA* state);
+typedef XrResult (XRAPI_PTR *PFN_xrQueryPerformanceMetricsCounterMETA)(XrSession session, XrPath counterPath, XrPerformanceMetricsCounterMETA* counter);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumeratePerformanceMetricsCounterPathsMETA(
+ XrInstance instance,
+ uint32_t counterPathCapacityInput,
+ uint32_t* counterPathCountOutput,
+ XrPath* counterPaths);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetPerformanceMetricsStateMETA(
+ XrSession session,
+ const XrPerformanceMetricsStateMETA* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPerformanceMetricsStateMETA(
+ XrSession session,
+ XrPerformanceMetricsStateMETA* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQueryPerformanceMetricsCounterMETA(
+ XrSession session,
+ XrPath counterPath,
+ XrPerformanceMetricsCounterMETA* counter);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_spatial_entity_storage_batch is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_storage_batch 1
+#define XR_FB_spatial_entity_storage_batch_SPEC_VERSION 1
+#define XR_FB_SPATIAL_ENTITY_STORAGE_BATCH_EXTENSION_NAME "XR_FB_spatial_entity_storage_batch"
+typedef struct XrSpaceListSaveInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+ XrSpaceStorageLocationFB location;
+} XrSpaceListSaveInfoFB;
+
+typedef struct XrEventDataSpaceListSaveCompleteFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpaceListSaveCompleteFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSaveSpaceListFB)(XrSession session, const XrSpaceListSaveInfoFB* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSaveSpaceListFB(
+ XrSession session,
+ const XrSpaceListSaveInfoFB* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_detached_controllers is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_detached_controllers 1
+#define XR_META_detached_controllers_SPEC_VERSION 1
+#define XR_META_DETACHED_CONTROLLERS_EXTENSION_NAME "XR_META_detached_controllers"
+
+
+// XR_FB_spatial_entity_user is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_spatial_entity_user 1
+typedef uint64_t XrSpaceUserIdFB;
+#define XR_FB_spatial_entity_user_SPEC_VERSION 1
+#define XR_FB_SPATIAL_ENTITY_USER_EXTENSION_NAME "XR_FB_spatial_entity_user"
+typedef struct XrSpaceUserCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceUserIdFB userId;
+} XrSpaceUserCreateInfoFB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpaceUserFB)(XrSession session, const XrSpaceUserCreateInfoFB* info, XrSpaceUserFB* user);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceUserIdFB)(XrSpaceUserFB user, XrSpaceUserIdFB* userId);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpaceUserFB)(XrSpaceUserFB user);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpaceUserFB(
+ XrSession session,
+ const XrSpaceUserCreateInfoFB* info,
+ XrSpaceUserFB* user);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceUserIdFB(
+ XrSpaceUserFB user,
+ XrSpaceUserIdFB* userId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpaceUserFB(
+ XrSpaceUserFB user);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_headset_id is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_headset_id 1
+#define XR_META_headset_id_SPEC_VERSION 2
+#define XR_META_HEADSET_ID_EXTENSION_NAME "XR_META_headset_id"
+// XrSystemHeadsetIdPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemHeadsetIdPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuidEXT id;
+} XrSystemHeadsetIdPropertiesMETA;
+
+
+
+// XR_META_spatial_entity_discovery is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_discovery 1
+#define XR_META_spatial_entity_discovery_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_DISCOVERY_EXTENSION_NAME "XR_META_spatial_entity_discovery"
+// XrSystemSpaceDiscoveryPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemSpaceDiscoveryPropertiesMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpaceDiscovery;
+} XrSystemSpaceDiscoveryPropertiesMETA;
+
+typedef struct XR_MAY_ALIAS XrSpaceFilterBaseHeaderMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpaceFilterBaseHeaderMETA;
+
+typedef struct XrSpaceDiscoveryInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t filterCount;
+ const XrSpaceFilterBaseHeaderMETA* const * filters;
+} XrSpaceDiscoveryInfoMETA;
+
+typedef struct XrSpaceFilterUuidMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCount;
+ const XrUuidEXT* uuids;
+} XrSpaceFilterUuidMETA;
+
+typedef struct XrSpaceFilterComponentMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpaceComponentTypeFB componentType;
+} XrSpaceFilterComponentMETA;
+
+typedef struct XrSpaceDiscoveryResultMETA {
+ XrSpace space;
+ XrUuidEXT uuid;
+} XrSpaceDiscoveryResultMETA;
+
+typedef struct XrSpaceDiscoveryResultsMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t resultCapacityInput;
+ uint32_t resultCountOutput;
+ XrSpaceDiscoveryResultMETA* results;
+} XrSpaceDiscoveryResultsMETA;
+
+typedef struct XrEventDataSpaceDiscoveryResultsAvailableMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+} XrEventDataSpaceDiscoveryResultsAvailableMETA;
+
+typedef struct XrEventDataSpaceDiscoveryCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpaceDiscoveryCompleteMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrDiscoverSpacesMETA)(XrSession session, const XrSpaceDiscoveryInfoMETA* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrRetrieveSpaceDiscoveryResultsMETA)(XrSession session, XrAsyncRequestIdFB requestId, XrSpaceDiscoveryResultsMETA* results);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrDiscoverSpacesMETA(
+ XrSession session,
+ const XrSpaceDiscoveryInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRetrieveSpaceDiscoveryResultsMETA(
+ XrSession session,
+ XrAsyncRequestIdFB requestId,
+ XrSpaceDiscoveryResultsMETA* results);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_hand_tracking_microgestures is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_hand_tracking_microgestures 1
+#define XR_META_hand_tracking_microgestures_SPEC_VERSION 1
+#define XR_META_HAND_TRACKING_MICROGESTURES_EXTENSION_NAME "XR_META_hand_tracking_microgestures"
+
+
+// XR_META_recommended_layer_resolution is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_recommended_layer_resolution 1
+#define XR_META_recommended_layer_resolution_SPEC_VERSION 1
+#define XR_META_RECOMMENDED_LAYER_RESOLUTION_EXTENSION_NAME "XR_META_recommended_layer_resolution"
+typedef struct XrRecommendedLayerResolutionMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrExtent2Di recommendedImageDimensions;
+ XrBool32 isValid;
+} XrRecommendedLayerResolutionMETA;
+
+typedef struct XrRecommendedLayerResolutionGetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const XrCompositionLayerBaseHeader* layer;
+ XrTime predictedDisplayTime;
+} XrRecommendedLayerResolutionGetInfoMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetRecommendedLayerResolutionMETA)(XrSession session, const XrRecommendedLayerResolutionGetInfoMETA* info, XrRecommendedLayerResolutionMETA* resolution);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRecommendedLayerResolutionMETA(
+ XrSession session,
+ const XrRecommendedLayerResolutionGetInfoMETA* info,
+ XrRecommendedLayerResolutionMETA* resolution);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_spatial_entity_persistence is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_persistence 1
+#define XR_META_spatial_entity_persistence_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_PERSISTENCE_EXTENSION_NAME "XR_META_spatial_entity_persistence"
+// XrSystemSpacePersistencePropertiesMETA extends XrSystemProperties
+typedef struct XrSystemSpacePersistencePropertiesMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpacePersistence;
+} XrSystemSpacePersistencePropertiesMETA;
+
+typedef struct XrSpacesSaveInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+} XrSpacesSaveInfoMETA;
+
+typedef struct XrEventDataSpacesSaveResultMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpacesSaveResultMETA;
+
+typedef struct XrSpacesEraseInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+ uint32_t uuidCount;
+ XrUuidEXT* uuids;
+} XrSpacesEraseInfoMETA;
+
+typedef struct XrEventDataSpacesEraseResultMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataSpacesEraseResultMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSaveSpacesMETA)(XrSession session, const XrSpacesSaveInfoMETA* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrEraseSpacesMETA)(XrSession session, const XrSpacesEraseInfoMETA* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSaveSpacesMETA(
+ XrSession session,
+ const XrSpacesSaveInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEraseSpacesMETA(
+ XrSession session,
+ const XrSpacesEraseInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_passthrough_color_lut is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_passthrough_color_lut 1
+XR_DEFINE_HANDLE(XrPassthroughColorLutMETA)
+#define XR_META_passthrough_color_lut_SPEC_VERSION 1
+#define XR_META_PASSTHROUGH_COLOR_LUT_EXTENSION_NAME "XR_META_passthrough_color_lut"
+
+typedef enum XrPassthroughColorLutChannelsMETA {
+ XR_PASSTHROUGH_COLOR_LUT_CHANNELS_RGB_META = 1,
+ XR_PASSTHROUGH_COLOR_LUT_CHANNELS_RGBA_META = 2,
+ XR_PASSTHROUGH_COLOR_LUT_CHANNELS_MAX_ENUM_META = 0x7FFFFFFF
+} XrPassthroughColorLutChannelsMETA;
+typedef struct XrPassthroughColorLutDataMETA {
+ uint32_t bufferSize;
+ const uint8_t* buffer;
+} XrPassthroughColorLutDataMETA;
+
+typedef struct XrPassthroughColorLutCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughColorLutChannelsMETA channels;
+ uint32_t resolution;
+ XrPassthroughColorLutDataMETA data;
+} XrPassthroughColorLutCreateInfoMETA;
+
+typedef struct XrPassthroughColorLutUpdateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughColorLutDataMETA data;
+} XrPassthroughColorLutUpdateInfoMETA;
+
+// XrPassthroughColorMapLutMETA extends XrPassthroughStyleFB
+typedef struct XrPassthroughColorMapLutMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughColorLutMETA colorLut;
+ float weight;
+} XrPassthroughColorMapLutMETA;
+
+// XrPassthroughColorMapInterpolatedLutMETA extends XrPassthroughStyleFB
+typedef struct XrPassthroughColorMapInterpolatedLutMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughColorLutMETA sourceColorLut;
+ XrPassthroughColorLutMETA targetColorLut;
+ float weight;
+} XrPassthroughColorMapInterpolatedLutMETA;
+
+// XrSystemPassthroughColorLutPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemPassthroughColorLutPropertiesMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t maxColorLutResolution;
+} XrSystemPassthroughColorLutPropertiesMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePassthroughColorLutMETA)(XrPassthroughFB passthrough, const XrPassthroughColorLutCreateInfoMETA* createInfo, XrPassthroughColorLutMETA* colorLut);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPassthroughColorLutMETA)(XrPassthroughColorLutMETA colorLut);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdatePassthroughColorLutMETA)(XrPassthroughColorLutMETA colorLut, const XrPassthroughColorLutUpdateInfoMETA* updateInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePassthroughColorLutMETA(
+ XrPassthroughFB passthrough,
+ const XrPassthroughColorLutCreateInfoMETA* createInfo,
+ XrPassthroughColorLutMETA* colorLut);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPassthroughColorLutMETA(
+ XrPassthroughColorLutMETA colorLut);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdatePassthroughColorLutMETA(
+ XrPassthroughColorLutMETA colorLut,
+ const XrPassthroughColorLutUpdateInfoMETA* updateInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_spatial_entity_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_mesh 1
+#define XR_META_spatial_entity_mesh_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_MESH_EXTENSION_NAME "XR_META_spatial_entity_mesh"
+typedef struct XrSpaceTriangleMeshGetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpaceTriangleMeshGetInfoMETA;
+
+typedef struct XrSpaceTriangleMeshMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertices;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrSpaceTriangleMeshMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceTriangleMeshMETA)(XrSpace space, const XrSpaceTriangleMeshGetInfoMETA* getInfo, XrSpaceTriangleMeshMETA* triangleMeshOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceTriangleMeshMETA(
+ XrSpace space,
+ const XrSpaceTriangleMeshGetInfoMETA* getInfo,
+ XrSpaceTriangleMeshMETA* triangleMeshOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_automatic_layer_filter is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_automatic_layer_filter 1
+#define XR_META_automatic_layer_filter_SPEC_VERSION 1
+#define XR_META_AUTOMATIC_LAYER_FILTER_EXTENSION_NAME "XR_META_automatic_layer_filter"
+
+
+// XR_META_body_tracking_full_body is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_body_tracking_full_body 1
+#define XR_META_body_tracking_full_body_SPEC_VERSION 1
+#define XR_META_BODY_TRACKING_FULL_BODY_EXTENSION_NAME "XR_META_body_tracking_full_body"
+
+typedef enum XrFullBodyJointMETA {
+ XR_FULL_BODY_JOINT_ROOT_META = 0,
+ XR_FULL_BODY_JOINT_HIPS_META = 1,
+ XR_FULL_BODY_JOINT_SPINE_LOWER_META = 2,
+ XR_FULL_BODY_JOINT_SPINE_MIDDLE_META = 3,
+ XR_FULL_BODY_JOINT_SPINE_UPPER_META = 4,
+ XR_FULL_BODY_JOINT_CHEST_META = 5,
+ XR_FULL_BODY_JOINT_NECK_META = 6,
+ XR_FULL_BODY_JOINT_HEAD_META = 7,
+ XR_FULL_BODY_JOINT_LEFT_SHOULDER_META = 8,
+ XR_FULL_BODY_JOINT_LEFT_SCAPULA_META = 9,
+ XR_FULL_BODY_JOINT_LEFT_ARM_UPPER_META = 10,
+ XR_FULL_BODY_JOINT_LEFT_ARM_LOWER_META = 11,
+ XR_FULL_BODY_JOINT_LEFT_HAND_WRIST_TWIST_META = 12,
+ XR_FULL_BODY_JOINT_RIGHT_SHOULDER_META = 13,
+ XR_FULL_BODY_JOINT_RIGHT_SCAPULA_META = 14,
+ XR_FULL_BODY_JOINT_RIGHT_ARM_UPPER_META = 15,
+ XR_FULL_BODY_JOINT_RIGHT_ARM_LOWER_META = 16,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_WRIST_TWIST_META = 17,
+ XR_FULL_BODY_JOINT_LEFT_HAND_PALM_META = 18,
+ XR_FULL_BODY_JOINT_LEFT_HAND_WRIST_META = 19,
+ XR_FULL_BODY_JOINT_LEFT_HAND_THUMB_METACARPAL_META = 20,
+ XR_FULL_BODY_JOINT_LEFT_HAND_THUMB_PROXIMAL_META = 21,
+ XR_FULL_BODY_JOINT_LEFT_HAND_THUMB_DISTAL_META = 22,
+ XR_FULL_BODY_JOINT_LEFT_HAND_THUMB_TIP_META = 23,
+ XR_FULL_BODY_JOINT_LEFT_HAND_INDEX_METACARPAL_META = 24,
+ XR_FULL_BODY_JOINT_LEFT_HAND_INDEX_PROXIMAL_META = 25,
+ XR_FULL_BODY_JOINT_LEFT_HAND_INDEX_INTERMEDIATE_META = 26,
+ XR_FULL_BODY_JOINT_LEFT_HAND_INDEX_DISTAL_META = 27,
+ XR_FULL_BODY_JOINT_LEFT_HAND_INDEX_TIP_META = 28,
+ XR_FULL_BODY_JOINT_LEFT_HAND_MIDDLE_METACARPAL_META = 29,
+ XR_FULL_BODY_JOINT_LEFT_HAND_MIDDLE_PROXIMAL_META = 30,
+ XR_FULL_BODY_JOINT_LEFT_HAND_MIDDLE_INTERMEDIATE_META = 31,
+ XR_FULL_BODY_JOINT_LEFT_HAND_MIDDLE_DISTAL_META = 32,
+ XR_FULL_BODY_JOINT_LEFT_HAND_MIDDLE_TIP_META = 33,
+ XR_FULL_BODY_JOINT_LEFT_HAND_RING_METACARPAL_META = 34,
+ XR_FULL_BODY_JOINT_LEFT_HAND_RING_PROXIMAL_META = 35,
+ XR_FULL_BODY_JOINT_LEFT_HAND_RING_INTERMEDIATE_META = 36,
+ XR_FULL_BODY_JOINT_LEFT_HAND_RING_DISTAL_META = 37,
+ XR_FULL_BODY_JOINT_LEFT_HAND_RING_TIP_META = 38,
+ XR_FULL_BODY_JOINT_LEFT_HAND_LITTLE_METACARPAL_META = 39,
+ XR_FULL_BODY_JOINT_LEFT_HAND_LITTLE_PROXIMAL_META = 40,
+ XR_FULL_BODY_JOINT_LEFT_HAND_LITTLE_INTERMEDIATE_META = 41,
+ XR_FULL_BODY_JOINT_LEFT_HAND_LITTLE_DISTAL_META = 42,
+ XR_FULL_BODY_JOINT_LEFT_HAND_LITTLE_TIP_META = 43,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_PALM_META = 44,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_WRIST_META = 45,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_THUMB_METACARPAL_META = 46,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_THUMB_PROXIMAL_META = 47,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_THUMB_DISTAL_META = 48,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_THUMB_TIP_META = 49,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_INDEX_METACARPAL_META = 50,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_INDEX_PROXIMAL_META = 51,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_INDEX_INTERMEDIATE_META = 52,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_INDEX_DISTAL_META = 53,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_INDEX_TIP_META = 54,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_MIDDLE_METACARPAL_META = 55,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_MIDDLE_PROXIMAL_META = 56,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_MIDDLE_INTERMEDIATE_META = 57,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_MIDDLE_DISTAL_META = 58,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_MIDDLE_TIP_META = 59,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_RING_METACARPAL_META = 60,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_RING_PROXIMAL_META = 61,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_RING_INTERMEDIATE_META = 62,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_RING_DISTAL_META = 63,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_RING_TIP_META = 64,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_LITTLE_METACARPAL_META = 65,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_LITTLE_PROXIMAL_META = 66,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_LITTLE_INTERMEDIATE_META = 67,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_LITTLE_DISTAL_META = 68,
+ XR_FULL_BODY_JOINT_RIGHT_HAND_LITTLE_TIP_META = 69,
+ XR_FULL_BODY_JOINT_LEFT_UPPER_LEG_META = 70,
+ XR_FULL_BODY_JOINT_LEFT_LOWER_LEG_META = 71,
+ XR_FULL_BODY_JOINT_LEFT_FOOT_ANKLE_TWIST_META = 72,
+ XR_FULL_BODY_JOINT_LEFT_FOOT_ANKLE_META = 73,
+ XR_FULL_BODY_JOINT_LEFT_FOOT_SUBTALAR_META = 74,
+ XR_FULL_BODY_JOINT_LEFT_FOOT_TRANSVERSE_META = 75,
+ XR_FULL_BODY_JOINT_LEFT_FOOT_BALL_META = 76,
+ XR_FULL_BODY_JOINT_RIGHT_UPPER_LEG_META = 77,
+ XR_FULL_BODY_JOINT_RIGHT_LOWER_LEG_META = 78,
+ XR_FULL_BODY_JOINT_RIGHT_FOOT_ANKLE_TWIST_META = 79,
+ XR_FULL_BODY_JOINT_RIGHT_FOOT_ANKLE_META = 80,
+ XR_FULL_BODY_JOINT_RIGHT_FOOT_SUBTALAR_META = 81,
+ XR_FULL_BODY_JOINT_RIGHT_FOOT_TRANSVERSE_META = 82,
+ XR_FULL_BODY_JOINT_RIGHT_FOOT_BALL_META = 83,
+ XR_FULL_BODY_JOINT_COUNT_META = 84,
+ XR_FULL_BODY_JOINT_NONE_META = 85,
+ XR_FULL_BODY_JOINT_MAX_ENUM_META = 0x7FFFFFFF
+} XrFullBodyJointMETA;
+// XrSystemPropertiesBodyTrackingFullBodyMETA extends XrSystemProperties
+typedef struct XrSystemPropertiesBodyTrackingFullBodyMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFullBodyTracking;
+} XrSystemPropertiesBodyTrackingFullBodyMETA;
+
+
+
+// XR_META_touch_controller_plus is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_touch_controller_plus 1
+#define XR_META_touch_controller_plus_SPEC_VERSION 1
+#define XR_META_TOUCH_CONTROLLER_PLUS_EXTENSION_NAME "XR_META_touch_controller_plus"
+
+
+// XR_META_passthrough_layer_resumed_event is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_passthrough_layer_resumed_event 1
+#define XR_META_passthrough_layer_resumed_event_SPEC_VERSION 1
+#define XR_META_PASSTHROUGH_LAYER_RESUMED_EVENT_EXTENSION_NAME "XR_META_passthrough_layer_resumed_event"
+typedef struct XrEventDataPassthroughLayerResumedMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughLayerFB layer;
+} XrEventDataPassthroughLayerResumedMETA;
+
+
+
+// XR_META_body_tracking_calibration is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_body_tracking_calibration 1
+#define XR_META_body_tracking_calibration_SPEC_VERSION 1
+#define XR_META_BODY_TRACKING_CALIBRATION_EXTENSION_NAME "XR_META_body_tracking_calibration"
+
+typedef enum XrBodyTrackingCalibrationStateMETA {
+ XR_BODY_TRACKING_CALIBRATION_STATE_VALID_META = 1,
+ XR_BODY_TRACKING_CALIBRATION_STATE_CALIBRATING_META = 2,
+ XR_BODY_TRACKING_CALIBRATION_STATE_INVALID_META = 3,
+ XR_BODY_TRACKING_CALIBRATION_STATE_MAX_ENUM_META = 0x7FFFFFFF
+} XrBodyTrackingCalibrationStateMETA;
+// XrBodyTrackingCalibrationStatusMETA extends XrBodyJointLocationsFB
+typedef struct XrBodyTrackingCalibrationStatusMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBodyTrackingCalibrationStateMETA status;
+} XrBodyTrackingCalibrationStatusMETA;
+
+typedef struct XrBodyTrackingCalibrationInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float bodyHeight;
+} XrBodyTrackingCalibrationInfoMETA;
+
+// XrSystemPropertiesBodyTrackingCalibrationMETA extends XrSystemProperties
+typedef struct XrSystemPropertiesBodyTrackingCalibrationMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsHeightOverride;
+} XrSystemPropertiesBodyTrackingCalibrationMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSuggestBodyTrackingCalibrationOverrideMETA)(XrBodyTrackerFB bodyTracker, const XrBodyTrackingCalibrationInfoMETA* calibrationInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrResetBodyTrackingCalibrationMETA)(XrBodyTrackerFB bodyTracker);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSuggestBodyTrackingCalibrationOverrideMETA(
+ XrBodyTrackerFB bodyTracker,
+ const XrBodyTrackingCalibrationInfoMETA* calibrationInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrResetBodyTrackingCalibrationMETA(
+ XrBodyTrackerFB bodyTracker);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_body_tracking_fidelity is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_body_tracking_fidelity 1
+#define XR_META_body_tracking_fidelity_SPEC_VERSION 1
+#define XR_META_BODY_TRACKING_FIDELITY_EXTENSION_NAME "XR_META_body_tracking_fidelity"
+
+typedef enum XrBodyTrackingFidelityMETA {
+ XR_BODY_TRACKING_FIDELITY_LOW_META = 1,
+ XR_BODY_TRACKING_FIDELITY_HIGH_META = 2,
+ XR_BODY_TRACKING_FIDELITY_MAX_ENUM_META = 0x7FFFFFFF
+} XrBodyTrackingFidelityMETA;
+// XrSystemPropertiesBodyTrackingFidelityMETA extends XrSystemProperties
+typedef struct XrSystemPropertiesBodyTrackingFidelityMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsBodyTrackingFidelity;
+} XrSystemPropertiesBodyTrackingFidelityMETA;
+
+// XrBodyTrackingFidelityStatusMETA extends XrBodyJointLocationsFB
+typedef struct XrBodyTrackingFidelityStatusMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBodyTrackingFidelityMETA fidelity;
+} XrBodyTrackingFidelityStatusMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrRequestBodyTrackingFidelityMETA)(XrBodyTrackerFB bodyTracker, const XrBodyTrackingFidelityMETA fidelity);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestBodyTrackingFidelityMETA(
+ XrBodyTrackerFB bodyTracker,
+ const XrBodyTrackingFidelityMETA fidelity);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_FB_face_tracking2 is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_face_tracking2 1
+XR_DEFINE_HANDLE(XrFaceTracker2FB)
+#define XR_FB_face_tracking2_SPEC_VERSION 1
+#define XR_FB_FACE_TRACKING2_EXTENSION_NAME "XR_FB_face_tracking2"
+
+typedef enum XrFaceExpression2FB {
+ XR_FACE_EXPRESSION2_BROW_LOWERER_L_FB = 0,
+ XR_FACE_EXPRESSION2_BROW_LOWERER_R_FB = 1,
+ XR_FACE_EXPRESSION2_CHEEK_PUFF_L_FB = 2,
+ XR_FACE_EXPRESSION2_CHEEK_PUFF_R_FB = 3,
+ XR_FACE_EXPRESSION2_CHEEK_RAISER_L_FB = 4,
+ XR_FACE_EXPRESSION2_CHEEK_RAISER_R_FB = 5,
+ XR_FACE_EXPRESSION2_CHEEK_SUCK_L_FB = 6,
+ XR_FACE_EXPRESSION2_CHEEK_SUCK_R_FB = 7,
+ XR_FACE_EXPRESSION2_CHIN_RAISER_B_FB = 8,
+ XR_FACE_EXPRESSION2_CHIN_RAISER_T_FB = 9,
+ XR_FACE_EXPRESSION2_DIMPLER_L_FB = 10,
+ XR_FACE_EXPRESSION2_DIMPLER_R_FB = 11,
+ XR_FACE_EXPRESSION2_EYES_CLOSED_L_FB = 12,
+ XR_FACE_EXPRESSION2_EYES_CLOSED_R_FB = 13,
+ XR_FACE_EXPRESSION2_EYES_LOOK_DOWN_L_FB = 14,
+ XR_FACE_EXPRESSION2_EYES_LOOK_DOWN_R_FB = 15,
+ XR_FACE_EXPRESSION2_EYES_LOOK_LEFT_L_FB = 16,
+ XR_FACE_EXPRESSION2_EYES_LOOK_LEFT_R_FB = 17,
+ XR_FACE_EXPRESSION2_EYES_LOOK_RIGHT_L_FB = 18,
+ XR_FACE_EXPRESSION2_EYES_LOOK_RIGHT_R_FB = 19,
+ XR_FACE_EXPRESSION2_EYES_LOOK_UP_L_FB = 20,
+ XR_FACE_EXPRESSION2_EYES_LOOK_UP_R_FB = 21,
+ XR_FACE_EXPRESSION2_INNER_BROW_RAISER_L_FB = 22,
+ XR_FACE_EXPRESSION2_INNER_BROW_RAISER_R_FB = 23,
+ XR_FACE_EXPRESSION2_JAW_DROP_FB = 24,
+ XR_FACE_EXPRESSION2_JAW_SIDEWAYS_LEFT_FB = 25,
+ XR_FACE_EXPRESSION2_JAW_SIDEWAYS_RIGHT_FB = 26,
+ XR_FACE_EXPRESSION2_JAW_THRUST_FB = 27,
+ XR_FACE_EXPRESSION2_LID_TIGHTENER_L_FB = 28,
+ XR_FACE_EXPRESSION2_LID_TIGHTENER_R_FB = 29,
+ XR_FACE_EXPRESSION2_LIP_CORNER_DEPRESSOR_L_FB = 30,
+ XR_FACE_EXPRESSION2_LIP_CORNER_DEPRESSOR_R_FB = 31,
+ XR_FACE_EXPRESSION2_LIP_CORNER_PULLER_L_FB = 32,
+ XR_FACE_EXPRESSION2_LIP_CORNER_PULLER_R_FB = 33,
+ XR_FACE_EXPRESSION2_LIP_FUNNELER_LB_FB = 34,
+ XR_FACE_EXPRESSION2_LIP_FUNNELER_LT_FB = 35,
+ XR_FACE_EXPRESSION2_LIP_FUNNELER_RB_FB = 36,
+ XR_FACE_EXPRESSION2_LIP_FUNNELER_RT_FB = 37,
+ XR_FACE_EXPRESSION2_LIP_PRESSOR_L_FB = 38,
+ XR_FACE_EXPRESSION2_LIP_PRESSOR_R_FB = 39,
+ XR_FACE_EXPRESSION2_LIP_PUCKER_L_FB = 40,
+ XR_FACE_EXPRESSION2_LIP_PUCKER_R_FB = 41,
+ XR_FACE_EXPRESSION2_LIP_STRETCHER_L_FB = 42,
+ XR_FACE_EXPRESSION2_LIP_STRETCHER_R_FB = 43,
+ XR_FACE_EXPRESSION2_LIP_SUCK_LB_FB = 44,
+ XR_FACE_EXPRESSION2_LIP_SUCK_LT_FB = 45,
+ XR_FACE_EXPRESSION2_LIP_SUCK_RB_FB = 46,
+ XR_FACE_EXPRESSION2_LIP_SUCK_RT_FB = 47,
+ XR_FACE_EXPRESSION2_LIP_TIGHTENER_L_FB = 48,
+ XR_FACE_EXPRESSION2_LIP_TIGHTENER_R_FB = 49,
+ XR_FACE_EXPRESSION2_LIPS_TOWARD_FB = 50,
+ XR_FACE_EXPRESSION2_LOWER_LIP_DEPRESSOR_L_FB = 51,
+ XR_FACE_EXPRESSION2_LOWER_LIP_DEPRESSOR_R_FB = 52,
+ XR_FACE_EXPRESSION2_MOUTH_LEFT_FB = 53,
+ XR_FACE_EXPRESSION2_MOUTH_RIGHT_FB = 54,
+ XR_FACE_EXPRESSION2_NOSE_WRINKLER_L_FB = 55,
+ XR_FACE_EXPRESSION2_NOSE_WRINKLER_R_FB = 56,
+ XR_FACE_EXPRESSION2_OUTER_BROW_RAISER_L_FB = 57,
+ XR_FACE_EXPRESSION2_OUTER_BROW_RAISER_R_FB = 58,
+ XR_FACE_EXPRESSION2_UPPER_LID_RAISER_L_FB = 59,
+ XR_FACE_EXPRESSION2_UPPER_LID_RAISER_R_FB = 60,
+ XR_FACE_EXPRESSION2_UPPER_LIP_RAISER_L_FB = 61,
+ XR_FACE_EXPRESSION2_UPPER_LIP_RAISER_R_FB = 62,
+ XR_FACE_EXPRESSION2_TONGUE_TIP_INTERDENTAL_FB = 63,
+ XR_FACE_EXPRESSION2_TONGUE_TIP_ALVEOLAR_FB = 64,
+ XR_FACE_EXPRESSION2_TONGUE_FRONT_DORSAL_PALATE_FB = 65,
+ XR_FACE_EXPRESSION2_TONGUE_MID_DORSAL_PALATE_FB = 66,
+ XR_FACE_EXPRESSION2_TONGUE_BACK_DORSAL_VELAR_FB = 67,
+ XR_FACE_EXPRESSION2_TONGUE_OUT_FB = 68,
+ XR_FACE_EXPRESSION2_TONGUE_RETREAT_FB = 69,
+ XR_FACE_EXPRESSION2_COUNT_FB = 70,
+ XR_FACE_EXPRESSION_2FB_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceExpression2FB;
+
+typedef enum XrFaceExpressionSet2FB {
+ XR_FACE_EXPRESSION_SET2_DEFAULT_FB = 0,
+ XR_FACE_EXPRESSION_SET_2FB_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceExpressionSet2FB;
+
+typedef enum XrFaceTrackingDataSource2FB {
+ XR_FACE_TRACKING_DATA_SOURCE2_VISUAL_FB = 0,
+ XR_FACE_TRACKING_DATA_SOURCE2_AUDIO_FB = 1,
+ XR_FACE_TRACKING_DATA_SOURCE_2FB_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceTrackingDataSource2FB;
+
+typedef enum XrFaceConfidence2FB {
+ XR_FACE_CONFIDENCE2_LOWER_FACE_FB = 0,
+ XR_FACE_CONFIDENCE2_UPPER_FACE_FB = 1,
+ XR_FACE_CONFIDENCE2_COUNT_FB = 2,
+ XR_FACE_CONFIDENCE_2FB_MAX_ENUM_FB = 0x7FFFFFFF
+} XrFaceConfidence2FB;
+// XrSystemFaceTrackingProperties2FB extends XrSystemProperties
+typedef struct XrSystemFaceTrackingProperties2FB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsVisualFaceTracking;
+ XrBool32 supportsAudioFaceTracking;
+} XrSystemFaceTrackingProperties2FB;
+
+typedef struct XrFaceTrackerCreateInfo2FB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFaceExpressionSet2FB faceExpressionSet;
+ uint32_t requestedDataSourceCount;
+ XrFaceTrackingDataSource2FB* requestedDataSources;
+} XrFaceTrackerCreateInfo2FB;
+
+typedef struct XrFaceExpressionInfo2FB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrFaceExpressionInfo2FB;
+
+typedef struct XrFaceExpressionWeights2FB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t weightCount;
+ float* weights;
+ uint32_t confidenceCount;
+ float* confidences;
+ XrBool32 isValid;
+ XrBool32 isEyeFollowingBlendshapesValid;
+ XrFaceTrackingDataSource2FB dataSource;
+ XrTime time;
+} XrFaceExpressionWeights2FB;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFaceTracker2FB)(XrSession session, const XrFaceTrackerCreateInfo2FB* createInfo, XrFaceTracker2FB* faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFaceTracker2FB)(XrFaceTracker2FB faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFaceExpressionWeights2FB)(XrFaceTracker2FB faceTracker, const XrFaceExpressionInfo2FB* expressionInfo, XrFaceExpressionWeights2FB* expressionWeights);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFaceTracker2FB(
+ XrSession session,
+ const XrFaceTrackerCreateInfo2FB* createInfo,
+ XrFaceTracker2FB* faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFaceTracker2FB(
+ XrFaceTracker2FB faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFaceExpressionWeights2FB(
+ XrFaceTracker2FB faceTracker,
+ const XrFaceExpressionInfo2FB* expressionInfo,
+ XrFaceExpressionWeights2FB* expressionWeights);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_spatial_entity_sharing is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_sharing 1
+#define XR_META_spatial_entity_sharing_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_SHARING_EXTENSION_NAME "XR_META_spatial_entity_sharing"
+#define XR_MAX_SPACES_PER_SHARE_REQUEST_META 32
+// XrSystemSpatialEntitySharingPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemSpatialEntitySharingPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialEntitySharing;
+} XrSystemSpatialEntitySharingPropertiesMETA;
+
+typedef struct XR_MAY_ALIAS XrShareSpacesRecipientBaseHeaderMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrShareSpacesRecipientBaseHeaderMETA;
+
+typedef struct XrShareSpacesInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spaceCount;
+ XrSpace* spaces;
+ const XrShareSpacesRecipientBaseHeaderMETA* recipientInfo;
+} XrShareSpacesInfoMETA;
+
+typedef struct XrEventDataShareSpacesCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataShareSpacesCompleteMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrShareSpacesMETA)(XrSession session, const XrShareSpacesInfoMETA* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrShareSpacesMETA(
+ XrSession session,
+ const XrShareSpacesInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_environment_depth is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_environment_depth 1
+XR_DEFINE_HANDLE(XrEnvironmentDepthProviderMETA)
+XR_DEFINE_HANDLE(XrEnvironmentDepthSwapchainMETA)
+#define XR_META_environment_depth_SPEC_VERSION 2
+#define XR_META_ENVIRONMENT_DEPTH_EXTENSION_NAME "XR_META_environment_depth"
+typedef XrFlags64 XrEnvironmentDepthProviderCreateFlagsMETA;
+
+// Flag bits for XrEnvironmentDepthProviderCreateFlagsMETA
+
+typedef XrFlags64 XrEnvironmentDepthSwapchainCreateFlagsMETA;
+
+// Flag bits for XrEnvironmentDepthSwapchainCreateFlagsMETA
+
+typedef struct XrEnvironmentDepthProviderCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEnvironmentDepthProviderCreateFlagsMETA createFlags;
+} XrEnvironmentDepthProviderCreateInfoMETA;
+
+typedef struct XrEnvironmentDepthSwapchainCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEnvironmentDepthSwapchainCreateFlagsMETA createFlags;
+} XrEnvironmentDepthSwapchainCreateInfoMETA;
+
+typedef struct XrEnvironmentDepthSwapchainStateMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t width;
+ uint32_t height;
+} XrEnvironmentDepthSwapchainStateMETA;
+
+typedef struct XrEnvironmentDepthImageAcquireInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime displayTime;
+} XrEnvironmentDepthImageAcquireInfoMETA;
+
+typedef struct XrEnvironmentDepthImageViewMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFovf fov;
+ XrPosef pose;
+} XrEnvironmentDepthImageViewMETA;
+
+typedef struct XrEnvironmentDepthImageMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t swapchainIndex;
+ float nearZ;
+ float farZ;
+ XrEnvironmentDepthImageViewMETA views[2];
+} XrEnvironmentDepthImageMETA;
+
+// XrEnvironmentDepthImageTimestampMETA extends XrEnvironmentDepthImageMETA
+typedef struct XrEnvironmentDepthImageTimestampMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime captureTime;
+} XrEnvironmentDepthImageTimestampMETA;
+
+typedef struct XrEnvironmentDepthHandRemovalSetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+} XrEnvironmentDepthHandRemovalSetInfoMETA;
+
+// XrSystemEnvironmentDepthPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemEnvironmentDepthPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsEnvironmentDepth;
+ XrBool32 supportsHandRemoval;
+} XrSystemEnvironmentDepthPropertiesMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEnvironmentDepthProviderMETA)(XrSession session, const XrEnvironmentDepthProviderCreateInfoMETA* createInfo, XrEnvironmentDepthProviderMETA* environmentDepthProvider);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyEnvironmentDepthProviderMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider);
+typedef XrResult (XRAPI_PTR *PFN_xrStartEnvironmentDepthProviderMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider);
+typedef XrResult (XRAPI_PTR *PFN_xrStopEnvironmentDepthProviderMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEnvironmentDepthSwapchainMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider, const XrEnvironmentDepthSwapchainCreateInfoMETA* createInfo, XrEnvironmentDepthSwapchainMETA* swapchain);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyEnvironmentDepthSwapchainMETA)(XrEnvironmentDepthSwapchainMETA swapchain);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateEnvironmentDepthSwapchainImagesMETA)(XrEnvironmentDepthSwapchainMETA swapchain, uint32_t imageCapacityInput, uint32_t* imageCountOutput, XrSwapchainImageBaseHeader* images);
+typedef XrResult (XRAPI_PTR *PFN_xrGetEnvironmentDepthSwapchainStateMETA)(XrEnvironmentDepthSwapchainMETA swapchain, XrEnvironmentDepthSwapchainStateMETA* state);
+typedef XrResult (XRAPI_PTR *PFN_xrAcquireEnvironmentDepthImageMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider, const XrEnvironmentDepthImageAcquireInfoMETA* acquireInfo, XrEnvironmentDepthImageMETA* environmentDepthImage);
+typedef XrResult (XRAPI_PTR *PFN_xrSetEnvironmentDepthHandRemovalMETA)(XrEnvironmentDepthProviderMETA environmentDepthProvider, const XrEnvironmentDepthHandRemovalSetInfoMETA* setInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEnvironmentDepthProviderMETA(
+ XrSession session,
+ const XrEnvironmentDepthProviderCreateInfoMETA* createInfo,
+ XrEnvironmentDepthProviderMETA* environmentDepthProvider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyEnvironmentDepthProviderMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStartEnvironmentDepthProviderMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStopEnvironmentDepthProviderMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEnvironmentDepthSwapchainMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider,
+ const XrEnvironmentDepthSwapchainCreateInfoMETA* createInfo,
+ XrEnvironmentDepthSwapchainMETA* swapchain);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyEnvironmentDepthSwapchainMETA(
+ XrEnvironmentDepthSwapchainMETA swapchain);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateEnvironmentDepthSwapchainImagesMETA(
+ XrEnvironmentDepthSwapchainMETA swapchain,
+ uint32_t imageCapacityInput,
+ uint32_t* imageCountOutput,
+ XrSwapchainImageBaseHeader* images);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetEnvironmentDepthSwapchainStateMETA(
+ XrEnvironmentDepthSwapchainMETA swapchain,
+ XrEnvironmentDepthSwapchainStateMETA* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAcquireEnvironmentDepthImageMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider,
+ const XrEnvironmentDepthImageAcquireInfoMETA* acquireInfo,
+ XrEnvironmentDepthImageMETA* environmentDepthImage);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetEnvironmentDepthHandRemovalMETA(
+ XrEnvironmentDepthProviderMETA environmentDepthProvider,
+ const XrEnvironmentDepthHandRemovalSetInfoMETA* setInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_uuid is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_uuid 1
+#define XR_EXT_uuid_SPEC_VERSION 1
+#define XR_EXT_UUID_EXTENSION_NAME "XR_EXT_uuid"
+#define XR_UUID_SIZE_EXT 16
+
+
+// XR_EXT_render_model is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_render_model 1
+XR_DEFINE_ATOM(XrRenderModelIdEXT)
+XR_DEFINE_HANDLE(XrRenderModelEXT)
+XR_DEFINE_HANDLE(XrRenderModelAssetEXT)
+#define XR_MAX_RENDER_MODEL_ASSET_NODE_NAME_SIZE_EXT 64
+#define XR_EXT_render_model_SPEC_VERSION 1
+#define XR_EXT_RENDER_MODEL_EXTENSION_NAME "XR_EXT_render_model"
+#define XR_NULL_RENDER_MODEL_ID_EXT 0
+typedef struct XrRenderModelCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrRenderModelIdEXT renderModelId;
+ uint32_t gltfExtensionCount;
+ const char* const* gltfExtensions;
+} XrRenderModelCreateInfoEXT;
+
+typedef struct XrRenderModelPropertiesGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrRenderModelPropertiesGetInfoEXT;
+
+typedef struct XrRenderModelPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuidEXT cacheId;
+ uint32_t animatableNodeCount;
+} XrRenderModelPropertiesEXT;
+
+typedef struct XrRenderModelSpaceCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrRenderModelEXT renderModel;
+} XrRenderModelSpaceCreateInfoEXT;
+
+typedef struct XrRenderModelStateGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime displayTime;
+} XrRenderModelStateGetInfoEXT;
+
+typedef struct XrRenderModelNodeStateEXT {
+ XrPosef nodePose;
+ XrBool32 isVisible;
+} XrRenderModelNodeStateEXT;
+
+typedef struct XrRenderModelStateEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t nodeStateCount;
+ XrRenderModelNodeStateEXT* nodeStates;
+} XrRenderModelStateEXT;
+
+typedef struct XrRenderModelAssetCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidEXT cacheId;
+} XrRenderModelAssetCreateInfoEXT;
+
+typedef struct XrRenderModelAssetDataGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrRenderModelAssetDataGetInfoEXT;
+
+typedef struct XrRenderModelAssetDataEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t bufferCapacityInput;
+ uint32_t bufferCountOutput;
+ uint8_t* buffer;
+} XrRenderModelAssetDataEXT;
+
+typedef struct XrRenderModelAssetPropertiesGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrRenderModelAssetPropertiesGetInfoEXT;
+
+typedef struct XrRenderModelAssetNodePropertiesEXT {
+ char uniqueName[XR_MAX_RENDER_MODEL_ASSET_NODE_NAME_SIZE_EXT];
+} XrRenderModelAssetNodePropertiesEXT;
+
+typedef struct XrRenderModelAssetPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t nodePropertyCount;
+ XrRenderModelAssetNodePropertiesEXT* nodeProperties;
+} XrRenderModelAssetPropertiesEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateRenderModelEXT)(XrSession session, const XrRenderModelCreateInfoEXT* createInfo, XrRenderModelEXT* renderModel);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyRenderModelEXT)(XrRenderModelEXT renderModel);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelPropertiesEXT)(XrRenderModelEXT renderModel, const XrRenderModelPropertiesGetInfoEXT* getInfo, XrRenderModelPropertiesEXT* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateRenderModelSpaceEXT)(XrSession session, const XrRenderModelSpaceCreateInfoEXT* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateRenderModelAssetEXT)(XrSession session, const XrRenderModelAssetCreateInfoEXT* createInfo, XrRenderModelAssetEXT* asset);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyRenderModelAssetEXT)(XrRenderModelAssetEXT asset);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelAssetDataEXT)(XrRenderModelAssetEXT asset, const XrRenderModelAssetDataGetInfoEXT* getInfo, XrRenderModelAssetDataEXT* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelAssetPropertiesEXT)(XrRenderModelAssetEXT asset, const XrRenderModelAssetPropertiesGetInfoEXT* getInfo, XrRenderModelAssetPropertiesEXT* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelStateEXT)(XrRenderModelEXT renderModel, const XrRenderModelStateGetInfoEXT* getInfo, XrRenderModelStateEXT* state);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateRenderModelEXT(
+ XrSession session,
+ const XrRenderModelCreateInfoEXT* createInfo,
+ XrRenderModelEXT* renderModel);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyRenderModelEXT(
+ XrRenderModelEXT renderModel);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelPropertiesEXT(
+ XrRenderModelEXT renderModel,
+ const XrRenderModelPropertiesGetInfoEXT* getInfo,
+ XrRenderModelPropertiesEXT* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateRenderModelSpaceEXT(
+ XrSession session,
+ const XrRenderModelSpaceCreateInfoEXT* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateRenderModelAssetEXT(
+ XrSession session,
+ const XrRenderModelAssetCreateInfoEXT* createInfo,
+ XrRenderModelAssetEXT* asset);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyRenderModelAssetEXT(
+ XrRenderModelAssetEXT asset);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelAssetDataEXT(
+ XrRenderModelAssetEXT asset,
+ const XrRenderModelAssetDataGetInfoEXT* getInfo,
+ XrRenderModelAssetDataEXT* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelAssetPropertiesEXT(
+ XrRenderModelAssetEXT asset,
+ const XrRenderModelAssetPropertiesGetInfoEXT* getInfo,
+ XrRenderModelAssetPropertiesEXT* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelStateEXT(
+ XrRenderModelEXT renderModel,
+ const XrRenderModelStateGetInfoEXT* getInfo,
+ XrRenderModelStateEXT* state);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_interaction_render_model is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_interaction_render_model 1
+#define XR_EXT_interaction_render_model_SPEC_VERSION 1
+#define XR_EXT_INTERACTION_RENDER_MODEL_EXTENSION_NAME "XR_EXT_interaction_render_model"
+typedef struct XrInteractionRenderModelIdsEnumerateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrInteractionRenderModelIdsEnumerateInfoEXT;
+
+typedef struct XrInteractionRenderModelSubactionPathInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrInteractionRenderModelSubactionPathInfoEXT;
+
+typedef struct XrInteractionRenderModelTopLevelUserPathGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t topLevelUserPathCount;
+ const XrPath* topLevelUserPaths;
+} XrInteractionRenderModelTopLevelUserPathGetInfoEXT;
+
+typedef struct XrEventDataInteractionRenderModelsChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEventDataInteractionRenderModelsChangedEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateInteractionRenderModelIdsEXT)(XrSession session, const XrInteractionRenderModelIdsEnumerateInfoEXT* getInfo, uint32_t renderModelIdCapacityInput, uint32_t* renderModelIdCountOutput, XrRenderModelIdEXT* renderModelIds);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateRenderModelSubactionPathsEXT)(XrRenderModelEXT renderModel, const XrInteractionRenderModelSubactionPathInfoEXT* info, uint32_t pathCapacityInput, uint32_t* pathCountOutput, XrPath* paths);
+typedef XrResult (XRAPI_PTR *PFN_xrGetRenderModelPoseTopLevelUserPathEXT)(XrRenderModelEXT renderModel, const XrInteractionRenderModelTopLevelUserPathGetInfoEXT* info, XrPath* topLevelUserPath);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateInteractionRenderModelIdsEXT(
+ XrSession session,
+ const XrInteractionRenderModelIdsEnumerateInfoEXT* getInfo,
+ uint32_t renderModelIdCapacityInput,
+ uint32_t* renderModelIdCountOutput,
+ XrRenderModelIdEXT* renderModelIds);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateRenderModelSubactionPathsEXT(
+ XrRenderModelEXT renderModel,
+ const XrInteractionRenderModelSubactionPathInfoEXT* info,
+ uint32_t pathCapacityInput,
+ uint32_t* pathCountOutput,
+ XrPath* paths);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetRenderModelPoseTopLevelUserPathEXT(
+ XrRenderModelEXT renderModel,
+ const XrInteractionRenderModelTopLevelUserPathGetInfoEXT* info,
+ XrPath* topLevelUserPath);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_hand_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_hand_interaction 1
+#define XR_EXT_hand_interaction_SPEC_VERSION 2
+#define XR_EXT_HAND_INTERACTION_EXTENSION_NAME "XR_EXT_hand_interaction"
+
+
+// XR_QCOM_tracking_optimization_settings is a preprocessor guard. Do not pass it to API calls.
+#define XR_QCOM_tracking_optimization_settings 1
+#define XR_QCOM_tracking_optimization_settings_SPEC_VERSION 2
+#define XR_QCOM_TRACKING_OPTIMIZATION_SETTINGS_EXTENSION_NAME "XR_QCOM_tracking_optimization_settings"
+
+typedef enum XrTrackingOptimizationSettingsDomainQCOM {
+ XR_TRACKING_OPTIMIZATION_SETTINGS_DOMAIN_ALL_QCOM = 1,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_DOMAIN_MAX_ENUM_QCOM = 0x7FFFFFFF
+} XrTrackingOptimizationSettingsDomainQCOM;
+
+typedef enum XrTrackingOptimizationSettingsHintQCOM {
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_NONE_QCOM = 0,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LONG_RANGE_PRIORITIZATION_QCOM = 1,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_CLOSE_RANGE_PRIORITIZATION_QCOM = 2,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LOW_POWER_PRIORITIZATION_QCOM = 3,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_HIGH_POWER_PRIORITIZATION_QCOM = 4,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LONG_RANGE_PRIORIZATION_QCOM = XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LONG_RANGE_PRIORITIZATION_QCOM,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_CLOSE_RANGE_PRIORIZATION_QCOM = XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_CLOSE_RANGE_PRIORITIZATION_QCOM,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LOW_POWER_PRIORIZATION_QCOM = XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_LOW_POWER_PRIORITIZATION_QCOM,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_HIGH_POWER_PRIORIZATION_QCOM = XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_HIGH_POWER_PRIORITIZATION_QCOM,
+ XR_TRACKING_OPTIMIZATION_SETTINGS_HINT_MAX_ENUM_QCOM = 0x7FFFFFFF
+} XrTrackingOptimizationSettingsHintQCOM;
+typedef XrResult (XRAPI_PTR *PFN_xrSetTrackingOptimizationSettingsHintQCOM)(XrSession session, XrTrackingOptimizationSettingsDomainQCOM domain, XrTrackingOptimizationSettingsHintQCOM hint);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetTrackingOptimizationSettingsHintQCOM(
+ XrSession session,
+ XrTrackingOptimizationSettingsDomainQCOM domain,
+ XrTrackingOptimizationSettingsHintQCOM hint);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_QCOM_hand_tracking_gesture is a preprocessor guard. Do not pass it to API calls.
+#define XR_QCOM_hand_tracking_gesture 1
+#define XR_QCOM_hand_tracking_gesture_SPEC_VERSION 1
+#define XR_QCOM_HAND_TRACKING_GESTURE_EXTENSION_NAME "XR_QCOM_hand_tracking_gesture"
+
+typedef enum XrHandGestureTypeQCOM {
+ XR_HAND_GESTURE_TYPE_UNKNOWN_QCOM = -1,
+ XR_HAND_GESTURE_TYPE_OPEN_HAND_QCOM = 0,
+ XR_HAND_GESTURE_TYPE_GRAB_QCOM = 2,
+ XR_HAND_GESTURE_TYPE_PINCH_QCOM = 7,
+ XR_HAND_GESTURE_TYPE_MAX_ENUM_QCOM = 0x7FFFFFFF
+} XrHandGestureTypeQCOM;
+typedef struct XrHandGestureQCOM {
+ XrHandGestureTypeQCOM gesture;
+ float gestureRatio;
+ float flipRatio;
+} XrHandGestureQCOM;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetHandGestureQCOM)(XrHandTrackerEXT handTracker, XrTime time, XrHandGestureQCOM* handGesture);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetHandGestureQCOM(
+ XrHandTrackerEXT handTracker,
+ XrTime time,
+ XrHandGestureQCOM* handGesture);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_passthrough is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_passthrough 1
+XR_DEFINE_HANDLE(XrPassthroughHTC)
+#define XR_HTC_passthrough_SPEC_VERSION 1
+#define XR_HTC_PASSTHROUGH_EXTENSION_NAME "XR_HTC_passthrough"
+
+typedef enum XrPassthroughFormHTC {
+ XR_PASSTHROUGH_FORM_PLANAR_HTC = 0,
+ XR_PASSTHROUGH_FORM_PROJECTED_HTC = 1,
+ XR_PASSTHROUGH_FORM_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrPassthroughFormHTC;
+typedef struct XrPassthroughCreateInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPassthroughFormHTC form;
+} XrPassthroughCreateInfoHTC;
+
+typedef struct XrPassthroughColorHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float alpha;
+} XrPassthroughColorHTC;
+
+// XrPassthroughMeshTransformInfoHTC extends XrCompositionLayerPassthroughHTC
+typedef struct XrPassthroughMeshTransformInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t vertexCount;
+ const XrVector3f* vertices;
+ uint32_t indexCount;
+ const uint32_t* indices;
+ XrSpace baseSpace;
+ XrTime time;
+ XrPosef pose;
+ XrVector3f scale;
+} XrPassthroughMeshTransformInfoHTC;
+
+typedef struct XrCompositionLayerPassthroughHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrPassthroughHTC passthrough;
+ XrPassthroughColorHTC color;
+} XrCompositionLayerPassthroughHTC;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePassthroughHTC)(XrSession session, const XrPassthroughCreateInfoHTC* createInfo, XrPassthroughHTC* passthrough);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPassthroughHTC)(XrPassthroughHTC passthrough);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePassthroughHTC(
+ XrSession session,
+ const XrPassthroughCreateInfoHTC* createInfo,
+ XrPassthroughHTC* passthrough);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPassthroughHTC(
+ XrPassthroughHTC passthrough);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_foveation is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_foveation 1
+#define XR_HTC_foveation_SPEC_VERSION 1
+#define XR_HTC_FOVEATION_EXTENSION_NAME "XR_HTC_foveation"
+
+typedef enum XrFoveationModeHTC {
+ XR_FOVEATION_MODE_DISABLE_HTC = 0,
+ XR_FOVEATION_MODE_FIXED_HTC = 1,
+ XR_FOVEATION_MODE_DYNAMIC_HTC = 2,
+ XR_FOVEATION_MODE_CUSTOM_HTC = 3,
+ XR_FOVEATION_MODE_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrFoveationModeHTC;
+
+typedef enum XrFoveationLevelHTC {
+ XR_FOVEATION_LEVEL_NONE_HTC = 0,
+ XR_FOVEATION_LEVEL_LOW_HTC = 1,
+ XR_FOVEATION_LEVEL_MEDIUM_HTC = 2,
+ XR_FOVEATION_LEVEL_HIGH_HTC = 3,
+ XR_FOVEATION_LEVEL_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrFoveationLevelHTC;
+typedef XrFlags64 XrFoveationDynamicFlagsHTC;
+
+// Flag bits for XrFoveationDynamicFlagsHTC
+static const XrFoveationDynamicFlagsHTC XR_FOVEATION_DYNAMIC_LEVEL_ENABLED_BIT_HTC = 0x00000001;
+static const XrFoveationDynamicFlagsHTC XR_FOVEATION_DYNAMIC_CLEAR_FOV_ENABLED_BIT_HTC = 0x00000002;
+static const XrFoveationDynamicFlagsHTC XR_FOVEATION_DYNAMIC_FOCAL_CENTER_OFFSET_ENABLED_BIT_HTC = 0x00000004;
+
+typedef struct XrFoveationApplyInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFoveationModeHTC mode;
+ uint32_t subImageCount;
+ XrSwapchainSubImage* subImages;
+} XrFoveationApplyInfoHTC;
+
+typedef struct XrFoveationConfigurationHTC {
+ XrFoveationLevelHTC level;
+ float clearFovDegree;
+ XrVector2f focalCenterOffset;
+} XrFoveationConfigurationHTC;
+
+// XrFoveationDynamicModeInfoHTC extends XrFoveationApplyInfoHTC
+typedef struct XrFoveationDynamicModeInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFoveationDynamicFlagsHTC dynamicFlags;
+} XrFoveationDynamicModeInfoHTC;
+
+// XrFoveationCustomModeInfoHTC extends XrFoveationApplyInfoHTC
+typedef struct XrFoveationCustomModeInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t configCount;
+ const XrFoveationConfigurationHTC* configs;
+} XrFoveationCustomModeInfoHTC;
+
+typedef XrResult (XRAPI_PTR *PFN_xrApplyFoveationHTC)(XrSession session, const XrFoveationApplyInfoHTC* applyInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrApplyFoveationHTC(
+ XrSession session,
+ const XrFoveationApplyInfoHTC* applyInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_anchor 1
+#define XR_MAX_SPATIAL_ANCHOR_NAME_SIZE_HTC 256
+#define XR_HTC_anchor_SPEC_VERSION 1
+#define XR_HTC_ANCHOR_EXTENSION_NAME "XR_HTC_anchor"
+// XrSystemAnchorPropertiesHTC extends XrSystemProperties
+typedef struct XrSystemAnchorPropertiesHTC {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsAnchor;
+} XrSystemAnchorPropertiesHTC;
+
+typedef struct XrSpatialAnchorNameHTC {
+ char name[XR_MAX_SPATIAL_ANCHOR_NAME_SIZE_HTC];
+} XrSpatialAnchorNameHTC;
+
+typedef struct XrSpatialAnchorCreateInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrPosef poseInSpace;
+ XrSpatialAnchorNameHTC name;
+} XrSpatialAnchorCreateInfoHTC;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorHTC)(XrSession session, const XrSpatialAnchorCreateInfoHTC* createInfo, XrSpace* anchor);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialAnchorNameHTC)(XrSpace anchor, XrSpatialAnchorNameHTC* name);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorHTC(
+ XrSession session,
+ const XrSpatialAnchorCreateInfoHTC* createInfo,
+ XrSpace* anchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialAnchorNameHTC(
+ XrSpace anchor,
+ XrSpatialAnchorNameHTC* name);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_HTC_body_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_HTC_body_tracking 1
+
+#define XR_BODY_JOINT_COUNT_HTC 26
+
+XR_DEFINE_HANDLE(XrBodyTrackerHTC)
+#define XR_HTC_body_tracking_SPEC_VERSION 1
+#define XR_HTC_BODY_TRACKING_EXTENSION_NAME "XR_HTC_body_tracking"
+
+typedef enum XrBodyJointHTC {
+ XR_BODY_JOINT_PELVIS_HTC = 0,
+ XR_BODY_JOINT_LEFT_HIP_HTC = 1,
+ XR_BODY_JOINT_LEFT_KNEE_HTC = 2,
+ XR_BODY_JOINT_LEFT_ANKLE_HTC = 3,
+ XR_BODY_JOINT_LEFT_FEET_HTC = 4,
+ XR_BODY_JOINT_RIGHT_HIP_HTC = 5,
+ XR_BODY_JOINT_RIGHT_KNEE_HTC = 6,
+ XR_BODY_JOINT_RIGHT_ANKLE_HTC = 7,
+ XR_BODY_JOINT_RIGHT_FEET_HTC = 8,
+ XR_BODY_JOINT_WAIST_HTC = 9,
+ XR_BODY_JOINT_SPINE_LOWER_HTC = 10,
+ XR_BODY_JOINT_SPINE_MIDDLE_HTC = 11,
+ XR_BODY_JOINT_SPINE_HIGH_HTC = 12,
+ XR_BODY_JOINT_CHEST_HTC = 13,
+ XR_BODY_JOINT_NECK_HTC = 14,
+ XR_BODY_JOINT_HEAD_HTC = 15,
+ XR_BODY_JOINT_LEFT_CLAVICLE_HTC = 16,
+ XR_BODY_JOINT_LEFT_SCAPULA_HTC = 17,
+ XR_BODY_JOINT_LEFT_ARM_HTC = 18,
+ XR_BODY_JOINT_LEFT_ELBOW_HTC = 19,
+ XR_BODY_JOINT_LEFT_WRIST_HTC = 20,
+ XR_BODY_JOINT_RIGHT_CLAVICLE_HTC = 21,
+ XR_BODY_JOINT_RIGHT_SCAPULA_HTC = 22,
+ XR_BODY_JOINT_RIGHT_ARM_HTC = 23,
+ XR_BODY_JOINT_RIGHT_ELBOW_HTC = 24,
+ XR_BODY_JOINT_RIGHT_WRIST_HTC = 25,
+ XR_BODY_JOINT_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrBodyJointHTC;
+
+typedef enum XrBodyJointSetHTC {
+ XR_BODY_JOINT_SET_FULL_HTC = 0,
+ XR_BODY_JOINT_SET_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrBodyJointSetHTC;
+
+typedef enum XrBodyJointConfidenceHTC {
+ XR_BODY_JOINT_CONFIDENCE_NONE_HTC = 0,
+ XR_BODY_JOINT_CONFIDENCE_LOW_HTC = 1,
+ XR_BODY_JOINT_CONFIDENCE_HIGH_HTC = 2,
+ XR_BODY_JOINT_CONFIDENCE_MAX_ENUM_HTC = 0x7FFFFFFF
+} XrBodyJointConfidenceHTC;
+// XrSystemBodyTrackingPropertiesHTC extends XrSystemProperties
+typedef struct XrSystemBodyTrackingPropertiesHTC {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsBodyTracking;
+} XrSystemBodyTrackingPropertiesHTC;
+
+typedef struct XrBodyTrackerCreateInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBodyJointSetHTC bodyJointSet;
+} XrBodyTrackerCreateInfoHTC;
+
+typedef struct XrBodyJointsLocateInfoHTC {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrBodyJointsLocateInfoHTC;
+
+typedef struct XrBodyJointLocationHTC {
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+} XrBodyJointLocationHTC;
+
+typedef struct XrBodyJointLocationsHTC {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpaceLocationFlags combinedLocationFlags;
+ XrBodyJointConfidenceHTC confidenceLevel;
+ uint32_t jointLocationCount;
+ XrBodyJointLocationHTC* jointLocations;
+ uint32_t skeletonGenerationId;
+} XrBodyJointLocationsHTC;
+
+typedef struct XrBodySkeletonJointHTC {
+ XrPosef pose;
+} XrBodySkeletonJointHTC;
+
+typedef struct XrBodySkeletonHTC {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t jointCount;
+ XrBodySkeletonJointHTC* joints;
+} XrBodySkeletonHTC;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateBodyTrackerHTC)(XrSession session, const XrBodyTrackerCreateInfoHTC* createInfo, XrBodyTrackerHTC* bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyBodyTrackerHTC)(XrBodyTrackerHTC bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateBodyJointsHTC)(XrBodyTrackerHTC bodyTracker, const XrBodyJointsLocateInfoHTC* locateInfo, XrBodyJointLocationsHTC* locations);
+typedef XrResult (XRAPI_PTR *PFN_xrGetBodySkeletonHTC)(XrBodyTrackerHTC bodyTracker, XrSpace baseSpace, uint32_t skeletonGenerationId, XrBodySkeletonHTC* skeleton);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateBodyTrackerHTC(
+ XrSession session,
+ const XrBodyTrackerCreateInfoHTC* createInfo,
+ XrBodyTrackerHTC* bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyBodyTrackerHTC(
+ XrBodyTrackerHTC bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateBodyJointsHTC(
+ XrBodyTrackerHTC bodyTracker,
+ const XrBodyJointsLocateInfoHTC* locateInfo,
+ XrBodyJointLocationsHTC* locations);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetBodySkeletonHTC(
+ XrBodyTrackerHTC bodyTracker,
+ XrSpace baseSpace,
+ uint32_t skeletonGenerationId,
+ XrBodySkeletonHTC* skeleton);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_active_action_set_priority is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_active_action_set_priority 1
+#define XR_EXT_active_action_set_priority_SPEC_VERSION 1
+#define XR_EXT_ACTIVE_ACTION_SET_PRIORITY_EXTENSION_NAME "XR_EXT_active_action_set_priority"
+typedef struct XrActiveActionSetPriorityEXT {
+ XrActionSet actionSet;
+ uint32_t priorityOverride;
+} XrActiveActionSetPriorityEXT;
+
+// XrActiveActionSetPrioritiesEXT extends XrActionsSyncInfo
+typedef struct XrActiveActionSetPrioritiesEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t actionSetPriorityCount;
+ const XrActiveActionSetPriorityEXT* actionSetPriorities;
+} XrActiveActionSetPrioritiesEXT;
+
+
+
+// XR_MNDX_force_feedback_curl is a preprocessor guard. Do not pass it to API calls.
+#define XR_MNDX_force_feedback_curl 1
+#define XR_MNDX_force_feedback_curl_SPEC_VERSION 1
+#define XR_MNDX_FORCE_FEEDBACK_CURL_EXTENSION_NAME "XR_MNDX_force_feedback_curl"
+
+typedef enum XrForceFeedbackCurlLocationMNDX {
+ XR_FORCE_FEEDBACK_CURL_LOCATION_THUMB_CURL_MNDX = 0,
+ XR_FORCE_FEEDBACK_CURL_LOCATION_INDEX_CURL_MNDX = 1,
+ XR_FORCE_FEEDBACK_CURL_LOCATION_MIDDLE_CURL_MNDX = 2,
+ XR_FORCE_FEEDBACK_CURL_LOCATION_RING_CURL_MNDX = 3,
+ XR_FORCE_FEEDBACK_CURL_LOCATION_LITTLE_CURL_MNDX = 4,
+ XR_FORCE_FEEDBACK_CURL_LOCATION_MAX_ENUM_MNDX = 0x7FFFFFFF
+} XrForceFeedbackCurlLocationMNDX;
+// XrSystemForceFeedbackCurlPropertiesMNDX extends XrSystemProperties
+typedef struct XrSystemForceFeedbackCurlPropertiesMNDX {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsForceFeedbackCurl;
+} XrSystemForceFeedbackCurlPropertiesMNDX;
+
+typedef struct XrForceFeedbackCurlApplyLocationMNDX {
+ XrForceFeedbackCurlLocationMNDX location;
+ float value;
+} XrForceFeedbackCurlApplyLocationMNDX;
+
+typedef struct XrForceFeedbackCurlApplyLocationsMNDX {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t locationCount;
+ XrForceFeedbackCurlApplyLocationMNDX* locations;
+} XrForceFeedbackCurlApplyLocationsMNDX;
+
+typedef XrResult (XRAPI_PTR *PFN_xrApplyForceFeedbackCurlMNDX)(XrHandTrackerEXT handTracker, const XrForceFeedbackCurlApplyLocationsMNDX* locations);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrApplyForceFeedbackCurlMNDX(
+ XrHandTrackerEXT handTracker,
+ const XrForceFeedbackCurlApplyLocationsMNDX* locations);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_controller_interaction 1
+#define XR_BD_controller_interaction_SPEC_VERSION 2
+#define XR_BD_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_BD_controller_interaction"
+
+
+// XR_BD_body_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_body_tracking 1
+
+#define XR_BODY_JOINT_COUNT_BD 24
+
+
+#define XR_BODY_JOINT_WITHOUT_ARM_COUNT_BD 16
+
+XR_DEFINE_HANDLE(XrBodyTrackerBD)
+#define XR_BD_body_tracking_SPEC_VERSION 1
+#define XR_BD_BODY_TRACKING_EXTENSION_NAME "XR_BD_body_tracking"
+
+typedef enum XrBodyJointBD {
+ XR_BODY_JOINT_PELVIS_BD = 0,
+ XR_BODY_JOINT_LEFT_HIP_BD = 1,
+ XR_BODY_JOINT_RIGHT_HIP_BD = 2,
+ XR_BODY_JOINT_SPINE1_BD = 3,
+ XR_BODY_JOINT_LEFT_KNEE_BD = 4,
+ XR_BODY_JOINT_RIGHT_KNEE_BD = 5,
+ XR_BODY_JOINT_SPINE2_BD = 6,
+ XR_BODY_JOINT_LEFT_ANKLE_BD = 7,
+ XR_BODY_JOINT_RIGHT_ANKLE_BD = 8,
+ XR_BODY_JOINT_SPINE3_BD = 9,
+ XR_BODY_JOINT_LEFT_FOOT_BD = 10,
+ XR_BODY_JOINT_RIGHT_FOOT_BD = 11,
+ XR_BODY_JOINT_NECK_BD = 12,
+ XR_BODY_JOINT_LEFT_COLLAR_BD = 13,
+ XR_BODY_JOINT_RIGHT_COLLAR_BD = 14,
+ XR_BODY_JOINT_HEAD_BD = 15,
+ XR_BODY_JOINT_LEFT_SHOULDER_BD = 16,
+ XR_BODY_JOINT_RIGHT_SHOULDER_BD = 17,
+ XR_BODY_JOINT_LEFT_ELBOW_BD = 18,
+ XR_BODY_JOINT_RIGHT_ELBOW_BD = 19,
+ XR_BODY_JOINT_LEFT_WRIST_BD = 20,
+ XR_BODY_JOINT_RIGHT_WRIST_BD = 21,
+ XR_BODY_JOINT_LEFT_HAND_BD = 22,
+ XR_BODY_JOINT_RIGHT_HAND_BD = 23,
+ XR_BODY_JOINT_MAX_ENUM_BD = 0x7FFFFFFF
+} XrBodyJointBD;
+
+typedef enum XrBodyJointSetBD {
+ XR_BODY_JOINT_SET_BODY_WITHOUT_ARM_BD = 1,
+ XR_BODY_JOINT_SET_FULL_BODY_JOINTS_BD = 2,
+ XR_BODY_JOINT_SET_MAX_ENUM_BD = 0x7FFFFFFF
+} XrBodyJointSetBD;
+// XrSystemBodyTrackingPropertiesBD extends XrSystemProperties
+typedef struct XrSystemBodyTrackingPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsBodyTracking;
+} XrSystemBodyTrackingPropertiesBD;
+
+typedef struct XrBodyTrackerCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBodyJointSetBD jointSet;
+} XrBodyTrackerCreateInfoBD;
+
+typedef struct XrBodyJointsLocateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrBodyJointsLocateInfoBD;
+
+typedef struct XrBodyJointLocationBD {
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+} XrBodyJointLocationBD;
+
+typedef struct XrBodyJointLocationsBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 allJointPosesTracked;
+ uint32_t jointLocationCount;
+ XrBodyJointLocationBD* jointLocations;
+} XrBodyJointLocationsBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateBodyTrackerBD)(XrSession session, const XrBodyTrackerCreateInfoBD* createInfo, XrBodyTrackerBD* bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyBodyTrackerBD)(XrBodyTrackerBD bodyTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateBodyJointsBD)(XrBodyTrackerBD bodyTracker, const XrBodyJointsLocateInfoBD* locateInfo, XrBodyJointLocationsBD* locations);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateBodyTrackerBD(
+ XrSession session,
+ const XrBodyTrackerCreateInfoBD* createInfo,
+ XrBodyTrackerBD* bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyBodyTrackerBD(
+ XrBodyTrackerBD bodyTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateBodyJointsBD(
+ XrBodyTrackerBD bodyTracker,
+ const XrBodyJointsLocateInfoBD* locateInfo,
+ XrBodyJointLocationsBD* locations);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_facial_simulation is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_facial_simulation 1
+
+#define XR_FACE_EXPRESSION_COUNT_BD 52
+
+
+#define XR_LIP_EXPRESSION_COUNT_BD 20
+
+ XR_DEFINE_HANDLE(XrFaceTrackerBD)
+#define XR_BD_facial_simulation_SPEC_VERSION 1
+#define XR_BD_FACIAL_SIMULATION_EXTENSION_NAME "XR_BD_facial_simulation"
+
+typedef enum XrFacialSimulationModeBD {
+ XR_FACIAL_SIMULATION_MODE_DEFAULT_BD = 0,
+ XR_FACIAL_SIMULATION_MODE_COMBINED_AUDIO_BD = 1,
+ XR_FACIAL_SIMULATION_MODE_COMBINED_AUDIO_WITH_LIP_BD = 2,
+ XR_FACIAL_SIMULATION_MODE_ONLY_AUDIO_WITH_LIP_BD = 3,
+ XR_FACIAL_SIMULATION_MODE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrFacialSimulationModeBD;
+
+typedef enum XrFaceExpressionBD {
+ XR_FACE_EXPRESSION_BROW_DROP_L_BD = 0,
+ XR_FACE_EXPRESSION_BROW_DROP_R_BD = 1,
+ XR_FACE_EXPRESSION_BROW_INNER_UPWARDS_BD = 2,
+ XR_FACE_EXPRESSION_BROW_OUTER_UPWARDS_L_BD = 3,
+ XR_FACE_EXPRESSION_BROW_OUTER_UPWARDS_R_BD = 4,
+ XR_FACE_EXPRESSION_EYE_BLINK_L_BD = 5,
+ XR_FACE_EXPRESSION_EYE_LOOK_DROP_L_BD = 6,
+ XR_FACE_EXPRESSION_EYE_LOOK_IN_L_BD = 7,
+ XR_FACE_EXPRESSION_EYE_LOOK_OUT_L_BD = 8,
+ XR_FACE_EXPRESSION_EYE_LOOK_UPWARDS_L_BD = 9,
+ XR_FACE_EXPRESSION_EYE_LOOK_SQUINT_L_BD = 10,
+ XR_FACE_EXPRESSION_EYE_LOOK_WIDE_L_BD = 11,
+ XR_FACE_EXPRESSION_EYE_BLINK_R_BD = 12,
+ XR_FACE_EXPRESSION_EYE_LOOK_DROP_R_BD = 13,
+ XR_FACE_EXPRESSION_EYE_LOOK_IN_R_BD = 14,
+ XR_FACE_EXPRESSION_EYE_LOOK_OUT_R_BD = 15,
+ XR_FACE_EXPRESSION_EYE_LOOK_UPWARDS_R_BD = 16,
+ XR_FACE_EXPRESSION_EYE_LOOK_SQUINT_R_BD = 17,
+ XR_FACE_EXPRESSION_EYE_LOOK_WIDE_R_BD = 18,
+ XR_FACE_EXPRESSION_NOSE_SNEER_L_BD = 19,
+ XR_FACE_EXPRESSION_NOSE_SNEER_R_BD = 20,
+ XR_FACE_EXPRESSION_CHEEK_PUFF_BD = 21,
+ XR_FACE_EXPRESSION_CHEEK_SQUINT_L_BD = 22,
+ XR_FACE_EXPRESSION_CHEEK_SQUINT_R_BD = 23,
+ XR_FACE_EXPRESSION_MOUTH_CLOSE_BD = 24,
+ XR_FACE_EXPRESSION_MOUTH_FUNNEL_BD = 25,
+ XR_FACE_EXPRESSION_MOUTH_PUCKER_BD = 26,
+ XR_FACE_EXPRESSION_MOUTH_L_BD = 27,
+ XR_FACE_EXPRESSION_MOUTH_R_BD = 28,
+ XR_FACE_EXPRESSION_MOUTH_SMILE_L_BD = 29,
+ XR_FACE_EXPRESSION_MOUTH_SMILE_R_BD = 30,
+ XR_FACE_EXPRESSION_MOUTH_FROWN_L_BD = 31,
+ XR_FACE_EXPRESSION_MOUTH_FROWN_R_BD = 32,
+ XR_FACE_EXPRESSION_MOUTH_DIMPLE_L_BD = 33,
+ XR_FACE_EXPRESSION_MOUTH_DIMPLE_R_BD = 34,
+ XR_FACE_EXPRESSION_MOUTH_STRETCH_L_BD = 35,
+ XR_FACE_EXPRESSION_MOUTH_STRETCH_R_BD = 36,
+ XR_FACE_EXPRESSION_MOUTH_ROLL_LOWER_BD = 37,
+ XR_FACE_EXPRESSION_MOUTH_ROLL_UPPER_BD = 38,
+ XR_FACE_EXPRESSION_MOUTH_SHRUG_LOWER_BD = 39,
+ XR_FACE_EXPRESSION_MOUTH_SHRUG_UPPER_BD = 40,
+ XR_FACE_EXPRESSION_MOUTH_PRESS_L_BD = 41,
+ XR_FACE_EXPRESSION_MOUTH_PRESS_R_BD = 42,
+ XR_FACE_EXPRESSION_MOUTH_LOWER_DROP_L_BD = 43,
+ XR_FACE_EXPRESSION_MOUTH_LOWER_DROP_R_BD = 44,
+ XR_FACE_EXPRESSION_MOUTH_UPPER_UPWARDS_L_BD = 45,
+ XR_FACE_EXPRESSION_MOUTH_UPPER_UPWARDS_R_BD = 46,
+ XR_FACE_EXPRESSION_JAW_FORWARD_BD = 47,
+ XR_FACE_EXPRESSION_JAW_L_BD = 48,
+ XR_FACE_EXPRESSION_JAW_R_BD = 49,
+ XR_FACE_EXPRESSION_JAW_OPEN_BD = 50,
+ XR_FACE_EXPRESSION_TONGUE_OUT_BD = 51,
+ XR_FACE_EXPRESSION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrFaceExpressionBD;
+
+typedef enum XrLipExpressionBD {
+ XR_LIP_EXPRESSION_PP_BD = 0,
+ XR_LIP_EXPRESSION_CH_BD = 1,
+ XR_LIP_EXPRESSION_LO_BD = 2,
+ XR_LIP_EXPRESSION_O_BD = 3,
+ XR_LIP_EXPRESSION_I_BD = 4,
+ XR_LIP_EXPRESSION_LU_BD = 5,
+ XR_LIP_EXPRESSION_RR_BD = 6,
+ XR_LIP_EXPRESSION_XX_BD = 7,
+ XR_LIP_EXPRESSION_LAA_BD = 8,
+ XR_LIP_EXPRESSION_LI_BD = 9,
+ XR_LIP_EXPRESSION_FF_BD = 10,
+ XR_LIP_EXPRESSION_U_BD = 11,
+ XR_LIP_EXPRESSION_TH_BD = 12,
+ XR_LIP_EXPRESSION_LKK_BD = 13,
+ XR_LIP_EXPRESSION_SS_BD = 14,
+ XR_LIP_EXPRESSION_LE_BD = 15,
+ XR_LIP_EXPRESSION_DD_BD = 16,
+ XR_LIP_EXPRESSION_E_BD = 17,
+ XR_LIP_EXPRESSION_LNN_BD = 18,
+ XR_LIP_EXPRESSION_SIL_BD = 19,
+ XR_LIP_EXPRESSION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrLipExpressionBD;
+// XrSystemFacialSimulationPropertiesBD extends XrSystemProperties
+typedef struct XrSystemFacialSimulationPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFaceTracking;
+} XrSystemFacialSimulationPropertiesBD;
+
+typedef struct XrFaceTrackerCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFacialSimulationModeBD mode;
+} XrFaceTrackerCreateInfoBD;
+
+typedef struct XrFacialSimulationDataGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrFacialSimulationDataGetInfoBD;
+
+typedef struct XrFacialSimulationDataBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t faceExpressionWeightCount;
+ float* faceExpressionWeights;
+ XrBool32 isUpperFaceDataValid;
+ XrBool32 isLowerFaceDataValid;
+ XrTime time;
+} XrFacialSimulationDataBD;
+
+// XrLipExpressionDataBD extends XrFacialSimulationDataBD
+typedef struct XrLipExpressionDataBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t lipsyncExpressionWeightCount;
+ float* lipsyncExpressionWeights;
+} XrLipExpressionDataBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateFacialSimulationModesBD)(XrSession session, uint32_t modeCapacityInput, uint32_t* modeCountOutput, XrFacialSimulationModeBD* modes);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFaceTrackerBD)(XrSession session, const XrFaceTrackerCreateInfoBD* createInfo, XrFaceTrackerBD* tracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFaceTrackerBD)(XrFaceTrackerBD tracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFacialSimulationDataBD)(XrFaceTrackerBD tracker, const XrFacialSimulationDataGetInfoBD*info, XrFacialSimulationDataBD* facialData);
+typedef XrResult (XRAPI_PTR *PFN_xrSetFacialSimulationModeBD)(XrFaceTrackerBD tracker, XrFacialSimulationModeBD mode);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFacialSimulationModeBD)(XrFaceTrackerBD tracker, XrFacialSimulationModeBD*mode);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateFacialSimulationModesBD(
+ XrSession session,
+ uint32_t modeCapacityInput,
+ uint32_t* modeCountOutput,
+ XrFacialSimulationModeBD* modes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFaceTrackerBD(
+ XrSession session,
+ const XrFaceTrackerCreateInfoBD* createInfo,
+ XrFaceTrackerBD* tracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFaceTrackerBD(
+ XrFaceTrackerBD tracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFacialSimulationDataBD(
+ XrFaceTrackerBD tracker,
+ const XrFacialSimulationDataGetInfoBD* info,
+ XrFacialSimulationDataBD* facialData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetFacialSimulationModeBD(
+ XrFaceTrackerBD tracker,
+ XrFacialSimulationModeBD mode);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFacialSimulationModeBD(
+ XrFaceTrackerBD tracker,
+ XrFacialSimulationModeBD* mode);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_sensing is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_sensing 1
+XR_DEFINE_ATOM(XrSpatialEntityIdBD)
+XR_DEFINE_HANDLE(XrSenseDataProviderBD)
+XR_DEFINE_HANDLE(XrSenseDataSnapshotBD)
+XR_DEFINE_HANDLE(XrAnchorBD)
+#define XR_BD_spatial_sensing_SPEC_VERSION 3
+#define XR_BD_SPATIAL_SENSING_EXTENSION_NAME "XR_BD_spatial_sensing"
+
+typedef enum XrSpatialEntityComponentTypeBD {
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_LOCATION_BD = 0,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_SEMANTIC_BD = 1,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_BOUNDING_BOX_2D_BD = 2,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_POLYGON_BD = 3,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_BOUNDING_BOX_3D_BD = 4,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_TRIANGLE_MESH_BD = 5,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_SPHERE_BD = 6,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_PLANE_ORIENTATION_BD = 1000396000,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_LIGHT_ESTIMATION_BD = 1000397000,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_DYNAMIC_OBJECT_BD = 1000746000,
+ XR_SPATIAL_ENTITY_COMPONENT_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSpatialEntityComponentTypeBD;
+
+typedef enum XrSemanticLabelBD {
+ XR_SEMANTIC_LABEL_UNKNOWN_BD = 0,
+ XR_SEMANTIC_LABEL_FLOOR_BD = 1,
+ XR_SEMANTIC_LABEL_CEILING_BD = 2,
+ XR_SEMANTIC_LABEL_WALL_BD = 3,
+ XR_SEMANTIC_LABEL_DOOR_BD = 4,
+ XR_SEMANTIC_LABEL_WINDOW_BD = 5,
+ XR_SEMANTIC_LABEL_OPENING_BD = 6,
+ XR_SEMANTIC_LABEL_TABLE_BD = 7,
+ XR_SEMANTIC_LABEL_SOFA_BD = 8,
+ XR_SEMANTIC_LABEL_CHAIR_BD = 9,
+ XR_SEMANTIC_LABEL_HUMAN_BD = 10,
+ XR_SEMANTIC_LABEL_BEAM_BD = 11,
+ XR_SEMANTIC_LABEL_COLUMN_BD = 12,
+ XR_SEMANTIC_LABEL_CURTAIN_BD = 13,
+ XR_SEMANTIC_LABEL_CABINET_BD = 14,
+ XR_SEMANTIC_LABEL_BED_BD = 15,
+ XR_SEMANTIC_LABEL_PLANT_BD = 16,
+ XR_SEMANTIC_LABEL_SCREEN_BD = 17,
+ XR_SEMANTIC_LABEL_VIRTUAL_WALL_BD = 18,
+ XR_SEMANTIC_LABEL_REFRIGERATOR_BD = 19,
+ XR_SEMANTIC_LABEL_WASHING_MACHINE_BD = 20,
+ XR_SEMANTIC_LABEL_AIR_CONDITIONER_BD = 21,
+ XR_SEMANTIC_LABEL_LAMP_BD = 22,
+ XR_SEMANTIC_LABEL_WALL_ART_BD = 23,
+ XR_SEMANTIC_LABEL_STAIRWAY_BD = 24,
+ XR_SEMANTIC_LABEL_KEYBOARD_BD = 25,
+ XR_SEMANTIC_LABEL_MOUSE_BD = 26,
+ XR_SEMANTIC_LABEL_LAPTOP_BD = 27,
+ XR_SEMANTIC_LABEL_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSemanticLabelBD;
+
+typedef enum XrSenseDataProviderTypeBD {
+ XR_SENSE_DATA_PROVIDER_TYPE_ANCHOR_BD = 1000390000,
+ XR_SENSE_DATA_PROVIDER_TYPE_SCENE_BD = 1000392000,
+ XR_SENSE_DATA_PROVIDER_TYPE_MESH_BD = 1000393000,
+ XR_SENSE_DATA_PROVIDER_TYPE_PLANE_BD = 1000396000,
+ XR_SENSE_DATA_PROVIDER_TYPE_LIGHT_ESTIMATION_BD = 1000397000,
+ XR_SENSE_DATA_PROVIDER_TYPE_DYNAMIC_OBJECT_BD = 1000746000,
+ XR_SENSE_DATA_PROVIDER_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSenseDataProviderTypeBD;
+
+typedef enum XrSenseDataProviderStateBD {
+ XR_SENSE_DATA_PROVIDER_STATE_INITIALIZED_BD = 0,
+ XR_SENSE_DATA_PROVIDER_STATE_RUNNING_BD = 1,
+ XR_SENSE_DATA_PROVIDER_STATE_STOPPED_BD = 2,
+ XR_SENSE_DATA_PROVIDER_STATE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSenseDataProviderStateBD;
+// XrSystemSpatialSensingPropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialSensingPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialSensing;
+} XrSystemSpatialSensingPropertiesBD;
+
+typedef struct XrSpatialEntityComponentGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialEntityIdBD entityId;
+ XrSpatialEntityComponentTypeBD componentType;
+} XrSpatialEntityComponentGetInfoBD;
+
+typedef struct XrSpatialEntityComponentDataBaseHeaderBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrSpatialEntityComponentDataBaseHeaderBD;
+
+// XrSpatialEntityLocationGetInfoBD extends XrSpatialEntityComponentGetInfoBD
+typedef struct XrSpatialEntityLocationGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+} XrSpatialEntityLocationGetInfoBD;
+
+// XrSpatialEntityComponentDataLocationBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataLocationBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpaceLocation location;
+} XrSpatialEntityComponentDataLocationBD;
+
+// XrSpatialEntityComponentDataSemanticBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataSemanticBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t labelCapacityInput;
+ uint32_t labelCountOutput;
+ XrSemanticLabelBD* labels;
+} XrSpatialEntityComponentDataSemanticBD;
+
+// XrSpatialEntityComponentDataBoundingBox2DBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataBoundingBox2DBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrRect2Df boundingBox2D;
+} XrSpatialEntityComponentDataBoundingBox2DBD;
+
+// XrSpatialEntityComponentDataPolygonBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataPolygonBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector2f* vertices;
+} XrSpatialEntityComponentDataPolygonBD;
+
+// XrSpatialEntityComponentDataBoundingBox3DBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataBoundingBox3DBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBoxf boundingBox3D;
+} XrSpatialEntityComponentDataBoundingBox3DBD;
+
+// XrSpatialEntityComponentDataTriangleMeshBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataTriangleMeshBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertices;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint16_t* indices;
+} XrSpatialEntityComponentDataTriangleMeshBD;
+
+// XrSpatialEntityComponentDataSphereBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataSphereBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpheref sphere;
+} XrSpatialEntityComponentDataSphereBD;
+
+typedef struct XrSenseDataProviderCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSenseDataProviderTypeBD providerType;
+} XrSenseDataProviderCreateInfoBD;
+
+typedef struct XrSenseDataProviderStartInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSenseDataProviderStartInfoBD;
+
+typedef struct XrEventDataSenseDataProviderStateChangedBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSenseDataProviderBD provider;
+ XrSenseDataProviderStateBD newState;
+} XrEventDataSenseDataProviderStateChangedBD;
+
+typedef struct XrEventDataSenseDataUpdatedBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSenseDataProviderBD provider;
+} XrEventDataSenseDataUpdatedBD;
+
+typedef struct XrSenseDataQueryInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSenseDataQueryInfoBD;
+
+typedef struct XrSenseDataQueryCompletionBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSenseDataSnapshotBD snapshot;
+} XrSenseDataQueryCompletionBD;
+
+typedef struct XrQueriedSenseDataGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrQueriedSenseDataGetInfoBD;
+
+typedef struct XrSpatialEntityStateBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrSpatialEntityIdBD entityId;
+ XrTime lastUpdateTime;
+ XrUuidEXT uuid;
+} XrSpatialEntityStateBD;
+
+typedef struct XrQueriedSenseDataBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t stateCapacityInput;
+ uint32_t stateCountOutput;
+ XrSpatialEntityStateBD* states;
+} XrQueriedSenseDataBD;
+
+// XrSenseDataFilterUuidBD extends XrSenseDataQueryInfoBD
+typedef struct XrSenseDataFilterUuidBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t uuidCount;
+ const XrUuidEXT* uuids;
+} XrSenseDataFilterUuidBD;
+
+// XrSenseDataFilterSemanticBD extends XrSenseDataQueryInfoBD
+typedef struct XrSenseDataFilterSemanticBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t labelCount;
+ const XrSemanticLabelBD* labels;
+} XrSenseDataFilterSemanticBD;
+
+typedef struct XrSpatialEntityAnchorCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSenseDataSnapshotBD snapshot;
+ XrSpatialEntityIdBD entityId;
+} XrSpatialEntityAnchorCreateInfoBD;
+
+typedef struct XrAnchorSpaceCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAnchorBD anchor;
+ XrPosef poseInAnchorSpace;
+} XrAnchorSpaceCreateInfoBD;
+
+typedef struct XrFutureCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+} XrFutureCompletionEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialEntityComponentTypesBD)(XrSenseDataSnapshotBD snapshot, XrSpatialEntityIdBD entityId, uint32_t componentTypeCapacityInput, uint32_t* componentTypeCountOutput, XrSpatialEntityComponentTypeBD* componentTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialEntityUuidBD)(XrSenseDataSnapshotBD snapshot, XrSpatialEntityIdBD entityId, XrUuidEXT* uuid);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialEntityComponentDataBD)(XrSenseDataSnapshotBD snapshot, const XrSpatialEntityComponentGetInfoBD* getInfo, XrSpatialEntityComponentDataBaseHeaderBD* componentData);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSenseDataProviderBD)(XrSession session, const XrSenseDataProviderCreateInfoBD* createInfo, XrSenseDataProviderBD* provider);
+typedef XrResult (XRAPI_PTR *PFN_xrStartSenseDataProviderAsyncBD)(XrSenseDataProviderBD provider, const XrSenseDataProviderStartInfoBD* startInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrStartSenseDataProviderCompleteBD)(XrSession session, XrFutureEXT future, XrFutureCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSenseDataProviderStateBD)(XrSenseDataProviderBD provider, XrSenseDataProviderStateBD* state);
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySenseDataAsyncBD)(XrSenseDataProviderBD provider, const XrSenseDataQueryInfoBD* queryInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySenseDataCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrSenseDataQueryCompletionBD* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySenseDataSnapshotBD)(XrSenseDataSnapshotBD snapshot);
+typedef XrResult (XRAPI_PTR *PFN_xrGetQueriedSenseDataBD)(XrSenseDataSnapshotBD snapshot, XrQueriedSenseDataGetInfoBD* getInfo, XrQueriedSenseDataBD* queriedSenseData);
+typedef XrResult (XRAPI_PTR *PFN_xrStopSenseDataProviderBD)(XrSenseDataProviderBD provider);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySenseDataProviderBD)(XrSenseDataProviderBD provider);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialEntityAnchorBD)(XrSenseDataProviderBD provider, const XrSpatialEntityAnchorCreateInfoBD* createInfo, XrAnchorBD* anchor);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyAnchorBD)(XrAnchorBD anchor);
+typedef XrResult (XRAPI_PTR *PFN_xrGetAnchorUuidBD)(XrAnchorBD anchor, XrUuidEXT* uuid);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateAnchorSpaceBD)(XrSession session, const XrAnchorSpaceCreateInfoBD* createInfo, XrSpace* space);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialEntityComponentTypesBD(
+ XrSenseDataSnapshotBD snapshot,
+ XrSpatialEntityIdBD entityId,
+ uint32_t componentTypeCapacityInput,
+ uint32_t* componentTypeCountOutput,
+ XrSpatialEntityComponentTypeBD* componentTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialEntityUuidBD(
+ XrSenseDataSnapshotBD snapshot,
+ XrSpatialEntityIdBD entityId,
+ XrUuidEXT* uuid);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialEntityComponentDataBD(
+ XrSenseDataSnapshotBD snapshot,
+ const XrSpatialEntityComponentGetInfoBD* getInfo,
+ XrSpatialEntityComponentDataBaseHeaderBD* componentData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSenseDataProviderBD(
+ XrSession session,
+ const XrSenseDataProviderCreateInfoBD* createInfo,
+ XrSenseDataProviderBD* provider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStartSenseDataProviderAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSenseDataProviderStartInfoBD* startInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStartSenseDataProviderCompleteBD(
+ XrSession session,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSenseDataProviderStateBD(
+ XrSenseDataProviderBD provider,
+ XrSenseDataProviderStateBD* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySenseDataAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSenseDataQueryInfoBD* queryInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySenseDataCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrSenseDataQueryCompletionBD* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySenseDataSnapshotBD(
+ XrSenseDataSnapshotBD snapshot);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetQueriedSenseDataBD(
+ XrSenseDataSnapshotBD snapshot,
+ XrQueriedSenseDataGetInfoBD* getInfo,
+ XrQueriedSenseDataBD* queriedSenseData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStopSenseDataProviderBD(
+ XrSenseDataProviderBD provider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySenseDataProviderBD(
+ XrSenseDataProviderBD provider);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialEntityAnchorBD(
+ XrSenseDataProviderBD provider,
+ const XrSpatialEntityAnchorCreateInfoBD* createInfo,
+ XrAnchorBD* anchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyAnchorBD(
+ XrAnchorBD anchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAnchorUuidBD(
+ XrAnchorBD anchor,
+ XrUuidEXT* uuid);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateAnchorSpaceBD(
+ XrSession session,
+ const XrAnchorSpaceCreateInfoBD* createInfo,
+ XrSpace* space);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_anchor 1
+#define XR_BD_spatial_anchor_SPEC_VERSION 2
+#define XR_BD_SPATIAL_ANCHOR_EXTENSION_NAME "XR_BD_spatial_anchor"
+
+typedef enum XrPersistenceLocationBD {
+ XR_PERSISTENCE_LOCATION_LOCAL_BD = 0,
+ XR_PERSISTENCE_LOCATION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrPersistenceLocationBD;
+// XrSystemSpatialAnchorPropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialAnchorPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialAnchor;
+} XrSystemSpatialAnchorPropertiesBD;
+
+typedef struct XrSpatialAnchorCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrPosef pose;
+ XrTime time;
+} XrSpatialAnchorCreateInfoBD;
+
+typedef struct XrSpatialAnchorCreateCompletionBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrUuidEXT uuid;
+ XrAnchorBD anchor;
+} XrSpatialAnchorCreateCompletionBD;
+
+typedef struct XrSpatialAnchorPersistInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPersistenceLocationBD location;
+ XrAnchorBD anchor;
+} XrSpatialAnchorPersistInfoBD;
+
+typedef struct XrSpatialAnchorUnpersistInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPersistenceLocationBD location;
+ XrAnchorBD anchor;
+} XrSpatialAnchorUnpersistInfoBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorAsyncBD)(XrSenseDataProviderBD provider, const XrSpatialAnchorCreateInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrSpatialAnchorCreateCompletionBD* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrPersistSpatialAnchorAsyncBD)(XrSenseDataProviderBD provider, const XrSpatialAnchorPersistInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrPersistSpatialAnchorCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrFutureCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistSpatialAnchorAsyncBD)(XrSenseDataProviderBD provider, const XrSpatialAnchorUnpersistInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistSpatialAnchorCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrFutureCompletionEXT* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSpatialAnchorCreateInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrSpatialAnchorCreateCompletionBD* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistSpatialAnchorAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSpatialAnchorPersistInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistSpatialAnchorCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistSpatialAnchorAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSpatialAnchorUnpersistInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistSpatialAnchorCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_anchor_sharing is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_anchor_sharing 1
+#define XR_BD_spatial_anchor_sharing_SPEC_VERSION 2
+#define XR_BD_SPATIAL_ANCHOR_SHARING_EXTENSION_NAME "XR_BD_spatial_anchor_sharing"
+// XrSystemSpatialAnchorSharingPropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialAnchorSharingPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialAnchorSharing;
+} XrSystemSpatialAnchorSharingPropertiesBD;
+
+typedef struct XrSpatialAnchorShareInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAnchorBD anchor;
+} XrSpatialAnchorShareInfoBD;
+
+typedef struct XrSharedSpatialAnchorDownloadInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidEXT uuid;
+} XrSharedSpatialAnchorDownloadInfoBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrShareSpatialAnchorAsyncBD)(XrSenseDataProviderBD provider, const XrSpatialAnchorShareInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrShareSpatialAnchorCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrFutureCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDownloadSharedSpatialAnchorAsyncBD)(XrSenseDataProviderBD provider, const XrSharedSpatialAnchorDownloadInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrDownloadSharedSpatialAnchorCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrFutureCompletionEXT* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrShareSpatialAnchorAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSpatialAnchorShareInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrShareSpatialAnchorCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDownloadSharedSpatialAnchorAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSharedSpatialAnchorDownloadInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDownloadSharedSpatialAnchorCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_scene is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_scene 1
+#define XR_BD_spatial_scene_SPEC_VERSION 1
+#define XR_BD_SPATIAL_SCENE_EXTENSION_NAME "XR_BD_spatial_scene"
+// XrSystemSpatialScenePropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialScenePropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialScene;
+} XrSystemSpatialScenePropertiesBD;
+
+typedef struct XrSceneCaptureInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSceneCaptureInfoBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCaptureSceneAsyncBD)(XrSenseDataProviderBD provider, const XrSceneCaptureInfoBD* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCaptureSceneCompleteBD)(XrSenseDataProviderBD provider, XrFutureEXT future, XrFutureCompletionEXT* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCaptureSceneAsyncBD(
+ XrSenseDataProviderBD provider,
+ const XrSceneCaptureInfoBD* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCaptureSceneCompleteBD(
+ XrSenseDataProviderBD provider,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_mesh 1
+#define XR_BD_spatial_mesh_SPEC_VERSION 1
+#define XR_BD_SPATIAL_MESH_EXTENSION_NAME "XR_BD_spatial_mesh"
+
+typedef enum XrSpatialMeshLodBD {
+ XR_SPATIAL_MESH_LOD_COARSE_BD = 0,
+ XR_SPATIAL_MESH_LOD_MEDIUM_BD = 1,
+ XR_SPATIAL_MESH_LOD_FINE_BD = 2,
+ XR_SPATIAL_MESH_LOD_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSpatialMeshLodBD;
+typedef XrFlags64 XrSpatialMeshConfigFlagsBD;
+
+// Flag bits for XrSpatialMeshConfigFlagsBD
+static const XrSpatialMeshConfigFlagsBD XR_SPATIAL_MESH_CONFIG_SEMANTIC_BIT_BD = 0x00000001;
+static const XrSpatialMeshConfigFlagsBD XR_SPATIAL_MESH_CONFIG_ALIGN_SEMANTIC_WITH_VERTEX_BIT_BD = 0x00000002;
+
+// XrSystemSpatialMeshPropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialMeshPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialMesh;
+} XrSystemSpatialMeshPropertiesBD;
+
+// XrSenseDataProviderCreateInfoSpatialMeshBD extends XrSenseDataProviderCreateInfoBD
+typedef struct XrSenseDataProviderCreateInfoSpatialMeshBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialMeshConfigFlagsBD configFlags;
+ XrSpatialMeshLodBD lod;
+} XrSenseDataProviderCreateInfoSpatialMeshBD;
+
+
+
+// XR_BD_future_progress is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_future_progress 1
+#define XR_BD_future_progress_SPEC_VERSION 1
+#define XR_BD_FUTURE_PROGRESS_EXTENSION_NAME "XR_BD_future_progress"
+// XrFuturePollResultProgressBD extends XrFuturePollResultEXT
+typedef struct XrFuturePollResultProgressBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isSupported;
+ uint32_t progressPercentage;
+} XrFuturePollResultProgressBD;
+
+
+
+// XR_BD_body_tracking_auxiliary_metrics is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_body_tracking_auxiliary_metrics 1
+#define XR_BD_body_tracking_auxiliary_metrics_SPEC_VERSION 1
+#define XR_BD_BODY_TRACKING_AUXILIARY_METRICS_EXTENSION_NAME "XR_BD_body_tracking_auxiliary_metrics"
+
+typedef enum XrBodyTrackingPostureBD {
+ XR_BODY_TRACKING_POSTURE_STOMP_BD = 1,
+ XR_BODY_TRACKING_POSTURE_STATIC_BD = 2,
+ XR_BODY_TRACKING_POSTURE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrBodyTrackingPostureBD;
+
+typedef enum XrBodyTrackingStatusBD {
+ XR_BODY_TRACKING_STATUS_INVALID_BD = 0,
+ XR_BODY_TRACKING_STATUS_VALID_BD = 1,
+ XR_BODY_TRACKING_STATUS_LIMITED_BD = 2,
+ XR_BODY_TRACKING_STATUS_MAX_ENUM_BD = 0x7FFFFFFF
+} XrBodyTrackingStatusBD;
+
+typedef enum XrBodyTrackingMessageBD {
+ XR_BODY_TRACKING_MESSAGE_NO_ERROR_BD = 0,
+ XR_BODY_TRACKING_MESSAGE_TRACKER_NOT_CALIBRATED_BD = 1,
+ XR_BODY_TRACKING_MESSAGE_TRACKER_NUM_NOT_ENOUGH_BD = 2,
+ XR_BODY_TRACKING_MESSAGE_TRACKER_STATE_NOT_SATISFIED_BD = 3,
+ XR_BODY_TRACKING_MESSAGE_TRACKER_PERSISTENT_INVISIBILITY_BD = 4,
+ XR_BODY_TRACKING_MESSAGE_TRACKER_DATA_ERROR_BD = 5,
+ XR_BODY_TRACKING_MESSAGE_USER_CHANGE_BD = 6,
+ XR_BODY_TRACKING_MESSAGE_TRACKING_POSE_ERROR_BD = 7,
+ XR_BODY_TRACKING_MESSAGE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrBodyTrackingMessageBD;
+typedef XrFlags64 XrSpaceAccelerationFlagsBD;
+
+// Flag bits for XrSpaceAccelerationFlagsBD
+static const XrSpaceAccelerationFlagsBD XR_SPACE_ACCELERATION_LINEAR_VALID_BIT_BD = 0x00000001;
+static const XrSpaceAccelerationFlagsBD XR_SPACE_ACCELERATION_ANGULAR_VALID_BIT_BD = 0x00000002;
+
+// XrBodyTrackingPostureDataBD extends XrBodyJointLocationsBD
+typedef struct XrBodyTrackingPostureDataBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t postureCount;
+ XrBodyTrackingPostureBD* postureData;
+} XrBodyTrackingPostureDataBD;
+
+typedef struct XrBodyJointVelocityBD {
+ XrSpaceVelocityFlags velocityFlags;
+ XrVector3f linearVelocity;
+ XrVector3f angularVelocity;
+} XrBodyJointVelocityBD;
+
+// XrBodyJointVelocitiesBD extends XrBodyJointLocationsBD
+typedef struct XrBodyJointVelocitiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t velocityCount;
+ XrBodyJointVelocityBD* velocities;
+} XrBodyJointVelocitiesBD;
+
+typedef struct XrBodyJointAccelerationBD {
+ XrSpaceAccelerationFlagsBD accelerationFlags;
+ XrVector3f linearAcceleration;
+ XrVector3f angularAcceleration;
+} XrBodyJointAccelerationBD;
+
+// XrBodyJointAccelerationsBD extends XrBodyJointLocationsBD
+typedef struct XrBodyJointAccelerationsBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t accelerationCount;
+ XrBodyJointAccelerationBD* accelerations;
+} XrBodyJointAccelerationsBD;
+
+typedef struct XrBodyTrackingStateBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBodyTrackingStatusBD status;
+ XrBodyTrackingMessageBD message;
+} XrBodyTrackingStateBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrStartBodyTrackingCalibrationAppBD)(XrSession session);
+typedef XrResult (XRAPI_PTR *PFN_xrGetBodyTrackingStateBD)(XrSession session, XrBodyTrackingStateBD* state);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrStartBodyTrackingCalibrationAppBD(
+ XrSession session);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetBodyTrackingStateBD(
+ XrSession session,
+ XrBodyTrackingStateBD* state);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_spatial_plane is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_plane 1
+#define XR_BD_spatial_plane_SPEC_VERSION 1
+#define XR_BD_SPATIAL_PLANE_EXTENSION_NAME "XR_BD_spatial_plane"
+
+typedef enum XrPlaneOrientationBD {
+ XR_PLANE_ORIENTATION_HORIZONTAL_UPWARD_BD = 0,
+ XR_PLANE_ORIENTATION_HORIZONTAL_DOWNWARD_BD = 1,
+ XR_PLANE_ORIENTATION_VERTICAL_BD = 2,
+ XR_PLANE_ORIENTATION_ARBITRARY_BD = 3,
+ XR_PLANE_ORIENTATION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrPlaneOrientationBD;
+// XrSystemSpatialPlanePropertiesBD extends XrSystemProperties
+typedef struct XrSystemSpatialPlanePropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialPlane;
+} XrSystemSpatialPlanePropertiesBD;
+
+// XrSpatialEntityComponentDataPlaneOrientationBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataPlaneOrientationBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPlaneOrientationBD orientation;
+} XrSpatialEntityComponentDataPlaneOrientationBD;
+
+// XrSenseDataFilterPlaneOrientationBD extends XrSenseDataQueryInfoBD
+typedef struct XrSenseDataFilterPlaneOrientationBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t orientationCount;
+ XrPlaneOrientationBD* orientations;
+} XrSenseDataFilterPlaneOrientationBD;
+
+
+
+// XR_BD_spatial_light_estimation is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_light_estimation 1
+#define XR_BD_spatial_light_estimation_SPEC_VERSION 1
+#define XR_BD_SPATIAL_LIGHT_ESTIMATION_EXTENSION_NAME "XR_BD_spatial_light_estimation"
+
+typedef enum XrEnvironmentTexturePixelFormatBD {
+ XR_ENVIRONMENT_TEXTURE_PIXEL_FORMAT_RGB_16FLOAT_BD = 0,
+ XR_ENVIRONMENT_TEXTURE_PIXEL_FORMAT_RGBA_16FLOAT_BD = 1,
+ XR_ENVIRONMENT_TEXTURE_PIXEL_FORMAT_MAX_ENUM_BD = 0x7FFFFFFF
+} XrEnvironmentTexturePixelFormatBD;
+
+typedef enum XrEnvironmentTextureResolutionBD {
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_8_8_BD = 0,
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_16_16_BD = 1,
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_32_32_BD = 2,
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_64_64_BD = 3,
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_128_128_BD = 4,
+ XR_ENVIRONMENT_TEXTURE_RESOLUTION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrEnvironmentTextureResolutionBD;
+
+typedef enum XrEnvironmentTextureTransferTypeBD {
+ XR_ENVIRONMENT_TEXTURE_TRANSFER_TYPE_RAW_BD = 0,
+ XR_ENVIRONMENT_TEXTURE_TRANSFER_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrEnvironmentTextureTransferTypeBD;
+
+typedef enum XrSphericalHarmonicsKindBD {
+ XR_SPHERICAL_HARMONICS_KIND_TOTAL_BD = 0,
+ XR_SPHERICAL_HARMONICS_KIND_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSphericalHarmonicsKindBD;
+typedef XrFlags64 XrLightEstimationCreateFlagsBD;
+
+// Flag bits for XrLightEstimationCreateFlagsBD
+static const XrLightEstimationCreateFlagsBD XR_LIGHT_ESTIMATION_CREATE_SPHERICAL_HARMONICS_BIT_BD = 0x00000001;
+static const XrLightEstimationCreateFlagsBD XR_LIGHT_ESTIMATION_CREATE_ENVIRONMENT_TEXTURE_BIT_BD = 0x00000002;
+
+// XrSystemLightEstimationPropertiesBD extends XrSystemProperties
+typedef struct XrSystemLightEstimationPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsLightEstimation;
+ XrBool32 supportsEnvironmentTexture;
+ XrBool32 supportsSphericalHarmonics;
+} XrSystemLightEstimationPropertiesBD;
+
+// XrSenseDataProviderCreateInfoLightEstimationBD extends XrSenseDataProviderCreateInfoBD
+typedef struct XrSenseDataProviderCreateInfoLightEstimationBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrLightEstimationCreateFlagsBD createFlags;
+} XrSenseDataProviderCreateInfoLightEstimationBD;
+
+// XrEnvironmentTextureCreateConfigInfoBD extends XrSenseDataProviderCreateInfoBD
+typedef struct XrEnvironmentTextureCreateConfigInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEnvironmentTexturePixelFormatBD pixelFormat;
+ XrEnvironmentTextureResolutionBD resolution;
+ XrEnvironmentTextureTransferTypeBD transferType;
+} XrEnvironmentTextureCreateConfigInfoBD;
+
+// XrLightEstimationDataEnvironmentTextureRawBD extends XrSpatialEntityComponentDataLightEstimationBD
+typedef struct XrLightEstimationDataEnvironmentTextureRawBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEnvironmentTexturePixelFormatBD pixelFormat;
+ uint32_t cubemapFaceBufferSize;
+ uint8_t* rightCubemapFaceBuffer;
+ uint8_t* leftCubemapFaceBuffer;
+ uint8_t* topCubemapFaceBuffer;
+ uint8_t* bottomCubemapFaceBuffer;
+ uint8_t* frontCubemapFaceBuffer;
+ uint8_t* backCubemapFaceBuffer;
+} XrLightEstimationDataEnvironmentTextureRawBD;
+
+// XrLightEstimationDataSphericalHarmonicsBD extends XrSpatialEntityComponentDataLightEstimationBD
+typedef struct XrLightEstimationDataSphericalHarmonicsBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSphericalHarmonicsKindBD kind;
+ uint32_t coefficientCapacityInput;
+ uint32_t coefficientCountOutput;
+ float* coefficients;
+} XrLightEstimationDataSphericalHarmonicsBD;
+
+// XrSpatialEntityComponentDataLightEstimationBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataLightEstimationBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isValid;
+} XrSpatialEntityComponentDataLightEstimationBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateEnvironmentTextureResolutionsBD)(XrSession session, uint32_t resolutionCapacityInput, uint32_t* resolutionCountOutput, XrEnvironmentTextureResolutionBD* resolutions);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateEnvironmentTexturePixelFormatsBD)(XrSession session, uint32_t pixelFormatCapacityInput, uint32_t* pixelFormatCountOutput, XrEnvironmentTexturePixelFormatBD* pixelFormats);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateEnvironmentTextureTransferTypesBD)(XrSession session, uint32_t transferTypeCapacityInput, uint32_t* transferTypeCountOutput, XrEnvironmentTextureTransferTypeBD* transferTypes);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateEnvironmentTextureResolutionsBD(
+ XrSession session,
+ uint32_t resolutionCapacityInput,
+ uint32_t* resolutionCountOutput,
+ XrEnvironmentTextureResolutionBD* resolutions);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateEnvironmentTexturePixelFormatsBD(
+ XrSession session,
+ uint32_t pixelFormatCapacityInput,
+ uint32_t* pixelFormatCountOutput,
+ XrEnvironmentTexturePixelFormatBD* pixelFormats);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateEnvironmentTextureTransferTypesBD(
+ XrSession session,
+ uint32_t transferTypeCapacityInput,
+ uint32_t* transferTypeCountOutput,
+ XrEnvironmentTextureTransferTypeBD* transferTypes);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_BD_ultra_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_ultra_controller_interaction 1
+#define XR_BD_ultra_controller_interaction_SPEC_VERSION 1
+#define XR_BD_ULTRA_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_BD_ultra_controller_interaction"
+
+
+// XR_BD_spatial_audio_rendering is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_spatial_audio_rendering 1
+XR_DEFINE_HANDLE(XrSpatialAudioRendererBD)
+XR_DEFINE_HANDLE(XrSoundFieldBD)
+XR_DEFINE_HANDLE(XrSoundObjectBD)
+XR_DEFINE_HANDLE(XrSoundObstacleBD)
+XR_DEFINE_HANDLE(XrSoundObstacleMaterialBD)
+#define XR_BD_spatial_audio_rendering_SPEC_VERSION 1
+#define XR_BD_SPATIAL_AUDIO_RENDERING_EXTENSION_NAME "XR_BD_spatial_audio_rendering"
+
+typedef enum XrAudioSampleRateBD {
+ XR_AUDIO_SAMPLE_RATE_192000_HZ_BD = 1,
+ XR_AUDIO_SAMPLE_RATE_96000_HZ_BD = 2,
+ XR_AUDIO_SAMPLE_RATE_48000_HZ_BD = 3,
+ XR_AUDIO_SAMPLE_RATE_44100_HZ_BD = 4,
+ XR_AUDIO_SAMPLE_RATE_32000_HZ_BD = 5,
+ XR_AUDIO_SAMPLE_RATE_24000_HZ_BD = 6,
+ XR_AUDIO_SAMPLE_RATE_22050_HZ_BD = 7,
+ XR_AUDIO_SAMPLE_RATE_16000_HZ_BD = 8,
+ XR_AUDIO_SAMPLE_RATE_12000_HZ_BD = 9,
+ XR_AUDIO_SAMPLE_RATE_11025_HZ_BD = 10,
+ XR_AUDIO_SAMPLE_RATE_8000_HZ_BD = 11,
+ XR_AUDIO_SAMPLE_RATE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrAudioSampleRateBD;
+
+typedef enum XrAudioBufferChannelLayoutBD {
+ XR_AUDIO_BUFFER_CHANNEL_LAYOUT_INTERLEAVED_BD = 0,
+ XR_AUDIO_BUFFER_CHANNEL_LAYOUT_PLANAR_BD = 1,
+ XR_AUDIO_BUFFER_CHANNEL_LAYOUT_MAX_ENUM_BD = 0x7FFFFFFF
+} XrAudioBufferChannelLayoutBD;
+
+typedef enum XrSoundObjectDistanceAttenuationTypeBD {
+ XR_SOUND_OBJECT_DISTANCE_ATTENUATION_TYPE_NONE_BD = 0,
+ XR_SOUND_OBJECT_DISTANCE_ATTENUATION_TYPE_INVERSE_SQUARE_BD = 1,
+ XR_SOUND_OBJECT_DISTANCE_ATTENUATION_TYPE_ROLLOFF_BD = 2,
+ XR_SOUND_OBJECT_DISTANCE_ATTENUATION_TYPE_CUSTOMIZED_BD = 100,
+ XR_SOUND_OBJECT_DISTANCE_ATTENUATION_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSoundObjectDistanceAttenuationTypeBD;
+
+typedef enum XrSoundFieldChannelMaskSurroundBD {
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_FRONT_LEFT_BD = 1,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_FRONT_RIGHT_BD = 2,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_FRONT_CENTER_BD = 4,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_LOW_FREQUENCY_BD = 8,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_BACK_LEFT_BD = 16,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_BACK_RIGHT_BD = 32,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SIDE_LEFT_BD = 64,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SIDE_RIGHT_BD = 128,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_FRONT_LEFT_BD = 256,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_FRONT_RIGHT_BD = 512,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_BACK_LEFT_BD = 1024,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_BACK_RIGHT_BD = 2048,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_BACK_CENTER_BD = 4096,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_SIDE_LEFT_BD = 8192,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_TOP_SIDE_RIGHT_BD = 16384,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_STEREO_BD = 3,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_2_1_BD = 11,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_3_0_BD = 7,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_4_0_BD = 4099,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_BACK_SURROUND_BD = 48,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_QUAD_BD = 51,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_3_1_BD = 15,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_0_BD = 55,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_SIDE_SURROUND_BD = 192,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_0_SIDE_BD = 199,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_1_BD = 63,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_1_SIDE_BD = 207,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_7_0_BD = 247,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_7_1_BD = 255,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_1_2_BD = 831,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_5_1_4_BD = 3903,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_7_1_2_BD = 24831,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_SETUP_7_1_4_BD = 4095,
+ XR_SOUND_FIELD_CHANNEL_MASK_SURROUND_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSoundFieldChannelMaskSurroundBD;
+
+typedef enum XrSoundFieldChannelMaskAmbixBD {
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_1ST_ORDER_BD = 1,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_2ND_ORDER_BD = 2,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_3RD_ORDER_BD = 3,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_4TH_ORDER_BD = 4,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_5TH_ORDER_BD = 5,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_6TH_ORDER_BD = 6,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_7TH_ORDER_BD = 7,
+ XR_SOUND_FIELD_CHANNEL_MASK_AMBIX_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSoundFieldChannelMaskAmbixBD;
+
+typedef enum XrSoundFieldChannelMaskFumaBD {
+ XR_SOUND_FIELD_CHANNEL_MASK_FUMA_1ST_ORDER_BD = 1,
+ XR_SOUND_FIELD_CHANNEL_MASK_FUMA_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSoundFieldChannelMaskFumaBD;
+
+typedef enum XrSoundObstacleMaterialTypeBD {
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_ACOUSTIC_TILE_BD = 0,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_BRICK_BD = 1,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_BRICK_PAINTED_BD = 2,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CARPET_BD = 3,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CARPET_HEAVY_BD = 4,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CARPET_HEAVY_PADDED_BD = 5,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CERAMIC_TILE_BD = 6,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CONCRETE_BD = 7,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CONCRETE_ROUGH_BD = 8,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CONCRETE_BLOCK_BD = 9,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CONCRETE_BLOCK_PAINTED_BD = 10,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CURTAIN_BD = 11,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_FOLIAGE_BD = 12,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_GLASS_BD = 13,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_GLASS_HEAVY_BD = 14,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_GRASS_BD = 15,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_GRAVEL_BD = 16,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_GYPSUM_BOARD_BD = 17,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_PLASTER_ON_BRICK_BD = 18,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_PLASTER_ON_CONCRETE_BLOCK_BD = 19,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_SOIL_BD = 20,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_SOUND_PROOF_BD = 21,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_SNOW_BD = 22,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_STEEL_BD = 23,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_WATER_BD = 24,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_WOOD_THIN_BD = 25,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_WOOD_THICK_BD = 26,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_WOOD_FLOOR_BD = 27,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_WOOD_ON_CONCRETE_BD = 28,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_CUSTOM_BD = 29,
+ XR_SOUND_OBSTACLE_MATERIAL_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrSoundObstacleMaterialTypeBD;
+typedef XrFlags64 XrSoundObstacleFlagsBD;
+
+// Flag bits for XrSoundObstacleFlagsBD
+static const XrSoundObstacleFlagsBD XR_SOUND_OBSTACLE_ENABLED_BIT_BD = 0x00000001;
+static const XrSoundObstacleFlagsBD XR_SOUND_OBSTACLE_POSE_BIT_BD = 0x00000002;
+static const XrSoundObstacleFlagsBD XR_SOUND_OBSTACLE_MESH_BIT_BD = 0x00000004;
+static const XrSoundObstacleFlagsBD XR_SOUND_OBSTACLE_MATERIALS_BIT_BD = 0x00000008;
+
+typedef XrFlags64 XrSoundObjectFlagsBD;
+
+// Flag bits for XrSoundObjectFlagsBD
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_ENABLED_BIT_BD = 0x00000001;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_POSE_BIT_BD = 0x00000002;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_DIRECTIVITY_BIT_BD = 0x00000004;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_SHAPE_BIT_BD = 0x00000008;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_MAIN_VOLUME_BIT_BD = 0x00000010;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_REFLECTION_GAIN_BIT_BD = 0x00000020;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_ENABLE_DOPPLER_BIT_BD = 0x00000040;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_DIRECT_SOUND_ATTENUATION_BIT_BD = 0x00000080;
+static const XrSoundObjectFlagsBD XR_SOUND_OBJECT_INDIRECT_SOUND_ATTENUATION_BIT_BD = 0x00000100;
+
+typedef XrFlags64 XrSoundFieldFlagsBD;
+
+// Flag bits for XrSoundFieldFlagsBD
+static const XrSoundFieldFlagsBD XR_SOUND_FIELD_ENABLED_BIT_BD = 0x00000001;
+static const XrSoundFieldFlagsBD XR_SOUND_FIELD_ORIENTATION_BIT_BD = 0x00000002;
+static const XrSoundFieldFlagsBD XR_SOUND_FIELD_MAIN_VOLUME_BIT_BD = 0x00000004;
+static const XrSoundFieldFlagsBD XR_SOUND_FIELD_LFE_GAIN_BIT_BD = 0x00000008;
+
+typedef struct XrSpatialAudioRendererCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t framesPerBuffer;
+ XrAudioSampleRateBD sampleRate;
+} XrSpatialAudioRendererCreateInfoBD;
+
+typedef struct XrAudioBufferBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAudioBufferChannelLayoutBD channelLayout;
+ uint32_t bufferChannels;
+ uint32_t bufferLength;
+ float* buffer;
+} XrAudioBufferBD;
+
+// XrSoundObjectDirectivityCardioidBD extends XrSoundObjectConfigBD
+typedef struct XrSoundObjectDirectivityCardioidBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float alpha;
+ float order;
+} XrSoundObjectDirectivityCardioidBD;
+
+// XrSoundObjectShapeSphereBD extends XrSoundObjectConfigBD
+typedef struct XrSoundObjectShapeSphereBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float radius;
+} XrSoundObjectShapeSphereBD;
+
+typedef struct XrAttenuationCurvePointBD {
+ float distance;
+ float gain;
+} XrAttenuationCurvePointBD;
+
+typedef struct XrSoundObjectDistanceAttenuationCurveBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t curvePointCount;
+ XrAttenuationCurvePointBD* curvePoints;
+} XrSoundObjectDistanceAttenuationCurveBD;
+
+typedef struct XrSoundObjectDistanceAttenuationBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSoundObjectDistanceAttenuationTypeBD distanceAttenuationType;
+ float minAttenuationRange;
+ float maxAttenuationRange;
+ float referenceDistance;
+ float rolloffFactor;
+ XrSoundObjectDistanceAttenuationCurveBD* customDistanceAttenuationCurve;
+} XrSoundObjectDistanceAttenuationBD;
+
+typedef struct XrSoundObjectConfigBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+ XrPosef pose;
+ XrSpace baseSpace;
+ float mainVolume;
+ float reflectionGain;
+ XrBool32 enableDoppler;
+ const XrSoundObjectDistanceAttenuationBD* directSoundAttenuation;
+ const XrSoundObjectDistanceAttenuationBD* indirectSoundAttenuation;
+} XrSoundObjectConfigBD;
+
+typedef struct XrSoundFieldConfigBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+ XrQuaternionf orientation;
+ XrSpace baseSpace;
+ float mainVolume;
+ float lfeGain;
+} XrSoundFieldConfigBD;
+
+// XrSoundFieldChannelDefinitionSurroundBD extends XrSoundFieldConfigBD
+typedef struct XrSoundFieldChannelDefinitionSurroundBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSoundFieldChannelMaskSurroundBD channelMask;
+} XrSoundFieldChannelDefinitionSurroundBD;
+
+// XrSoundFieldChannelDefinitionAmbixBD extends XrSoundFieldConfigBD
+typedef struct XrSoundFieldChannelDefinitionAmbixBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSoundFieldChannelMaskAmbixBD channelMask;
+} XrSoundFieldChannelDefinitionAmbixBD;
+
+// XrSoundFieldChannelDefinitionFumaBD extends XrSoundFieldConfigBD
+typedef struct XrSoundFieldChannelDefinitionFumaBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSoundFieldChannelMaskFumaBD channelMask;
+} XrSoundFieldChannelDefinitionFumaBD;
+
+typedef struct XrSoundTriangleMeshBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t vertexCount;
+ XrVector3f* vertices;
+ uint32_t indexCount;
+ uint32_t* indices;
+} XrSoundTriangleMeshBD;
+
+typedef struct XrSoundObstacleConfigBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+ XrPosef pose;
+ XrSpace baseSpace;
+ uint32_t materialCount;
+ XrSoundObstacleMaterialBD* materials;
+} XrSoundObstacleConfigBD;
+
+typedef struct XrSoundObstacleMaterialConfigBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSoundObstacleMaterialTypeBD materialType;
+ uint32_t bandCount;
+ float* bandFrequencies;
+ float* bandAbsorptions;
+ float* bandScatterings;
+ float* bandTransmissions;
+} XrSoundObstacleMaterialConfigBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedAudioSampleRateBD)(XrSession session, uint32_t sampleRateCapacityInput, uint32_t* sampleRateCountOutput, XrAudioSampleRateBD* sampleRates);
+typedef XrResult (XRAPI_PTR *PFN_xrQueryFramesPerBufferRangeBD)(XrSession session, XrAudioSampleRateBD sampleRate, uint32_t* min, uint32_t* max);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAudioRendererBD)(XrSession session, const XrSpatialAudioRendererCreateInfoBD* createInfo, XrSpatialAudioRendererBD* renderer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialAudioRendererBD)(XrSpatialAudioRendererBD renderer);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSoundObstacleMaterialBD)(XrSpatialAudioRendererBD renderer, const XrSoundObstacleMaterialConfigBD* config, XrSoundObstacleMaterialBD* material);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSoundObstacleMaterialConfigBD)(XrSoundObstacleMaterialBD material, const XrSoundObstacleMaterialConfigBD* config);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySoundObstacleMaterialBD)(XrSoundObstacleMaterialBD material);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSoundObstacleBD)(XrSpatialAudioRendererBD renderer, const XrSoundObstacleConfigBD* config, const XrSoundTriangleMeshBD* mesh, XrSoundObstacleBD* soundObstacle);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSoundObstacleConfigBD)(XrSoundObstacleBD soundObstacle, const XrSoundObstacleConfigBD* config, const XrSoundTriangleMeshBD* mesh, XrSoundObstacleFlagsBD flags);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySoundObstacleBD)(XrSoundObstacleBD soundObstacle);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSoundObjectBD)(XrSpatialAudioRendererBD renderer, const XrSoundObjectConfigBD* config, XrSoundObjectBD* soundObject);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSoundObjectConfigBD)(XrSoundObjectBD soundObject, const XrSoundObjectConfigBD* config, XrSoundObjectFlagsBD flags);
+typedef XrResult (XRAPI_PTR *PFN_xrSubmitSoundObjectBufferBD)(XrSoundObjectBD soundObject, const XrAudioBufferBD* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySoundObjectBD)(XrSoundObjectBD soundObject);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSoundFieldBD)(XrSpatialAudioRendererBD renderer, const XrSoundFieldConfigBD* config, XrSoundFieldBD* soundField);
+typedef XrResult (XRAPI_PTR *PFN_xrUpdateSoundFieldConfigBD)(XrSoundFieldBD soundField, const XrSoundFieldConfigBD* config, XrSoundFieldFlagsBD flags);
+typedef XrResult (XRAPI_PTR *PFN_xrSubmitSoundFieldBufferBD)(XrSoundFieldBD soundField, const XrAudioBufferBD* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySoundFieldBD)(XrSoundFieldBD soundField);
+typedef XrResult (XRAPI_PTR *PFN_xrWaitAudioPeriodBD)(XrSpatialAudioRendererBD renderer, XrDuration timeout);
+typedef XrResult (XRAPI_PTR *PFN_xrEndAudioPeriodBD)(XrSpatialAudioRendererBD renderer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedAudioSampleRateBD(
+ XrSession session,
+ uint32_t sampleRateCapacityInput,
+ uint32_t* sampleRateCountOutput,
+ XrAudioSampleRateBD* sampleRates);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQueryFramesPerBufferRangeBD(
+ XrSession session,
+ XrAudioSampleRateBD sampleRate,
+ uint32_t* min,
+ uint32_t* max);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAudioRendererBD(
+ XrSession session,
+ const XrSpatialAudioRendererCreateInfoBD* createInfo,
+ XrSpatialAudioRendererBD* renderer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialAudioRendererBD(
+ XrSpatialAudioRendererBD renderer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSoundObstacleMaterialBD(
+ XrSpatialAudioRendererBD renderer,
+ const XrSoundObstacleMaterialConfigBD* config,
+ XrSoundObstacleMaterialBD* material);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSoundObstacleMaterialConfigBD(
+ XrSoundObstacleMaterialBD material,
+ const XrSoundObstacleMaterialConfigBD* config);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySoundObstacleMaterialBD(
+ XrSoundObstacleMaterialBD material);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSoundObstacleBD(
+ XrSpatialAudioRendererBD renderer,
+ const XrSoundObstacleConfigBD* config,
+ const XrSoundTriangleMeshBD* mesh,
+ XrSoundObstacleBD* soundObstacle);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSoundObstacleConfigBD(
+ XrSoundObstacleBD soundObstacle,
+ const XrSoundObstacleConfigBD* config,
+ const XrSoundTriangleMeshBD* mesh,
+ XrSoundObstacleFlagsBD flags);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySoundObstacleBD(
+ XrSoundObstacleBD soundObstacle);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSoundObjectBD(
+ XrSpatialAudioRendererBD renderer,
+ const XrSoundObjectConfigBD* config,
+ XrSoundObjectBD* soundObject);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSoundObjectConfigBD(
+ XrSoundObjectBD soundObject,
+ const XrSoundObjectConfigBD* config,
+ XrSoundObjectFlagsBD flags);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSubmitSoundObjectBufferBD(
+ XrSoundObjectBD soundObject,
+ const XrAudioBufferBD* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySoundObjectBD(
+ XrSoundObjectBD soundObject);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSoundFieldBD(
+ XrSpatialAudioRendererBD renderer,
+ const XrSoundFieldConfigBD* config,
+ XrSoundFieldBD* soundField);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUpdateSoundFieldConfigBD(
+ XrSoundFieldBD soundField,
+ const XrSoundFieldConfigBD* config,
+ XrSoundFieldFlagsBD flags);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSubmitSoundFieldBufferBD(
+ XrSoundFieldBD soundField,
+ const XrAudioBufferBD* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySoundFieldBD(
+ XrSoundFieldBD soundField);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrWaitAudioPeriodBD(
+ XrSpatialAudioRendererBD renderer,
+ XrDuration timeout);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEndAudioPeriodBD(
+ XrSpatialAudioRendererBD renderer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_local_floor is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_local_floor 1
+#define XR_EXT_local_floor_SPEC_VERSION 1
+#define XR_EXT_LOCAL_FLOOR_EXTENSION_NAME "XR_EXT_local_floor"
+
+
+// XR_EXT_hand_tracking_data_source is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_hand_tracking_data_source 1
+#define XR_EXT_hand_tracking_data_source_SPEC_VERSION 1
+#define XR_EXT_HAND_TRACKING_DATA_SOURCE_EXTENSION_NAME "XR_EXT_hand_tracking_data_source"
+
+typedef enum XrHandTrackingDataSourceEXT {
+ XR_HAND_TRACKING_DATA_SOURCE_UNOBSTRUCTED_EXT = 1,
+ XR_HAND_TRACKING_DATA_SOURCE_CONTROLLER_EXT = 2,
+ XR_HAND_TRACKING_DATA_SOURCE_UNOBSTRUCTED_WIDE_MOTION_META = 1000695000,
+ XR_HAND_TRACKING_DATA_SOURCE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHandTrackingDataSourceEXT;
+// XrHandTrackingDataSourceInfoEXT extends XrHandTrackerCreateInfoEXT
+typedef struct XrHandTrackingDataSourceInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t requestedDataSourceCount;
+ XrHandTrackingDataSourceEXT* requestedDataSources;
+} XrHandTrackingDataSourceInfoEXT;
+
+// XrHandTrackingDataSourceStateEXT extends XrHandJointLocationsEXT
+typedef struct XrHandTrackingDataSourceStateEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 isActive;
+ XrHandTrackingDataSourceEXT dataSource;
+} XrHandTrackingDataSourceStateEXT;
+
+
+
+// XR_EXT_plane_detection is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_plane_detection 1
+XR_DEFINE_HANDLE(XrPlaneDetectorEXT)
+#define XR_EXT_plane_detection_SPEC_VERSION 2
+#define XR_EXT_PLANE_DETECTION_EXTENSION_NAME "XR_EXT_plane_detection"
+
+typedef enum XrPlaneDetectorOrientationEXT {
+ XR_PLANE_DETECTOR_ORIENTATION_HORIZONTAL_UPWARD_EXT = 0,
+ XR_PLANE_DETECTOR_ORIENTATION_HORIZONTAL_DOWNWARD_EXT = 1,
+ XR_PLANE_DETECTOR_ORIENTATION_VERTICAL_EXT = 2,
+ XR_PLANE_DETECTOR_ORIENTATION_ARBITRARY_EXT = 3,
+ XR_PLANE_DETECTOR_ORIENTATION_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPlaneDetectorOrientationEXT;
+
+typedef enum XrPlaneDetectorSemanticTypeEXT {
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_UNDEFINED_EXT = 0,
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_CEILING_EXT = 1,
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_FLOOR_EXT = 2,
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_WALL_EXT = 3,
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_PLATFORM_EXT = 4,
+ XR_PLANE_DETECTOR_SEMANTIC_TYPE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPlaneDetectorSemanticTypeEXT;
+
+typedef enum XrPlaneDetectionStateEXT {
+ XR_PLANE_DETECTION_STATE_NONE_EXT = 0,
+ XR_PLANE_DETECTION_STATE_PENDING_EXT = 1,
+ XR_PLANE_DETECTION_STATE_DONE_EXT = 2,
+ XR_PLANE_DETECTION_STATE_ERROR_EXT = 3,
+ XR_PLANE_DETECTION_STATE_FATAL_EXT = 4,
+ XR_PLANE_DETECTION_STATE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrPlaneDetectionStateEXT;
+typedef XrFlags64 XrPlaneDetectionCapabilityFlagsEXT;
+
+// Flag bits for XrPlaneDetectionCapabilityFlagsEXT
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_PLANE_DETECTION_BIT_EXT = 0x00000001;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_PLANE_HOLES_BIT_EXT = 0x00000002;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_SEMANTIC_CEILING_BIT_EXT = 0x00000004;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_SEMANTIC_FLOOR_BIT_EXT = 0x00000008;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_SEMANTIC_WALL_BIT_EXT = 0x00000010;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_SEMANTIC_PLATFORM_BIT_EXT = 0x00000020;
+static const XrPlaneDetectionCapabilityFlagsEXT XR_PLANE_DETECTION_CAPABILITY_ORIENTATION_BIT_EXT = 0x00000040;
+
+typedef XrFlags64 XrPlaneDetectorFlagsEXT;
+
+// Flag bits for XrPlaneDetectorFlagsEXT
+static const XrPlaneDetectorFlagsEXT XR_PLANE_DETECTOR_ENABLE_CONTOUR_BIT_EXT = 0x00000001;
+
+// XrSystemPlaneDetectionPropertiesEXT extends XrSystemProperties
+typedef struct XrSystemPlaneDetectionPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPlaneDetectionCapabilityFlagsEXT supportedFeatures;
+} XrSystemPlaneDetectionPropertiesEXT;
+
+typedef struct XrPlaneDetectorCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrPlaneDetectorFlagsEXT flags;
+} XrPlaneDetectorCreateInfoEXT;
+
+typedef XrExtent3Df XrExtent3DfEXT;
+
+typedef struct XrPlaneDetectorBeginInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ uint32_t orientationCount;
+ const XrPlaneDetectorOrientationEXT* orientations;
+ uint32_t semanticTypeCount;
+ const XrPlaneDetectorSemanticTypeEXT* semanticTypes;
+ uint32_t maxPlanes;
+ float minArea;
+ XrPosef boundingBoxPose;
+ XrExtent3DfEXT boundingBoxExtent;
+} XrPlaneDetectorBeginInfoEXT;
+
+typedef struct XrPlaneDetectorGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+} XrPlaneDetectorGetInfoEXT;
+
+typedef struct XrPlaneDetectorLocationEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint64_t planeId;
+ XrSpaceLocationFlags locationFlags;
+ XrPosef pose;
+ XrExtent2Df extents;
+ XrPlaneDetectorOrientationEXT orientation;
+ XrPlaneDetectorSemanticTypeEXT semanticType;
+ uint32_t polygonBufferCount;
+} XrPlaneDetectorLocationEXT;
+
+typedef struct XrPlaneDetectorLocationsEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t planeLocationCapacityInput;
+ uint32_t planeLocationCountOutput;
+ XrPlaneDetectorLocationEXT* planeLocations;
+} XrPlaneDetectorLocationsEXT;
+
+typedef struct XrPlaneDetectorPolygonBufferEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector2f* vertices;
+} XrPlaneDetectorPolygonBufferEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePlaneDetectorEXT)(XrSession session, const XrPlaneDetectorCreateInfoEXT* createInfo, XrPlaneDetectorEXT* planeDetector);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPlaneDetectorEXT)(XrPlaneDetectorEXT planeDetector);
+typedef XrResult (XRAPI_PTR *PFN_xrBeginPlaneDetectionEXT)(XrPlaneDetectorEXT planeDetector, const XrPlaneDetectorBeginInfoEXT* beginInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrGetPlaneDetectionStateEXT)(XrPlaneDetectorEXT planeDetector, XrPlaneDetectionStateEXT* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetPlaneDetectionsEXT)(XrPlaneDetectorEXT planeDetector, const XrPlaneDetectorGetInfoEXT* info, XrPlaneDetectorLocationsEXT* locations);
+typedef XrResult (XRAPI_PTR *PFN_xrGetPlanePolygonBufferEXT)(XrPlaneDetectorEXT planeDetector, uint64_t planeId, uint32_t polygonBufferIndex, XrPlaneDetectorPolygonBufferEXT* polygonBuffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePlaneDetectorEXT(
+ XrSession session,
+ const XrPlaneDetectorCreateInfoEXT* createInfo,
+ XrPlaneDetectorEXT* planeDetector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPlaneDetectorEXT(
+ XrPlaneDetectorEXT planeDetector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrBeginPlaneDetectionEXT(
+ XrPlaneDetectorEXT planeDetector,
+ const XrPlaneDetectorBeginInfoEXT* beginInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPlaneDetectionStateEXT(
+ XrPlaneDetectorEXT planeDetector,
+ XrPlaneDetectionStateEXT* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPlaneDetectionsEXT(
+ XrPlaneDetectorEXT planeDetector,
+ const XrPlaneDetectorGetInfoEXT* info,
+ XrPlaneDetectorLocationsEXT* locations);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPlanePolygonBufferEXT(
+ XrPlaneDetectorEXT planeDetector,
+ uint64_t planeId,
+ uint32_t polygonBufferIndex,
+ XrPlaneDetectorPolygonBufferEXT* polygonBuffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_OPPO_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_OPPO_controller_interaction 1
+#define XR_OPPO_controller_interaction_SPEC_VERSION 1
+#define XR_OPPO_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_OPPO_controller_interaction"
+
+
+// XR_ANDROID_trackables is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_trackables 1
+
+#define XR_NULL_TRACKABLE_ANDROID 0
+
+XR_DEFINE_ATOM(XrTrackableANDROID)
+XR_DEFINE_HANDLE(XrTrackableTrackerANDROID)
+#define XR_ANDROID_trackables_SPEC_VERSION 2
+#define XR_ANDROID_TRACKABLES_EXTENSION_NAME "XR_ANDROID_trackables"
+
+typedef enum XrTrackingStateANDROID {
+ XR_TRACKING_STATE_PAUSED_ANDROID = 0,
+ XR_TRACKING_STATE_STOPPED_ANDROID = 1,
+ XR_TRACKING_STATE_TRACKING_ANDROID = 2,
+ XR_TRACKING_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackingStateANDROID;
+
+typedef enum XrTrackableTypeANDROID {
+ XR_TRACKABLE_TYPE_NOT_VALID_ANDROID = 0,
+ XR_TRACKABLE_TYPE_PLANE_ANDROID = 1,
+ XR_TRACKABLE_TYPE_DEPTH_ANDROID = 1000463000,
+ XR_TRACKABLE_TYPE_OBJECT_ANDROID = 1000466000,
+ XR_TRACKABLE_TYPE_MARKER_ANDROID = 1000707000,
+ XR_TRACKABLE_TYPE_QR_CODE_ANDROID = 1000708000,
+ XR_TRACKABLE_TYPE_IMAGE_ANDROID = 1000709000,
+ XR_TRACKABLE_TYPE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackableTypeANDROID;
+
+typedef enum XrPlaneTypeANDROID {
+ XR_PLANE_TYPE_HORIZONTAL_DOWNWARD_FACING_ANDROID = 0,
+ XR_PLANE_TYPE_HORIZONTAL_UPWARD_FACING_ANDROID = 1,
+ XR_PLANE_TYPE_VERTICAL_ANDROID = 2,
+ XR_PLANE_TYPE_ARBITRARY_ANDROID = 3,
+ XR_PLANE_TYPE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrPlaneTypeANDROID;
+
+typedef enum XrPlaneLabelANDROID {
+ XR_PLANE_LABEL_UNKNOWN_ANDROID = 0,
+ XR_PLANE_LABEL_WALL_ANDROID = 1,
+ XR_PLANE_LABEL_FLOOR_ANDROID = 2,
+ XR_PLANE_LABEL_CEILING_ANDROID = 3,
+ XR_PLANE_LABEL_TABLE_ANDROID = 4,
+ XR_PLANE_LABEL_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrPlaneLabelANDROID;
+typedef struct XrTrackableTrackerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTrackableTypeANDROID trackableType;
+} XrTrackableTrackerCreateInfoANDROID;
+
+typedef struct XrTrackableGetInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTrackableANDROID trackable;
+ XrSpace baseSpace;
+ XrTime time;
+} XrTrackableGetInfoANDROID;
+
+typedef struct XrTrackablePlaneANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTrackingStateANDROID trackingState;
+ XrPosef centerPose;
+ XrExtent2Df extents;
+ XrPlaneTypeANDROID planeType;
+ XrPlaneLabelANDROID planeLabel;
+ XrTrackableANDROID subsumedByPlane;
+ XrTime lastUpdatedTime;
+ uint32_t vertexCapacityInput;
+ uint32_t* vertexCountOutput;
+ XrVector2f* vertices;
+} XrTrackablePlaneANDROID;
+
+typedef struct XrAnchorSpaceCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrPosef pose;
+ XrTrackableANDROID trackable;
+} XrAnchorSpaceCreateInfoANDROID;
+
+// XrSystemTrackablesPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemTrackablesPropertiesANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 supportsAnchor;
+ uint32_t maxAnchors;
+} XrSystemTrackablesPropertiesANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedTrackableTypesANDROID)(XrInstance instance, XrSystemId systemId, uint32_t trackableTypeCapacityInput, uint32_t* trackableTypeCountOutput, XrTrackableTypeANDROID* trackableTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedAnchorTrackableTypesANDROID)(XrInstance instance, XrSystemId systemId, uint32_t trackableTypeCapacityInput, uint32_t* trackableTypeCountOutput, XrTrackableTypeANDROID* trackableTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateTrackableTrackerANDROID)(XrSession session, const XrTrackableTrackerCreateInfoANDROID* createInfo, XrTrackableTrackerANDROID* trackableTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyTrackableTrackerANDROID)(XrTrackableTrackerANDROID trackableTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetAllTrackablesANDROID)(XrTrackableTrackerANDROID trackableTracker, uint32_t trackableCapacityInput, uint32_t* trackableCountOutput, XrTrackableANDROID* trackables);
+typedef XrResult (XRAPI_PTR *PFN_xrGetTrackablePlaneANDROID)(XrTrackableTrackerANDROID trackableTracker, const XrTrackableGetInfoANDROID* getInfo, XrTrackablePlaneANDROID* planeOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateAnchorSpaceANDROID)(XrSession session, const XrAnchorSpaceCreateInfoANDROID* createInfo, XrSpace* anchorOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedTrackableTypesANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t trackableTypeCapacityInput,
+ uint32_t* trackableTypeCountOutput,
+ XrTrackableTypeANDROID* trackableTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedAnchorTrackableTypesANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t trackableTypeCapacityInput,
+ uint32_t* trackableTypeCountOutput,
+ XrTrackableTypeANDROID* trackableTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateTrackableTrackerANDROID(
+ XrSession session,
+ const XrTrackableTrackerCreateInfoANDROID* createInfo,
+ XrTrackableTrackerANDROID* trackableTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyTrackableTrackerANDROID(
+ XrTrackableTrackerANDROID trackableTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAllTrackablesANDROID(
+ XrTrackableTrackerANDROID trackableTracker,
+ uint32_t trackableCapacityInput,
+ uint32_t* trackableCountOutput,
+ XrTrackableANDROID* trackables);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetTrackablePlaneANDROID(
+ XrTrackableTrackerANDROID trackableTracker,
+ const XrTrackableGetInfoANDROID* getInfo,
+ XrTrackablePlaneANDROID* planeOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateAnchorSpaceANDROID(
+ XrSession session,
+ const XrAnchorSpaceCreateInfoANDROID* createInfo,
+ XrSpace* anchorOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_eye_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_eye_tracking 1
+XR_DEFINE_HANDLE(XrEyeTrackerANDROID)
+#define XR_EYE_MAX_ANDROID 2
+#define XR_ANDROID_eye_tracking_SPEC_VERSION 1
+#define XR_ANDROID_EYE_TRACKING_EXTENSION_NAME "XR_ANDROID_eye_tracking"
+
+typedef enum XrEyeIndexANDROID {
+ XR_EYE_INDEX_LEFT_ANDROID = 0,
+ XR_EYE_INDEX_RIGHT_ANDROID = 1,
+ XR_EYE_INDEX_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrEyeIndexANDROID;
+
+typedef enum XrEyeStateANDROID {
+ XR_EYE_STATE_INVALID_ANDROID = 0,
+ XR_EYE_STATE_GAZING_ANDROID = 1,
+ XR_EYE_STATE_SHUT_ANDROID = 2,
+ XR_EYE_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrEyeStateANDROID;
+
+typedef enum XrEyeTrackingModeANDROID {
+ XR_EYE_TRACKING_MODE_NOT_TRACKING_ANDROID = 0,
+ XR_EYE_TRACKING_MODE_RIGHT_ANDROID = 1,
+ XR_EYE_TRACKING_MODE_LEFT_ANDROID = 2,
+ XR_EYE_TRACKING_MODE_BOTH_ANDROID = 3,
+ XR_EYE_TRACKING_MODE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrEyeTrackingModeANDROID;
+// XrSystemEyeTrackingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemEyeTrackingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsEyeTracking;
+} XrSystemEyeTrackingPropertiesANDROID;
+
+typedef struct XrEyeANDROID {
+ XrEyeStateANDROID eyeState;
+ XrPosef eyePose;
+} XrEyeANDROID;
+
+typedef struct XrEyesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrEyeANDROID eyes[XR_EYE_MAX_ANDROID];
+ XrEyeTrackingModeANDROID mode;
+} XrEyesANDROID;
+
+typedef struct XrEyesGetInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+ XrSpace baseSpace;
+} XrEyesGetInfoANDROID;
+
+typedef struct XrEyeTrackerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEyeTrackerCreateInfoANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEyeTrackerANDROID)(XrSession session, const XrEyeTrackerCreateInfoANDROID* createInfo, XrEyeTrackerANDROID* eyeTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyEyeTrackerANDROID)(XrEyeTrackerANDROID eyeTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFineTrackingEyesInfoANDROID)(XrEyeTrackerANDROID eyeTracker, const XrEyesGetInfoANDROID* getInfo, XrEyesANDROID* eyesOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetCoarseTrackingEyesInfoANDROID)(XrEyeTrackerANDROID eyeTracker, const XrEyesGetInfoANDROID* getInfo, XrEyesANDROID* eyesOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEyeTrackerANDROID(
+ XrSession session,
+ const XrEyeTrackerCreateInfoANDROID* createInfo,
+ XrEyeTrackerANDROID* eyeTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyEyeTrackerANDROID(
+ XrEyeTrackerANDROID eyeTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFineTrackingEyesInfoANDROID(
+ XrEyeTrackerANDROID eyeTracker,
+ const XrEyesGetInfoANDROID* getInfo,
+ XrEyesANDROID* eyesOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetCoarseTrackingEyesInfoANDROID(
+ XrEyeTrackerANDROID eyeTracker,
+ const XrEyesGetInfoANDROID* getInfo,
+ XrEyesANDROID* eyesOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_device_anchor_persistence is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_device_anchor_persistence 1
+XR_DEFINE_HANDLE(XrDeviceAnchorPersistenceANDROID)
+#define XR_ANDROID_device_anchor_persistence_SPEC_VERSION 1
+#define XR_ANDROID_DEVICE_ANCHOR_PERSISTENCE_EXTENSION_NAME "XR_ANDROID_device_anchor_persistence"
+
+typedef enum XrAnchorPersistStateANDROID {
+ XR_ANCHOR_PERSIST_STATE_PERSIST_NOT_REQUESTED_ANDROID = 0,
+ XR_ANCHOR_PERSIST_STATE_PERSIST_PENDING_ANDROID = 1,
+ XR_ANCHOR_PERSIST_STATE_PERSISTED_ANDROID = 2,
+ XR_ANCHOR_PERSIST_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrAnchorPersistStateANDROID;
+typedef struct XrDeviceAnchorPersistenceCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrDeviceAnchorPersistenceCreateInfoANDROID;
+
+typedef struct XrPersistedAnchorSpaceCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuidEXT anchorId;
+} XrPersistedAnchorSpaceCreateInfoANDROID;
+
+typedef struct XrPersistedAnchorSpaceInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace anchor;
+} XrPersistedAnchorSpaceInfoANDROID;
+
+// XrSystemDeviceAnchorPersistencePropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemDeviceAnchorPersistencePropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsAnchorPersistence;
+} XrSystemDeviceAnchorPersistencePropertiesANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedPersistenceAnchorTypesANDROID)(XrInstance instance, XrSystemId systemId, uint32_t trackableTypeCapacityInput, uint32_t* trackableTypeCountOutput, XrTrackableTypeANDROID* trackableTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateDeviceAnchorPersistenceANDROID)(XrSession session, const XrDeviceAnchorPersistenceCreateInfoANDROID* createInfo, XrDeviceAnchorPersistenceANDROID* outHandle);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyDeviceAnchorPersistenceANDROID)(XrDeviceAnchorPersistenceANDROID handle);
+typedef XrResult (XRAPI_PTR *PFN_xrPersistAnchorANDROID)(XrDeviceAnchorPersistenceANDROID handle, const XrPersistedAnchorSpaceInfoANDROID* persistedInfo, XrUuidEXT* anchorIdOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetAnchorPersistStateANDROID)(XrDeviceAnchorPersistenceANDROID handle, const XrUuidEXT* anchorId, XrAnchorPersistStateANDROID* persistState);
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePersistedAnchorSpaceANDROID)(XrDeviceAnchorPersistenceANDROID handle, const XrPersistedAnchorSpaceCreateInfoANDROID* createInfo, XrSpace* anchorOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumeratePersistedAnchorsANDROID)(XrDeviceAnchorPersistenceANDROID handle, uint32_t anchorIdCapacityInput, uint32_t* anchorIdCountOutput, XrUuidEXT* anchorIds);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistAnchorANDROID)(XrDeviceAnchorPersistenceANDROID handle, const XrUuidEXT* anchorId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedPersistenceAnchorTypesANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t trackableTypeCapacityInput,
+ uint32_t* trackableTypeCountOutput,
+ XrTrackableTypeANDROID* trackableTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateDeviceAnchorPersistenceANDROID(
+ XrSession session,
+ const XrDeviceAnchorPersistenceCreateInfoANDROID* createInfo,
+ XrDeviceAnchorPersistenceANDROID* outHandle);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyDeviceAnchorPersistenceANDROID(
+ XrDeviceAnchorPersistenceANDROID handle);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistAnchorANDROID(
+ XrDeviceAnchorPersistenceANDROID handle,
+ const XrPersistedAnchorSpaceInfoANDROID* persistedInfo,
+ XrUuidEXT* anchorIdOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAnchorPersistStateANDROID(
+ XrDeviceAnchorPersistenceANDROID handle,
+ const XrUuidEXT* anchorId,
+ XrAnchorPersistStateANDROID* persistState);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePersistedAnchorSpaceANDROID(
+ XrDeviceAnchorPersistenceANDROID handle,
+ const XrPersistedAnchorSpaceCreateInfoANDROID* createInfo,
+ XrSpace* anchorOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumeratePersistedAnchorsANDROID(
+ XrDeviceAnchorPersistenceANDROID handle,
+ uint32_t anchorIdCapacityInput,
+ uint32_t* anchorIdCountOutput,
+ XrUuidEXT* anchorIds);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistAnchorANDROID(
+ XrDeviceAnchorPersistenceANDROID handle,
+ const XrUuidEXT* anchorId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_face_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_face_tracking 1
+XR_DEFINE_HANDLE(XrFaceTrackerANDROID)
+#define XR_ANDROID_face_tracking_SPEC_VERSION 1
+#define XR_ANDROID_FACE_TRACKING_EXTENSION_NAME "XR_ANDROID_face_tracking"
+#define XR_FACE_PARAMETER_COUNT_ANDROID 68
+#define XR_FACE_REGION_CONFIDENCE_COUNT_ANDROID 3
+
+typedef enum XrFaceParameterIndicesANDROID {
+ XR_FACE_PARAMETER_INDICES_BROW_LOWERER_L_ANDROID = 0,
+ XR_FACE_PARAMETER_INDICES_BROW_LOWERER_R_ANDROID = 1,
+ XR_FACE_PARAMETER_INDICES_CHEEK_PUFF_L_ANDROID = 2,
+ XR_FACE_PARAMETER_INDICES_CHEEK_PUFF_R_ANDROID = 3,
+ XR_FACE_PARAMETER_INDICES_CHEEK_RAISER_L_ANDROID = 4,
+ XR_FACE_PARAMETER_INDICES_CHEEK_RAISER_R_ANDROID = 5,
+ XR_FACE_PARAMETER_INDICES_CHEEK_SUCK_L_ANDROID = 6,
+ XR_FACE_PARAMETER_INDICES_CHEEK_SUCK_R_ANDROID = 7,
+ XR_FACE_PARAMETER_INDICES_CHIN_RAISER_B_ANDROID = 8,
+ XR_FACE_PARAMETER_INDICES_CHIN_RAISER_T_ANDROID = 9,
+ XR_FACE_PARAMETER_INDICES_DIMPLER_L_ANDROID = 10,
+ XR_FACE_PARAMETER_INDICES_DIMPLER_R_ANDROID = 11,
+ XR_FACE_PARAMETER_INDICES_EYES_CLOSED_L_ANDROID = 12,
+ XR_FACE_PARAMETER_INDICES_EYES_CLOSED_R_ANDROID = 13,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_DOWN_L_ANDROID = 14,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_DOWN_R_ANDROID = 15,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_LEFT_L_ANDROID = 16,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_LEFT_R_ANDROID = 17,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_RIGHT_L_ANDROID = 18,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_RIGHT_R_ANDROID = 19,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_UP_L_ANDROID = 20,
+ XR_FACE_PARAMETER_INDICES_EYES_LOOK_UP_R_ANDROID = 21,
+ XR_FACE_PARAMETER_INDICES_INNER_BROW_RAISER_L_ANDROID = 22,
+ XR_FACE_PARAMETER_INDICES_INNER_BROW_RAISER_R_ANDROID = 23,
+ XR_FACE_PARAMETER_INDICES_JAW_DROP_ANDROID = 24,
+ XR_FACE_PARAMETER_INDICES_JAW_SIDEWAYS_LEFT_ANDROID = 25,
+ XR_FACE_PARAMETER_INDICES_JAW_SIDEWAYS_RIGHT_ANDROID = 26,
+ XR_FACE_PARAMETER_INDICES_JAW_THRUST_ANDROID = 27,
+ XR_FACE_PARAMETER_INDICES_LID_TIGHTENER_L_ANDROID = 28,
+ XR_FACE_PARAMETER_INDICES_LID_TIGHTENER_R_ANDROID = 29,
+ XR_FACE_PARAMETER_INDICES_LIP_CORNER_DEPRESSOR_L_ANDROID = 30,
+ XR_FACE_PARAMETER_INDICES_LIP_CORNER_DEPRESSOR_R_ANDROID = 31,
+ XR_FACE_PARAMETER_INDICES_LIP_CORNER_PULLER_L_ANDROID = 32,
+ XR_FACE_PARAMETER_INDICES_LIP_CORNER_PULLER_R_ANDROID = 33,
+ XR_FACE_PARAMETER_INDICES_LIP_FUNNELER_LB_ANDROID = 34,
+ XR_FACE_PARAMETER_INDICES_LIP_FUNNELER_LT_ANDROID = 35,
+ XR_FACE_PARAMETER_INDICES_LIP_FUNNELER_RB_ANDROID = 36,
+ XR_FACE_PARAMETER_INDICES_LIP_FUNNELER_RT_ANDROID = 37,
+ XR_FACE_PARAMETER_INDICES_LIP_PRESSOR_L_ANDROID = 38,
+ XR_FACE_PARAMETER_INDICES_LIP_PRESSOR_R_ANDROID = 39,
+ XR_FACE_PARAMETER_INDICES_LIP_PUCKER_L_ANDROID = 40,
+ XR_FACE_PARAMETER_INDICES_LIP_PUCKER_R_ANDROID = 41,
+ XR_FACE_PARAMETER_INDICES_LIP_STRETCHER_L_ANDROID = 42,
+ XR_FACE_PARAMETER_INDICES_LIP_STRETCHER_R_ANDROID = 43,
+ XR_FACE_PARAMETER_INDICES_LIP_SUCK_LB_ANDROID = 44,
+ XR_FACE_PARAMETER_INDICES_LIP_SUCK_LT_ANDROID = 45,
+ XR_FACE_PARAMETER_INDICES_LIP_SUCK_RB_ANDROID = 46,
+ XR_FACE_PARAMETER_INDICES_LIP_SUCK_RT_ANDROID = 47,
+ XR_FACE_PARAMETER_INDICES_LIP_TIGHTENER_L_ANDROID = 48,
+ XR_FACE_PARAMETER_INDICES_LIP_TIGHTENER_R_ANDROID = 49,
+ XR_FACE_PARAMETER_INDICES_LIPS_TOWARD_ANDROID = 50,
+ XR_FACE_PARAMETER_INDICES_LOWER_LIP_DEPRESSOR_L_ANDROID = 51,
+ XR_FACE_PARAMETER_INDICES_LOWER_LIP_DEPRESSOR_R_ANDROID = 52,
+ XR_FACE_PARAMETER_INDICES_MOUTH_LEFT_ANDROID = 53,
+ XR_FACE_PARAMETER_INDICES_MOUTH_RIGHT_ANDROID = 54,
+ XR_FACE_PARAMETER_INDICES_NOSE_WRINKLER_L_ANDROID = 55,
+ XR_FACE_PARAMETER_INDICES_NOSE_WRINKLER_R_ANDROID = 56,
+ XR_FACE_PARAMETER_INDICES_OUTER_BROW_RAISER_L_ANDROID = 57,
+ XR_FACE_PARAMETER_INDICES_OUTER_BROW_RAISER_R_ANDROID = 58,
+ XR_FACE_PARAMETER_INDICES_UPPER_LID_RAISER_L_ANDROID = 59,
+ XR_FACE_PARAMETER_INDICES_UPPER_LID_RAISER_R_ANDROID = 60,
+ XR_FACE_PARAMETER_INDICES_UPPER_LIP_RAISER_L_ANDROID = 61,
+ XR_FACE_PARAMETER_INDICES_UPPER_LIP_RAISER_R_ANDROID = 62,
+ XR_FACE_PARAMETER_INDICES_TONGUE_OUT_ANDROID = 63,
+ XR_FACE_PARAMETER_INDICES_TONGUE_LEFT_ANDROID = 64,
+ XR_FACE_PARAMETER_INDICES_TONGUE_RIGHT_ANDROID = 65,
+ XR_FACE_PARAMETER_INDICES_TONGUE_UP_ANDROID = 66,
+ XR_FACE_PARAMETER_INDICES_TONGUE_DOWN_ANDROID = 67,
+ XR_FACE_PARAMETER_INDICES_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrFaceParameterIndicesANDROID;
+
+typedef enum XrFaceTrackingStateANDROID {
+ XR_FACE_TRACKING_STATE_PAUSED_ANDROID = 0,
+ XR_FACE_TRACKING_STATE_STOPPED_ANDROID = 1,
+ XR_FACE_TRACKING_STATE_TRACKING_ANDROID = 2,
+ XR_FACE_TRACKING_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrFaceTrackingStateANDROID;
+
+typedef enum XrFaceConfidenceRegionsANDROID {
+ XR_FACE_CONFIDENCE_REGIONS_LOWER_ANDROID = 0,
+ XR_FACE_CONFIDENCE_REGIONS_LEFT_UPPER_ANDROID = 1,
+ XR_FACE_CONFIDENCE_REGIONS_RIGHT_UPPER_ANDROID = 2,
+ XR_FACE_CONFIDENCE_REGIONS_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrFaceConfidenceRegionsANDROID;
+typedef struct XrFaceTrackerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrFaceTrackerCreateInfoANDROID;
+
+typedef struct XrFaceStateGetInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrFaceStateGetInfoANDROID;
+
+typedef struct XrFaceStateANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t parametersCapacityInput;
+ uint32_t parametersCountOutput;
+ float* parameters;
+ XrFaceTrackingStateANDROID faceTrackingState;
+ XrTime sampleTime;
+ XrBool32 isValid;
+ uint32_t regionConfidencesCapacityInput;
+ uint32_t regionConfidencesCountOutput;
+ float* regionConfidences;
+} XrFaceStateANDROID;
+
+// XrSystemFaceTrackingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemFaceTrackingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFaceTracking;
+} XrSystemFaceTrackingPropertiesANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFaceTrackerANDROID)(XrSession session, const XrFaceTrackerCreateInfoANDROID* createInfo, XrFaceTrackerANDROID* faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFaceTrackerANDROID)(XrFaceTrackerANDROID faceTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFaceStateANDROID)(XrFaceTrackerANDROID faceTracker, const XrFaceStateGetInfoANDROID* getInfo, XrFaceStateANDROID* faceStateOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFaceCalibrationStateANDROID)(XrFaceTrackerANDROID faceTracker, XrBool32* faceIsCalibratedOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFaceTrackerANDROID(
+ XrSession session,
+ const XrFaceTrackerCreateInfoANDROID* createInfo,
+ XrFaceTrackerANDROID* faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFaceTrackerANDROID(
+ XrFaceTrackerANDROID faceTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFaceStateANDROID(
+ XrFaceTrackerANDROID faceTracker,
+ const XrFaceStateGetInfoANDROID* getInfo,
+ XrFaceStateANDROID* faceStateOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFaceCalibrationStateANDROID(
+ XrFaceTrackerANDROID faceTracker,
+ XrBool32* faceIsCalibratedOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_passthrough_camera_state is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_passthrough_camera_state 1
+#define XR_ANDROID_passthrough_camera_state_SPEC_VERSION 1
+#define XR_ANDROID_PASSTHROUGH_CAMERA_STATE_EXTENSION_NAME "XR_ANDROID_passthrough_camera_state"
+
+typedef enum XrPassthroughCameraStateANDROID {
+ XR_PASSTHROUGH_CAMERA_STATE_DISABLED_ANDROID = 0,
+ XR_PASSTHROUGH_CAMERA_STATE_INITIALIZING_ANDROID = 1,
+ XR_PASSTHROUGH_CAMERA_STATE_READY_ANDROID = 2,
+ XR_PASSTHROUGH_CAMERA_STATE_ERROR_ANDROID = 3,
+ XR_PASSTHROUGH_CAMERA_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrPassthroughCameraStateANDROID;
+// XrSystemPassthroughCameraStatePropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemPassthroughCameraStatePropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsPassthroughCameraState;
+} XrSystemPassthroughCameraStatePropertiesANDROID;
+
+typedef struct XrPassthroughCameraStateGetInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrPassthroughCameraStateGetInfoANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetPassthroughCameraStateANDROID)(XrSession session, const XrPassthroughCameraStateGetInfoANDROID* getInfo, XrPassthroughCameraStateANDROID* cameraStateOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPassthroughCameraStateANDROID(
+ XrSession session,
+ const XrPassthroughCameraStateGetInfoANDROID* getInfo,
+ XrPassthroughCameraStateANDROID* cameraStateOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_recommended_resolution is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_recommended_resolution 1
+#define XR_ANDROID_recommended_resolution_SPEC_VERSION 1
+#define XR_ANDROID_RECOMMENDED_RESOLUTION_EXTENSION_NAME "XR_ANDROID_recommended_resolution"
+typedef struct XrEventDataRecommendedResolutionChangedANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEventDataRecommendedResolutionChangedANDROID;
+
+
+
+// XR_ANDROID_composition_layer_passthrough_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_composition_layer_passthrough_mesh 1
+XR_DEFINE_HANDLE(XrPassthroughLayerANDROID)
+#define XR_ANDROID_composition_layer_passthrough_mesh_SPEC_VERSION 1
+#define XR_ANDROID_COMPOSITION_LAYER_PASSTHROUGH_MESH_EXTENSION_NAME "XR_ANDROID_composition_layer_passthrough_mesh"
+
+typedef enum XrWindingOrderANDROID {
+ XR_WINDING_ORDER_UNKNOWN_ANDROID = 0,
+ XR_WINDING_ORDER_CW_ANDROID = 1,
+ XR_WINDING_ORDER_CCW_ANDROID = 2,
+ XR_WINDING_ORDER_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrWindingOrderANDROID;
+typedef struct XrPassthroughLayerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacity;
+ uint32_t indexCapacity;
+} XrPassthroughLayerCreateInfoANDROID;
+
+// XrPassthroughLayerMeshANDROID extends XrPassthroughLayerCreateInfoANDROID
+typedef struct XrPassthroughLayerMeshANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrWindingOrderANDROID windingOrder;
+ uint32_t vertexCount;
+ const XrVector3f* vertices;
+ uint32_t indexCount;
+ const uint16_t* indices;
+} XrPassthroughLayerMeshANDROID;
+
+typedef struct XrCompositionLayerPassthroughANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCompositionLayerFlags layerFlags;
+ XrSpace space;
+ XrPosef pose;
+ XrVector3f scale;
+ float opacity;
+ XrPassthroughLayerANDROID layer;
+} XrCompositionLayerPassthroughANDROID;
+
+// XrSystemPassthroughLayerPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemPassthroughLayerPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsPassthroughLayer;
+ uint32_t maxMeshIndexCount;
+ uint32_t maxMeshVertexCount;
+} XrSystemPassthroughLayerPropertiesANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreatePassthroughLayerANDROID)(XrSession session, const XrPassthroughLayerCreateInfoANDROID* createInfo, XrPassthroughLayerANDROID* layer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyPassthroughLayerANDROID)(XrPassthroughLayerANDROID layer);
+typedef XrResult (XRAPI_PTR *PFN_xrSetPassthroughLayerMeshANDROID)(XrPassthroughLayerANDROID layer, const XrPassthroughLayerMeshANDROID* mesh);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreatePassthroughLayerANDROID(
+ XrSession session,
+ const XrPassthroughLayerCreateInfoANDROID* createInfo,
+ XrPassthroughLayerANDROID* layer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyPassthroughLayerANDROID(
+ XrPassthroughLayerANDROID layer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetPassthroughLayerMeshANDROID(
+ XrPassthroughLayerANDROID layer,
+ const XrPassthroughLayerMeshANDROID* mesh);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_raycast is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_raycast 1
+#define XR_ANDROID_raycast_SPEC_VERSION 1
+#define XR_ANDROID_RAYCAST_EXTENSION_NAME "XR_ANDROID_raycast"
+typedef struct XrRaycastInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t maxResults;
+ uint32_t trackerCount;
+ const XrTrackableTrackerANDROID* trackers;
+ XrVector3f origin;
+ XrVector3f trajectory;
+ XrSpace space;
+ XrTime time;
+} XrRaycastInfoANDROID;
+
+typedef struct XrRaycastHitResultANDROID {
+ XrTrackableTypeANDROID type;
+ XrTrackableANDROID trackable;
+ XrPosef pose;
+} XrRaycastHitResultANDROID;
+
+typedef struct XrRaycastHitResultsANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resultsCapacityInput;
+ uint32_t resultsCountOutput;
+ XrRaycastHitResultANDROID* results;
+} XrRaycastHitResultsANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateRaycastSupportedTrackableTypesANDROID)(XrInstance instance, XrSystemId systemId, uint32_t trackableTypeCapacityInput, uint32_t* trackableTypeCountOutput, XrTrackableTypeANDROID* trackableTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrRaycastANDROID)(XrSession session, const XrRaycastInfoANDROID* rayInfo, XrRaycastHitResultsANDROID* results);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateRaycastSupportedTrackableTypesANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t trackableTypeCapacityInput,
+ uint32_t* trackableTypeCountOutput,
+ XrTrackableTypeANDROID* trackableTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRaycastANDROID(
+ XrSession session,
+ const XrRaycastInfoANDROID* rayInfo,
+ XrRaycastHitResultsANDROID* results);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_performance_metrics is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_performance_metrics 1
+#define XR_ANDROID_performance_metrics_SPEC_VERSION 1
+#define XR_ANDROID_PERFORMANCE_METRICS_EXTENSION_NAME "XR_ANDROID_performance_metrics"
+
+typedef enum XrPerformanceMetricsCounterUnitANDROID {
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_GENERIC_ANDROID = 0,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_PERCENTAGE_ANDROID = 1,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_MILLISECONDS_ANDROID = 2,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_BYTES_ANDROID = 3,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_HERTZ_ANDROID = 4,
+ XR_PERFORMANCE_METRICS_COUNTER_UNIT_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrPerformanceMetricsCounterUnitANDROID;
+typedef XrFlags64 XrPerformanceMetricsCounterFlagsANDROID;
+
+// Flag bits for XrPerformanceMetricsCounterFlagsANDROID
+static const XrPerformanceMetricsCounterFlagsANDROID XR_PERFORMANCE_METRICS_COUNTER_ANY_VALUE_VALID_BIT_ANDROID = 0x00000001;
+static const XrPerformanceMetricsCounterFlagsANDROID XR_PERFORMANCE_METRICS_COUNTER_UINT_VALUE_VALID_BIT_ANDROID = 0x00000002;
+static const XrPerformanceMetricsCounterFlagsANDROID XR_PERFORMANCE_METRICS_COUNTER_FLOAT_VALUE_VALID_BIT_ANDROID = 0x00000004;
+
+typedef struct XrPerformanceMetricsStateANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+} XrPerformanceMetricsStateANDROID;
+
+typedef struct XrPerformanceMetricsCounterANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPerformanceMetricsCounterFlagsANDROID counterFlags;
+ XrPerformanceMetricsCounterUnitANDROID counterUnit;
+ uint32_t uintValue;
+ float floatValue;
+} XrPerformanceMetricsCounterANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumeratePerformanceMetricsCounterPathsANDROID)(XrInstance instance, uint32_t counterPathCapacityInput, uint32_t* counterPathCountOutput, XrPath* counterPaths);
+typedef XrResult (XRAPI_PTR *PFN_xrSetPerformanceMetricsStateANDROID)(XrSession session, const XrPerformanceMetricsStateANDROID* state);
+typedef XrResult (XRAPI_PTR *PFN_xrGetPerformanceMetricsStateANDROID)(XrSession session, XrPerformanceMetricsStateANDROID* state);
+typedef XrResult (XRAPI_PTR *PFN_xrQueryPerformanceMetricsCounterANDROID)(XrSession session, XrPath counterPath, XrPerformanceMetricsCounterANDROID* counter);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumeratePerformanceMetricsCounterPathsANDROID(
+ XrInstance instance,
+ uint32_t counterPathCapacityInput,
+ uint32_t* counterPathCountOutput,
+ XrPath* counterPaths);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetPerformanceMetricsStateANDROID(
+ XrSession session,
+ const XrPerformanceMetricsStateANDROID* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetPerformanceMetricsStateANDROID(
+ XrSession session,
+ XrPerformanceMetricsStateANDROID* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQueryPerformanceMetricsCounterANDROID(
+ XrSession session,
+ XrPath counterPath,
+ XrPerformanceMetricsCounterANDROID* counter);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_trackables_object is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_trackables_object 1
+#define XR_ANDROID_trackables_object_SPEC_VERSION 2
+#define XR_ANDROID_TRACKABLES_OBJECT_EXTENSION_NAME "XR_ANDROID_trackables_object"
+
+typedef enum XrObjectLabelANDROID {
+ XR_OBJECT_LABEL_UNKNOWN_ANDROID = 0,
+ XR_OBJECT_LABEL_KEYBOARD_ANDROID = 1,
+ XR_OBJECT_LABEL_MOUSE_ANDROID = 2,
+ XR_OBJECT_LABEL_LAPTOP_ANDROID = 3,
+ XR_OBJECT_LABEL_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrObjectLabelANDROID;
+typedef struct XrTrackableObjectANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTrackingStateANDROID trackingState;
+ XrPosef centerPose;
+ XrExtent3DfEXT extents;
+ XrObjectLabelANDROID objectLabel;
+ XrTime lastUpdatedTime;
+} XrTrackableObjectANDROID;
+
+// XrTrackableObjectConfigurationANDROID extends XrTrackableTrackerCreateInfoANDROID
+typedef struct XrTrackableObjectConfigurationANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t labelCount;
+ const XrObjectLabelANDROID* activeLabels;
+} XrTrackableObjectConfigurationANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetTrackableObjectANDROID)(XrTrackableTrackerANDROID tracker, const XrTrackableGetInfoANDROID* getInfo, XrTrackableObjectANDROID* objectOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetTrackableObjectANDROID(
+ XrTrackableTrackerANDROID tracker,
+ const XrTrackableGetInfoANDROID* getInfo,
+ XrTrackableObjectANDROID* objectOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_unbounded_reference_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_unbounded_reference_space 1
+#define XR_ANDROID_unbounded_reference_space_SPEC_VERSION 1
+#define XR_ANDROID_UNBOUNDED_REFERENCE_SPACE_EXTENSION_NAME "XR_ANDROID_unbounded_reference_space"
+
+
+// XR_EXT_future is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_future 1
+#define XR_EXT_future_SPEC_VERSION 2
+#define XR_EXT_FUTURE_EXTENSION_NAME "XR_EXT_future"
+#define XR_NULL_FUTURE_EXT 0
+
+typedef enum XrFutureStateEXT {
+ XR_FUTURE_STATE_PENDING_EXT = 1,
+ XR_FUTURE_STATE_READY_EXT = 2,
+ XR_FUTURE_STATE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrFutureStateEXT;
+typedef struct XrFutureCancelInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFutureEXT future;
+} XrFutureCancelInfoEXT;
+
+typedef struct XrFuturePollInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrFutureEXT future;
+} XrFuturePollInfoEXT;
+
+typedef struct XR_MAY_ALIAS XrFutureCompletionBaseHeaderEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+} XrFutureCompletionBaseHeaderEXT;
+
+typedef struct XrFuturePollResultEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrFutureStateEXT state;
+} XrFuturePollResultEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrPollFutureEXT)(XrInstance instance, const XrFuturePollInfoEXT* pollInfo, XrFuturePollResultEXT* pollResult);
+typedef XrResult (XRAPI_PTR *PFN_xrCancelFutureEXT)(XrInstance instance, const XrFutureCancelInfoEXT* cancelInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrPollFutureEXT(
+ XrInstance instance,
+ const XrFuturePollInfoEXT* pollInfo,
+ XrFuturePollResultEXT* pollResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCancelFutureEXT(
+ XrInstance instance,
+ const XrFutureCancelInfoEXT* cancelInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_user_presence is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_user_presence 1
+#define XR_EXT_user_presence_SPEC_VERSION 1
+#define XR_EXT_USER_PRESENCE_EXTENSION_NAME "XR_EXT_user_presence"
+typedef struct XrEventDataUserPresenceChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSession session;
+ XrBool32 isUserPresent;
+} XrEventDataUserPresenceChangedEXT;
+
+// XrSystemUserPresencePropertiesEXT extends XrSystemProperties
+typedef struct XrSystemUserPresencePropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsUserPresence;
+} XrSystemUserPresencePropertiesEXT;
+
+
+
+// XR_ML_user_calibration is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_user_calibration 1
+#define XR_ML_user_calibration_SPEC_VERSION 1
+#define XR_ML_USER_CALIBRATION_EXTENSION_NAME "XR_ML_user_calibration"
+
+typedef enum XrHeadsetFitStatusML {
+ XR_HEADSET_FIT_STATUS_UNKNOWN_ML = 0,
+ XR_HEADSET_FIT_STATUS_NOT_WORN_ML = 1,
+ XR_HEADSET_FIT_STATUS_GOOD_FIT_ML = 2,
+ XR_HEADSET_FIT_STATUS_BAD_FIT_ML = 3,
+ XR_HEADSET_FIT_STATUS_MAX_ENUM_ML = 0x7FFFFFFF
+} XrHeadsetFitStatusML;
+
+typedef enum XrEyeCalibrationStatusML {
+ XR_EYE_CALIBRATION_STATUS_UNKNOWN_ML = 0,
+ XR_EYE_CALIBRATION_STATUS_NONE_ML = 1,
+ XR_EYE_CALIBRATION_STATUS_COARSE_ML = 2,
+ XR_EYE_CALIBRATION_STATUS_FINE_ML = 3,
+ XR_EYE_CALIBRATION_STATUS_MAX_ENUM_ML = 0x7FFFFFFF
+} XrEyeCalibrationStatusML;
+typedef struct XrEventDataHeadsetFitChangedML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrHeadsetFitStatusML status;
+ XrTime time;
+} XrEventDataHeadsetFitChangedML;
+
+typedef struct XrEventDataEyeCalibrationChangedML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEyeCalibrationStatusML status;
+} XrEventDataEyeCalibrationChangedML;
+
+typedef struct XrUserCalibrationEnableEventsInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 enabled;
+} XrUserCalibrationEnableEventsInfoML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnableUserCalibrationEventsML)(XrInstance instance, const XrUserCalibrationEnableEventsInfoML* enableInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnableUserCalibrationEventsML(
+ XrInstance instance,
+ const XrUserCalibrationEnableEventsInfoML* enableInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_system_notifications is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_system_notifications 1
+#define XR_ML_system_notifications_SPEC_VERSION 1
+#define XR_ML_SYSTEM_NOTIFICATIONS_EXTENSION_NAME "XR_ML_system_notifications"
+typedef struct XrSystemNotificationsSetInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 suppressNotifications;
+} XrSystemNotificationsSetInfoML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetSystemNotificationsML)(XrInstance instance, const XrSystemNotificationsSetInfoML* info);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetSystemNotificationsML(
+ XrInstance instance,
+ const XrSystemNotificationsSetInfoML* info);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_world_mesh_detection is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_world_mesh_detection 1
+XR_DEFINE_HANDLE(XrWorldMeshDetectorML)
+#define XR_ML_world_mesh_detection_SPEC_VERSION 1
+#define XR_ML_WORLD_MESH_DETECTION_EXTENSION_NAME "XR_ML_world_mesh_detection"
+
+typedef enum XrWorldMeshDetectorLodML {
+ XR_WORLD_MESH_DETECTOR_LOD_MINIMUM_ML = 0,
+ XR_WORLD_MESH_DETECTOR_LOD_MEDIUM_ML = 1,
+ XR_WORLD_MESH_DETECTOR_LOD_MAXIMUM_ML = 2,
+ XR_WORLD_MESH_DETECTOR_LOD_MAX_ENUM_ML = 0x7FFFFFFF
+} XrWorldMeshDetectorLodML;
+
+typedef enum XrWorldMeshBlockStatusML {
+ XR_WORLD_MESH_BLOCK_STATUS_NEW_ML = 0,
+ XR_WORLD_MESH_BLOCK_STATUS_UPDATED_ML = 1,
+ XR_WORLD_MESH_BLOCK_STATUS_DELETED_ML = 2,
+ XR_WORLD_MESH_BLOCK_STATUS_UNCHANGED_ML = 3,
+ XR_WORLD_MESH_BLOCK_STATUS_MAX_ENUM_ML = 0x7FFFFFFF
+} XrWorldMeshBlockStatusML;
+
+typedef enum XrWorldMeshBlockResultML {
+ XR_WORLD_MESH_BLOCK_RESULT_SUCCESS_ML = 0,
+ XR_WORLD_MESH_BLOCK_RESULT_FAILED_ML = 1,
+ XR_WORLD_MESH_BLOCK_RESULT_PENDING_ML = 2,
+ XR_WORLD_MESH_BLOCK_RESULT_PARTIAL_UPDATE_ML = 3,
+ XR_WORLD_MESH_BLOCK_RESULT_MAX_ENUM_ML = 0x7FFFFFFF
+} XrWorldMeshBlockResultML;
+typedef XrFlags64 XrWorldMeshDetectorFlagsML;
+
+// Flag bits for XrWorldMeshDetectorFlagsML
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_POINT_CLOUD_BIT_ML = 0x00000001;
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_COMPUTE_NORMALS_BIT_ML = 0x00000002;
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_COMPUTE_CONFIDENCE_BIT_ML = 0x00000004;
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_PLANARIZE_BIT_ML = 0x00000008;
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_REMOVE_MESH_SKIRT_BIT_ML = 0x00000010;
+static const XrWorldMeshDetectorFlagsML XR_WORLD_MESH_DETECTOR_INDEX_ORDER_CW_BIT_ML = 0x00000020;
+
+typedef struct XrWorldMeshDetectorCreateInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrWorldMeshDetectorCreateInfoML;
+
+typedef struct XrWorldMeshBlockStateML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuidEXT uuid;
+ XrPosef meshBoundingBoxCenter;
+ XrExtent3DfEXT meshBoundingBoxExtents;
+ XrTime lastUpdateTime;
+ XrWorldMeshBlockStatusML status;
+} XrWorldMeshBlockStateML;
+
+typedef struct XrWorldMeshStateRequestInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrPosef boundingBoxCenter;
+ XrExtent3DfEXT boundingBoxExtents;
+} XrWorldMeshStateRequestInfoML;
+
+typedef struct XrWorldMeshStateRequestCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrTime timestamp;
+ uint32_t meshBlockStateCapacityInput;
+ uint32_t meshBlockStateCountOutput;
+ XrWorldMeshBlockStateML* meshBlockStates;
+} XrWorldMeshStateRequestCompletionML;
+
+typedef struct XrWorldMeshBufferRecommendedSizeInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t maxBlockCount;
+} XrWorldMeshBufferRecommendedSizeInfoML;
+
+typedef struct XrWorldMeshBufferSizeML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t size;
+} XrWorldMeshBufferSizeML;
+
+typedef struct XrWorldMeshBufferML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t bufferSize;
+ void* XR_MAY_ALIAS buffer;
+} XrWorldMeshBufferML;
+
+typedef struct XrWorldMeshBlockRequestML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuidEXT uuid;
+ XrWorldMeshDetectorLodML lod;
+} XrWorldMeshBlockRequestML;
+
+typedef struct XrWorldMeshGetInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrWorldMeshDetectorFlagsML flags;
+ float fillHoleLength;
+ float disconnectedComponentArea;
+ uint32_t blockCount;
+ XrWorldMeshBlockRequestML* blocks;
+} XrWorldMeshGetInfoML;
+
+typedef struct XrWorldMeshBlockML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuidEXT uuid;
+ XrWorldMeshBlockResultML blockResult;
+ XrWorldMeshDetectorLodML lod;
+ XrWorldMeshDetectorFlagsML flags;
+ uint32_t indexCount;
+ uint16_t* indexBuffer;
+ uint32_t vertexCount;
+ XrVector3f* vertexBuffer;
+ uint32_t normalCount;
+ XrVector3f* normalBuffer;
+ uint32_t confidenceCount;
+ float* confidenceBuffer;
+} XrWorldMeshBlockML;
+
+typedef struct XrWorldMeshRequestCompletionInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace meshSpace;
+ XrTime meshSpaceLocateTime;
+} XrWorldMeshRequestCompletionInfoML;
+
+typedef struct XrWorldMeshRequestCompletionML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ uint32_t blockCount;
+ XrWorldMeshBlockML* blocks;
+} XrWorldMeshRequestCompletionML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateWorldMeshDetectorML)(XrSession session, const XrWorldMeshDetectorCreateInfoML* createInfo, XrWorldMeshDetectorML* detector);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyWorldMeshDetectorML)(XrWorldMeshDetectorML detector);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestWorldMeshStateAsyncML)(XrWorldMeshDetectorML detector, const XrWorldMeshStateRequestInfoML* stateRequest, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestWorldMeshStateCompleteML)(XrWorldMeshDetectorML detector, XrFutureEXT future, XrWorldMeshStateRequestCompletionML* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrGetWorldMeshBufferRecommendSizeML)(XrWorldMeshDetectorML detector, const XrWorldMeshBufferRecommendedSizeInfoML* sizeInfo, XrWorldMeshBufferSizeML* size);
+typedef XrResult (XRAPI_PTR *PFN_xrAllocateWorldMeshBufferML)(XrWorldMeshDetectorML detector, const XrWorldMeshBufferSizeML* size, XrWorldMeshBufferML* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrFreeWorldMeshBufferML)(XrWorldMeshDetectorML detector, const XrWorldMeshBufferML* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestWorldMeshAsyncML)(XrWorldMeshDetectorML detector, const XrWorldMeshGetInfoML* getInfo, XrWorldMeshBufferML* buffer, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestWorldMeshCompleteML)(XrWorldMeshDetectorML detector, const XrWorldMeshRequestCompletionInfoML* completionInfo, XrFutureEXT future, XrWorldMeshRequestCompletionML* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateWorldMeshDetectorML(
+ XrSession session,
+ const XrWorldMeshDetectorCreateInfoML* createInfo,
+ XrWorldMeshDetectorML* detector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyWorldMeshDetectorML(
+ XrWorldMeshDetectorML detector);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestWorldMeshStateAsyncML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshStateRequestInfoML* stateRequest,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestWorldMeshStateCompleteML(
+ XrWorldMeshDetectorML detector,
+ XrFutureEXT future,
+ XrWorldMeshStateRequestCompletionML* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetWorldMeshBufferRecommendSizeML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshBufferRecommendedSizeInfoML* sizeInfo,
+ XrWorldMeshBufferSizeML* size);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAllocateWorldMeshBufferML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshBufferSizeML* size,
+ XrWorldMeshBufferML* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrFreeWorldMeshBufferML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshBufferML* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestWorldMeshAsyncML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshGetInfoML* getInfo,
+ XrWorldMeshBufferML* buffer,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestWorldMeshCompleteML(
+ XrWorldMeshDetectorML detector,
+ const XrWorldMeshRequestCompletionInfoML* completionInfo,
+ XrFutureEXT future,
+ XrWorldMeshRequestCompletionML* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_facial_expression is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_facial_expression 1
+XR_DEFINE_HANDLE(XrFacialExpressionClientML)
+#define XR_ML_facial_expression_SPEC_VERSION 1
+#define XR_ML_FACIAL_EXPRESSION_EXTENSION_NAME "XR_ML_facial_expression"
+
+typedef enum XrFacialBlendShapeML {
+ XR_FACIAL_BLEND_SHAPE_BROW_LOWERER_L_ML = 0,
+ XR_FACIAL_BLEND_SHAPE_BROW_LOWERER_R_ML = 1,
+ XR_FACIAL_BLEND_SHAPE_CHEEK_RAISER_L_ML = 2,
+ XR_FACIAL_BLEND_SHAPE_CHEEK_RAISER_R_ML = 3,
+ XR_FACIAL_BLEND_SHAPE_CHIN_RAISER_ML = 4,
+ XR_FACIAL_BLEND_SHAPE_DIMPLER_L_ML = 5,
+ XR_FACIAL_BLEND_SHAPE_DIMPLER_R_ML = 6,
+ XR_FACIAL_BLEND_SHAPE_EYES_CLOSED_L_ML = 7,
+ XR_FACIAL_BLEND_SHAPE_EYES_CLOSED_R_ML = 8,
+ XR_FACIAL_BLEND_SHAPE_INNER_BROW_RAISER_L_ML = 9,
+ XR_FACIAL_BLEND_SHAPE_INNER_BROW_RAISER_R_ML = 10,
+ XR_FACIAL_BLEND_SHAPE_JAW_DROP_ML = 11,
+ XR_FACIAL_BLEND_SHAPE_LID_TIGHTENER_L_ML = 12,
+ XR_FACIAL_BLEND_SHAPE_LID_TIGHTENER_R_ML = 13,
+ XR_FACIAL_BLEND_SHAPE_LIP_CORNER_DEPRESSOR_L_ML = 14,
+ XR_FACIAL_BLEND_SHAPE_LIP_CORNER_DEPRESSOR_R_ML = 15,
+ XR_FACIAL_BLEND_SHAPE_LIP_CORNER_PULLER_L_ML = 16,
+ XR_FACIAL_BLEND_SHAPE_LIP_CORNER_PULLER_R_ML = 17,
+ XR_FACIAL_BLEND_SHAPE_LIP_FUNNELER_LB_ML = 18,
+ XR_FACIAL_BLEND_SHAPE_LIP_FUNNELER_LT_ML = 19,
+ XR_FACIAL_BLEND_SHAPE_LIP_FUNNELER_RB_ML = 20,
+ XR_FACIAL_BLEND_SHAPE_LIP_FUNNELER_RT_ML = 21,
+ XR_FACIAL_BLEND_SHAPE_LIP_PRESSOR_L_ML = 22,
+ XR_FACIAL_BLEND_SHAPE_LIP_PRESSOR_R_ML = 23,
+ XR_FACIAL_BLEND_SHAPE_LIP_PUCKER_L_ML = 24,
+ XR_FACIAL_BLEND_SHAPE_LIP_PUCKER_R_ML = 25,
+ XR_FACIAL_BLEND_SHAPE_LIP_STRETCHER_L_ML = 26,
+ XR_FACIAL_BLEND_SHAPE_LIP_STRETCHER_R_ML = 27,
+ XR_FACIAL_BLEND_SHAPE_LIP_SUCK_LB_ML = 28,
+ XR_FACIAL_BLEND_SHAPE_LIP_SUCK_LT_ML = 29,
+ XR_FACIAL_BLEND_SHAPE_LIP_SUCK_RB_ML = 30,
+ XR_FACIAL_BLEND_SHAPE_LIP_SUCK_RT_ML = 31,
+ XR_FACIAL_BLEND_SHAPE_LIP_TIGHTENER_L_ML = 32,
+ XR_FACIAL_BLEND_SHAPE_LIP_TIGHTENER_R_ML = 33,
+ XR_FACIAL_BLEND_SHAPE_LIPS_TOWARD_ML = 34,
+ XR_FACIAL_BLEND_SHAPE_LOWER_LIP_DEPRESSOR_L_ML = 35,
+ XR_FACIAL_BLEND_SHAPE_LOWER_LIP_DEPRESSOR_R_ML = 36,
+ XR_FACIAL_BLEND_SHAPE_NOSE_WRINKLER_L_ML = 37,
+ XR_FACIAL_BLEND_SHAPE_NOSE_WRINKLER_R_ML = 38,
+ XR_FACIAL_BLEND_SHAPE_OUTER_BROW_RAISER_L_ML = 39,
+ XR_FACIAL_BLEND_SHAPE_OUTER_BROW_RAISER_R_ML = 40,
+ XR_FACIAL_BLEND_SHAPE_UPPER_LID_RAISER_L_ML = 41,
+ XR_FACIAL_BLEND_SHAPE_UPPER_LID_RAISER_R_ML = 42,
+ XR_FACIAL_BLEND_SHAPE_UPPER_LIP_RAISER_L_ML = 43,
+ XR_FACIAL_BLEND_SHAPE_UPPER_LIP_RAISER_R_ML = 44,
+ XR_FACIAL_BLEND_SHAPE_TONGUE_OUT_ML = 45,
+ XR_FACIAL_BLEND_SHAPE_MAX_ENUM_ML = 0x7FFFFFFF
+} XrFacialBlendShapeML;
+typedef XrFlags64 XrFacialExpressionBlendShapePropertiesFlagsML;
+
+// Flag bits for XrFacialExpressionBlendShapePropertiesFlagsML
+static const XrFacialExpressionBlendShapePropertiesFlagsML XR_FACIAL_EXPRESSION_BLEND_SHAPE_PROPERTIES_VALID_BIT_ML = 0x00000001;
+static const XrFacialExpressionBlendShapePropertiesFlagsML XR_FACIAL_EXPRESSION_BLEND_SHAPE_PROPERTIES_TRACKED_BIT_ML = 0x00000002;
+
+// XrSystemFacialExpressionPropertiesML extends XrSystemProperties
+typedef struct XrSystemFacialExpressionPropertiesML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsFacialExpression;
+} XrSystemFacialExpressionPropertiesML;
+
+typedef struct XrFacialExpressionClientCreateInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t requestedCount;
+ const XrFacialBlendShapeML* requestedFacialBlendShapes;
+} XrFacialExpressionClientCreateInfoML;
+
+typedef struct XrFacialExpressionBlendShapeGetInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrFacialExpressionBlendShapeGetInfoML;
+
+typedef struct XrFacialExpressionBlendShapePropertiesML {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrFacialBlendShapeML requestedFacialBlendShape;
+ float weight;
+ XrFacialExpressionBlendShapePropertiesFlagsML flags;
+ XrTime time;
+} XrFacialExpressionBlendShapePropertiesML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateFacialExpressionClientML)(XrSession session, const XrFacialExpressionClientCreateInfoML* createInfo, XrFacialExpressionClientML* facialExpressionClient);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyFacialExpressionClientML)(XrFacialExpressionClientML facialExpressionClient);
+typedef XrResult (XRAPI_PTR *PFN_xrGetFacialExpressionBlendShapePropertiesML)(XrFacialExpressionClientML facialExpressionClient, const XrFacialExpressionBlendShapeGetInfoML* blendShapeGetInfo, uint32_t blendShapeCount, XrFacialExpressionBlendShapePropertiesML* blendShapes);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateFacialExpressionClientML(
+ XrSession session,
+ const XrFacialExpressionClientCreateInfoML* createInfo,
+ XrFacialExpressionClientML* facialExpressionClient);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyFacialExpressionClientML(
+ XrFacialExpressionClientML facialExpressionClient);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetFacialExpressionBlendShapePropertiesML(
+ XrFacialExpressionClientML facialExpressionClient,
+ const XrFacialExpressionBlendShapeGetInfoML* blendShapeGetInfo,
+ uint32_t blendShapeCount,
+ XrFacialExpressionBlendShapePropertiesML* blendShapes);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ML_view_configuration_depth_range_change is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_view_configuration_depth_range_change 1
+#define XR_ML_view_configuration_depth_range_change_SPEC_VERSION 1
+#define XR_ML_VIEW_CONFIGURATION_DEPTH_RANGE_CHANGE_EXTENSION_NAME "XR_ML_view_configuration_depth_range_change"
+
+
+// XR_YVR_controller_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_YVR_controller_interaction 1
+#define XR_YVR_controller_interaction_SPEC_VERSION 1
+#define XR_YVR_CONTROLLER_INTERACTION_EXTENSION_NAME "XR_YVR_controller_interaction"
+
+
+// XR_META_boundary_visibility is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_boundary_visibility 1
+#define XR_META_boundary_visibility_SPEC_VERSION 1
+#define XR_META_BOUNDARY_VISIBILITY_EXTENSION_NAME "XR_META_boundary_visibility"
+
+typedef enum XrBoundaryVisibilityMETA {
+ XR_BOUNDARY_VISIBILITY_NOT_SUPPRESSED_META = 1,
+ XR_BOUNDARY_VISIBILITY_SUPPRESSED_META = 2,
+ XR_BOUNDARY_VISIBILITY_MAX_ENUM_META = 0x7FFFFFFF
+} XrBoundaryVisibilityMETA;
+// XrSystemBoundaryVisibilityPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemBoundaryVisibilityPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsBoundaryVisibility;
+} XrSystemBoundaryVisibilityPropertiesMETA;
+
+typedef struct XrEventDataBoundaryVisibilityChangedMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBoundaryVisibilityMETA boundaryVisibility;
+} XrEventDataBoundaryVisibilityChangedMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrRequestBoundaryVisibilityMETA)(XrSession session, XrBoundaryVisibilityMETA boundaryVisibility);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestBoundaryVisibilityMETA(
+ XrSession session,
+ XrBoundaryVisibilityMETA boundaryVisibility);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_simultaneous_hands_and_controllers is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_simultaneous_hands_and_controllers 1
+#define XR_META_simultaneous_hands_and_controllers_SPEC_VERSION 1
+#define XR_META_SIMULTANEOUS_HANDS_AND_CONTROLLERS_EXTENSION_NAME "XR_META_simultaneous_hands_and_controllers"
+// XrSystemSimultaneousHandsAndControllersPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemSimultaneousHandsAndControllersPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSimultaneousHandsAndControllers;
+} XrSystemSimultaneousHandsAndControllersPropertiesMETA;
+
+typedef struct XrSimultaneousHandsAndControllersTrackingResumeInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSimultaneousHandsAndControllersTrackingResumeInfoMETA;
+
+typedef struct XrSimultaneousHandsAndControllersTrackingPauseInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSimultaneousHandsAndControllersTrackingPauseInfoMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrResumeSimultaneousHandsAndControllersTrackingMETA)(XrSession session, const XrSimultaneousHandsAndControllersTrackingResumeInfoMETA* resumeInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrPauseSimultaneousHandsAndControllersTrackingMETA)(XrSession session, const XrSimultaneousHandsAndControllersTrackingPauseInfoMETA* pauseInfo);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrResumeSimultaneousHandsAndControllersTrackingMETA(
+ XrSession session,
+ const XrSimultaneousHandsAndControllersTrackingResumeInfoMETA* resumeInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPauseSimultaneousHandsAndControllersTrackingMETA(
+ XrSession session,
+ const XrSimultaneousHandsAndControllersTrackingPauseInfoMETA* pauseInfo);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_face_tracking_visemes is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_face_tracking_visemes 1
+#define XR_FACE_TRACKING_VISEME_COUNT_META 15
+#define XR_META_face_tracking_visemes_SPEC_VERSION 1
+#define XR_META_FACE_TRACKING_VISEMES_EXTENSION_NAME "XR_META_face_tracking_visemes"
+
+typedef enum XrFaceTrackingVisemeMETA {
+ XR_FACE_TRACKING_VISEME_SIL_META = 0,
+ XR_FACE_TRACKING_VISEME_PP_META = 1,
+ XR_FACE_TRACKING_VISEME_FF_META = 2,
+ XR_FACE_TRACKING_VISEME_TH_META = 3,
+ XR_FACE_TRACKING_VISEME_DD_META = 4,
+ XR_FACE_TRACKING_VISEME_KK_META = 5,
+ XR_FACE_TRACKING_VISEME_CH_META = 6,
+ XR_FACE_TRACKING_VISEME_SS_META = 7,
+ XR_FACE_TRACKING_VISEME_NN_META = 8,
+ XR_FACE_TRACKING_VISEME_RR_META = 9,
+ XR_FACE_TRACKING_VISEME_AA_META = 10,
+ XR_FACE_TRACKING_VISEME_E_META = 11,
+ XR_FACE_TRACKING_VISEME_IH_META = 12,
+ XR_FACE_TRACKING_VISEME_OH_META = 13,
+ XR_FACE_TRACKING_VISEME_OU_META = 14,
+ XR_FACE_TRACKING_VISEME_MAX_ENUM_META = 0x7FFFFFFF
+} XrFaceTrackingVisemeMETA;
+// XrFaceTrackingVisemesMETA extends XrFaceExpressionWeights2FB
+typedef struct XrFaceTrackingVisemesMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 isValid;
+ float visemes[XR_FACE_TRACKING_VISEME_COUNT_META];
+} XrFaceTrackingVisemesMETA;
+
+// XrSystemFaceTrackingVisemesPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemFaceTrackingVisemesPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsVisemes;
+} XrSystemFaceTrackingVisemesPropertiesMETA;
+
+
+
+// XR_META_spatial_entity_semantic_label is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_semantic_label 1
+#define XR_META_spatial_entity_semantic_label_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_SEMANTIC_LABEL_EXTENSION_NAME "XR_META_spatial_entity_semantic_label"
+
+typedef enum XrSemanticLabelMETA {
+ XR_SEMANTIC_LABEL_UNKNOWN_META = 0,
+ XR_SEMANTIC_LABEL_FLOOR_META = 1,
+ XR_SEMANTIC_LABEL_CEILING_META = 2,
+ XR_SEMANTIC_LABEL_WALL_FACE_META = 3,
+ XR_SEMANTIC_LABEL_INNER_WALL_FACE_META = 4,
+ XR_SEMANTIC_LABEL_INVISIBLE_WALL_FACE_META = 5,
+ XR_SEMANTIC_LABEL_DOOR_FRAME_META = 6,
+ XR_SEMANTIC_LABEL_WINDOW_FRAME_META = 7,
+ XR_SEMANTIC_LABEL_MAX_ENUM_META = 0x7FFFFFFF
+} XrSemanticLabelMETA;
+
+
+// XR_META_spatial_entity_room_mesh is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_room_mesh 1
+#define XR_META_spatial_entity_room_mesh_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_ROOM_MESH_EXTENSION_NAME "XR_META_spatial_entity_room_mesh"
+typedef struct XrRoomMeshFaceMETA {
+ XrUuid uuid;
+ XrUuid parentUuid;
+ XrSemanticLabelMETA semanticLabel;
+} XrRoomMeshFaceMETA;
+
+typedef struct XrRoomMeshFaceIndicesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrRoomMeshFaceIndicesMETA;
+
+typedef struct XrSpaceRoomMeshGetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t recognizedSemanticLabelCount;
+ const XrSemanticLabelMETA* recognizedSemanticLabels;
+} XrSpaceRoomMeshGetInfoMETA;
+
+typedef struct XrRoomMeshMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertices;
+ uint32_t faceCapacityInput;
+ uint32_t faceCountOutput;
+ XrRoomMeshFaceMETA* faces;
+} XrRoomMeshMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceRoomMeshMETA)(XrSpace space, const XrSpaceRoomMeshGetInfoMETA* getInfo, XrRoomMeshMETA* roomMeshOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpaceRoomMeshFaceIndicesMETA)(XrSpace space, const XrUuid* faceUuid, XrRoomMeshFaceIndicesMETA* roomMeshFaceIndicesOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceRoomMeshMETA(
+ XrSpace space,
+ const XrSpaceRoomMeshGetInfoMETA* getInfo,
+ XrRoomMeshMETA* roomMeshOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpaceRoomMeshFaceIndicesMETA(
+ XrSpace space,
+ const XrUuid* faceUuid,
+ XrRoomMeshFaceIndicesMETA* roomMeshFaceIndicesOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_composition_layer_inverted_alpha is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_composition_layer_inverted_alpha 1
+#define XR_EXT_composition_layer_inverted_alpha_SPEC_VERSION 1
+#define XR_EXT_COMPOSITION_LAYER_INVERTED_ALPHA_EXTENSION_NAME "XR_EXT_composition_layer_inverted_alpha"
+
+
+// XR_META_colocation_discovery is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_colocation_discovery 1
+#define XR_MAX_COLOCATION_DISCOVERY_BUFFER_SIZE_META 1024
+#define XR_META_colocation_discovery_SPEC_VERSION 1
+#define XR_META_COLOCATION_DISCOVERY_EXTENSION_NAME "XR_META_colocation_discovery"
+typedef struct XrColocationDiscoveryStartInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrColocationDiscoveryStartInfoMETA;
+
+typedef struct XrColocationDiscoveryStopInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrColocationDiscoveryStopInfoMETA;
+
+typedef struct XrColocationAdvertisementStartInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t bufferSize;
+ uint8_t* buffer;
+} XrColocationAdvertisementStartInfoMETA;
+
+typedef struct XrColocationAdvertisementStopInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrColocationAdvertisementStopInfoMETA;
+
+typedef struct XrEventDataStartColocationAdvertisementCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB advertisementRequestId;
+ XrResult result;
+ XrUuid advertisementUuid;
+} XrEventDataStartColocationAdvertisementCompleteMETA;
+
+typedef struct XrEventDataStopColocationAdvertisementCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataStopColocationAdvertisementCompleteMETA;
+
+typedef struct XrEventDataColocationAdvertisementCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB advertisementRequestId;
+ XrResult result;
+} XrEventDataColocationAdvertisementCompleteMETA;
+
+typedef struct XrEventDataStartColocationDiscoveryCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB discoveryRequestId;
+ XrResult result;
+} XrEventDataStartColocationDiscoveryCompleteMETA;
+
+typedef struct XrEventDataColocationDiscoveryResultMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB discoveryRequestId;
+ XrUuid advertisementUuid;
+ uint32_t bufferSize;
+ uint8_t buffer[XR_MAX_COLOCATION_DISCOVERY_BUFFER_SIZE_META];
+} XrEventDataColocationDiscoveryResultMETA;
+
+typedef struct XrEventDataColocationDiscoveryCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB discoveryRequestId;
+ XrResult result;
+} XrEventDataColocationDiscoveryCompleteMETA;
+
+typedef struct XrEventDataStopColocationDiscoveryCompleteMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAsyncRequestIdFB requestId;
+ XrResult result;
+} XrEventDataStopColocationDiscoveryCompleteMETA;
+
+// XrSystemColocationDiscoveryPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemColocationDiscoveryPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsColocationDiscovery;
+} XrSystemColocationDiscoveryPropertiesMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrStartColocationDiscoveryMETA)(XrSession session, const XrColocationDiscoveryStartInfoMETA* info, XrAsyncRequestIdFB* discoveryRequestId);
+typedef XrResult (XRAPI_PTR *PFN_xrStopColocationDiscoveryMETA)(XrSession session, const XrColocationDiscoveryStopInfoMETA* info, XrAsyncRequestIdFB* requestId);
+typedef XrResult (XRAPI_PTR *PFN_xrStartColocationAdvertisementMETA)(XrSession session, const XrColocationAdvertisementStartInfoMETA* info, XrAsyncRequestIdFB* advertisementRequestId);
+typedef XrResult (XRAPI_PTR *PFN_xrStopColocationAdvertisementMETA)(XrSession session, const XrColocationAdvertisementStopInfoMETA* info, XrAsyncRequestIdFB* requestId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrStartColocationDiscoveryMETA(
+ XrSession session,
+ const XrColocationDiscoveryStartInfoMETA* info,
+ XrAsyncRequestIdFB* discoveryRequestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStopColocationDiscoveryMETA(
+ XrSession session,
+ const XrColocationDiscoveryStopInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStartColocationAdvertisementMETA(
+ XrSession session,
+ const XrColocationAdvertisementStartInfoMETA* info,
+ XrAsyncRequestIdFB* advertisementRequestId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrStopColocationAdvertisementMETA(
+ XrSession session,
+ const XrColocationAdvertisementStopInfoMETA* info,
+ XrAsyncRequestIdFB* requestId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_spatial_entity_group_sharing is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_spatial_entity_group_sharing 1
+#define XR_META_spatial_entity_group_sharing_SPEC_VERSION 1
+#define XR_META_SPATIAL_ENTITY_GROUP_SHARING_EXTENSION_NAME "XR_META_spatial_entity_group_sharing"
+// XrSystemSpatialEntityGroupSharingPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemSpatialEntityGroupSharingPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSpatialEntityGroupSharing;
+} XrSystemSpatialEntityGroupSharingPropertiesMETA;
+
+typedef struct XrShareSpacesRecipientGroupsMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t groupCount;
+ XrUuid* groups;
+} XrShareSpacesRecipientGroupsMETA;
+
+// XrSpaceGroupUuidFilterInfoMETA extends XrSpaceQueryInfoBaseHeaderFB
+typedef struct XrSpaceGroupUuidFilterInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuid groupUuid;
+} XrSpaceGroupUuidFilterInfoMETA;
+
+
+
+// XR_META_environment_raycast is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_environment_raycast 1
+XR_DEFINE_HANDLE(XrEnvironmentRaycasterMETA)
+#define XR_META_environment_raycast_SPEC_VERSION 1
+#define XR_META_ENVIRONMENT_RAYCAST_EXTENSION_NAME "XR_META_environment_raycast"
+
+typedef enum XrEnvironmentRaycastHitStatusMETA {
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_HIT_META = 1,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_NO_HIT_META = 2,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_HIT_POINT_OCCLUDED_META = 3,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_HIT_POINT_OUTSIDE_OF_FOV_META = 4,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_RAY_OCCLUDED_META = 5,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_HIT_INVALID_ORIENTATION_META = 6,
+ XR_ENVIRONMENT_RAYCAST_HIT_STATUS_MAX_ENUM_META = 0x7FFFFFFF
+} XrEnvironmentRaycastHitStatusMETA;
+// XrSystemEnvironmentRaycastPropertiesMETA extends XrSystemProperties
+typedef struct XrSystemEnvironmentRaycastPropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsEnvironmentRaycast;
+} XrSystemEnvironmentRaycastPropertiesMETA;
+
+typedef struct XrEnvironmentRaycasterCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEnvironmentRaycasterCreateInfoMETA;
+
+typedef struct XrEnvironmentRaycasterCreateCompletionMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrEnvironmentRaycasterMETA environmentRaycaster;
+} XrEnvironmentRaycasterCreateCompletionMETA;
+
+typedef struct XR_MAY_ALIAS XrEnvironmentRaycastFilterBaseHeaderMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrEnvironmentRaycastFilterBaseHeaderMETA;
+
+typedef struct XrEnvironmentRaycastHitGetInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrVector3f origin;
+ XrVector3f direction;
+ uint32_t filterCount;
+ const XrEnvironmentRaycastFilterBaseHeaderMETA* const * filters;
+} XrEnvironmentRaycastHitGetInfoMETA;
+
+typedef struct XrEnvironmentRaycastHitMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrEnvironmentRaycastHitStatusMETA status;
+ XrPosef pose;
+} XrEnvironmentRaycastHitMETA;
+
+typedef struct XrEnvironmentRaycastFilterDistanceMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float maxDistance;
+} XrEnvironmentRaycastFilterDistanceMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEnvironmentRaycasterAsyncMETA)(XrSession session, const XrEnvironmentRaycasterCreateInfoMETA* info, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateEnvironmentRaycasterCompleteMETA)(XrSession session, XrFutureEXT future, XrEnvironmentRaycasterCreateCompletionMETA* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyEnvironmentRaycasterMETA)(XrEnvironmentRaycasterMETA environmentRaycaster);
+typedef XrResult (XRAPI_PTR *PFN_xrPerformEnvironmentRaycastMETA)(XrEnvironmentRaycasterMETA environmentRaycaster, const XrEnvironmentRaycastHitGetInfoMETA* info, XrEnvironmentRaycastHitMETA* hitPoint);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEnvironmentRaycasterAsyncMETA(
+ XrSession session,
+ const XrEnvironmentRaycasterCreateInfoMETA* info,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateEnvironmentRaycasterCompleteMETA(
+ XrSession session,
+ XrFutureEXT future,
+ XrEnvironmentRaycasterCreateCompletionMETA* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyEnvironmentRaycasterMETA(
+ XrEnvironmentRaycasterMETA environmentRaycaster);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPerformEnvironmentRaycastMETA(
+ XrEnvironmentRaycasterMETA environmentRaycaster,
+ const XrEnvironmentRaycastHitGetInfoMETA* info,
+ XrEnvironmentRaycastHitMETA* hitPoint);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_tile_properties_hint is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_tile_properties_hint 1
+#define XR_META_tile_properties_hint_SPEC_VERSION 1
+#define XR_META_TILE_PROPERTIES_HINT_EXTENSION_NAME "XR_META_tile_properties_hint"
+typedef struct XrExtent3DiMETA {
+ int32_t width;
+ int32_t height;
+ int32_t depth;
+} XrExtent3DiMETA;
+
+typedef struct XrTilePropertiesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrExtent3DiMETA tileDimensions;
+ XrExtent2Di apronDimensions;
+ XrOffset2Di origin;
+} XrTilePropertiesMETA;
+
+typedef struct XrTilePropertiesHintMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t propertiesCount;
+ const XrTilePropertiesMETA* properties;
+} XrTilePropertiesHintMETA;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetTilePropertiesHintMETA)(XrSession session, const XrTilePropertiesHintMETA* properties);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetTilePropertiesHintMETA(
+ XrSession session,
+ const XrTilePropertiesHintMETA* properties);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_hand_tracking_unextrapolated_poses is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_hand_tracking_unextrapolated_poses 1
+#define XR_META_hand_tracking_unextrapolated_poses_SPEC_VERSION 1
+#define XR_META_HAND_TRACKING_UNEXTRAPOLATED_POSES_EXTENSION_NAME "XR_META_hand_tracking_unextrapolated_poses"
+// XrHandTrackingUnextrapolatedPosesRequestMETA extends XrHandJointsLocateInfoEXT
+typedef struct XrHandTrackingUnextrapolatedPosesRequestMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrHandTrackingUnextrapolatedPosesRequestMETA;
+
+// XrHandTrackingUnextrapolatedPosesMETA extends XrHandJointLocationsEXT
+typedef struct XrHandTrackingUnextrapolatedPosesMETA {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTime captureTime;
+} XrHandTrackingUnextrapolatedPosesMETA;
+
+
+
+// XR_META_hand_tracking_frequency_hint is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_hand_tracking_frequency_hint 1
+#define XR_META_hand_tracking_frequency_hint_SPEC_VERSION 1
+#define XR_META_HAND_TRACKING_FREQUENCY_HINT_EXTENSION_NAME "XR_META_hand_tracking_frequency_hint"
+
+typedef enum XrHandTrackingFrequencyHintMETA {
+ XR_HAND_TRACKING_FREQUENCY_HINT_DEFAULT_META = 1,
+ XR_HAND_TRACKING_FREQUENCY_HINT_HIGH_META = 2,
+ XR_HAND_TRACKING_FREQUENCY_HINT_MAX_ENUM_META = 0x7FFFFFFF
+} XrHandTrackingFrequencyHintMETA;
+typedef XrResult (XRAPI_PTR *PFN_xrSetHandTrackingFrequencyHintMETA)(XrSession session, XrHandTrackingFrequencyHintMETA frequencyHint);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetHandTrackingFrequencyHintMETA(
+ XrSession session,
+ XrHandTrackingFrequencyHintMETA frequencyHint);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_META_hand_tracking_wide_motion_mode2 is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_hand_tracking_wide_motion_mode2 1
+#define XR_META_hand_tracking_wide_motion_mode2_SPEC_VERSION 1
+#define XR_META_HAND_TRACKING_WIDE_MOTION_MODE2_EXTENSION_NAME "XR_META_hand_tracking_wide_motion_mode2"
+
+
+// XR_ANDROID_light_estimation is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_light_estimation 1
+XR_DEFINE_HANDLE(XrLightEstimatorANDROID)
+#define XR_ANDROID_light_estimation_SPEC_VERSION 1
+#define XR_ANDROID_LIGHT_ESTIMATION_EXTENSION_NAME "XR_ANDROID_light_estimation"
+
+typedef enum XrLightEstimateStateANDROID {
+ XR_LIGHT_ESTIMATE_STATE_VALID_ANDROID = 0,
+ XR_LIGHT_ESTIMATE_STATE_INVALID_ANDROID = 1,
+ XR_LIGHT_ESTIMATE_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrLightEstimateStateANDROID;
+
+typedef enum XrSphericalHarmonicsKindANDROID {
+ XR_SPHERICAL_HARMONICS_KIND_TOTAL_ANDROID = 0,
+ XR_SPHERICAL_HARMONICS_KIND_AMBIENT_ANDROID = 1,
+ XR_SPHERICAL_HARMONICS_KIND_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSphericalHarmonicsKindANDROID;
+// XrSystemLightEstimationPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemLightEstimationPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsLightEstimation;
+} XrSystemLightEstimationPropertiesANDROID;
+
+typedef struct XrLightEstimatorCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrLightEstimatorCreateInfoANDROID;
+
+typedef struct XrLightEstimateGetInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+} XrLightEstimateGetInfoANDROID;
+
+typedef struct XrLightEstimateANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrLightEstimateStateANDROID state;
+ XrTime lastUpdatedTime;
+} XrLightEstimateANDROID;
+
+// XrDirectionalLightANDROID extends XrLightEstimateANDROID
+typedef struct XrDirectionalLightANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrLightEstimateStateANDROID state;
+ XrVector3f intensity;
+ XrVector3f direction;
+} XrDirectionalLightANDROID;
+
+// XrAmbientLightANDROID extends XrLightEstimateANDROID
+typedef struct XrAmbientLightANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrLightEstimateStateANDROID state;
+ XrVector3f intensity;
+ XrVector3f colorCorrection;
+} XrAmbientLightANDROID;
+
+// XrSphericalHarmonicsANDROID extends XrLightEstimateANDROID
+typedef struct XrSphericalHarmonicsANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrLightEstimateStateANDROID state;
+ XrSphericalHarmonicsKindANDROID kind;
+ float coefficients[9][3];
+} XrSphericalHarmonicsANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateLightEstimatorANDROID)(XrSession session, XrLightEstimatorCreateInfoANDROID* createInfo, XrLightEstimatorANDROID* outHandle);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyLightEstimatorANDROID)(XrLightEstimatorANDROID estimator);
+typedef XrResult (XRAPI_PTR *PFN_xrGetLightEstimateANDROID)(XrLightEstimatorANDROID estimator, const XrLightEstimateGetInfoANDROID* input, XrLightEstimateANDROID* output);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateLightEstimatorANDROID(
+ XrSession session,
+ XrLightEstimatorCreateInfoANDROID* createInfo,
+ XrLightEstimatorANDROID* outHandle);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyLightEstimatorANDROID(
+ XrLightEstimatorANDROID estimator);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetLightEstimateANDROID(
+ XrLightEstimatorANDROID estimator,
+ const XrLightEstimateGetInfoANDROID* input,
+ XrLightEstimateANDROID* output);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_mouse_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_mouse_interaction 1
+#define XR_ANDROID_mouse_interaction_SPEC_VERSION 1
+#define XR_ANDROID_MOUSE_INTERACTION_EXTENSION_NAME "XR_ANDROID_mouse_interaction"
+
+
+// XR_ANDROID_trackables_marker is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_trackables_marker 1
+#define XR_ANDROID_trackables_marker_SPEC_VERSION 1
+#define XR_ANDROID_TRACKABLES_MARKER_EXTENSION_NAME "XR_ANDROID_trackables_marker"
+
+typedef enum XrTrackableMarkerTrackingModeANDROID {
+ XR_TRACKABLE_MARKER_TRACKING_MODE_DYNAMIC_ANDROID = 0,
+ XR_TRACKABLE_MARKER_TRACKING_MODE_STATIC_ANDROID = 1,
+ XR_TRACKABLE_MARKER_TRACKING_MODE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackableMarkerTrackingModeANDROID;
+
+typedef enum XrTrackableMarkerDictionaryANDROID {
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_4X4_50_ANDROID = 0,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_4X4_100_ANDROID = 1,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_4X4_250_ANDROID = 2,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_4X4_1000_ANDROID = 3,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_5X5_50_ANDROID = 4,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_5X5_100_ANDROID = 5,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_5X5_250_ANDROID = 6,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_5X5_1000_ANDROID = 7,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_6X6_50_ANDROID = 8,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_6X6_100_ANDROID = 9,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_6X6_250_ANDROID = 10,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_6X6_1000_ANDROID = 11,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_7X7_50_ANDROID = 12,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_7X7_100_ANDROID = 13,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_7X7_250_ANDROID = 14,
+ XR_TRACKABLE_MARKER_DICTIONARY_ARUCO_7X7_1000_ANDROID = 15,
+ XR_TRACKABLE_MARKER_DICTIONARY_APRILTAG_16H5_ANDROID = 16,
+ XR_TRACKABLE_MARKER_DICTIONARY_APRILTAG_25H9_ANDROID = 17,
+ XR_TRACKABLE_MARKER_DICTIONARY_APRILTAG_36H10_ANDROID = 18,
+ XR_TRACKABLE_MARKER_DICTIONARY_APRILTAG_36H11_ANDROID = 19,
+ XR_TRACKABLE_MARKER_DICTIONARY_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackableMarkerDictionaryANDROID;
+// XrSystemMarkerTrackingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemMarkerTrackingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsMarkerTracking;
+ XrBool32 supportsMarkerSizeEstimation;
+ uint16_t maxMarkerCount;
+} XrSystemMarkerTrackingPropertiesANDROID;
+
+typedef struct XrTrackableMarkerDatabaseEntryANDROID {
+ int32_t id;
+ float edgeSize;
+} XrTrackableMarkerDatabaseEntryANDROID;
+
+typedef struct XrTrackableMarkerDatabaseANDROID {
+ XrTrackableMarkerDictionaryANDROID dictionary;
+ uint32_t entryCount;
+ const XrTrackableMarkerDatabaseEntryANDROID* entries;
+} XrTrackableMarkerDatabaseANDROID;
+
+// XrTrackableMarkerConfigurationANDROID extends XrTrackableTrackerCreateInfoANDROID
+typedef struct XrTrackableMarkerConfigurationANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTrackableMarkerTrackingModeANDROID trackingMode;
+ uint32_t databaseCount;
+ const XrTrackableMarkerDatabaseANDROID* databases;
+} XrTrackableMarkerConfigurationANDROID;
+
+typedef struct XrTrackableMarkerANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTrackingStateANDROID trackingState;
+ XrTime lastUpdatedTime;
+ XrTrackableMarkerDictionaryANDROID dictionary;
+ int32_t markerId;
+ XrPosef centerPose;
+ XrExtent2Df extents;
+} XrTrackableMarkerANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetTrackableMarkerANDROID)(XrTrackableTrackerANDROID tracker, const XrTrackableGetInfoANDROID* getInfo, XrTrackableMarkerANDROID* markerOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetTrackableMarkerANDROID(
+ XrTrackableTrackerANDROID tracker,
+ const XrTrackableGetInfoANDROID* getInfo,
+ XrTrackableMarkerANDROID* markerOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_trackables_qr_code is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_trackables_qr_code 1
+#define XR_ANDROID_trackables_qr_code_SPEC_VERSION 1
+#define XR_ANDROID_TRACKABLES_QR_CODE_EXTENSION_NAME "XR_ANDROID_trackables_qr_code"
+
+typedef enum XrQrCodeTrackingModeANDROID {
+ XR_QR_CODE_TRACKING_MODE_DYNAMIC_ANDROID = 0,
+ XR_QR_CODE_TRACKING_MODE_STATIC_ANDROID = 1,
+ XR_QR_CODE_TRACKING_MODE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrQrCodeTrackingModeANDROID;
+// XrSystemQrCodeTrackingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemQrCodeTrackingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsQrCodeTracking;
+ XrBool32 supportsQrCodeSizeEstimation;
+ uint16_t maxQrCodeCount;
+} XrSystemQrCodeTrackingPropertiesANDROID;
+
+// XrTrackableQrCodeConfigurationANDROID extends XrTrackableTrackerCreateInfoANDROID
+typedef struct XrTrackableQrCodeConfigurationANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrQrCodeTrackingModeANDROID trackingMode;
+ float qrCodeEdgeSize;
+} XrTrackableQrCodeConfigurationANDROID;
+
+typedef struct XrTrackableQrCodeANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrTrackingStateANDROID trackingState;
+ XrTime lastUpdatedTime;
+ XrPosef centerPose;
+ XrExtent2Df extents;
+ uint32_t bufferCapacityInput;
+ uint32_t bufferCountOutput;
+ char* buffer;
+} XrTrackableQrCodeANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetTrackableQrCodeANDROID)(XrTrackableTrackerANDROID tracker, const XrTrackableGetInfoANDROID* getInfo, XrTrackableQrCodeANDROID* qrCodeOutput);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetTrackableQrCodeANDROID(
+ XrTrackableTrackerANDROID tracker,
+ const XrTrackableGetInfoANDROID* getInfo,
+ XrTrackableQrCodeANDROID* qrCodeOutput);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_trackables_image is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_trackables_image 1
+XR_DEFINE_HANDLE(XrTrackableImageDatabaseANDROID)
+#define XR_ANDROID_trackables_image_SPEC_VERSION 1
+#define XR_ANDROID_TRACKABLES_IMAGE_EXTENSION_NAME "XR_ANDROID_trackables_image"
+
+typedef enum XrTrackableImageTrackingModeANDROID {
+ XR_TRACKABLE_IMAGE_TRACKING_MODE_DYNAMIC_ANDROID = 1,
+ XR_TRACKABLE_IMAGE_TRACKING_MODE_STATIC_ANDROID = 2,
+ XR_TRACKABLE_IMAGE_TRACKING_MODE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackableImageTrackingModeANDROID;
+
+typedef enum XrTrackableImageFormatANDROID {
+ XR_TRACKABLE_IMAGE_FORMAT_R8G8B8A8_ANDROID = 1,
+ XR_TRACKABLE_IMAGE_FORMAT_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrTrackableImageFormatANDROID;
+// XrSystemImageTrackingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemImageTrackingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsImageTracking;
+ XrBool32 supportsPhysicalSizeEstimation;
+ uint32_t maxTrackedImageCount;
+ uint32_t maxLoadedImageCount;
+} XrSystemImageTrackingPropertiesANDROID;
+
+typedef struct XrTrackableImageDatabaseEntryANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTrackableImageTrackingModeANDROID trackingMode;
+ float physicalWidth;
+ uint32_t imageWidth;
+ uint32_t imageHeight;
+ XrTrackableImageFormatANDROID format;
+ uint32_t bufferSize;
+ const uint8_t* buffer;
+} XrTrackableImageDatabaseEntryANDROID;
+
+typedef struct XrTrackableImageDatabaseCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t entryCount;
+ const XrTrackableImageDatabaseEntryANDROID* entries;
+} XrTrackableImageDatabaseCreateInfoANDROID;
+
+typedef struct XrCreateTrackableImageDatabaseCompletionANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrTrackableImageDatabaseANDROID database;
+} XrCreateTrackableImageDatabaseCompletionANDROID;
+
+typedef struct XrTrackableImageConfigurationANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t databaseCount;
+ const XrTrackableImageDatabaseANDROID* databases;
+} XrTrackableImageConfigurationANDROID;
+
+typedef struct XrTrackableImageANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTrackingStateANDROID trackingState;
+ XrTime lastUpdatedTime;
+ XrTrackableImageDatabaseANDROID database;
+ uint32_t databaseEntryIndex;
+ XrPosef centerPose;
+ XrExtent2Df extents;
+} XrTrackableImageANDROID;
+
+typedef struct XrEventDataImageTrackingLostANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime time;
+} XrEventDataImageTrackingLostANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateTrackableImageDatabaseAsyncANDROID)(XrSession session, const XrTrackableImageDatabaseCreateInfoANDROID* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateTrackableImageDatabaseCompleteANDROID)(XrSession session, XrFutureEXT future, XrCreateTrackableImageDatabaseCompletionANDROID* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyTrackableImageDatabaseANDROID)(XrTrackableImageDatabaseANDROID database);
+typedef XrResult (XRAPI_PTR *PFN_xrAddTrackableImageDatabaseANDROID)(XrTrackableTrackerANDROID tracker, XrTrackableImageDatabaseANDROID database);
+typedef XrResult (XRAPI_PTR *PFN_xrRemoveTrackableImageDatabaseANDROID)(XrTrackableTrackerANDROID tracker, XrTrackableImageDatabaseANDROID database);
+typedef XrResult (XRAPI_PTR *PFN_xrGetTrackableImageANDROID)(XrTrackableTrackerANDROID tracker, const XrTrackableGetInfoANDROID* getInfo, XrTrackableImageANDROID* trackable);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateTrackableImageDatabaseAsyncANDROID(
+ XrSession session,
+ const XrTrackableImageDatabaseCreateInfoANDROID* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateTrackableImageDatabaseCompleteANDROID(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateTrackableImageDatabaseCompletionANDROID* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyTrackableImageDatabaseANDROID(
+ XrTrackableImageDatabaseANDROID database);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAddTrackableImageDatabaseANDROID(
+ XrTrackableTrackerANDROID tracker,
+ XrTrackableImageDatabaseANDROID database);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRemoveTrackableImageDatabaseANDROID(
+ XrTrackableTrackerANDROID tracker,
+ XrTrackableImageDatabaseANDROID database);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetTrackableImageANDROID(
+ XrTrackableTrackerANDROID tracker,
+ const XrTrackableGetInfoANDROID* getInfo,
+ XrTrackableImageANDROID* trackable);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_scene_meshing is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_scene_meshing 1
+XR_DEFINE_HANDLE(XrSceneMeshingTrackerANDROID)
+XR_DEFINE_HANDLE(XrSceneMeshSnapshotANDROID)
+#define XR_ANDROID_scene_meshing_SPEC_VERSION 3
+#define XR_ANDROID_SCENE_MESHING_EXTENSION_NAME "XR_ANDROID_scene_meshing"
+
+typedef enum XrSceneMeshSemanticLabelSetANDROID {
+ XR_SCENE_MESH_SEMANTIC_LABEL_SET_NONE_ANDROID = 0,
+ XR_SCENE_MESH_SEMANTIC_LABEL_SET_DEFAULT_ANDROID = 1,
+ XR_SCENE_MESH_SEMANTIC_LABEL_SET_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSceneMeshSemanticLabelSetANDROID;
+
+typedef enum XrSceneMeshTrackingStateANDROID {
+ XR_SCENE_MESH_TRACKING_STATE_INITIALIZING_ANDROID = 0,
+ XR_SCENE_MESH_TRACKING_STATE_TRACKING_ANDROID = 1,
+ XR_SCENE_MESH_TRACKING_STATE_WAITING_ANDROID = 2,
+ XR_SCENE_MESH_TRACKING_STATE_ERROR_ANDROID = 3,
+ XR_SCENE_MESH_TRACKING_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSceneMeshTrackingStateANDROID;
+
+typedef enum XrSceneMeshSemanticLabelANDROID {
+ XR_SCENE_MESH_SEMANTIC_LABEL_OTHER_ANDROID = 0,
+ XR_SCENE_MESH_SEMANTIC_LABEL_FLOOR_ANDROID = 1,
+ XR_SCENE_MESH_SEMANTIC_LABEL_CEILING_ANDROID = 2,
+ XR_SCENE_MESH_SEMANTIC_LABEL_WALL_ANDROID = 3,
+ XR_SCENE_MESH_SEMANTIC_LABEL_TABLE_ANDROID = 4,
+ XR_SCENE_MESH_SEMANTIC_LABEL_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSceneMeshSemanticLabelANDROID;
+// XrSystemSceneMeshingPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemSceneMeshingPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsSceneMeshing;
+} XrSystemSceneMeshingPropertiesANDROID;
+
+typedef struct XrSceneMeshingTrackerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSceneMeshSemanticLabelSetANDROID semanticLabelSet;
+ XrBool32 enableNormals;
+} XrSceneMeshingTrackerCreateInfoANDROID;
+
+typedef struct XrSceneMeshSnapshotCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrBoxf boundingBox;
+} XrSceneMeshSnapshotCreateInfoANDROID;
+
+typedef struct XrSceneMeshSnapshotCreationResultANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSceneMeshSnapshotANDROID snapshot;
+ XrSceneMeshTrackingStateANDROID trackingState;
+} XrSceneMeshSnapshotCreationResultANDROID;
+
+typedef struct XrSceneSubmeshStateANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuid submeshId;
+ XrTime lastUpdatedTime;
+ XrPosef submeshPoseInBaseSpace;
+ XrExtent3Df bounds;
+} XrSceneSubmeshStateANDROID;
+
+typedef struct XrSceneSubmeshDataANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuid submeshId;
+ uint32_t vertexCapacityInput;
+ uint32_t vertexCountOutput;
+ XrVector3f* vertexPositions;
+ XrVector3f* vertexNormals;
+ uint8_t* vertexSemantics;
+ uint32_t indexCapacityInput;
+ uint32_t indexCountOutput;
+ uint32_t* indices;
+} XrSceneSubmeshDataANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedSemanticLabelSetsANDROID)(XrInstance instance, XrSystemId systemId, uint32_t supportedSemanticLabelSetsInputCapacity, uint32_t* supportedSemanticLabelSetsOutputCount, XrSceneMeshSemanticLabelSetANDROID* supportedSemanticLabelSets);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSceneMeshingTrackerANDROID)(XrSession session, const XrSceneMeshingTrackerCreateInfoANDROID* createInfo, XrSceneMeshingTrackerANDROID* tracker);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySceneMeshingTrackerANDROID)(XrSceneMeshingTrackerANDROID tracker);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSceneMeshSnapshotANDROID)(XrSceneMeshingTrackerANDROID tracker, const XrSceneMeshSnapshotCreateInfoANDROID* createInfo, XrSceneMeshSnapshotCreationResultANDROID* outSnapshotCreationResult);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySceneMeshSnapshotANDROID)(XrSceneMeshSnapshotANDROID snapshot);
+typedef XrResult (XRAPI_PTR *PFN_xrGetAllSubmeshStatesANDROID)(XrSceneMeshSnapshotANDROID snapshot, uint32_t submeshStateCapacityInput, uint32_t* submeshStateCountOutput, XrSceneSubmeshStateANDROID* submeshStates);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSubmeshDataANDROID)(XrSceneMeshSnapshotANDROID snapshot, uint32_t submeshDataCount, XrSceneSubmeshDataANDROID* inoutSubmeshData);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedSemanticLabelSetsANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t supportedSemanticLabelSetsInputCapacity,
+ uint32_t* supportedSemanticLabelSetsOutputCount,
+ XrSceneMeshSemanticLabelSetANDROID* supportedSemanticLabelSets);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSceneMeshingTrackerANDROID(
+ XrSession session,
+ const XrSceneMeshingTrackerCreateInfoANDROID* createInfo,
+ XrSceneMeshingTrackerANDROID* tracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySceneMeshingTrackerANDROID(
+ XrSceneMeshingTrackerANDROID tracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSceneMeshSnapshotANDROID(
+ XrSceneMeshingTrackerANDROID tracker,
+ const XrSceneMeshSnapshotCreateInfoANDROID* createInfo,
+ XrSceneMeshSnapshotCreationResultANDROID* outSnapshotCreationResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySceneMeshSnapshotANDROID(
+ XrSceneMeshSnapshotANDROID snapshot);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAllSubmeshStatesANDROID(
+ XrSceneMeshSnapshotANDROID snapshot,
+ uint32_t submeshStateCapacityInput,
+ uint32_t* submeshStateCountOutput,
+ XrSceneSubmeshStateANDROID* submeshStates);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSubmeshDataANDROID(
+ XrSceneMeshSnapshotANDROID snapshot,
+ uint32_t submeshDataCount,
+ XrSceneSubmeshDataANDROID* inoutSubmeshData);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_entity is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_entity 1
+
+#define XR_NULL_SPATIAL_ENTITY_ID_EXT 0
+
+
+#define XR_NULL_SPATIAL_BUFFER_ID_EXT 0
+
+XR_DEFINE_ATOM(XrSpatialEntityIdEXT)
+XR_DEFINE_ATOM(XrSpatialBufferIdEXT)
+XR_DEFINE_HANDLE(XrSpatialEntityEXT)
+XR_DEFINE_HANDLE(XrSpatialContextEXT)
+XR_DEFINE_HANDLE(XrSpatialSnapshotEXT)
+#define XR_EXT_spatial_entity_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_ENTITY_EXTENSION_NAME "XR_EXT_spatial_entity"
+
+typedef enum XrSpatialCapabilityEXT {
+ XR_SPATIAL_CAPABILITY_PLANE_TRACKING_EXT = 1000741000,
+ XR_SPATIAL_CAPABILITY_MARKER_TRACKING_QR_CODE_EXT = 1000743000,
+ XR_SPATIAL_CAPABILITY_MARKER_TRACKING_MICRO_QR_CODE_EXT = 1000743001,
+ XR_SPATIAL_CAPABILITY_MARKER_TRACKING_ARUCO_MARKER_EXT = 1000743002,
+ XR_SPATIAL_CAPABILITY_MARKER_TRACKING_APRIL_TAG_EXT = 1000743003,
+ XR_SPATIAL_CAPABILITY_ANCHOR_EXT = 1000762000,
+ XR_SPATIAL_CAPABILITY_IMAGE_TRACKING_EXT = 1000782000,
+ XR_SPATIAL_CAPABILITY_OBJECT_TRACKING_ANDROID = 1000785000,
+ XR_SPATIAL_CAPABILITY_DEPTH_RAYCAST_ANDROID = 1000786000,
+ XR_SPATIAL_CAPABILITY_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialCapabilityEXT;
+
+typedef enum XrSpatialCapabilityFeatureEXT {
+ XR_SPATIAL_CAPABILITY_FEATURE_MARKER_TRACKING_FIXED_SIZE_MARKERS_EXT = 1000743000,
+ XR_SPATIAL_CAPABILITY_FEATURE_MARKER_TRACKING_STATIC_MARKERS_EXT = 1000743001,
+ XR_SPATIAL_CAPABILITY_FEATURE_SPHERE_BOUNDS_FILTER_ANDROID = 1000761000,
+ XR_SPATIAL_CAPABILITY_FEATURE_BOX_BOUNDS_FILTER_ANDROID = 1000761001,
+ XR_SPATIAL_CAPABILITY_FEATURE_FRUSTUM_BOUNDS_FILTER_ANDROID = 1000761002,
+ XR_SPATIAL_CAPABILITY_FEATURE_IMAGE_TRACKING_AUTOMATIC_SIZE_IMAGES_EXT = 1000782000,
+ XR_SPATIAL_CAPABILITY_FEATURE_IMAGE_TRACKING_STATIC_IMAGES_EXT = 1000782001,
+ XR_SPATIAL_CAPABILITY_FEATURE_IMAGE_TRACKING_FIXED_SIZE_IMAGES_EXT = 1000782002,
+ XR_SPATIAL_CAPABILITY_FEATURE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialCapabilityFeatureEXT;
+
+typedef enum XrSpatialComponentTypeEXT {
+ XR_SPATIAL_COMPONENT_TYPE_BOUNDED_2D_EXT = 1,
+ XR_SPATIAL_COMPONENT_TYPE_BOUNDED_3D_EXT = 2,
+ XR_SPATIAL_COMPONENT_TYPE_PARENT_EXT = 3,
+ XR_SPATIAL_COMPONENT_TYPE_MESH_3D_EXT = 4,
+ XR_SPATIAL_COMPONENT_TYPE_PLANE_ALIGNMENT_EXT = 1000741000,
+ XR_SPATIAL_COMPONENT_TYPE_MESH_2D_EXT = 1000741001,
+ XR_SPATIAL_COMPONENT_TYPE_POLYGON_2D_EXT = 1000741002,
+ XR_SPATIAL_COMPONENT_TYPE_PLANE_SEMANTIC_LABEL_EXT = 1000741003,
+ XR_SPATIAL_COMPONENT_TYPE_MARKER_EXT = 1000743000,
+ XR_SPATIAL_COMPONENT_TYPE_ANCHOR_EXT = 1000762000,
+ XR_SPATIAL_COMPONENT_TYPE_PERSISTENCE_EXT = 1000763000,
+ XR_SPATIAL_COMPONENT_TYPE_IMAGE_2D_EXT = 1000782000,
+ XR_SPATIAL_COMPONENT_TYPE_OBJECT_SEMANTIC_LABEL_ANDROID = 1000785000,
+ XR_SPATIAL_COMPONENT_TYPE_RAYCAST_RESULT_ANDROID = 1000786000,
+ XR_SPATIAL_COMPONENT_TYPE_SUBSUMED_BY_ANDROID = 1000791000,
+ XR_SPATIAL_COMPONENT_TYPE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialComponentTypeEXT;
+
+typedef enum XrSpatialEntityTrackingStateEXT {
+ XR_SPATIAL_ENTITY_TRACKING_STATE_STOPPED_EXT = 1,
+ XR_SPATIAL_ENTITY_TRACKING_STATE_PAUSED_EXT = 2,
+ XR_SPATIAL_ENTITY_TRACKING_STATE_TRACKING_EXT = 3,
+ XR_SPATIAL_ENTITY_TRACKING_STATE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialEntityTrackingStateEXT;
+
+typedef enum XrSpatialBufferTypeEXT {
+ XR_SPATIAL_BUFFER_TYPE_UNKNOWN_EXT = 0,
+ XR_SPATIAL_BUFFER_TYPE_STRING_EXT = 1,
+ XR_SPATIAL_BUFFER_TYPE_UINT8_EXT = 2,
+ XR_SPATIAL_BUFFER_TYPE_UINT16_EXT = 3,
+ XR_SPATIAL_BUFFER_TYPE_UINT32_EXT = 4,
+ XR_SPATIAL_BUFFER_TYPE_FLOAT_EXT = 5,
+ XR_SPATIAL_BUFFER_TYPE_VECTOR2F_EXT = 6,
+ XR_SPATIAL_BUFFER_TYPE_VECTOR3F_EXT = 7,
+ XR_SPATIAL_BUFFER_TYPE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialBufferTypeEXT;
+typedef struct XrSpatialCapabilityComponentTypesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t componentTypeCapacityInput;
+ uint32_t componentTypeCountOutput;
+ XrSpatialComponentTypeEXT* componentTypes;
+} XrSpatialCapabilityComponentTypesEXT;
+
+typedef struct XR_MAY_ALIAS XrSpatialCapabilityConfigurationBaseHeaderEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationBaseHeaderEXT;
+
+typedef struct XrSpatialContextCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t capabilityConfigCount;
+ const XrSpatialCapabilityConfigurationBaseHeaderEXT* const* capabilityConfigs;
+} XrSpatialContextCreateInfoEXT;
+
+typedef struct XrCreateSpatialContextCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialContextEXT spatialContext;
+} XrCreateSpatialContextCompletionEXT;
+
+typedef struct XrSpatialDiscoverySnapshotCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t componentTypeCount;
+ const XrSpatialComponentTypeEXT* componentTypes;
+} XrSpatialDiscoverySnapshotCreateInfoEXT;
+
+typedef struct XrCreateSpatialDiscoverySnapshotCompletionInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrFutureEXT future;
+} XrCreateSpatialDiscoverySnapshotCompletionInfoEXT;
+
+typedef struct XrCreateSpatialDiscoverySnapshotCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialSnapshotEXT snapshot;
+} XrCreateSpatialDiscoverySnapshotCompletionEXT;
+
+typedef struct XrSpatialComponentDataQueryConditionEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t componentTypeCount;
+ const XrSpatialComponentTypeEXT* componentTypes;
+} XrSpatialComponentDataQueryConditionEXT;
+
+typedef struct XrSpatialComponentDataQueryResultEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t entityIdCapacityInput;
+ uint32_t entityIdCountOutput;
+ XrSpatialEntityIdEXT* entityIds;
+ uint32_t entityStateCapacityInput;
+ uint32_t entityStateCountOutput;
+ XrSpatialEntityTrackingStateEXT* entityStates;
+} XrSpatialComponentDataQueryResultEXT;
+
+typedef struct XrSpatialBufferEXT {
+ XrSpatialBufferIdEXT bufferId;
+ XrSpatialBufferTypeEXT bufferType;
+} XrSpatialBufferEXT;
+
+typedef struct XrSpatialBufferGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialBufferIdEXT bufferId;
+} XrSpatialBufferGetInfoEXT;
+
+typedef struct XrSpatialBounded2DDataEXT {
+ XrPosef center;
+ XrExtent2Df extents;
+} XrSpatialBounded2DDataEXT;
+
+// XrSpatialComponentBounded2DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentBounded2DListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t boundCount;
+ XrSpatialBounded2DDataEXT* bounds;
+} XrSpatialComponentBounded2DListEXT;
+
+// XrSpatialComponentBounded3DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentBounded3DListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t boundCount;
+ XrBoxf* bounds;
+} XrSpatialComponentBounded3DListEXT;
+
+// XrSpatialComponentParentListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentParentListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t parentCount;
+ XrSpatialEntityIdEXT* parents;
+} XrSpatialComponentParentListEXT;
+
+typedef struct XrSpatialMeshDataEXT {
+ XrPosef origin;
+ XrSpatialBufferEXT vertexBuffer;
+ XrSpatialBufferEXT indexBuffer;
+} XrSpatialMeshDataEXT;
+
+// XrSpatialComponentMesh3DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentMesh3DListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t meshCount;
+ XrSpatialMeshDataEXT* meshes;
+} XrSpatialComponentMesh3DListEXT;
+
+typedef struct XrSpatialEntityFromIdCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialEntityIdEXT entityId;
+} XrSpatialEntityFromIdCreateInfoEXT;
+
+typedef struct XrSpatialUpdateSnapshotCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t entityCount;
+ const XrSpatialEntityEXT* entities;
+ uint32_t componentTypeCount;
+ const XrSpatialComponentTypeEXT* componentTypes;
+ XrSpace baseSpace;
+ XrTime time;
+} XrSpatialUpdateSnapshotCreateInfoEXT;
+
+typedef struct XrEventDataSpatialDiscoveryRecommendedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContextEXT spatialContext;
+} XrEventDataSpatialDiscoveryRecommendedEXT;
+
+// XrSpatialFilterTrackingStateEXT extends XrSpatialDiscoverySnapshotCreateInfoEXT,XrSpatialComponentDataQueryConditionEXT
+typedef struct XrSpatialFilterTrackingStateEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialEntityTrackingStateEXT trackingState;
+} XrSpatialFilterTrackingStateEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialCapabilitiesEXT)(XrInstance instance, XrSystemId systemId, uint32_t capabilityCapacityInput, uint32_t* capabilityCountOutput, XrSpatialCapabilityEXT* capabilities);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialCapabilityComponentTypesEXT)(XrInstance instance, XrSystemId systemId, XrSpatialCapabilityEXT capability, XrSpatialCapabilityComponentTypesEXT* capabilityComponents);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialCapabilityFeaturesEXT)(XrInstance instance, XrSystemId systemId, XrSpatialCapabilityEXT capability, uint32_t capabilityFeatureCapacityInput, uint32_t* capabilityFeatureCountOutput, XrSpatialCapabilityFeatureEXT* capabilityFeatures);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialContextAsyncEXT)(XrSession session, const XrSpatialContextCreateInfoEXT* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialContextCompleteEXT)(XrSession session, XrFutureEXT future, XrCreateSpatialContextCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialContextEXT)(XrSpatialContextEXT spatialContext);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialDiscoverySnapshotAsyncEXT)(XrSpatialContextEXT spatialContext, const XrSpatialDiscoverySnapshotCreateInfoEXT* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialDiscoverySnapshotCompleteEXT)(XrSpatialContextEXT spatialContext, const XrCreateSpatialDiscoverySnapshotCompletionInfoEXT* createSnapshotCompletionInfo, XrCreateSpatialDiscoverySnapshotCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrQuerySpatialComponentDataEXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialComponentDataQueryConditionEXT* queryCondition, XrSpatialComponentDataQueryResultEXT* queryResult);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialSnapshotEXT)(XrSpatialSnapshotEXT snapshot);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialEntityFromIdEXT)(XrSpatialContextEXT spatialContext, const XrSpatialEntityFromIdCreateInfoEXT* createInfo, XrSpatialEntityEXT* spatialEntity);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialEntityEXT)(XrSpatialEntityEXT spatialEntity);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialUpdateSnapshotEXT)(XrSpatialContextEXT spatialContext, const XrSpatialUpdateSnapshotCreateInfoEXT* createInfo, XrSpatialSnapshotEXT* snapshot);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferStringEXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferUint8EXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, uint8_t* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferUint16EXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, uint16_t* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferUint32EXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, uint32_t* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferFloatEXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, float* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferVector2fEXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, XrVector2f* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialBufferVector3fEXT)(XrSpatialSnapshotEXT snapshot, const XrSpatialBufferGetInfoEXT* info, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, XrVector3f* buffer);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialCapabilitiesEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t capabilityCapacityInput,
+ uint32_t* capabilityCountOutput,
+ XrSpatialCapabilityEXT* capabilities);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialCapabilityComponentTypesEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrSpatialCapabilityEXT capability,
+ XrSpatialCapabilityComponentTypesEXT* capabilityComponents);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialCapabilityFeaturesEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrSpatialCapabilityEXT capability,
+ uint32_t capabilityFeatureCapacityInput,
+ uint32_t* capabilityFeatureCountOutput,
+ XrSpatialCapabilityFeatureEXT* capabilityFeatures);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialContextAsyncEXT(
+ XrSession session,
+ const XrSpatialContextCreateInfoEXT* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialContextCompleteEXT(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateSpatialContextCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialContextEXT(
+ XrSpatialContextEXT spatialContext);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialDiscoverySnapshotAsyncEXT(
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialDiscoverySnapshotCreateInfoEXT* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialDiscoverySnapshotCompleteEXT(
+ XrSpatialContextEXT spatialContext,
+ const XrCreateSpatialDiscoverySnapshotCompletionInfoEXT* createSnapshotCompletionInfo,
+ XrCreateSpatialDiscoverySnapshotCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrQuerySpatialComponentDataEXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialComponentDataQueryConditionEXT* queryCondition,
+ XrSpatialComponentDataQueryResultEXT* queryResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialSnapshotEXT(
+ XrSpatialSnapshotEXT snapshot);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialEntityFromIdEXT(
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialEntityFromIdCreateInfoEXT* createInfo,
+ XrSpatialEntityEXT* spatialEntity);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialEntityEXT(
+ XrSpatialEntityEXT spatialEntity);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialUpdateSnapshotEXT(
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialUpdateSnapshotCreateInfoEXT* createInfo,
+ XrSpatialSnapshotEXT* snapshot);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferStringEXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferUint8EXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ uint8_t* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferUint16EXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ uint16_t* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferUint32EXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ uint32_t* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferFloatEXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ float* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferVector2fEXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ XrVector2f* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialBufferVector3fEXT(
+ XrSpatialSnapshotEXT snapshot,
+ const XrSpatialBufferGetInfoEXT* info,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ XrVector3f* buffer);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_plane_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_plane_tracking 1
+#define XR_EXT_spatial_plane_tracking_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_PLANE_TRACKING_EXTENSION_NAME "XR_EXT_spatial_plane_tracking"
+
+typedef enum XrSpatialPlaneAlignmentEXT {
+ XR_SPATIAL_PLANE_ALIGNMENT_HORIZONTAL_UPWARD_EXT = 0,
+ XR_SPATIAL_PLANE_ALIGNMENT_HORIZONTAL_DOWNWARD_EXT = 1,
+ XR_SPATIAL_PLANE_ALIGNMENT_VERTICAL_EXT = 2,
+ XR_SPATIAL_PLANE_ALIGNMENT_ARBITRARY_EXT = 3,
+ XR_SPATIAL_PLANE_ALIGNMENT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialPlaneAlignmentEXT;
+
+typedef enum XrSpatialPlaneSemanticLabelEXT {
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_UNCATEGORIZED_EXT = 1,
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_FLOOR_EXT = 2,
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_WALL_EXT = 3,
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_CEILING_EXT = 4,
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_TABLE_EXT = 5,
+ XR_SPATIAL_PLANE_SEMANTIC_LABEL_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialPlaneSemanticLabelEXT;
+typedef struct XrSpatialCapabilityConfigurationPlaneTrackingEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationPlaneTrackingEXT;
+
+// XrSpatialComponentPlaneAlignmentListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentPlaneAlignmentListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t planeAlignmentCount;
+ XrSpatialPlaneAlignmentEXT* planeAlignments;
+} XrSpatialComponentPlaneAlignmentListEXT;
+
+// XrSpatialComponentMesh2DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentMesh2DListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t meshCount;
+ XrSpatialMeshDataEXT* meshes;
+} XrSpatialComponentMesh2DListEXT;
+
+typedef struct XrSpatialPolygon2DDataEXT {
+ XrPosef origin;
+ XrSpatialBufferEXT vertexBuffer;
+} XrSpatialPolygon2DDataEXT;
+
+// XrSpatialComponentPolygon2DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentPolygon2DListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t polygonCount;
+ XrSpatialPolygon2DDataEXT* polygons;
+} XrSpatialComponentPolygon2DListEXT;
+
+// XrSpatialComponentPlaneSemanticLabelListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentPlaneSemanticLabelListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t semanticLabelCount;
+ XrSpatialPlaneSemanticLabelEXT* semanticLabels;
+} XrSpatialComponentPlaneSemanticLabelListEXT;
+
+
+
+// XR_EXT_stationary_reference_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_stationary_reference_space 1
+#define XR_EXT_stationary_reference_space_SPEC_VERSION 1
+#define XR_EXT_STATIONARY_REFERENCE_SPACE_EXTENSION_NAME "XR_EXT_stationary_reference_space"
+typedef struct XrStationaryReferenceSpaceGenerationIdGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrStationaryReferenceSpaceGenerationIdGetInfoEXT;
+
+typedef struct XrStationaryReferenceSpaceGenerationIdResultEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrUuid generationId;
+} XrStationaryReferenceSpaceGenerationIdResultEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetStationaryReferenceSpaceGenerationIdEXT)(XrSession session, const XrStationaryReferenceSpaceGenerationIdGetInfoEXT* getInfo, XrStationaryReferenceSpaceGenerationIdResultEXT* generationIdResult);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetStationaryReferenceSpaceGenerationIdEXT(
+ XrSession session,
+ const XrStationaryReferenceSpaceGenerationIdGetInfoEXT* getInfo,
+ XrStationaryReferenceSpaceGenerationIdResultEXT* generationIdResult);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_marker_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_marker_tracking 1
+#define XR_EXT_spatial_marker_tracking_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_MARKER_TRACKING_EXTENSION_NAME "XR_EXT_spatial_marker_tracking"
+
+typedef enum XrSpatialMarkerArucoDictEXT {
+ XR_SPATIAL_MARKER_ARUCO_DICT_4X4_50_EXT = 1,
+ XR_SPATIAL_MARKER_ARUCO_DICT_4X4_100_EXT = 2,
+ XR_SPATIAL_MARKER_ARUCO_DICT_4X4_250_EXT = 3,
+ XR_SPATIAL_MARKER_ARUCO_DICT_4X4_1000_EXT = 4,
+ XR_SPATIAL_MARKER_ARUCO_DICT_5X5_50_EXT = 5,
+ XR_SPATIAL_MARKER_ARUCO_DICT_5X5_100_EXT = 6,
+ XR_SPATIAL_MARKER_ARUCO_DICT_5X5_250_EXT = 7,
+ XR_SPATIAL_MARKER_ARUCO_DICT_5X5_1000_EXT = 8,
+ XR_SPATIAL_MARKER_ARUCO_DICT_6X6_50_EXT = 9,
+ XR_SPATIAL_MARKER_ARUCO_DICT_6X6_100_EXT = 10,
+ XR_SPATIAL_MARKER_ARUCO_DICT_6X6_250_EXT = 11,
+ XR_SPATIAL_MARKER_ARUCO_DICT_6X6_1000_EXT = 12,
+ XR_SPATIAL_MARKER_ARUCO_DICT_7X7_50_EXT = 13,
+ XR_SPATIAL_MARKER_ARUCO_DICT_7X7_100_EXT = 14,
+ XR_SPATIAL_MARKER_ARUCO_DICT_7X7_250_EXT = 15,
+ XR_SPATIAL_MARKER_ARUCO_DICT_7X7_1000_EXT = 16,
+ XR_SPATIAL_MARKER_ARUCO_DICT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialMarkerArucoDictEXT;
+
+typedef enum XrSpatialMarkerAprilTagDictEXT {
+ XR_SPATIAL_MARKER_APRIL_TAG_DICT_16H5_EXT = 1,
+ XR_SPATIAL_MARKER_APRIL_TAG_DICT_25H9_EXT = 2,
+ XR_SPATIAL_MARKER_APRIL_TAG_DICT_36H10_EXT = 3,
+ XR_SPATIAL_MARKER_APRIL_TAG_DICT_36H11_EXT = 4,
+ XR_SPATIAL_MARKER_APRIL_TAG_DICT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialMarkerAprilTagDictEXT;
+typedef struct XrSpatialCapabilityConfigurationQrCodeEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationQrCodeEXT;
+
+typedef struct XrSpatialCapabilityConfigurationMicroQrCodeEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationMicroQrCodeEXT;
+
+typedef struct XrSpatialCapabilityConfigurationArucoMarkerEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+ XrSpatialMarkerArucoDictEXT arUcoDict;
+} XrSpatialCapabilityConfigurationArucoMarkerEXT;
+
+typedef struct XrSpatialCapabilityConfigurationAprilTagEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+ XrSpatialMarkerAprilTagDictEXT aprilDict;
+} XrSpatialCapabilityConfigurationAprilTagEXT;
+
+// XrSpatialMarkerSizeEXT extends XrSpatialCapabilityConfigurationArucoMarkerEXT,XrSpatialCapabilityConfigurationAprilTagEXT,XrSpatialCapabilityConfigurationQrCodeEXT,XrSpatialCapabilityConfigurationMicroQrCodeEXT
+typedef struct XrSpatialMarkerSizeEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float markerSideLength;
+} XrSpatialMarkerSizeEXT;
+
+// XrSpatialMarkerStaticOptimizationEXT extends XrSpatialCapabilityConfigurationArucoMarkerEXT,XrSpatialCapabilityConfigurationAprilTagEXT,XrSpatialCapabilityConfigurationQrCodeEXT,XrSpatialCapabilityConfigurationMicroQrCodeEXT
+typedef struct XrSpatialMarkerStaticOptimizationEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 optimizeForStaticMarker;
+} XrSpatialMarkerStaticOptimizationEXT;
+
+typedef struct XrSpatialMarkerDataEXT {
+ XrSpatialCapabilityEXT capability;
+ uint32_t markerId;
+ XrSpatialBufferEXT data;
+} XrSpatialMarkerDataEXT;
+
+// XrSpatialComponentMarkerListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentMarkerListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t markerCount;
+ XrSpatialMarkerDataEXT* markers;
+} XrSpatialComponentMarkerListEXT;
+
+
+
+// XR_LOGITECH_mx_ink_stylus_interaction is a preprocessor guard. Do not pass it to API calls.
+#define XR_LOGITECH_mx_ink_stylus_interaction 1
+#define XR_LOGITECH_mx_ink_stylus_interaction_SPEC_VERSION 1
+#define XR_LOGITECH_MX_INK_STYLUS_INTERACTION_EXTENSION_NAME "XR_LOGITECH_mx_ink_stylus_interaction"
+
+
+// XR_BD_dynamic_object_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_dynamic_object_tracking 1
+#define XR_BD_dynamic_object_tracking_SPEC_VERSION 1
+#define XR_BD_DYNAMIC_OBJECT_TRACKING_EXTENSION_NAME "XR_BD_dynamic_object_tracking"
+
+typedef enum XrDynamicObjectTypeBD {
+ XR_DYNAMIC_OBJECT_TYPE_UNKNOWN_BD = 0,
+ XR_DYNAMIC_OBJECT_TYPE_KEYBOARD_BD = 1000747000,
+ XR_DYNAMIC_OBJECT_TYPE_MOUSE_BD = 1000748000,
+ XR_DYNAMIC_OBJECT_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrDynamicObjectTypeBD;
+// XrSystemDynamicObjectTrackingPropertiesBD extends XrSystemProperties
+typedef struct XrSystemDynamicObjectTrackingPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsDynamicObjectTracking;
+} XrSystemDynamicObjectTrackingPropertiesBD;
+
+// XrSenseDataProviderCreateInfoDynamicObjectBD extends XrSenseDataProviderCreateInfoBD
+typedef struct XrSenseDataProviderCreateInfoDynamicObjectBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t trackingTypeCount;
+ const XrDynamicObjectTypeBD* trackingTypes;
+} XrSenseDataProviderCreateInfoDynamicObjectBD;
+
+typedef struct XrDynamicObjectDataBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrDynamicObjectTypeBD objectType;
+} XrDynamicObjectDataBD;
+
+// XrSpatialEntityComponentDataDynamicObjectBD extends XrSpatialEntityComponentDataBaseHeaderBD
+typedef struct XrSpatialEntityComponentDataDynamicObjectBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrDynamicObjectDataBD data;
+} XrSpatialEntityComponentDataDynamicObjectBD;
+
+// XrSenseDataFilterDynamicObjectTypeBD extends XrSenseDataQueryInfoBD
+typedef struct XrSenseDataFilterDynamicObjectTypeBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t typeCount;
+ const XrDynamicObjectTypeBD* types;
+} XrSenseDataFilterDynamicObjectTypeBD;
+
+
+
+// XR_BD_dynamic_object_keyboard is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_dynamic_object_keyboard 1
+#define XR_BD_dynamic_object_keyboard_SPEC_VERSION 1
+#define XR_BD_DYNAMIC_OBJECT_KEYBOARD_EXTENSION_NAME "XR_BD_dynamic_object_keyboard"
+// XrSystemDynamicObjectKeyboardPropertiesBD extends XrSystemProperties
+typedef struct XrSystemDynamicObjectKeyboardPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsDynamicObjectKeyboard;
+} XrSystemDynamicObjectKeyboardPropertiesBD;
+
+
+
+// XR_BD_dynamic_object_mouse is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_dynamic_object_mouse 1
+#define XR_BD_dynamic_object_mouse_SPEC_VERSION 1
+#define XR_BD_DYNAMIC_OBJECT_MOUSE_EXTENSION_NAME "XR_BD_dynamic_object_mouse"
+// XrSystemDynamicObjectMousePropertiesBD extends XrSystemProperties
+typedef struct XrSystemDynamicObjectMousePropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsDynamicObjectMouse;
+} XrSystemDynamicObjectMousePropertiesBD;
+
+
+
+// XR_BD_camera_image is a preprocessor guard. Do not pass it to API calls.
+#define XR_BD_camera_image 1
+XR_DEFINE_ATOM(XrCameraIdBD)
+XR_DEFINE_ATOM(XrCameraImageIdBD)
+XR_DEFINE_HANDLE(XrCameraDeviceBD)
+XR_DEFINE_HANDLE(XrCameraCaptureSessionBD)
+#define XR_BD_camera_image_SPEC_VERSION 1
+#define XR_BD_CAMERA_IMAGE_EXTENSION_NAME "XR_BD_camera_image"
+
+typedef enum XrCameraPropertyTypeBD {
+ XR_CAMERA_PROPERTY_TYPE_FACING_BD = 1,
+ XR_CAMERA_PROPERTY_TYPE_POSITION_BD = 2,
+ XR_CAMERA_PROPERTY_TYPE_CAMERA_TYPE_BD = 3,
+ XR_CAMERA_PROPERTY_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraPropertyTypeBD;
+
+typedef enum XrCameraFacingBD {
+ XR_CAMERA_FACING_WORLD_BD = 1,
+ XR_CAMERA_FACING_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraFacingBD;
+
+typedef enum XrCameraPositionBD {
+ XR_CAMERA_POSITION_UNSPECIFIED_BD = 1,
+ XR_CAMERA_POSITION_LEFT_BD = 2,
+ XR_CAMERA_POSITION_RIGHT_BD = 3,
+ XR_CAMERA_POSITION_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraPositionBD;
+
+typedef enum XrCameraTypeBD {
+ XR_CAMERA_TYPE_PASSTHROUGH_COLOR_BD = 1,
+ XR_CAMERA_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraTypeBD;
+
+typedef enum XrCameraCapabilityTypeBD {
+ XR_CAMERA_CAPABILITY_TYPE_IMAGE_RESOLUTION_AND_FRAME_RATE_BD = 1,
+ XR_CAMERA_CAPABILITY_TYPE_IMAGE_FORMAT_BD = 2,
+ XR_CAMERA_CAPABILITY_TYPE_DATA_TRANSFER_TYPE_BD = 3,
+ XR_CAMERA_CAPABILITY_TYPE_CAMERA_MODEL_BD = 4,
+ XR_CAMERA_CAPABILITY_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraCapabilityTypeBD;
+
+typedef enum XrCameraDataTransferTypeBD {
+ XR_CAMERA_DATA_TRANSFER_TYPE_RAW_BUFFER_BD = 1,
+ XR_CAMERA_DATA_TRANSFER_TYPE_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraDataTransferTypeBD;
+
+typedef enum XrCameraImageFormatBD {
+ XR_CAMERA_IMAGE_FORMAT_RGBA_8888_BD = 1,
+ XR_CAMERA_IMAGE_FORMAT_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraImageFormatBD;
+
+typedef enum XrCameraModelBD {
+ XR_CAMERA_MODEL_PINHOLE_BD = 1,
+ XR_CAMERA_MODEL_MAX_ENUM_BD = 0x7FFFFFFF
+} XrCameraModelBD;
+typedef struct XR_MAY_ALIAS XrCameraPropertyBaseHeaderBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrCameraPropertyBaseHeaderBD;
+
+typedef struct XrCameraPropertiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t propertyCount;
+ XrCameraPropertyBaseHeaderBD** properties;
+} XrCameraPropertiesBD;
+
+typedef struct XR_MAY_ALIAS XrCameraCapabilityBaseHeaderBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrCameraCapabilityBaseHeaderBD;
+
+typedef struct XrCameraCapabilitiesBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t capabilityCount;
+ XrCameraCapabilityBaseHeaderBD** capabilities;
+} XrCameraCapabilitiesBD;
+
+typedef struct XrAvailableCamerasEnumerateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const XrCameraPropertiesBD* properties;
+ const XrCameraCapabilitiesBD* capabilities;
+} XrAvailableCamerasEnumerateInfoBD;
+
+typedef struct XrAvailableCameraBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrCameraIdBD cameraId;
+} XrAvailableCameraBD;
+
+typedef struct XrCameraPropertyTypesEnumerateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraIdBD cameraId;
+} XrCameraPropertyTypesEnumerateInfoBD;
+
+typedef struct XrCameraPropertyTypesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t propertyTypeCapacityInput;
+ uint32_t propertyTypeCountOutput;
+ XrCameraPropertyTypeBD* propertyTypes;
+} XrCameraPropertyTypesBD;
+
+typedef struct XrCameraPropertiesGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraIdBD cameraId;
+} XrCameraPropertiesGetInfoBD;
+
+typedef struct XrCameraPropertyFacingBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraFacingBD facing;
+} XrCameraPropertyFacingBD;
+
+typedef struct XrCameraPropertyPositionBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraPositionBD position;
+} XrCameraPropertyPositionBD;
+
+typedef struct XrCameraPropertyCameraTypeBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraTypeBD cameraType;
+} XrCameraPropertyCameraTypeBD;
+
+typedef struct XrCameraCapabilityTypesEnumerateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraIdBD cameraId;
+} XrCameraCapabilityTypesEnumerateInfoBD;
+
+typedef struct XrCameraCapabilityTypesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t capabilityTypeCapacityInput;
+ uint32_t capabilityTypeCountOutput;
+ XrCameraCapabilityTypeBD* capabilityTypes;
+} XrCameraCapabilityTypesBD;
+
+typedef struct XrCameraSupportedCapabilitiesGetInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraIdBD id;
+} XrCameraSupportedCapabilitiesGetInfoBD;
+
+typedef struct XR_MAY_ALIAS XrCameraSupportedCapabilityBaseHeaderBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrCameraSupportedCapabilityBaseHeaderBD;
+
+typedef struct XrCameraSupportedCapabilitiesBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t capabilityCount;
+ XrCameraSupportedCapabilityBaseHeaderBD** capabilities;
+} XrCameraSupportedCapabilitiesBD;
+
+typedef struct XrCameraImageResolutionAndFrameRateBD {
+ XrExtent2Di resolution;
+ uint32_t frameRate;
+} XrCameraImageResolutionAndFrameRateBD;
+
+typedef struct XrCameraSupportedCapabilityImageResolutionAndFrameRateBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t resolutionAndFrameRateCapacityInput;
+ uint32_t resolutionAndFrameRateCountOutput;
+ XrCameraImageResolutionAndFrameRateBD* resolutionAndFrameRates;
+} XrCameraSupportedCapabilityImageResolutionAndFrameRateBD;
+
+typedef struct XrCameraCapabilityImageResolutionAndFrameRateBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrExtent2Di resolution;
+ uint32_t frameRate;
+} XrCameraCapabilityImageResolutionAndFrameRateBD;
+
+typedef struct XrCameraSupportedCapabilityDataTransferTypeBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t transferTypeCapacityInput;
+ uint32_t transferTypeCountOutput;
+ XrCameraDataTransferTypeBD* transferTypes;
+} XrCameraSupportedCapabilityDataTransferTypeBD;
+
+typedef struct XrCameraCapabilityDataTransferTypeBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraDataTransferTypeBD transferType;
+} XrCameraCapabilityDataTransferTypeBD;
+
+typedef struct XrCameraSupportedCapabilityImageFormatBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t formatCapacityInput;
+ uint32_t formatCountOutput;
+ XrCameraImageFormatBD* formats;
+} XrCameraSupportedCapabilityImageFormatBD;
+
+typedef struct XrCameraCapabilityImageFormatBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraImageFormatBD format;
+} XrCameraCapabilityImageFormatBD;
+
+typedef struct XrCameraSupportedCapabilityCameraModelBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t modelCapacityInput;
+ uint32_t modelCountOutput;
+ XrCameraModelBD* models;
+} XrCameraSupportedCapabilityCameraModelBD;
+
+typedef struct XrCameraCapabilityCameraModelBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraModelBD model;
+} XrCameraCapabilityCameraModelBD;
+
+typedef struct XrCameraDeviceCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraIdBD cameraId;
+} XrCameraDeviceCreateInfoBD;
+
+typedef struct XrCreateCameraDeviceCompletionBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrCameraDeviceBD device;
+} XrCreateCameraDeviceCompletionBD;
+
+typedef struct XrCameraCaptureSessionCreateInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrCameraDeviceBD camera;
+ uint32_t configCount;
+ const XrCameraCapabilityBaseHeaderBD* const* configs;
+} XrCameraCaptureSessionCreateInfoBD;
+
+typedef struct XrCreateCameraCaptureSessionCompletionBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrCameraCaptureSessionBD captureSession;
+} XrCreateCameraCaptureSessionCompletionBD;
+
+// XrCameraIntrinsicsBD extends XrCameraImageDataBaseHeaderBD
+typedef struct XrCameraIntrinsicsBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVector2f focalLength;
+ XrVector2f principalPoint;
+ XrVector2f fov;
+} XrCameraIntrinsicsBD;
+
+// XrCameraExtrinsicsBD extends XrCameraImageDataBaseHeaderBD
+typedef struct XrCameraExtrinsicsBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrPosef pose;
+} XrCameraExtrinsicsBD;
+
+typedef struct XrCameraCaptureBeginInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrCameraCaptureBeginInfoBD;
+
+typedef struct XrCameraImageAcquireInfoBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime lastCaptureTime;
+} XrCameraImageAcquireInfoBD;
+
+typedef struct XrCameraImageBD {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 available;
+ XrTime captureTime;
+ XrCameraImageIdBD imageId;
+} XrCameraImageBD;
+
+typedef struct XR_MAY_ALIAS XrCameraImageDataBaseHeaderBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+} XrCameraImageDataBaseHeaderBD;
+
+typedef struct XrCameraImageDataRawBufferBD {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t width;
+ uint32_t height;
+ uint32_t stride;
+ uint32_t bytesPerPixel;
+ uint32_t pixelStride;
+ uint32_t bufferSize;
+ uint8_t* buffer;
+} XrCameraImageDataRawBufferBD;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateAvailableCamerasBD)(XrInstance instance, const XrAvailableCamerasEnumerateInfoBD* enumerateInfo, uint32_t cameraCapacityInput, uint32_t* cameraCountOutput, XrAvailableCameraBD* cameras);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateCameraPropertyTypesBD)(XrInstance instance, const XrCameraPropertyTypesEnumerateInfoBD* enumerateInfo, XrCameraPropertyTypesBD* propertyTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrGetCameraPropertiesBD)(XrInstance instance, const XrCameraPropertiesGetInfoBD* getInfo, XrCameraPropertiesBD* properties);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateCameraCapabilityTypesBD)(XrInstance instance, const XrCameraCapabilityTypesEnumerateInfoBD* enumerateInfo, XrCameraCapabilityTypesBD* capabilityTypes);
+typedef XrResult (XRAPI_PTR *PFN_xrGetCameraSupportedCapabilitiesBD)(XrInstance instance, const XrCameraSupportedCapabilitiesGetInfoBD* getInfo, XrCameraSupportedCapabilitiesBD* capabilities);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateCameraDeviceAsyncBD)(XrInstance instance, const XrCameraDeviceCreateInfoBD* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateCameraDeviceCompleteBD)(XrInstance instance, XrFutureEXT future, XrCreateCameraDeviceCompletionBD* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyCameraDeviceBD)(XrCameraDeviceBD device);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateCameraCaptureSessionAsyncBD)(XrSession session, const XrCameraCaptureSessionCreateInfoBD* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateCameraCaptureSessionCompleteBD)(XrSession session, XrFutureEXT future, XrCreateCameraCaptureSessionCompletionBD* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyCameraCaptureSessionBD)(XrCameraCaptureSessionBD captureSession);
+typedef XrResult (XRAPI_PTR *PFN_xrBeginCameraCaptureBD)(XrCameraCaptureSessionBD captureSession, const XrCameraCaptureBeginInfoBD* beginInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEndCameraCaptureBD)(XrCameraCaptureSessionBD captureSession);
+typedef XrResult (XRAPI_PTR *PFN_xrAcquireCameraImageBD)(XrCameraCaptureSessionBD captureSession, const XrCameraImageAcquireInfoBD* acquireInfo, XrCameraImageBD* image);
+typedef XrResult (XRAPI_PTR *PFN_xrGetCameraImageDataBD)(XrCameraCaptureSessionBD captureSession, XrCameraImageIdBD imageId, XrCameraImageDataBaseHeaderBD* imageData);
+typedef XrResult (XRAPI_PTR *PFN_xrReleaseCameraImageBD)(XrCameraCaptureSessionBD captureSession, XrCameraImageIdBD imageId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateAvailableCamerasBD(
+ XrInstance instance,
+ const XrAvailableCamerasEnumerateInfoBD* enumerateInfo,
+ uint32_t cameraCapacityInput,
+ uint32_t* cameraCountOutput,
+ XrAvailableCameraBD* cameras);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateCameraPropertyTypesBD(
+ XrInstance instance,
+ const XrCameraPropertyTypesEnumerateInfoBD* enumerateInfo,
+ XrCameraPropertyTypesBD* propertyTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetCameraPropertiesBD(
+ XrInstance instance,
+ const XrCameraPropertiesGetInfoBD* getInfo,
+ XrCameraPropertiesBD* properties);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateCameraCapabilityTypesBD(
+ XrInstance instance,
+ const XrCameraCapabilityTypesEnumerateInfoBD* enumerateInfo,
+ XrCameraCapabilityTypesBD* capabilityTypes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetCameraSupportedCapabilitiesBD(
+ XrInstance instance,
+ const XrCameraSupportedCapabilitiesGetInfoBD* getInfo,
+ XrCameraSupportedCapabilitiesBD* capabilities);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateCameraDeviceAsyncBD(
+ XrInstance instance,
+ const XrCameraDeviceCreateInfoBD* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateCameraDeviceCompleteBD(
+ XrInstance instance,
+ XrFutureEXT future,
+ XrCreateCameraDeviceCompletionBD* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyCameraDeviceBD(
+ XrCameraDeviceBD device);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateCameraCaptureSessionAsyncBD(
+ XrSession session,
+ const XrCameraCaptureSessionCreateInfoBD* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateCameraCaptureSessionCompleteBD(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateCameraCaptureSessionCompletionBD* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyCameraCaptureSessionBD(
+ XrCameraCaptureSessionBD captureSession);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrBeginCameraCaptureBD(
+ XrCameraCaptureSessionBD captureSession,
+ const XrCameraCaptureBeginInfoBD* beginInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEndCameraCaptureBD(
+ XrCameraCaptureSessionBD captureSession);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrAcquireCameraImageBD(
+ XrCameraCaptureSessionBD captureSession,
+ const XrCameraImageAcquireInfoBD* acquireInfo,
+ XrCameraImageBD* image);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetCameraImageDataBD(
+ XrCameraCaptureSessionBD captureSession,
+ XrCameraImageIdBD imageId,
+ XrCameraImageDataBaseHeaderBD* imageData);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrReleaseCameraImageBD(
+ XrCameraCaptureSessionBD captureSession,
+ XrCameraImageIdBD imageId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_spatial_discovery_bounds is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_discovery_bounds 1
+#define XR_ANDROID_spatial_discovery_bounds_SPEC_VERSION 1
+#define XR_ANDROID_SPATIAL_DISCOVERY_BOUNDS_EXTENSION_NAME "XR_ANDROID_spatial_discovery_bounds"
+// XrSpatialBoundsSpherefANDROID extends XrSpatialDiscoverySnapshotCreateInfoEXT
+typedef struct XrSpatialBoundsSpherefANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrSpheref sphere;
+} XrSpatialBoundsSpherefANDROID;
+
+// XrSpatialBoundsBoxfANDROID extends XrSpatialDiscoverySnapshotCreateInfoEXT
+typedef struct XrSpatialBoundsBoxfANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrBoxf box;
+} XrSpatialBoundsBoxfANDROID;
+
+// XrSpatialBoundsFrustumfANDROID extends XrSpatialDiscoverySnapshotCreateInfoEXT
+typedef struct XrSpatialBoundsFrustumfANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrFrustumf frustum;
+} XrSpatialBoundsFrustumfANDROID;
+
+
+
+// XR_EXT_spatial_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_anchor 1
+#define XR_EXT_spatial_anchor_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_ANCHOR_EXTENSION_NAME "XR_EXT_spatial_anchor"
+typedef struct XrSpatialCapabilityConfigurationAnchorEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationAnchorEXT;
+
+// XrSpatialComponentAnchorListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentAnchorListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t locationCount;
+ XrPosef* locations;
+} XrSpatialComponentAnchorListEXT;
+
+typedef struct XrSpatialAnchorCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace baseSpace;
+ XrTime time;
+ XrPosef pose;
+} XrSpatialAnchorCreateInfoEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorEXT)(XrSpatialContextEXT spatialContext, const XrSpatialAnchorCreateInfoEXT* createInfo, XrSpatialEntityIdEXT* anchorEntityId, XrSpatialEntityEXT* anchorEntity);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorEXT(
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialAnchorCreateInfoEXT* createInfo,
+ XrSpatialEntityIdEXT* anchorEntityId,
+ XrSpatialEntityEXT* anchorEntity);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_persistence is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_persistence 1
+XR_DEFINE_HANDLE(XrSpatialPersistenceContextEXT)
+#define XR_EXT_spatial_persistence_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_PERSISTENCE_EXTENSION_NAME "XR_EXT_spatial_persistence"
+
+typedef enum XrSpatialPersistenceScopeEXT {
+ XR_SPATIAL_PERSISTENCE_SCOPE_SYSTEM_MANAGED_EXT = 1,
+ XR_SPATIAL_PERSISTENCE_SCOPE_LOCAL_ANCHORS_EXT = 1000781000,
+ XR_SPATIAL_PERSISTENCE_SCOPE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialPersistenceScopeEXT;
+
+typedef enum XrSpatialPersistenceContextResultEXT {
+ XR_SPATIAL_PERSISTENCE_CONTEXT_RESULT_SUCCESS_EXT = 0,
+ XR_SPATIAL_PERSISTENCE_CONTEXT_RESULT_ENTITY_NOT_TRACKING_EXT = -1000781001,
+ XR_SPATIAL_PERSISTENCE_CONTEXT_RESULT_PERSIST_UUID_NOT_FOUND_EXT = -1000781002,
+ XR_SPATIAL_PERSISTENCE_CONTEXT_RESULT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialPersistenceContextResultEXT;
+
+typedef enum XrSpatialPersistenceStateEXT {
+ XR_SPATIAL_PERSISTENCE_STATE_LOADED_EXT = 1,
+ XR_SPATIAL_PERSISTENCE_STATE_NOT_FOUND_EXT = 2,
+ XR_SPATIAL_PERSISTENCE_STATE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialPersistenceStateEXT;
+typedef struct XrSpatialPersistenceContextCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialPersistenceScopeEXT scope;
+} XrSpatialPersistenceContextCreateInfoEXT;
+
+typedef struct XrCreateSpatialPersistenceContextCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialPersistenceContextResultEXT createResult;
+ XrSpatialPersistenceContextEXT persistenceContext;
+} XrCreateSpatialPersistenceContextCompletionEXT;
+
+// XrSpatialContextPersistenceConfigEXT extends XrSpatialContextCreateInfoEXT
+typedef struct XrSpatialContextPersistenceConfigEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t persistenceContextCount;
+ const XrSpatialPersistenceContextEXT* persistenceContexts;
+} XrSpatialContextPersistenceConfigEXT;
+
+// XrSpatialDiscoveryPersistenceUuidFilterEXT extends XrSpatialDiscoverySnapshotCreateInfoEXT,XrSpatialComponentDataQueryConditionEXT
+typedef struct XrSpatialDiscoveryPersistenceUuidFilterEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t persistedUuidCount;
+ const XrUuid* persistedUuids;
+} XrSpatialDiscoveryPersistenceUuidFilterEXT;
+
+typedef struct XrSpatialPersistenceDataEXT {
+ XrUuid persistUuid;
+ XrSpatialPersistenceStateEXT persistState;
+} XrSpatialPersistenceDataEXT;
+
+// XrSpatialComponentPersistenceListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentPersistenceListEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t persistDataCount;
+ XrSpatialPersistenceDataEXT* persistData;
+} XrSpatialComponentPersistenceListEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialPersistenceScopesEXT)(XrInstance instance, XrSystemId systemId, uint32_t persistenceScopeCapacityInput, uint32_t* persistenceScopeCountOutput, XrSpatialPersistenceScopeEXT* persistenceScopes);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialPersistenceContextAsyncEXT)(XrSession session, const XrSpatialPersistenceContextCreateInfoEXT* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialPersistenceContextCompleteEXT)(XrSession session, XrFutureEXT future, XrCreateSpatialPersistenceContextCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialPersistenceContextEXT)(XrSpatialPersistenceContextEXT persistenceContext);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialPersistenceScopesEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t persistenceScopeCapacityInput,
+ uint32_t* persistenceScopeCountOutput,
+ XrSpatialPersistenceScopeEXT* persistenceScopes);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialPersistenceContextAsyncEXT(
+ XrSession session,
+ const XrSpatialPersistenceContextCreateInfoEXT* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialPersistenceContextCompleteEXT(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateSpatialPersistenceContextCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialPersistenceContextEXT(
+ XrSpatialPersistenceContextEXT persistenceContext);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_haptic_parametric is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_haptic_parametric 1
+
+#define XR_HAPTIC_PARAMETRIC_MAX_POINTS_TRANSIENTS_EXT 500
+
+
+#define XR_HAPTIC_PARAMETRIC_VIBRATION_EXTEND_DURATION_EXT 50000000
+
+
+#define XR_HAPTIC_PARAMETRIC_FREQUENCY_MIN_HZ_EXT 1
+
+
+#define XR_HAPTIC_PARAMETRIC_FREQUENCY_MAX_HZ_EXT 1000
+
+#define XR_EXT_haptic_parametric_SPEC_VERSION 1
+#define XR_EXT_HAPTIC_PARAMETRIC_EXTENSION_NAME "XR_EXT_haptic_parametric"
+
+typedef enum XrHapticParametricStreamFrameTypeEXT {
+ XR_HAPTIC_PARAMETRIC_STREAM_FRAME_TYPE_NONE_EXT = 0,
+ XR_HAPTIC_PARAMETRIC_STREAM_FRAME_TYPE_FIRST_FRAME_EXT = 1,
+ XR_HAPTIC_PARAMETRIC_STREAM_FRAME_TYPE_INTERMEDIATE_FRAME_EXT = 2,
+ XR_HAPTIC_PARAMETRIC_STREAM_FRAME_TYPE_LAST_FRAME_EXT = 3,
+ XR_HAPTIC_PARAMETRIC_STREAM_FRAME_TYPE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrHapticParametricStreamFrameTypeEXT;
+typedef struct XrHapticParametricPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrDuration idealFrameSubmissionRate;
+ XrDuration minimumFirstFrameDuration;
+ float minFrequencyHz;
+ float maxFrequencyHz;
+} XrHapticParametricPropertiesEXT;
+
+typedef struct XrHapticParametricPointEXT {
+ XrDuration time;
+ float value;
+} XrHapticParametricPointEXT;
+
+typedef struct XrHapticParametricTransientEXT {
+ XrDuration time;
+ float amplitude;
+ float frequency;
+} XrHapticParametricTransientEXT;
+
+typedef struct XrHapticParametricVibrationEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t amplitudePointCount;
+ const XrHapticParametricPointEXT* amplitudePoints;
+ uint32_t frequencyPointCount;
+ const XrHapticParametricPointEXT* frequencyPoints;
+ uint32_t transientCount;
+ const XrHapticParametricTransientEXT* transients;
+ float minFrequencyHz;
+ float maxFrequencyHz;
+ XrHapticParametricStreamFrameTypeEXT streamFrameType;
+} XrHapticParametricVibrationEXT;
+
+// XrSystemHapticParametricPropertiesEXT extends XrSystemProperties
+typedef struct XrSystemHapticParametricPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsParametricHaptics;
+} XrSystemHapticParametricPropertiesEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrHapticParametricGetPropertiesEXT)(XrSession session, const XrHapticActionInfo* hapticActionInfo, XrHapticParametricPropertiesEXT* parametricProperties);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrHapticParametricGetPropertiesEXT(
+ XrSession session,
+ const XrHapticActionInfo* hapticActionInfo,
+ XrHapticParametricPropertiesEXT* parametricProperties);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_SONY_swapchain_color_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_SONY_swapchain_color_space 1
+#define XR_SONY_swapchain_color_space_SPEC_VERSION 1
+#define XR_SONY_SWAPCHAIN_COLOR_SPACE_EXTENSION_NAME "XR_SONY_swapchain_color_space"
+
+typedef enum XrColorSpaceSONY {
+ XR_COLOR_SPACE_SRGB_NONLINEAR_SONY = 0,
+ XR_COLOR_SPACE_DISPLAY_P3_LINEAR_SONY = 1,
+ XR_COLOR_SPACE_DISPLAY_P3_NONLINEAR_SONY = 2,
+ XR_COLOR_SPACE_DCI_P3_LINEAR_SONY = 3,
+ XR_COLOR_SPACE_DCI_P3_NONLINEAR_SONY = 4,
+ XR_COLOR_SPACE_EXTENDED_SRGB_LINEAR_SONY = 5,
+ XR_COLOR_SPACE_BT709_LINEAR_SONY = 6,
+ XR_COLOR_SPACE_BT709_NONLINEAR_SONY = 7,
+ XR_COLOR_SPACE_BT2020_LINEAR_SONY = 8,
+ XR_COLOR_SPACE_BT2020_PQ_SONY = 9,
+ XR_COLOR_SPACE_BT2020_HLG_SONY = 10,
+ XR_COLOR_SPACE_MAX_ENUM_SONY = 0x7FFFFFFF
+} XrColorSpaceSONY;
+typedef struct XrColorSpacesEnumerateInfoSONY {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ int64_t format;
+} XrColorSpacesEnumerateInfoSONY;
+
+// XrSwapchainCreateInfoColorSpaceSONY extends XrSwapchainCreateInfo
+typedef struct XrSwapchainCreateInfoColorSpaceSONY {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrColorSpaceSONY colorSpace;
+} XrSwapchainCreateInfoColorSpaceSONY;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateColorSpacesSONY)(XrSession session, const XrColorSpacesEnumerateInfoSONY* enumerateInfo, uint32_t colorSpaceCapacityInput, uint32_t* colorSpaceCountOutput, XrColorSpaceSONY* colorSpaces);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateColorSpacesSONY(
+ XrSession session,
+ const XrColorSpacesEnumerateInfoSONY* enumerateInfo,
+ uint32_t colorSpaceCapacityInput,
+ uint32_t* colorSpaceCountOutput,
+ XrColorSpaceSONY* colorSpaces);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_SONY_hdr_metadata is a preprocessor guard. Do not pass it to API calls.
+#define XR_SONY_hdr_metadata 1
+#define XR_SONY_hdr_metadata_SPEC_VERSION 1
+#define XR_SONY_HDR_METADATA_EXTENSION_NAME "XR_SONY_hdr_metadata"
+typedef struct XrXYColorSONY {
+ float x;
+ float y;
+} XrXYColorSONY;
+
+typedef struct XrHdrMetadataSONY {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrXYColorSONY displayPrimaryRed;
+ XrXYColorSONY displayPrimaryGreen;
+ XrXYColorSONY displayPrimaryBlue;
+ XrXYColorSONY whitePoint;
+ float maxLuminance;
+ float minLuminance;
+ float maxContentLightLevel;
+ float maxFrameAverageLightLevel;
+} XrHdrMetadataSONY;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetHdrMetadataSONY)(XrSwapchain swapchain, const XrHdrMetadataSONY* hdrMetadata);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetHdrMetadataSONY(
+ XrSwapchain swapchain,
+ const XrHdrMetadataSONY* hdrMetadata);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_persistence_operations is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_persistence_operations 1
+#define XR_EXT_spatial_persistence_operations_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_PERSISTENCE_OPERATIONS_EXTENSION_NAME "XR_EXT_spatial_persistence_operations"
+typedef struct XrSpatialEntityPersistInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContextEXT spatialContext;
+ XrSpatialEntityIdEXT spatialEntityId;
+} XrSpatialEntityPersistInfoEXT;
+
+typedef struct XrPersistSpatialEntityCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialPersistenceContextResultEXT persistResult;
+ XrUuid persistUuid;
+} XrPersistSpatialEntityCompletionEXT;
+
+typedef struct XrSpatialEntityUnpersistInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrUuid persistUuid;
+} XrSpatialEntityUnpersistInfoEXT;
+
+typedef struct XrUnpersistSpatialEntityCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialPersistenceContextResultEXT unpersistResult;
+} XrUnpersistSpatialEntityCompletionEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrPersistSpatialEntityAsyncEXT)(XrSpatialPersistenceContextEXT persistenceContext, const XrSpatialEntityPersistInfoEXT* persistInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrPersistSpatialEntityCompleteEXT)(XrSpatialPersistenceContextEXT persistenceContext, XrFutureEXT future, XrPersistSpatialEntityCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistSpatialEntityAsyncEXT)(XrSpatialPersistenceContextEXT persistenceContext, const XrSpatialEntityUnpersistInfoEXT* unpersistInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrUnpersistSpatialEntityCompleteEXT)(XrSpatialPersistenceContextEXT persistenceContext, XrFutureEXT future, XrUnpersistSpatialEntityCompletionEXT* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistSpatialEntityAsyncEXT(
+ XrSpatialPersistenceContextEXT persistenceContext,
+ const XrSpatialEntityPersistInfoEXT* persistInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrPersistSpatialEntityCompleteEXT(
+ XrSpatialPersistenceContextEXT persistenceContext,
+ XrFutureEXT future,
+ XrPersistSpatialEntityCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistSpatialEntityAsyncEXT(
+ XrSpatialPersistenceContextEXT persistenceContext,
+ const XrSpatialEntityUnpersistInfoEXT* unpersistInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnpersistSpatialEntityCompleteEXT(
+ XrSpatialPersistenceContextEXT persistenceContext,
+ XrFutureEXT future,
+ XrUnpersistSpatialEntityCompletionEXT* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_image_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_image_tracking 1
+XR_DEFINE_HANDLE(XrSpatialImageTrackingDatabaseEXT)
+#define XR_EXT_spatial_image_tracking_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_IMAGE_TRACKING_EXTENSION_NAME "XR_EXT_spatial_image_tracking"
+
+typedef enum XrSpatialReferenceImageFormatEXT {
+ XR_SPATIAL_REFERENCE_IMAGE_FORMAT_RGBA_8888_EXT = 1,
+ XR_SPATIAL_REFERENCE_IMAGE_FORMAT_RGB_888_EXT = 2,
+ XR_SPATIAL_REFERENCE_IMAGE_FORMAT_YUV_420_888_EXT = 3,
+ XR_SPATIAL_REFERENCE_IMAGE_FORMAT_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialReferenceImageFormatEXT;
+typedef struct XrSpatialReferenceImagePlaneEXT {
+ uint32_t bufferSize;
+ const uint8_t* buffer;
+ uint32_t rowStride;
+ uint32_t pixelStride;
+} XrSpatialReferenceImagePlaneEXT;
+
+typedef struct XrSpatialReferenceImageEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t width;
+ uint32_t height;
+ XrSpatialReferenceImageFormatEXT format;
+ uint32_t planeCount;
+ const XrSpatialReferenceImagePlaneEXT* planes;
+} XrSpatialReferenceImageEXT;
+
+// XrSpatialImageStaticOptimizationEXT extends XrSpatialReferenceImageEXT,XrSpatialImageTrackingDatabaseCreateInfoEXT
+typedef struct XrSpatialImageStaticOptimizationEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 optimizeForStaticImage;
+} XrSpatialImageStaticOptimizationEXT;
+
+// XrSpatialImageSizeEXT extends XrSpatialReferenceImageEXT,XrSpatialImageTrackingDatabaseCreateInfoEXT
+typedef struct XrSpatialImageSizeEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ float physicalWidth;
+} XrSpatialImageSizeEXT;
+
+typedef struct XrSpatialCapabilityConfigurationImageTrackingEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+ uint32_t imageTrackingDatabaseCount;
+ const XrSpatialImageTrackingDatabaseEXT* imageTrackingDatabases;
+} XrSpatialCapabilityConfigurationImageTrackingEXT;
+
+typedef struct XrSpatialImageTrackingDatabaseCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t spatialReferenceImageCount;
+ const XrSpatialReferenceImageEXT* spatialReferenceImages;
+} XrSpatialImageTrackingDatabaseCreateInfoEXT;
+
+typedef struct XrSpatialImage2DDataEXT {
+ XrSpatialImageTrackingDatabaseEXT imageTrackingDatabase;
+ uint32_t referenceImageIndex;
+} XrSpatialImage2DDataEXT;
+
+// XrSpatialComponentImage2DListEXT extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentImage2DListEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t imageCount;
+ XrSpatialImage2DDataEXT* images;
+} XrSpatialComponentImage2DListEXT;
+
+typedef struct XrCreateSpatialImageTrackingDatabaseCompletionEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialImageTrackingDatabaseEXT database;
+} XrCreateSpatialImageTrackingDatabaseCompletionEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialReferenceImageFormatsEXT)(XrInstance instance, XrSystemId systemId, XrSpatialCapabilityEXT capability, uint32_t formatCapacityInput, uint32_t* formatCountOutput, XrSpatialReferenceImageFormatEXT* formats);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialImageTrackingDatabaseAsyncEXT)(XrSession session, const XrSpatialImageTrackingDatabaseCreateInfoEXT* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialImageTrackingDatabaseCompleteEXT)(XrSession session, XrFutureEXT future, XrCreateSpatialImageTrackingDatabaseCompletionEXT* completion);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialImageTrackingDatabaseEXT)(XrSpatialImageTrackingDatabaseEXT database);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialReferenceImageFormatsEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrSpatialCapabilityEXT capability,
+ uint32_t formatCapacityInput,
+ uint32_t* formatCountOutput,
+ XrSpatialReferenceImageFormatEXT* formats);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialImageTrackingDatabaseAsyncEXT(
+ XrSession session,
+ const XrSpatialImageTrackingDatabaseCreateInfoEXT* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialImageTrackingDatabaseCompleteEXT(
+ XrSession session,
+ XrFutureEXT future,
+ XrCreateSpatialImageTrackingDatabaseCompletionEXT* completion);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialImageTrackingDatabaseEXT(
+ XrSpatialImageTrackingDatabaseEXT database);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_spatial_object_tracking is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_object_tracking 1
+#define XR_ANDROID_spatial_object_tracking_SPEC_VERSION 2
+#define XR_ANDROID_SPATIAL_OBJECT_TRACKING_EXTENSION_NAME "XR_ANDROID_spatial_object_tracking"
+
+typedef enum XrSpatialObjectSemanticLabelANDROID {
+ XR_SPATIAL_OBJECT_SEMANTIC_LABEL_UNCATEGORIZED_ANDROID = 0,
+ XR_SPATIAL_OBJECT_SEMANTIC_LABEL_KEYBOARD_ANDROID = 1,
+ XR_SPATIAL_OBJECT_SEMANTIC_LABEL_MOUSE_ANDROID = 2,
+ XR_SPATIAL_OBJECT_SEMANTIC_LABEL_LAPTOP_BASE_ANDROID = 3,
+ XR_SPATIAL_OBJECT_SEMANTIC_LABEL_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSpatialObjectSemanticLabelANDROID;
+typedef struct XrSpatialCapabilityConfigurationObjectTrackingANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+ uint32_t activeSemanticLabelCount;
+ const XrSpatialObjectSemanticLabelANDROID* activeSemanticLabels;
+} XrSpatialCapabilityConfigurationObjectTrackingANDROID;
+
+// XrSpatialComponentObjectSemanticLabelListANDROID extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentObjectSemanticLabelListANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t semanticLabelCount;
+ XrSpatialObjectSemanticLabelANDROID* semanticLabels;
+} XrSpatialComponentObjectSemanticLabelListANDROID;
+
+
+
+// XR_ANDROID_spatial_discovery_raycast is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_discovery_raycast 1
+#define XR_ANDROID_spatial_discovery_raycast_SPEC_VERSION 1
+#define XR_ANDROID_SPATIAL_DISCOVERY_RAYCAST_EXTENSION_NAME "XR_ANDROID_spatial_discovery_raycast"
+typedef struct XrSpatialRaycastResultDataANDROID {
+ XrPosef hitPose;
+ float distanceSquared;
+} XrSpatialRaycastResultDataANDROID;
+
+typedef struct XrSpatialCapabilityConfigurationDepthRaycastANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialCapabilityEXT capability;
+ uint32_t enabledComponentCount;
+ const XrSpatialComponentTypeEXT* enabledComponents;
+} XrSpatialCapabilityConfigurationDepthRaycastANDROID;
+
+// XrSpatialRaycastInfoANDROID extends XrSpatialDiscoverySnapshotCreateInfoEXT
+typedef struct XrSpatialRaycastInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrVector3f origin;
+ XrVector3f direction;
+ float maxDistance;
+} XrSpatialRaycastInfoANDROID;
+
+// XrSpatialComponentRaycastResultListANDROID extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentRaycastResultListANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t raycastResultCount;
+ XrSpatialRaycastResultDataANDROID* raycastResults;
+} XrSpatialComponentRaycastResultListANDROID;
+
+typedef struct XrSpatialRaycastSnapshotCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t componentTypeCount;
+ const XrSpatialComponentTypeEXT* componentTypes;
+ const XrSpatialRaycastInfoANDROID* raycastInfo;
+} XrSpatialRaycastSnapshotCreateInfoANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialRaycastSnapshotANDROID)(XrSpatialContextEXT spatialContext, const XrSpatialRaycastSnapshotCreateInfoANDROID* createInfo, XrSpatialSnapshotEXT* snapshot);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialRaycastSnapshotANDROID(
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialRaycastSnapshotCreateInfoANDROID* createInfo,
+ XrSpatialSnapshotEXT* snapshot);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_google_cloud_auth is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_google_cloud_auth 1
+#define XR_ANDROID_google_cloud_auth_SPEC_VERSION 1
+#define XR_ANDROID_GOOGLE_CLOUD_AUTH_EXTENSION_NAME "XR_ANDROID_google_cloud_auth"
+
+typedef enum XrGoogleCloudAuthErrorANDROID {
+ XR_GOOGLE_CLOUD_AUTH_ERROR_NONE_ANDROID = 0,
+ XR_GOOGLE_CLOUD_AUTH_ERROR_QUOTA_EXCEEDED_ANDROID = -1,
+ XR_GOOGLE_CLOUD_AUTH_ERROR_UNREACHABLE_ANDROID = -2,
+ XR_GOOGLE_CLOUD_AUTH_ERROR_ANDROID = -3,
+ XR_GOOGLE_CLOUD_AUTH_ERROR_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrGoogleCloudAuthErrorANDROID;
+typedef struct XR_MAY_ALIAS XrGoogleCloudAuthInfoBaseHeaderANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrGoogleCloudAuthInfoBaseHeaderANDROID;
+
+typedef struct XrGoogleCloudAuthInfoApiKeyANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const char* apiKey;
+} XrGoogleCloudAuthInfoApiKeyANDROID;
+
+typedef struct XrGoogleCloudAuthInfoTokenANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ const char* authToken;
+} XrGoogleCloudAuthInfoTokenANDROID;
+
+typedef struct XrGoogleCloudAuthInfoKeylessANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrGoogleCloudAuthInfoKeylessANDROID;
+
+// XrGoogleCloudAuthErrorResultANDROID extends XrGoogleCloudAuthInfoBaseHeaderANDROID
+typedef struct XrGoogleCloudAuthErrorResultANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrGoogleCloudAuthErrorANDROID error;
+} XrGoogleCloudAuthErrorResultANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrSetGoogleCloudAuthAsyncANDROID)(XrSession session, const XrGoogleCloudAuthInfoBaseHeaderANDROID* authInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrSetGoogleCloudAuthCompleteANDROID)(XrSession session, XrFutureEXT future, XrFutureCompletionEXT* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetGoogleCloudAuthAsyncANDROID(
+ XrSession session,
+ const XrGoogleCloudAuthInfoBaseHeaderANDROID* authInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrSetGoogleCloudAuthCompleteANDROID(
+ XrSession session,
+ XrFutureEXT future,
+ XrFutureCompletionEXT* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_geospatial is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_geospatial 1
+XR_DEFINE_HANDLE(XrGeospatialTrackerANDROID)
+#define XR_ANDROID_geospatial_SPEC_VERSION 1
+#define XR_ANDROID_GEOSPATIAL_EXTENSION_NAME "XR_ANDROID_geospatial"
+
+typedef enum XrGeospatialTrackerStateANDROID {
+ XR_GEOSPATIAL_TRACKER_STATE_STOPPED_ANDROID = 0,
+ XR_GEOSPATIAL_TRACKER_STATE_RUNNING_ANDROID = 1,
+ XR_GEOSPATIAL_TRACKER_STATE_INITIALIZATION_FAILED_ANDROID = 2,
+ XR_GEOSPATIAL_TRACKER_STATE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrGeospatialTrackerStateANDROID;
+
+typedef enum XrVPSAvailabilityANDROID {
+ XR_VPS_AVAILABILITY_UNAVAILABLE_ANDROID = 1,
+ XR_VPS_AVAILABILITY_AVAILABLE_ANDROID = 2,
+ XR_VPSAVAILABILITY_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrVPSAvailabilityANDROID;
+typedef XrFlags64 XrGeospatialPoseFlagsANDROID;
+
+// Flag bits for XrGeospatialPoseFlagsANDROID
+static const XrGeospatialPoseFlagsANDROID XR_GEOSPATIAL_POSE_ORIENTATION_VALID_BIT_ANDROID = 0x00000001;
+static const XrGeospatialPoseFlagsANDROID XR_GEOSPATIAL_POSE_POSITION_VALID_BIT_ANDROID = 0x00000002;
+
+typedef struct XrGeospatialPoseANDROID {
+ XrQuaternionf eastUpSouthOrientation;
+ double latitude;
+ double longitude;
+ double altitude;
+} XrGeospatialPoseANDROID;
+
+// XrSystemGeospatialPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemGeospatialPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsGeospatial;
+} XrSystemGeospatialPropertiesANDROID;
+
+typedef struct XrGeospatialTrackerCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrGeospatialTrackerCreateInfoANDROID;
+
+typedef struct XrEventDataGeospatialTrackerStateChangedANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrGeospatialTrackerANDROID geospatialTracker;
+ XrGeospatialTrackerStateANDROID state;
+ XrResult initializationResult;
+ XrTime time;
+} XrEventDataGeospatialTrackerStateChangedANDROID;
+
+typedef struct XrGeospatialPoseFromPoseLocateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrPosef pose;
+} XrGeospatialPoseFromPoseLocateInfoANDROID;
+
+typedef struct XrGeospatialPoseResultANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrGeospatialPoseFlagsANDROID poseFlags;
+ XrGeospatialPoseANDROID geospatialPose;
+ double horizontalAccuracy;
+ double verticalAccuracy;
+ double orientationYawAccuracy;
+} XrGeospatialPoseResultANDROID;
+
+typedef struct XrGeospatialPoseLocateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace space;
+ XrTime time;
+ XrGeospatialPoseANDROID geospatialPose;
+} XrGeospatialPoseLocateInfoANDROID;
+
+typedef struct XrVPSAvailabilityCheckCompletionANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrVPSAvailabilityANDROID availability;
+} XrVPSAvailabilityCheckCompletionANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateGeospatialTrackerANDROID)(XrSession session, const XrGeospatialTrackerCreateInfoANDROID* createInfo, XrGeospatialTrackerANDROID* geospatialTrackerOutput);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroyGeospatialTrackerANDROID)(XrGeospatialTrackerANDROID geospatialTracker);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateGeospatialPoseFromPoseANDROID)(XrGeospatialTrackerANDROID geospatialTracker, const XrGeospatialPoseFromPoseLocateInfoANDROID* locateInfo, XrGeospatialPoseResultANDROID* geospatialPoseResult);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateGeospatialPoseANDROID)(XrGeospatialTrackerANDROID geospatialTracker, const XrGeospatialPoseLocateInfoANDROID* locateInfo, XrSpaceLocation* location);
+typedef XrResult (XRAPI_PTR *PFN_xrCheckVpsAvailabilityAsyncANDROID)(XrSession session, double latitude, double longitude, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCheckVpsAvailabilityCompleteANDROID)(XrSession session, XrFutureEXT future, XrVPSAvailabilityCheckCompletionANDROID* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateGeospatialTrackerANDROID(
+ XrSession session,
+ const XrGeospatialTrackerCreateInfoANDROID* createInfo,
+ XrGeospatialTrackerANDROID* geospatialTrackerOutput);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroyGeospatialTrackerANDROID(
+ XrGeospatialTrackerANDROID geospatialTracker);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateGeospatialPoseFromPoseANDROID(
+ XrGeospatialTrackerANDROID geospatialTracker,
+ const XrGeospatialPoseFromPoseLocateInfoANDROID* locateInfo,
+ XrGeospatialPoseResultANDROID* geospatialPoseResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateGeospatialPoseANDROID(
+ XrGeospatialTrackerANDROID geospatialTracker,
+ const XrGeospatialPoseLocateInfoANDROID* locateInfo,
+ XrSpaceLocation* location);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCheckVpsAvailabilityAsyncANDROID(
+ XrSession session,
+ double latitude,
+ double longitude,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCheckVpsAvailabilityCompleteANDROID(
+ XrSession session,
+ XrFutureEXT future,
+ XrVPSAvailabilityCheckCompletionANDROID* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_spatial_entity_bound_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_entity_bound_anchor 1
+#define XR_ANDROID_spatial_entity_bound_anchor_SPEC_VERSION 2
+#define XR_ANDROID_SPATIAL_ENTITY_BOUND_ANCHOR_EXTENSION_NAME "XR_ANDROID_spatial_entity_bound_anchor"
+// XrSpatialAnchorParentANDROID extends XrSpatialAnchorCreateInfoEXT
+typedef struct XrSpatialAnchorParentANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialEntityIdEXT parentId;
+} XrSpatialAnchorParentANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSpatialAnchorAttachableComponentsANDROID)(XrInstance instance, XrSystemId systemId, uint32_t attachableComponentCapacityInput, uint32_t* attachableComponentCountOutput, XrSpatialComponentTypeEXT* attachableComponents);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSpatialAnchorAttachableComponentsANDROID(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t attachableComponentCapacityInput,
+ uint32_t* attachableComponentCountOutput,
+ XrSpatialComponentTypeEXT* attachableComponents);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_spatial_component_subsumed_by is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_component_subsumed_by 1
+#define XR_ANDROID_spatial_component_subsumed_by_SPEC_VERSION 1
+#define XR_ANDROID_SPATIAL_COMPONENT_SUBSUMED_BY_EXTENSION_NAME "XR_ANDROID_spatial_component_subsumed_by"
+// XrSpatialDiscoveryUniqueEntitiesFilterANDROID extends XrSpatialDiscoverySnapshotCreateInfoEXT
+typedef struct XrSpatialDiscoveryUniqueEntitiesFilterANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialDiscoveryUniqueEntitiesFilterANDROID;
+
+// XrSpatialComponentSubsumedByListANDROID extends XrSpatialComponentDataQueryResultEXT
+typedef struct XrSpatialComponentSubsumedByListANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t subsumedUniqueIdCount;
+ XrSpatialEntityIdEXT* subsumedUniqueIds;
+} XrSpatialComponentSubsumedByListANDROID;
+
+
+
+// XR_ANDROID_spatial_anchor_space is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_spatial_anchor_space 1
+#define XR_ANDROID_spatial_anchor_space_SPEC_VERSION 1
+#define XR_ANDROID_SPATIAL_ANCHOR_SPACE_EXTENSION_NAME "XR_ANDROID_spatial_anchor_space"
+typedef struct XrSpatialAnchorSpaceFromIdCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialEntityIdEXT anchorEntityId;
+} XrSpatialAnchorSpaceFromIdCreateInfoANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorSpaceANDROID)(XrSession session, XrSpatialContextEXT spatialContext, const XrSpatialAnchorCreateInfoEXT* createInfo, XrSpatialEntityIdEXT* anchorEntityId, XrSpace* anchorSpace);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorSpaceFromIdANDROID)(XrSession session, XrSpatialContextEXT spatialContext, const XrSpatialAnchorSpaceFromIdCreateInfoANDROID* createInfo, XrSpace* anchorSpace);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorSpaceANDROID(
+ XrSession session,
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialAnchorCreateInfoEXT* createInfo,
+ XrSpatialEntityIdEXT* anchorEntityId,
+ XrSpace* anchorSpace);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorSpaceFromIdANDROID(
+ XrSession session,
+ XrSpatialContextEXT spatialContext,
+ const XrSpatialAnchorSpaceFromIdCreateInfoANDROID* createInfo,
+ XrSpace* anchorSpace);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_ANDROID_geospatial_anchor is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_geospatial_anchor 1
+#define XR_ANDROID_geospatial_anchor_SPEC_VERSION 1
+#define XR_ANDROID_GEOSPATIAL_ANCHOR_EXTENSION_NAME "XR_ANDROID_geospatial_anchor"
+
+typedef enum XrSurfaceAnchorTypeANDROID {
+ XR_SURFACE_ANCHOR_TYPE_TERRAIN_ANDROID = 1,
+ XR_SURFACE_ANCHOR_TYPE_ROOFTOP_ANDROID = 2,
+ XR_SURFACE_ANCHOR_TYPE_MAX_ENUM_ANDROID = 0x7FFFFFFF
+} XrSurfaceAnchorTypeANDROID;
+// XrSystemGeospatialAnchorPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemGeospatialAnchorPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t maxSurfaceAnchorCount;
+} XrSystemGeospatialAnchorPropertiesANDROID;
+
+// XrGeospatialTrackerAnchorTrackingInfoANDROID extends XrGeospatialTrackerCreateInfoANDROID
+typedef struct XrGeospatialTrackerAnchorTrackingInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 shouldTrackPlanes;
+} XrGeospatialTrackerAnchorTrackingInfoANDROID;
+
+typedef struct XrGeospatialAnchorCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrGeospatialTrackerANDROID geospatialTracker;
+ XrGeospatialPoseANDROID geospatialPose;
+} XrGeospatialAnchorCreateInfoANDROID;
+
+typedef struct XrSurfaceAnchorCreateInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrGeospatialTrackerANDROID geospatialTracker;
+ XrSurfaceAnchorTypeANDROID surfaceAnchorType;
+ XrQuaternionf eastUpSouthOrientation;
+ double latitude;
+ double longitude;
+ double altitudeRelativeToSurface;
+} XrSurfaceAnchorCreateInfoANDROID;
+
+typedef struct XrSurfaceAnchorCreateCompletionANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrResult futureResult;
+ XrSpatialEntityIdEXT anchorEntityId;
+} XrSurfaceAnchorCreateCompletionANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateGeospatialAnchorANDROID)(XrSpatialContextEXT spatialContext, const XrGeospatialAnchorCreateInfoANDROID* createInfo, XrSpatialEntityIdEXT* anchorEntityId);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSurfaceAnchorAsyncANDROID)(XrSpatialContextEXT spatialContext, const XrSurfaceAnchorCreateInfoANDROID* createInfo, XrFutureEXT* future);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSurfaceAnchorCompleteANDROID)(XrSpatialContextEXT spatialContext, XrFutureEXT future, XrSurfaceAnchorCreateCompletionANDROID* completion);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateGeospatialAnchorANDROID(
+ XrSpatialContextEXT spatialContext,
+ const XrGeospatialAnchorCreateInfoANDROID* createInfo,
+ XrSpatialEntityIdEXT* anchorEntityId);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSurfaceAnchorAsyncANDROID(
+ XrSpatialContextEXT spatialContext,
+ const XrSurfaceAnchorCreateInfoANDROID* createInfo,
+ XrFutureEXT* future);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSurfaceAnchorCompleteANDROID(
+ XrSpatialContextEXT spatialContext,
+ XrFutureEXT future,
+ XrSurfaceAnchorCreateCompletionANDROID* completion);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_container is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_container 1
+XR_DEFINE_HANDLE(XrSpatialContainerEXT)
+#define XR_EXT_spatial_container_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_CONTAINER_EXTENSION_NAME "XR_EXT_spatial_container"
+
+typedef enum XrSpatialContainerGraphicsPresentationEXT {
+ XR_SPATIAL_CONTAINER_GRAPHICS_PRESENTATION_SELF_RENDERING_EXT = 1000813000,
+ XR_SPATIAL_CONTAINER_GRAPHICS_PRESENTATION_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialContainerGraphicsPresentationEXT;
+
+typedef enum XrSpatialContainerBoundsModeEXT {
+ XR_SPATIAL_CONTAINER_BOUNDS_MODE_BOUNDED_EXT = 1,
+ XR_SPATIAL_CONTAINER_BOUNDS_MODE_IMMERSIVE_EXT = 2,
+ XR_SPATIAL_CONTAINER_BOUNDS_MODE_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialContainerBoundsModeEXT;
+
+typedef enum XrSpatialContainerVolumeClippingEXT {
+ XR_SPATIAL_CONTAINER_VOLUME_CLIPPING_NONE_EXT = 0,
+ XR_SPATIAL_CONTAINER_VOLUME_CLIPPING_STRICT_EXT = 1,
+ XR_SPATIAL_CONTAINER_VOLUME_CLIPPING_MAX_ENUM_EXT = 0x7FFFFFFF
+} XrSpatialContainerVolumeClippingEXT;
+// XrSessionCreateInfoSpatialContainersEXT extends XrSessionCreateInfo
+typedef struct XrSessionCreateInfoSpatialContainersEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSessionCreateInfoSpatialContainersEXT;
+
+typedef struct XrSpatialContainerCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerGraphicsPresentationEXT graphicsPresentation;
+ XrExtent3Df suggestedBounds;
+} XrSpatialContainerCreateInfoEXT;
+
+typedef struct XrSpatialContainerSpaceCreateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+} XrSpatialContainerSpaceCreateInfoEXT;
+
+typedef struct XrEventDataSpatialContainerClosedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+} XrEventDataSpatialContainerClosedEXT;
+
+// XrSystemSpatialContainerPropertiesEXT extends XrSystemProperties
+typedef struct XrSystemSpatialContainerPropertiesEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t maxSpatialContainerCount;
+ XrBool32 supportsBounded;
+ XrBool32 supportsImmersive;
+} XrSystemSpatialContainerPropertiesEXT;
+
+typedef struct XrSpatialContainerBoundsEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrExtent3Df bounds;
+ XrBool32 infiniteBounds;
+} XrSpatialContainerBoundsEXT;
+
+typedef struct XrEventDataSpatialContainerBoundsChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+ XrExtent3Df bounds;
+ XrBool32 infiniteBounds;
+ XrSpatialContainerBoundsModeEXT boundsMode;
+} XrEventDataSpatialContainerBoundsChangedEXT;
+
+typedef struct XrSpatialContainerBoundsGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialContainerBoundsGetInfoEXT;
+
+typedef struct XrSpatialContainerStateGetInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+} XrSpatialContainerStateGetInfoEXT;
+
+typedef struct XrSpatialContainerVisibleRequestInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrBool32 visible;
+} XrSpatialContainerVisibleRequestInfoEXT;
+
+typedef struct XrEventDataSpatialContainerVisibleChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+ XrBool32 visible;
+} XrEventDataSpatialContainerVisibleChangedEXT;
+
+typedef struct XrEventDataSpatialContainerVisibleRequestDeniedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+} XrEventDataSpatialContainerVisibleRequestDeniedEXT;
+
+typedef struct XrEventDataSpatialContainerInteractableChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+ XrBool32 interactable;
+} XrEventDataSpatialContainerInteractableChangedEXT;
+
+typedef struct XrSpatialContainerBoundsModeRequestInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerBoundsModeEXT boundsMode;
+} XrSpatialContainerBoundsModeRequestInfoEXT;
+
+typedef struct XrEventDataSpatialContainerBoundsModeRequestDeniedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+} XrEventDataSpatialContainerBoundsModeRequestDeniedEXT;
+
+typedef struct XrSpatialContainerStateEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 visible;
+ XrBool32 interactable;
+ XrSpatialContainerBoundsModeEXT boundsMode;
+} XrSpatialContainerStateEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialContainerEXT)(XrSession session, const XrSpatialContainerCreateInfoEXT* createInfo, XrSpatialContainerEXT* spatialContainer);
+typedef XrResult (XRAPI_PTR *PFN_xrDestroySpatialContainerEXT)(XrSpatialContainerEXT spatialContainer);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialContainerSpaceEXT)(XrSession session, const XrSpatialContainerSpaceCreateInfoEXT* createInfo, XrSpace* space);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestSpatialContainerVisibleEXT)(XrSpatialContainerEXT spatialContainer, const XrSpatialContainerVisibleRequestInfoEXT* info);
+typedef XrResult (XRAPI_PTR *PFN_xrRequestSpatialContainerBoundsModeEXT)(XrSpatialContainerEXT spatialContainer, const XrSpatialContainerBoundsModeRequestInfoEXT* info);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialContainerBoundsEXT)(XrSpatialContainerEXT spatialContainer, const XrSpatialContainerBoundsGetInfoEXT* getInfo, XrSpatialContainerBoundsEXT* bounds);
+typedef XrResult (XRAPI_PTR *PFN_xrGetSpatialContainerStateEXT)(XrSpatialContainerEXT spatialContainer, const XrSpatialContainerStateGetInfoEXT* getInfo, XrSpatialContainerStateEXT* state);
+typedef XrResult (XRAPI_PTR *PFN_xrEnumerateSupportedSpatialContainerGraphicsPresentationsEXT)(XrInstance instance, XrSystemId systemId, uint32_t graphicsPresentationCapacityInput, uint32_t* graphicsPresentationCountOutput, XrSpatialContainerGraphicsPresentationEXT* graphicsPresentations);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialContainerEXT(
+ XrSession session,
+ const XrSpatialContainerCreateInfoEXT* createInfo,
+ XrSpatialContainerEXT* spatialContainer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrDestroySpatialContainerEXT(
+ XrSpatialContainerEXT spatialContainer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialContainerSpaceEXT(
+ XrSession session,
+ const XrSpatialContainerSpaceCreateInfoEXT* createInfo,
+ XrSpace* space);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestSpatialContainerVisibleEXT(
+ XrSpatialContainerEXT spatialContainer,
+ const XrSpatialContainerVisibleRequestInfoEXT* info);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrRequestSpatialContainerBoundsModeEXT(
+ XrSpatialContainerEXT spatialContainer,
+ const XrSpatialContainerBoundsModeRequestInfoEXT* info);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialContainerBoundsEXT(
+ XrSpatialContainerEXT spatialContainer,
+ const XrSpatialContainerBoundsGetInfoEXT* getInfo,
+ XrSpatialContainerBoundsEXT* bounds);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetSpatialContainerStateEXT(
+ XrSpatialContainerEXT spatialContainer,
+ const XrSpatialContainerStateGetInfoEXT* getInfo,
+ XrSpatialContainerStateEXT* state);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEnumerateSupportedSpatialContainerGraphicsPresentationsEXT(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t graphicsPresentationCapacityInput,
+ uint32_t* graphicsPresentationCountOutput,
+ XrSpatialContainerGraphicsPresentationEXT* graphicsPresentations);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_spatial_container_self_rendering is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_spatial_container_self_rendering 1
+#define XR_EXT_spatial_container_self_rendering_SPEC_VERSION 1
+#define XR_EXT_SPATIAL_CONTAINER_SELF_RENDERING_EXTENSION_NAME "XR_EXT_spatial_container_self_rendering"
+typedef struct XrSpatialContainerViewLocateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+ XrSpace space;
+ XrSpatialContainerEXT spatialContainer;
+} XrSpatialContainerViewLocateInfoEXT;
+
+typedef struct XrSpatialContainerViewsLocateInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrTime displayTime;
+ uint32_t viewLocateInfoCount;
+ const XrSpatialContainerViewLocateInfoEXT* viewLocateInfos;
+} XrSpatialContainerViewsLocateInfoEXT;
+
+typedef struct XrSpatialContainerViewStateEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrViewStateFlags viewStateFlags;
+ XrViewConfigurationType viewConfigurationType;
+ XrBool32 shouldSubmitLayers;
+ XrExtent2Di recommendedImageExtent;
+} XrSpatialContainerViewStateEXT;
+
+typedef struct XrSpatialContainerLayerEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+ XrBool32 retainPreviousSubmission;
+ uint32_t layerCount;
+ const XrCompositionLayerBaseHeader* const* layers;
+} XrSpatialContainerLayerEXT;
+
+// XrSpatialContainerLayerFrameEndInfoEXT extends XrFrameEndInfo
+typedef struct XrSpatialContainerLayerFrameEndInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t containerLayerCount;
+ const XrSpatialContainerLayerEXT* containerLayers;
+} XrSpatialContainerLayerFrameEndInfoEXT;
+
+typedef struct XrSpatialContainerBeginInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+ XrViewConfigurationType primaryViewConfigurationType;
+} XrSpatialContainerBeginInfoEXT;
+
+typedef struct XrSpatialContainerEndInfoEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerEXT spatialContainer;
+} XrSpatialContainerEndInfoEXT;
+
+// XrSpatialContainerCompositionLayerViewConfigurationEXT extends XrCompositionLayerProjection
+typedef struct XrSpatialContainerCompositionLayerViewConfigurationEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrViewConfigurationType viewConfigurationType;
+} XrSpatialContainerCompositionLayerViewConfigurationEXT;
+
+// XrSpatialContainerLayerVolumeClippingEXT extends XrSpatialContainerLayerEXT
+typedef struct XrSpatialContainerLayerVolumeClippingEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpatialContainerVolumeClippingEXT volumeClipping;
+} XrSpatialContainerLayerVolumeClippingEXT;
+
+typedef XrResult (XRAPI_PTR *PFN_xrBeginSpatialContainerRenderingEXT)(XrSession session, const XrSpatialContainerBeginInfoEXT* beginInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrEndSpatialContainerRenderingEXT)(XrSession session, const XrSpatialContainerEndInfoEXT* endInfo);
+typedef XrResult (XRAPI_PTR *PFN_xrLocateSpatialContainerViewsEXT)(XrSession session, const XrSpatialContainerViewsLocateInfoEXT* locateInfo, uint32_t viewStateCount, XrSpatialContainerViewStateEXT* viewStates, uint32_t viewCount, XrView* views);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrBeginSpatialContainerRenderingEXT(
+ XrSession session,
+ const XrSpatialContainerBeginInfoEXT* beginInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrEndSpatialContainerRenderingEXT(
+ XrSession session,
+ const XrSpatialContainerEndInfoEXT* endInfo);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrLocateSpatialContainerViewsEXT(
+ XrSession session,
+ const XrSpatialContainerViewsLocateInfoEXT* locateInfo,
+ uint32_t viewStateCount,
+ XrSpatialContainerViewStateEXT* viewStates,
+ uint32_t viewCount,
+ XrView* views);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+
+
+// XR_EXT_interaction_profile_battery_state_display is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_interaction_profile_battery_state_display 1
+#define XR_EXT_interaction_profile_battery_state_display_SPEC_VERSION 1
+#define XR_EXT_INTERACTION_PROFILE_BATTERY_STATE_DISPLAY_EXTENSION_NAME "XR_EXT_interaction_profile_battery_state_display"
+typedef XrFlags64 XrBatteryStateDisplayStateFlagsEXT;
+
+// Flag bits for XrBatteryStateDisplayStateFlagsEXT
+static const XrBatteryStateDisplayStateFlagsEXT XR_BATTERY_STATE_DISPLAY_STATE_VALID_BIT_EXT = 0x00000001;
+static const XrBatteryStateDisplayStateFlagsEXT XR_BATTERY_STATE_DISPLAY_STATE_CHARGING_BIT_EXT = 0x00000002;
+static const XrBatteryStateDisplayStateFlagsEXT XR_BATTERY_STATE_DISPLAY_STATE_PLUGGED_IN_BIT_EXT = 0x00000004;
+static const XrBatteryStateDisplayStateFlagsEXT XR_BATTERY_STATE_DISPLAY_STATE_NO_BATTERY_BIT_EXT = 0x00000008;
+
+// XrBatteryStateDisplayEXT extends XrInteractionProfileState
+typedef struct XrBatteryStateDisplayEXT {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBatteryStateDisplayStateFlagsEXT stateFlags;
+ float batteryLevel;
+} XrBatteryStateDisplayEXT;
+
+
+
+// XR_EXT_loader_init_properties is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_loader_init_properties 1
+#define XR_EXT_loader_init_properties_SPEC_VERSION 1
+#define XR_EXT_LOADER_INIT_PROPERTIES_EXTENSION_NAME "XR_EXT_loader_init_properties"
+typedef struct XrLoaderInitPropertyValueEXT {
+ const char* name;
+ const char* value;
+} XrLoaderInitPropertyValueEXT;
+
+// XrLoaderInitInfoPropertiesEXT extends XrLoaderInitInfoBaseHeaderKHR
+typedef struct XrLoaderInitInfoPropertiesEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t propertyValueCount;
+ const XrLoaderInitPropertyValueEXT* propertyValues;
+} XrLoaderInitInfoPropertiesEXT;
+
+
+
+// XR_EXT_view_configuration_views_change is a preprocessor guard. Do not pass it to API calls.
+#define XR_EXT_view_configuration_views_change 1
+#define XR_EXT_view_configuration_views_change_SPEC_VERSION 1
+#define XR_EXT_VIEW_CONFIGURATION_VIEWS_CHANGE_EXTENSION_NAME "XR_EXT_view_configuration_views_change"
+typedef struct XrEventDataViewConfigurationViewsChangedEXT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSystemId systemId;
+ XrViewConfigurationType viewConfigurationType;
+} XrEventDataViewConfigurationViewsChangedEXT;
+
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/frame/openxr-compat/vendor/openxr/openxr_platform.h b/frame/openxr-compat/vendor/openxr/openxr_platform.h
new file mode 100644
index 0000000..9de27ad
--- /dev/null
+++ b/frame/openxr-compat/vendor/openxr/openxr_platform.h
@@ -0,0 +1,819 @@
+#ifndef OPENXR_PLATFORM_H_
+#define OPENXR_PLATFORM_H_ 1
+
+/*
+** Copyright 2017-2026 The Khronos Group Inc.
+**
+** SPDX-License-Identifier: Apache-2.0 OR MIT
+*/
+
+/*
+** This header is generated from the Khronos OpenXR XML API Registry.
+**
+*/
+
+#include "openxr.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_KHR_android_thread_settings is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_android_thread_settings 1
+#define XR_KHR_android_thread_settings_SPEC_VERSION 6
+#define XR_KHR_ANDROID_THREAD_SETTINGS_EXTENSION_NAME "XR_KHR_android_thread_settings"
+
+typedef enum XrAndroidThreadTypeKHR {
+ XR_ANDROID_THREAD_TYPE_APPLICATION_MAIN_KHR = 1,
+ XR_ANDROID_THREAD_TYPE_APPLICATION_WORKER_KHR = 2,
+ XR_ANDROID_THREAD_TYPE_RENDERER_MAIN_KHR = 3,
+ XR_ANDROID_THREAD_TYPE_RENDERER_WORKER_KHR = 4,
+ XR_ANDROID_THREAD_TYPE_MAX_ENUM_KHR = 0x7FFFFFFF
+} XrAndroidThreadTypeKHR;
+typedef XrResult (XRAPI_PTR *PFN_xrSetAndroidApplicationThreadKHR)(XrSession session, XrAndroidThreadTypeKHR threadType, uint32_t threadId);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrSetAndroidApplicationThreadKHR(
+ XrSession session,
+ XrAndroidThreadTypeKHR threadType,
+ uint32_t threadId);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_KHR_android_surface_swapchain is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_android_surface_swapchain 1
+#define XR_KHR_android_surface_swapchain_SPEC_VERSION 4
+#define XR_KHR_ANDROID_SURFACE_SWAPCHAIN_EXTENSION_NAME "XR_KHR_android_surface_swapchain"
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSwapchainAndroidSurfaceKHR)(XrSession session, const XrSwapchainCreateInfo* info, XrSwapchain* swapchain, jobject* surface);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSwapchainAndroidSurfaceKHR(
+ XrSession session,
+ const XrSwapchainCreateInfo* info,
+ XrSwapchain* swapchain,
+ jobject* surface);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_KHR_android_create_instance is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_android_create_instance 1
+#define XR_KHR_android_create_instance_SPEC_VERSION 3
+#define XR_KHR_ANDROID_CREATE_INSTANCE_EXTENSION_NAME "XR_KHR_android_create_instance"
+// XrInstanceCreateInfoAndroidKHR extends XrInstanceCreateInfo
+typedef struct XrInstanceCreateInfoAndroidKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ void* XR_MAY_ALIAS applicationVM;
+ void* XR_MAY_ALIAS applicationActivity;
+} XrInstanceCreateInfoAndroidKHR;
+
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_KHR_vulkan_swapchain_format_list is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_vulkan_swapchain_format_list 1
+#define XR_KHR_vulkan_swapchain_format_list_SPEC_VERSION 5
+#define XR_KHR_VULKAN_SWAPCHAIN_FORMAT_LIST_EXTENSION_NAME "XR_KHR_vulkan_swapchain_format_list"
+// XrVulkanSwapchainFormatListCreateInfoKHR extends XrSwapchainCreateInfo
+typedef struct XrVulkanSwapchainFormatListCreateInfoKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ uint32_t viewFormatCount;
+ const VkFormat* viewFormats;
+} XrVulkanSwapchainFormatListCreateInfoKHR;
+
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_GRAPHICS_API_OPENGL
+
+// XR_KHR_opengl_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_opengl_enable 1
+#define XR_KHR_opengl_enable_SPEC_VERSION 12
+#define XR_KHR_OPENGL_ENABLE_EXTENSION_NAME "XR_KHR_opengl_enable"
+#ifdef XR_USE_PLATFORM_WIN32
+// XrGraphicsBindingOpenGLWin32KHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingOpenGLWin32KHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ HDC hDC;
+ HGLRC hGLRC;
+} XrGraphicsBindingOpenGLWin32KHR;
+#endif // XR_USE_PLATFORM_WIN32
+
+#ifdef XR_USE_PLATFORM_XLIB
+// XrGraphicsBindingOpenGLXlibKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingOpenGLXlibKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ Display* xDisplay;
+ uint32_t visualid;
+ GLXFBConfig glxFBConfig;
+ GLXDrawable glxDrawable;
+ GLXContext glxContext;
+} XrGraphicsBindingOpenGLXlibKHR;
+#endif // XR_USE_PLATFORM_XLIB
+
+#ifdef XR_USE_PLATFORM_XCB
+// XrGraphicsBindingOpenGLXcbKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingOpenGLXcbKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ xcb_connection_t* connection;
+ uint32_t screenNumber;
+ xcb_glx_fbconfig_t fbconfigid;
+ xcb_visualid_t visualid;
+ xcb_glx_drawable_t glxDrawable;
+ xcb_glx_context_t glxContext;
+} XrGraphicsBindingOpenGLXcbKHR;
+#endif // XR_USE_PLATFORM_XCB
+
+#ifdef XR_USE_PLATFORM_WAYLAND
+// XrGraphicsBindingOpenGLWaylandKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingOpenGLWaylandKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ struct wl_display* display;
+} XrGraphicsBindingOpenGLWaylandKHR;
+#endif // XR_USE_PLATFORM_WAYLAND
+
+typedef struct XrSwapchainImageOpenGLKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t image;
+} XrSwapchainImageOpenGLKHR;
+
+typedef struct XrGraphicsRequirementsOpenGLKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVersion minApiVersionSupported;
+ XrVersion maxApiVersionSupported;
+} XrGraphicsRequirementsOpenGLKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetOpenGLGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsOpenGLKHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetOpenGLGraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsOpenGLKHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_OPENGL */
+
+#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
+
+// XR_KHR_opengl_es_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_opengl_es_enable 1
+#define XR_KHR_opengl_es_enable_SPEC_VERSION 10
+#define XR_KHR_OPENGL_ES_ENABLE_EXTENSION_NAME "XR_KHR_opengl_es_enable"
+#ifdef XR_USE_PLATFORM_ANDROID
+// XrGraphicsBindingOpenGLESAndroidKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingOpenGLESAndroidKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ EGLDisplay display;
+ EGLConfig config;
+ EGLContext context;
+} XrGraphicsBindingOpenGLESAndroidKHR;
+#endif // XR_USE_PLATFORM_ANDROID
+
+typedef struct XrSwapchainImageOpenGLESKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t image;
+} XrSwapchainImageOpenGLESKHR;
+
+typedef struct XrGraphicsRequirementsOpenGLESKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVersion minApiVersionSupported;
+ XrVersion maxApiVersionSupported;
+} XrGraphicsRequirementsOpenGLESKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetOpenGLESGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsOpenGLESKHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetOpenGLESGraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsOpenGLESKHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_OPENGL_ES */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_KHR_vulkan_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_vulkan_enable 1
+#define XR_KHR_vulkan_enable_SPEC_VERSION 10
+#define XR_KHR_VULKAN_ENABLE_EXTENSION_NAME "XR_KHR_vulkan_enable"
+// XrGraphicsBindingVulkanKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingVulkanKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ VkInstance instance;
+ VkPhysicalDevice physicalDevice;
+ VkDevice device;
+ uint32_t queueFamilyIndex;
+ uint32_t queueIndex;
+} XrGraphicsBindingVulkanKHR;
+
+typedef struct XrSwapchainImageVulkanKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ VkImage image;
+} XrSwapchainImageVulkanKHR;
+
+typedef struct XrGraphicsRequirementsVulkanKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrVersion minApiVersionSupported;
+ XrVersion maxApiVersionSupported;
+} XrGraphicsRequirementsVulkanKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanInstanceExtensionsKHR)(XrInstance instance, XrSystemId systemId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanDeviceExtensionsKHR)(XrInstance instance, XrSystemId systemId, uint32_t bufferCapacityInput, uint32_t* bufferCountOutput, char* buffer);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsDeviceKHR)(XrInstance instance, XrSystemId systemId, VkInstance vkInstance, VkPhysicalDevice* vkPhysicalDevice);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanInstanceExtensionsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanDeviceExtensionsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ uint32_t bufferCapacityInput,
+ uint32_t* bufferCountOutput,
+ char* buffer);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsDeviceKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ VkInstance vkInstance,
+ VkPhysicalDevice* vkPhysicalDevice);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_GRAPHICS_API_D3D11
+
+// XR_KHR_D3D11_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_D3D11_enable 1
+#define XR_KHR_D3D11_enable_SPEC_VERSION 11
+#define XR_KHR_D3D11_ENABLE_EXTENSION_NAME "XR_KHR_D3D11_enable"
+// XrGraphicsBindingD3D11KHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingD3D11KHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ ID3D11Device* device;
+} XrGraphicsBindingD3D11KHR;
+
+typedef struct XrSwapchainImageD3D11KHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ ID3D11Texture2D* texture;
+} XrSwapchainImageD3D11KHR;
+
+typedef struct XrGraphicsRequirementsD3D11KHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ LUID adapterLuid;
+ D3D_FEATURE_LEVEL minFeatureLevel;
+} XrGraphicsRequirementsD3D11KHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetD3D11GraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsD3D11KHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetD3D11GraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsD3D11KHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_D3D11 */
+
+#ifdef XR_USE_GRAPHICS_API_D3D12
+
+// XR_KHR_D3D12_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_D3D12_enable 1
+#define XR_KHR_D3D12_enable_SPEC_VERSION 11
+#define XR_KHR_D3D12_ENABLE_EXTENSION_NAME "XR_KHR_D3D12_enable"
+// XrGraphicsBindingD3D12KHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingD3D12KHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ ID3D12Device* device;
+ ID3D12CommandQueue* queue;
+} XrGraphicsBindingD3D12KHR;
+
+typedef struct XrSwapchainImageD3D12KHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ ID3D12Resource* texture;
+} XrSwapchainImageD3D12KHR;
+
+typedef struct XrGraphicsRequirementsD3D12KHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ LUID adapterLuid;
+ D3D_FEATURE_LEVEL minFeatureLevel;
+} XrGraphicsRequirementsD3D12KHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetD3D12GraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsD3D12KHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetD3D12GraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsD3D12KHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_D3D12 */
+
+#ifdef XR_USE_GRAPHICS_API_METAL
+
+// XR_KHR_metal_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_metal_enable 1
+#define XR_KHR_metal_enable_SPEC_VERSION 3
+#define XR_KHR_METAL_ENABLE_EXTENSION_NAME "XR_KHR_metal_enable"
+// XrGraphicsBindingMetalKHR extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingMetalKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ void* XR_MAY_ALIAS commandQueue;
+} XrGraphicsBindingMetalKHR;
+
+typedef struct XrSwapchainImageMetalKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ void* XR_MAY_ALIAS texture;
+} XrSwapchainImageMetalKHR;
+
+typedef struct XrGraphicsRequirementsMetalKHR {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ void* XR_MAY_ALIAS metalDevice;
+} XrGraphicsRequirementsMetalKHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrGetMetalGraphicsRequirementsKHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsMetalKHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetMetalGraphicsRequirementsKHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsMetalKHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_METAL */
+
+#ifdef XR_USE_PLATFORM_WIN32
+
+// XR_KHR_win32_convert_performance_counter_time is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_win32_convert_performance_counter_time 1
+#define XR_KHR_win32_convert_performance_counter_time_SPEC_VERSION 1
+#define XR_KHR_WIN32_CONVERT_PERFORMANCE_COUNTER_TIME_EXTENSION_NAME "XR_KHR_win32_convert_performance_counter_time"
+typedef XrResult (XRAPI_PTR *PFN_xrConvertWin32PerformanceCounterToTimeKHR)(XrInstance instance, const LARGE_INTEGER* performanceCounter, XrTime* time);
+typedef XrResult (XRAPI_PTR *PFN_xrConvertTimeToWin32PerformanceCounterKHR)(XrInstance instance, XrTime time, LARGE_INTEGER* performanceCounter);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrConvertWin32PerformanceCounterToTimeKHR(
+ XrInstance instance,
+ const LARGE_INTEGER* performanceCounter,
+ XrTime* time);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimeToWin32PerformanceCounterKHR(
+ XrInstance instance,
+ XrTime time,
+ LARGE_INTEGER* performanceCounter);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_WIN32 */
+
+#ifdef XR_USE_TIMESPEC
+
+// XR_KHR_convert_timespec_time is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_convert_timespec_time 1
+#define XR_KHR_convert_timespec_time_SPEC_VERSION 1
+#define XR_KHR_CONVERT_TIMESPEC_TIME_EXTENSION_NAME "XR_KHR_convert_timespec_time"
+typedef XrResult (XRAPI_PTR *PFN_xrConvertTimespecTimeToTimeKHR)(XrInstance instance, const struct timespec* timespecTime, XrTime* time);
+typedef XrResult (XRAPI_PTR *PFN_xrConvertTimeToTimespecTimeKHR)(XrInstance instance, XrTime time, struct timespec* timespecTime);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimespecTimeToTimeKHR(
+ XrInstance instance,
+ const struct timespec* timespecTime,
+ XrTime* time);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrConvertTimeToTimespecTimeKHR(
+ XrInstance instance,
+ XrTime time,
+ struct timespec* timespecTime);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_TIMESPEC */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_KHR_loader_init_android is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_loader_init_android 1
+#define XR_KHR_loader_init_android_SPEC_VERSION 1
+#define XR_KHR_LOADER_INIT_ANDROID_EXTENSION_NAME "XR_KHR_loader_init_android"
+typedef struct XrLoaderInitInfoAndroidKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ void* XR_MAY_ALIAS applicationVM;
+ void* XR_MAY_ALIAS applicationContext;
+} XrLoaderInitInfoAndroidKHR;
+
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_KHR_vulkan_enable2 is a preprocessor guard. Do not pass it to API calls.
+#define XR_KHR_vulkan_enable2 1
+#define XR_KHR_vulkan_enable2_SPEC_VERSION 4
+#define XR_KHR_VULKAN_ENABLE2_EXTENSION_NAME "XR_KHR_vulkan_enable2"
+typedef XrFlags64 XrVulkanInstanceCreateFlagsKHR;
+
+// Flag bits for XrVulkanInstanceCreateFlagsKHR
+
+typedef XrFlags64 XrVulkanDeviceCreateFlagsKHR;
+
+// Flag bits for XrVulkanDeviceCreateFlagsKHR
+
+typedef struct XrVulkanInstanceCreateInfoKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSystemId systemId;
+ XrVulkanInstanceCreateFlagsKHR createFlags;
+ PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr;
+ const VkInstanceCreateInfo* vulkanCreateInfo;
+ const VkAllocationCallbacks* vulkanAllocator;
+} XrVulkanInstanceCreateInfoKHR;
+
+typedef struct XrVulkanDeviceCreateInfoKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSystemId systemId;
+ XrVulkanDeviceCreateFlagsKHR createFlags;
+ PFN_vkGetInstanceProcAddr pfnGetInstanceProcAddr;
+ VkPhysicalDevice vulkanPhysicalDevice;
+ const VkDeviceCreateInfo* vulkanCreateInfo;
+ const VkAllocationCallbacks* vulkanAllocator;
+} XrVulkanDeviceCreateInfoKHR;
+
+typedef XrGraphicsBindingVulkanKHR XrGraphicsBindingVulkan2KHR;
+
+typedef struct XrVulkanGraphicsDeviceGetInfoKHR {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSystemId systemId;
+ VkInstance vulkanInstance;
+} XrVulkanGraphicsDeviceGetInfoKHR;
+
+typedef XrSwapchainImageVulkanKHR XrSwapchainImageVulkan2KHR;
+
+typedef XrGraphicsRequirementsVulkanKHR XrGraphicsRequirementsVulkan2KHR;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateVulkanInstanceKHR)(XrInstance instance, const XrVulkanInstanceCreateInfoKHR* createInfo, VkInstance* vulkanInstance, VkResult* vulkanResult);
+typedef XrResult (XRAPI_PTR *PFN_xrCreateVulkanDeviceKHR)(XrInstance instance, const XrVulkanDeviceCreateInfoKHR* createInfo, VkDevice* vulkanDevice, VkResult* vulkanResult);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsDevice2KHR)(XrInstance instance, const XrVulkanGraphicsDeviceGetInfoKHR* getInfo, VkPhysicalDevice* vulkanPhysicalDevice);
+typedef XrResult (XRAPI_PTR *PFN_xrGetVulkanGraphicsRequirements2KHR)(XrInstance instance, XrSystemId systemId, XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateVulkanInstanceKHR(
+ XrInstance instance,
+ const XrVulkanInstanceCreateInfoKHR* createInfo,
+ VkInstance* vulkanInstance,
+ VkResult* vulkanResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateVulkanDeviceKHR(
+ XrInstance instance,
+ const XrVulkanDeviceCreateInfoKHR* createInfo,
+ VkDevice* vulkanDevice,
+ VkResult* vulkanResult);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsDevice2KHR(
+ XrInstance instance,
+ const XrVulkanGraphicsDeviceGetInfoKHR* getInfo,
+ VkPhysicalDevice* vulkanPhysicalDevice);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetVulkanGraphicsRequirements2KHR(
+ XrInstance instance,
+ XrSystemId systemId,
+ XrGraphicsRequirementsVulkanKHR* graphicsRequirements);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_PLATFORM_EGL
+
+// XR_MNDX_egl_enable is a preprocessor guard. Do not pass it to API calls.
+#define XR_MNDX_egl_enable 1
+#define XR_MNDX_egl_enable_SPEC_VERSION 2
+#define XR_MNDX_EGL_ENABLE_EXTENSION_NAME "XR_MNDX_egl_enable"
+typedef PFN_xrVoidFunction (*PFN_xrEglGetProcAddressMNDX)(const char *name);
+// XrGraphicsBindingEGLMNDX extends XrSessionCreateInfo
+typedef struct XrGraphicsBindingEGLMNDX {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ PFN_xrEglGetProcAddressMNDX getProcAddress;
+ EGLDisplay display;
+ EGLConfig config;
+ EGLContext context;
+} XrGraphicsBindingEGLMNDX;
+
+#endif /* XR_USE_PLATFORM_EGL */
+
+#ifdef XR_USE_PLATFORM_WIN32
+
+// XR_MSFT_perception_anchor_interop is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_perception_anchor_interop 1
+#define XR_MSFT_perception_anchor_interop_SPEC_VERSION 1
+#define XR_MSFT_PERCEPTION_ANCHOR_INTEROP_EXTENSION_NAME "XR_MSFT_perception_anchor_interop"
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpatialAnchorFromPerceptionAnchorMSFT)(XrSession session, IUnknown* perceptionAnchor, XrSpatialAnchorMSFT* anchor);
+typedef XrResult (XRAPI_PTR *PFN_xrTryGetPerceptionAnchorFromSpatialAnchorMSFT)(XrSession session, XrSpatialAnchorMSFT anchor, IUnknown** perceptionAnchor);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpatialAnchorFromPerceptionAnchorMSFT(
+ XrSession session,
+ IUnknown* perceptionAnchor,
+ XrSpatialAnchorMSFT* anchor);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrTryGetPerceptionAnchorFromSpatialAnchorMSFT(
+ XrSession session,
+ XrSpatialAnchorMSFT anchor,
+ IUnknown** perceptionAnchor);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_WIN32 */
+
+#ifdef XR_USE_PLATFORM_WIN32
+
+// XR_MSFT_holographic_window_attachment is a preprocessor guard. Do not pass it to API calls.
+#define XR_MSFT_holographic_window_attachment 1
+#define XR_MSFT_holographic_window_attachment_SPEC_VERSION 1
+#define XR_MSFT_HOLOGRAPHIC_WINDOW_ATTACHMENT_EXTENSION_NAME "XR_MSFT_holographic_window_attachment"
+#ifdef XR_USE_PLATFORM_WIN32
+// XrHolographicWindowAttachmentMSFT extends XrSessionCreateInfo
+typedef struct XrHolographicWindowAttachmentMSFT {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ IUnknown* holographicSpace;
+ IUnknown* coreWindow;
+} XrHolographicWindowAttachmentMSFT;
+#endif // XR_USE_PLATFORM_WIN32
+
+#endif /* XR_USE_PLATFORM_WIN32 */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_FB_android_surface_swapchain_create is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_android_surface_swapchain_create 1
+#define XR_FB_android_surface_swapchain_create_SPEC_VERSION 1
+#define XR_FB_ANDROID_SURFACE_SWAPCHAIN_CREATE_EXTENSION_NAME "XR_FB_android_surface_swapchain_create"
+typedef XrFlags64 XrAndroidSurfaceSwapchainFlagsFB;
+
+// Flag bits for XrAndroidSurfaceSwapchainFlagsFB
+static const XrAndroidSurfaceSwapchainFlagsFB XR_ANDROID_SURFACE_SWAPCHAIN_SYNCHRONOUS_BIT_FB = 0x00000001;
+static const XrAndroidSurfaceSwapchainFlagsFB XR_ANDROID_SURFACE_SWAPCHAIN_USE_TIMESTAMPS_BIT_FB = 0x00000002;
+
+#ifdef XR_USE_PLATFORM_ANDROID
+// XrAndroidSurfaceSwapchainCreateInfoFB extends XrSwapchainCreateInfo
+typedef struct XrAndroidSurfaceSwapchainCreateInfoFB {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrAndroidSurfaceSwapchainFlagsFB createFlags;
+} XrAndroidSurfaceSwapchainCreateInfoFB;
+#endif // XR_USE_PLATFORM_ANDROID
+
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_PLATFORM_ML
+
+// XR_ML_compat is a preprocessor guard. Do not pass it to API calls.
+#define XR_ML_compat 1
+#define XR_ML_compat_SPEC_VERSION 1
+#define XR_ML_COMPAT_EXTENSION_NAME "XR_ML_compat"
+typedef struct XrCoordinateSpaceCreateInfoML {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ MLCoordinateFrameUID cfuid;
+ XrPosef poseInCoordinateSpace;
+} XrCoordinateSpaceCreateInfoML;
+
+typedef XrResult (XRAPI_PTR *PFN_xrCreateSpaceFromCoordinateFrameUIDML)(XrSession session, const XrCoordinateSpaceCreateInfoML *createInfo, XrSpace* space);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrCreateSpaceFromCoordinateFrameUIDML(
+ XrSession session,
+ const XrCoordinateSpaceCreateInfoML * createInfo,
+ XrSpace* space);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_ML */
+
+#ifdef XR_USE_PLATFORM_WIN32
+
+// XR_OCULUS_audio_device_guid is a preprocessor guard. Do not pass it to API calls.
+#define XR_OCULUS_audio_device_guid 1
+#define XR_OCULUS_audio_device_guid_SPEC_VERSION 1
+#define XR_OCULUS_AUDIO_DEVICE_GUID_EXTENSION_NAME "XR_OCULUS_audio_device_guid"
+#define XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS 128
+typedef XrResult (XRAPI_PTR *PFN_xrGetAudioOutputDeviceGuidOculus)(XrInstance instance, wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
+typedef XrResult (XRAPI_PTR *PFN_xrGetAudioInputDeviceGuidOculus)(XrInstance instance, wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAudioOutputDeviceGuidOculus(
+ XrInstance instance,
+ wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrGetAudioInputDeviceGuidOculus(
+ XrInstance instance,
+ wchar_t buffer[XR_MAX_AUDIO_DEVICE_STR_SIZE_OCULUS]);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_WIN32 */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_FB_foveation_vulkan is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_foveation_vulkan 1
+#define XR_FB_foveation_vulkan_SPEC_VERSION 1
+#define XR_FB_FOVEATION_VULKAN_EXTENSION_NAME "XR_FB_foveation_vulkan"
+// XrSwapchainImageFoveationVulkanFB extends XrSwapchainImageVulkanKHR
+typedef struct XrSwapchainImageFoveationVulkanFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ VkImage image;
+ uint32_t width;
+ uint32_t height;
+} XrSwapchainImageFoveationVulkanFB;
+
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_FB_swapchain_update_state_android_surface is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_swapchain_update_state_android_surface 1
+#define XR_FB_swapchain_update_state_android_surface_SPEC_VERSION 1
+#define XR_FB_SWAPCHAIN_UPDATE_STATE_ANDROID_SURFACE_EXTENSION_NAME "XR_FB_swapchain_update_state_android_surface"
+#ifdef XR_USE_PLATFORM_ANDROID
+typedef struct XrSwapchainStateAndroidSurfaceDimensionsFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ uint32_t width;
+ uint32_t height;
+} XrSwapchainStateAndroidSurfaceDimensionsFB;
+#endif // XR_USE_PLATFORM_ANDROID
+
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
+
+// XR_FB_swapchain_update_state_opengl_es is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_swapchain_update_state_opengl_es 1
+#define XR_FB_swapchain_update_state_opengl_es_SPEC_VERSION 1
+#define XR_FB_SWAPCHAIN_UPDATE_STATE_OPENGL_ES_EXTENSION_NAME "XR_FB_swapchain_update_state_opengl_es"
+#ifdef XR_USE_GRAPHICS_API_OPENGL_ES
+typedef struct XrSwapchainStateSamplerOpenGLESFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ EGLenum minFilter;
+ EGLenum magFilter;
+ EGLenum wrapModeS;
+ EGLenum wrapModeT;
+ EGLenum swizzleRed;
+ EGLenum swizzleGreen;
+ EGLenum swizzleBlue;
+ EGLenum swizzleAlpha;
+ float maxAnisotropy;
+ XrColor4f borderColor;
+} XrSwapchainStateSamplerOpenGLESFB;
+#endif // XR_USE_GRAPHICS_API_OPENGL_ES
+
+#endif /* XR_USE_GRAPHICS_API_OPENGL_ES */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_FB_swapchain_update_state_vulkan is a preprocessor guard. Do not pass it to API calls.
+#define XR_FB_swapchain_update_state_vulkan 1
+#define XR_FB_swapchain_update_state_vulkan_SPEC_VERSION 1
+#define XR_FB_SWAPCHAIN_UPDATE_STATE_VULKAN_EXTENSION_NAME "XR_FB_swapchain_update_state_vulkan"
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+typedef struct XrSwapchainStateSamplerVulkanFB {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ VkFilter minFilter;
+ VkFilter magFilter;
+ VkSamplerMipmapMode mipmapMode;
+ VkSamplerAddressMode wrapModeS;
+ VkSamplerAddressMode wrapModeT;
+ VkComponentSwizzle swizzleRed;
+ VkComponentSwizzle swizzleGreen;
+ VkComponentSwizzle swizzleBlue;
+ VkComponentSwizzle swizzleAlpha;
+ float maxAnisotropy;
+ XrColor4f borderColor;
+} XrSwapchainStateSamplerVulkanFB;
+#endif // XR_USE_GRAPHICS_API_VULKAN
+
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_GRAPHICS_API_VULKAN
+
+// XR_META_vulkan_swapchain_create_info is a preprocessor guard. Do not pass it to API calls.
+#define XR_META_vulkan_swapchain_create_info 1
+#define XR_META_vulkan_swapchain_create_info_SPEC_VERSION 1
+#define XR_META_VULKAN_SWAPCHAIN_CREATE_INFO_EXTENSION_NAME "XR_META_vulkan_swapchain_create_info"
+// XrVulkanSwapchainCreateInfoMETA extends XrSwapchainCreateInfo
+typedef struct XrVulkanSwapchainCreateInfoMETA {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ VkImageCreateFlags additionalCreateFlags;
+ VkImageUsageFlags additionalUsageFlags;
+} XrVulkanSwapchainCreateInfoMETA;
+
+#endif /* XR_USE_GRAPHICS_API_VULKAN */
+
+#ifdef XR_USE_PLATFORM_ANDROID
+
+// XR_ANDROID_anchor_sharing_export is a preprocessor guard. Do not pass it to API calls.
+#define XR_ANDROID_anchor_sharing_export 1
+#define XR_ANDROID_anchor_sharing_export_SPEC_VERSION 1
+#define XR_ANDROID_ANCHOR_SHARING_EXPORT_EXTENSION_NAME "XR_ANDROID_anchor_sharing_export"
+typedef struct XrAnchorSharingInfoANDROID {
+ XrStructureType type;
+ const void* XR_MAY_ALIAS next;
+ XrSpace anchor;
+} XrAnchorSharingInfoANDROID;
+
+typedef struct XrAnchorSharingTokenANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ struct AIBinder* token;
+} XrAnchorSharingTokenANDROID;
+
+// XrSystemAnchorSharingExportPropertiesANDROID extends XrSystemProperties
+typedef struct XrSystemAnchorSharingExportPropertiesANDROID {
+ XrStructureType type;
+ void* XR_MAY_ALIAS next;
+ XrBool32 supportsAnchorSharingExport;
+} XrSystemAnchorSharingExportPropertiesANDROID;
+
+typedef XrResult (XRAPI_PTR *PFN_xrShareAnchorANDROID)(XrSession session, const XrAnchorSharingInfoANDROID* sharingInfo, XrAnchorSharingTokenANDROID* anchorToken);
+typedef XrResult (XRAPI_PTR *PFN_xrUnshareAnchorANDROID)(XrSession session, XrSpace anchor);
+
+#ifndef XR_NO_PROTOTYPES
+#ifdef XR_EXTENSION_PROTOTYPES
+XRAPI_ATTR XrResult XRAPI_CALL xrShareAnchorANDROID(
+ XrSession session,
+ const XrAnchorSharingInfoANDROID* sharingInfo,
+ XrAnchorSharingTokenANDROID* anchorToken);
+
+XRAPI_ATTR XrResult XRAPI_CALL xrUnshareAnchorANDROID(
+ XrSession session,
+ XrSpace anchor);
+#endif /* XR_EXTENSION_PROTOTYPES */
+#endif /* !XR_NO_PROTOTYPES */
+#endif /* XR_USE_PLATFORM_ANDROID */
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/frame/openxr-compat/vendor/openxr/openxr_platform_defines.h b/frame/openxr-compat/vendor/openxr/openxr_platform_defines.h
new file mode 100644
index 0000000..79c5b2c
--- /dev/null
+++ b/frame/openxr-compat/vendor/openxr/openxr_platform_defines.h
@@ -0,0 +1,114 @@
+/*
+** Copyright (c) 2017-2026 The Khronos Group Inc.
+**
+** SPDX-License-Identifier: Apache-2.0 OR MIT
+*/
+
+#ifndef OPENXR_PLATFORM_DEFINES_H_
+#define OPENXR_PLATFORM_DEFINES_H_ 1
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* Platform-specific calling convention macros.
+ *
+ * Platforms should define these so that OpenXR clients call OpenXR functions
+ * with the same calling conventions that the OpenXR implementation expects.
+ *
+ * XRAPI_ATTR - Placed before the return type in function declarations.
+ * Useful for C++11 and GCC/Clang-style function attribute syntax.
+ * XRAPI_CALL - Placed after the return type in function declarations.
+ * Useful for MSVC-style calling convention syntax.
+ * XRAPI_PTR - Placed between the '(' and '*' in function pointer types.
+ *
+ * Function declaration: XRAPI_ATTR void XRAPI_CALL xrFunction(void);
+ * Function pointer type: typedef void (XRAPI_PTR *PFN_xrFunction)(void);
+ */
+#if defined(_WIN32)
+#define XRAPI_ATTR
+// On Windows, functions use the stdcall convention
+#define XRAPI_CALL __stdcall
+#define XRAPI_PTR XRAPI_CALL
+#elif defined(__ANDROID__) && defined(__ARM_ARCH) && __ARM_ARCH < 7
+#error "API not supported for the 'armeabi' NDK ABI"
+#elif defined(__ANDROID__) && defined(__ARM_ARCH) && __ARM_ARCH >= 7 && defined(__ARM_32BIT_STATE)
+// On Android 32-bit ARM targets, functions use the "hardfloat"
+// calling convention, i.e. float parameters are passed in registers. This
+// is true even if the rest of the application passes floats on the stack,
+// as it does by default when compiling for the armeabi-v7a NDK ABI.
+#define XRAPI_ATTR __attribute__((pcs("aapcs-vfp")))
+#define XRAPI_CALL
+#define XRAPI_PTR XRAPI_ATTR
+#else
+// On other platforms, use the default calling convention
+#define XRAPI_ATTR
+#define XRAPI_CALL
+#define XRAPI_PTR
+#endif
+
+#include
+
+#if !defined(XR_NO_STDINT_H)
+#if defined(_MSC_VER) && (_MSC_VER < 1600)
+typedef signed __int8 int8_t;
+typedef unsigned __int8 uint8_t;
+typedef signed __int16 int16_t;
+typedef unsigned __int16 uint16_t;
+typedef signed __int32 int32_t;
+typedef unsigned __int32 uint32_t;
+typedef signed __int64 int64_t;
+typedef unsigned __int64 uint64_t;
+#else
+#include
+#endif
+#endif // !defined( XR_NO_STDINT_H )
+
+// XR_PTR_SIZE (in bytes)
+#if (defined(__LP64__) || defined(_WIN64) || (defined(__x86_64__) && !defined(__ILP32__) ) || defined(_M_X64) || defined(__ia64) || defined(_M_IA64) || defined(__aarch64__) || defined(__powerpc64__))
+#define XR_PTR_SIZE 8
+#else
+#define XR_PTR_SIZE 4
+#endif
+
+// Needed so we can use clang __has_feature portably.
+#if !defined(XR_COMPILER_HAS_FEATURE)
+#if defined(__clang__)
+#define XR_COMPILER_HAS_FEATURE(x) __has_feature(x)
+#else
+#define XR_COMPILER_HAS_FEATURE(x) 0
+#endif
+#endif
+
+// Identifies if the current compiler has C++11 support enabled.
+// Does not by itself identify if any given C++11 feature is present.
+#if !defined(XR_CPP11_ENABLED) && defined(__cplusplus)
+#if defined(__GNUC__) && defined(__GXX_EXPERIMENTAL_CXX0X__)
+#define XR_CPP11_ENABLED 1
+#elif defined(_MSC_VER) && (_MSC_VER >= 1600)
+#define XR_CPP11_ENABLED 1
+#elif (__cplusplus >= 201103L) // 201103 is the first C++11 version.
+#define XR_CPP11_ENABLED 1
+#endif
+#endif
+
+// Identifies if the current compiler supports C++11 nullptr.
+#if !defined(XR_CPP_NULLPTR_SUPPORTED)
+#if defined(XR_CPP11_ENABLED) && \
+ ((defined(__clang__) && XR_COMPILER_HAS_FEATURE(cxx_nullptr)) || \
+ (defined(__GNUC__) && (((__GNUC__ * 1000) + __GNUC_MINOR__) >= 4006)) || \
+ (defined(_MSC_VER) && (_MSC_VER >= 1600)) || \
+ (defined(__EDG_VERSION__) && (__EDG_VERSION__ >= 403)))
+#define XR_CPP_NULLPTR_SUPPORTED 1
+#endif
+#endif
+
+#if !defined(XR_CPP_NULLPTR_SUPPORTED)
+#define XR_CPP_NULLPTR_SUPPORTED 0
+#endif // !defined(XR_CPP_NULLPTR_SUPPORTED)
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/ios/FrameControl.xcodeproj/project.pbxproj b/ios/FrameControl.xcodeproj/project.pbxproj
index d23f1b8..dcc0271 100644
--- a/ios/FrameControl.xcodeproj/project.pbxproj
+++ b/ios/FrameControl.xcodeproj/project.pbxproj
@@ -17,6 +17,7 @@
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
+ A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */; };
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.swift */; };
@@ -48,6 +49,7 @@
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = ""; };
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = ""; };
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; };
+ E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = ""; };
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = ""; };
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -107,6 +109,7 @@
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
isa = PBXGroup;
children = (
+ E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
);
path = FrameControlTests;
@@ -247,7 +250,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "mkdir -p \"${DERIVED_FILE_DIR}\"\npython3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n";
+ shellScript = "set -e\nmkdir -p \"${DERIVED_FILE_DIR}\"\n# Written aside and moved into place together, so a failed download can't leave a stale pair.\npython3 \"${SRCROOT}/scripts/make_frame_bundle.py\" \"${DERIVED_FILE_DIR}/frame-bundle.new.tar.gz\" > \"${DERIVED_FILE_DIR}/frame-bundle.new.version\"\nmv \"${DERIVED_FILE_DIR}/frame-bundle.new.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\"\nmv \"${DERIVED_FILE_DIR}/frame-bundle.new.version\" \"${DERIVED_FILE_DIR}/frame-bundle.version\"\nmkdir -p \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\ncp \"${DERIVED_FILE_DIR}/frame-bundle.tar.gz\" \"${DERIVED_FILE_DIR}/frame-bundle.version\" \"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/\"\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -274,6 +277,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
diff --git a/ios/FrameControl/App/AppModel.swift b/ios/FrameControl/App/AppModel.swift
index 94e25fa..c56f144 100644
--- a/ios/FrameControl/App/AppModel.swift
+++ b/ios/FrameControl/App/AppModel.swift
@@ -1,4 +1,5 @@
import Citadel
+import CryptoKit
import Foundation
import SwiftUI
import UIKit
@@ -33,6 +34,11 @@ final class AppModel: ObservableObject {
}
var deviceName: String { UIDevice.current.userInterfaceIdiom == .pad ? "iPad" : "iPhone" }
+ /// Stable per install: a hash of this device's SSH key, which is made once and kept in the Keychain.
+ var clientID: String {
+ let digest = SHA256.hash(data: Data(authorizedKeysLine.utf8))
+ return deviceName.lowercased() + "-" + digest.prefix(6).map { String(format: "%02x", $0) }.joined()
+ }
private var hostKey: String? { UserDefaults.standard.string(forKey: Self.hostKeyKey) }
// MARK: pairing
@@ -167,7 +173,7 @@ final class AppModel: ObservableObject {
guard current() else { throw CancellationError() }
step("Starting Frame Control on the headset")
let key = Self.randomKey()
- let server = try await HeadsetServer.start(in: dir, over: l, key: key, device: deviceName)
+ let server = try await HeadsetServer.start(in: dir, over: l, key: key, device: deviceName, client: clientID)
guard current() else { throw CancellationError() }
let f = try await PortForwarder.start(over: l, to: server.port)
forwarder = f
diff --git a/ios/FrameControl/SSH/HeadsetServer.swift b/ios/FrameControl/SSH/HeadsetServer.swift
index 443e99d..d59388d 100644
--- a/ios/FrameControl/SSH/HeadsetServer.swift
+++ b/ios/FrameControl/SSH/HeadsetServer.swift
@@ -80,8 +80,10 @@ final class HeadsetServer: @unchecked Sendable {
}
/// Starts the server in dir and waits for it to say which port it took.
- static func start(in dir: String, over link: FrameLink, key: String, device: String) async throws -> HeadsetServer {
+ /// client is a stable id for this install (keyboard-and-trackpad pairing is kept per client).
+ static func start(in dir: String, over link: FrameLink, key: String, device: String, client: String) async throws -> HeadsetServer {
let command = "cd \(dir) && FRAME_LOCAL=1 FRAME_UI_KEY=\(key) FRAME_DEVICE=\(shellQuote(device)) "
+ + "FRAME_CLIENT=\(shellQuote(client)) "
+ "exec python3 -I -u -B \"$PWD/ui/server.py\" --port 0 --exit-on-eof 2>&1"
let stream = try await link.client.executeCommandStream(command)
let box = PortWaiter()
diff --git a/ios/FrameControl/Web/WebShell.swift b/ios/FrameControl/Web/WebShell.swift
index fee7df2..3ddbe4d 100644
--- a/ios/FrameControl/Web/WebShell.swift
+++ b/ios/FrameControl/Web/WebShell.swift
@@ -1,6 +1,7 @@
import SwiftUI
import UIKit
import WebKit
+import UserNotifications
/// The Frame Control page, served by the server on the headset, in a web view.
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
@@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable {
let installCb = null;
window.frameApp = {
platform: "ios",
+ notify: (message, request) => call("notify", { message, request }),
readClipboard: () => call("readClipboard"),
setUpConnection: () => call("setUpConnection"),
open: (what) => call("open", what),
@@ -58,13 +60,31 @@ struct WebShell: UIViewRepresentable {
})();
"""
- final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate {
+ final class Coordinator: NSObject, WKScriptMessageHandlerWithReply, WKNavigationDelegate, WKUIDelegate, UNUserNotificationCenterDelegate {
let model: AppModel
weak var web: WKWebView?
var loaded: URL?
private var installReady = false
- init(model: AppModel) { self.model = model }
+ init(model: AppModel) {
+ self.model = model
+ super.init()
+ UNUserNotificationCenter.current().delegate = self
+ }
+
+ func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
+ completionHandler([.banner, .sound, .list])
+ }
+
+ static func notificationContent(_ message: String) -> UNMutableNotificationContent? {
+ guard !message.isEmpty, message.count <= 500 else { return nil }
+ let content = UNMutableNotificationContent()
+ content.title = "Frame Control"
+ content.body = message
+ content.sound = .default
+ return content
+ }
// MARK: bridge
@@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable {
}
let arg = body["arg"]
switch name {
+ case "notify":
+ guard message.frameInfo.isMainFrame,
+ message.frameInfo.securityOrigin.host == "127.0.0.1",
+ let args = arg as? [String: Any], let text = args["message"] as? String,
+ let content = Self.notificationContent(text) else {
+ return replyHandler(nil, "Invalid notification")
+ }
+ let center = UNUserNotificationCenter.current()
+ let send: (Bool, Error?) -> Void = { allowed, error in
+ guard allowed else {
+ return replyHandler(nil, error?.localizedDescription ?? "Notifications are off. Enable them in iOS Settings.")
+ }
+ let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
+ center.add(request) { error in replyHandler(error == nil, error?.localizedDescription) }
+ }
+ if args["request"] as? Bool == true {
+ center.requestAuthorization(options: [.alert, .sound], completionHandler: send)
+ } else {
+ center.getNotificationSettings { settings in
+ send(settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional, nil)
+ }
+ }
case "readClipboard":
replyHandler(UIPasteboard.general.string ?? "", nil)
case "setUpConnection":
diff --git a/ios/FrameControlTests/ComfortNotificationTests.swift b/ios/FrameControlTests/ComfortNotificationTests.swift
new file mode 100644
index 0000000..68fe4c1
--- /dev/null
+++ b/ios/FrameControlTests/ComfortNotificationTests.swift
@@ -0,0 +1,14 @@
+import XCTest
+import UserNotifications
+@testable import Frame_Control
+
+final class ComfortNotificationTests: XCTestCase {
+ func testNotificationContentAndBounds() {
+ let content = WebShell.Coordinator.notificationContent("Time for a break")
+ XCTAssertEqual(content?.title, "Frame Control")
+ XCTAssertEqual(content?.body, "Time for a break")
+ XCTAssertNotNil(content?.sound)
+ XCTAssertNil(WebShell.Coordinator.notificationContent(""))
+ XCTAssertNil(WebShell.Coordinator.notificationContent(String(repeating: "x", count: 501)))
+ }
+}
diff --git a/ios/project.yml b/ios/project.yml
index 2b9743b..30a5aa8 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -52,8 +52,12 @@ targets:
- name: Pack the Frame bundle
# The server, headset helpers and catalogue, as the app copies them to the Frame.
script: |
+ set -e
mkdir -p "${DERIVED_FILE_DIR}"
- python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.version"
+ # Written aside and moved into place together, so a failed download can't leave a stale pair.
+ python3 "${SRCROOT}/scripts/make_frame_bundle.py" "${DERIVED_FILE_DIR}/frame-bundle.new.tar.gz" > "${DERIVED_FILE_DIR}/frame-bundle.new.version"
+ mv "${DERIVED_FILE_DIR}/frame-bundle.new.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.tar.gz"
+ mv "${DERIVED_FILE_DIR}/frame-bundle.new.version" "${DERIVED_FILE_DIR}/frame-bundle.version"
mkdir -p "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}"
cp "${DERIVED_FILE_DIR}/frame-bundle.tar.gz" "${DERIVED_FILE_DIR}/frame-bundle.version" "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/"
basedOnDependencyAnalysis: false
diff --git a/ios/scripts/make_frame_bundle.py b/ios/scripts/make_frame_bundle.py
index c26083b..ef487a5 100644
--- a/ios/scripts/make_frame_bundle.py
+++ b/ios/scripts/make_frame_bundle.py
@@ -1,6 +1,8 @@
#!/usr/bin/env python3
"""Pack what Frame Control's server needs to run on the Frame itself (the files the
-desktop app ships, plus ui/local-bin) into one reproducible .tar.gz.
+desktop app ships, plus ui/local-bin) into one reproducible .tar.gz. That includes
+KDE Connect for the keyboard and trackpad (frame/kdeconnect), downloaded first and
+checked against its pinned SHA-256s.
The iPhone app copies it to ~/.cache/frame-control/ on the Frame and
starts ui/server.py there. is the SHA-256 of the archive, so a new
@@ -17,7 +19,9 @@ 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",
- "frame/devkit-utils/**/*", "apk-catalog/*.py", "apk-catalog/pins.json", "apk-catalog/site/apps.js"]
+ "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"]
def files():
@@ -47,6 +51,9 @@ def build():
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit(__doc__)
+ sys.path.insert(0, str(ROOT / "frame" / "kdeconnect"))
+ import fetch
+ fetch.fetch()
data = build()
Path(sys.argv[1]).write_bytes(data)
print(hashlib.sha256(data).hexdigest()[:16])
diff --git a/mac/frame-mac-view/Sources/CGVirtualDisplay.h b/mac/frame-mac-view/Sources/CGVirtualDisplay.h
new file mode 100644
index 0000000..f67f628
--- /dev/null
+++ b/mac/frame-mac-view/Sources/CGVirtualDisplay.h
@@ -0,0 +1,28 @@
+// Private CoreGraphics classes for virtual displays (as used by DeskPad,
+// BetterDisplay and quest-display). Not in any SDK; see
+// docs/mac-window-separation.md.
+#import
+#import
+@interface CGVirtualDisplayDescriptor : NSObject
+@property (retain, nonatomic) dispatch_queue_t queue;
+@property (retain, nonatomic) NSString *name;
+@property (nonatomic) unsigned int maxPixelsHigh;
+@property (nonatomic) unsigned int maxPixelsWide;
+@property (nonatomic) CGSize sizeInMillimeters;
+@property (nonatomic) unsigned int serialNum;
+@property (nonatomic) unsigned int productID;
+@property (nonatomic) unsigned int vendorID;
+@property (copy, nonatomic) void (^terminationHandler)(id, id);
+@end
+@interface CGVirtualDisplayMode : NSObject
+- (instancetype)initWithWidth:(unsigned int)width height:(unsigned int)height refreshRate:(double)refreshRate;
+@end
+@interface CGVirtualDisplaySettings : NSObject
+@property (retain, nonatomic) NSArray *modes;
+@property (nonatomic) unsigned int hiDPI;
+@end
+@interface CGVirtualDisplay : NSObject
+@property (readonly, nonatomic) unsigned int displayID;
+- (instancetype)initWithDescriptor:(CGVirtualDisplayDescriptor *)descriptor;
+- (BOOL)applySettings:(CGVirtualDisplaySettings *)settings;
+@end
diff --git a/mac/frame-mac-view/Sources/Capture.swift b/mac/frame-mac-view/Sources/Capture.swift
new file mode 100644
index 0000000..dcf3b3b
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Capture.swift
@@ -0,0 +1,414 @@
+// Where pictures come from: one Mac window or one display (ScreenCaptureKit),
+// or a generated test pattern that needs no permissions and shows the input
+// the viewer sends, for checking the whole path without touching the Mac.
+import AppKit
+import CoreMedia
+import CoreVideo
+import Foundation
+import ScreenCaptureKit
+
+enum Source: Equatable {
+ case window(CGWindowID)
+ case display(CGDirectDisplayID)
+ case separate(CGWindowID) // the window on a display of its own
+ case test
+
+ init?(_ s: String) {
+ let parts = s.split(separator: ":", maxSplits: 1).map(String.init)
+ switch (parts.first, parts.count > 1 ? UInt32(parts[1]) : nil) {
+ case ("window", let id?): self = .window(id)
+ case ("display", let id?): self = .display(id)
+ case ("separate", let id?): self = .separate(id)
+ case ("test", _): self = .test
+ default: return nil
+ }
+ }
+
+ var key: String {
+ switch self {
+ case .window(let id): return "window:\(id)"
+ case .display(let id): return "display:\(id)"
+ case .separate(let id): return "separate:\(id)"
+ case .test: return "test"
+ }
+ }
+}
+
+/// Size to capture at: the source's pixel size, shrunk to fit a box whose
+/// long side is `maxLong` (default 1920), even numbers for the encoder.
+func fitSize(width: Double, height: Double, maxLong: Int) -> (Int, Int) {
+ let scale = min(1, Double(maxLong) / max(width, height, 1))
+ let w = max(16, Int((width * scale / 2).rounded()) * 2), h = max(16, Int((height * scale / 2).rounded()) * 2)
+ return (w, h)
+}
+
+/// What CGWindowList says about a window right now (no permission needed for
+/// bounds; titles need Screen Recording).
+struct WindowInfo {
+ let id: CGWindowID
+ let pid: pid_t
+ let app: String
+ let title: String
+ let bounds: CGRect
+ let layer: Int
+ let onScreen: Bool
+
+ static func all(onScreenOnly: Bool = true) -> [WindowInfo] {
+ let opts: CGWindowListOption = onScreenOnly ? [.optionOnScreenOnly, .excludeDesktopElements] : [.excludeDesktopElements]
+ let list = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[CFString: Any]] ?? []
+ return list.compactMap(WindowInfo.init)
+ }
+
+ static func find(_ id: CGWindowID) -> WindowInfo? {
+ let list = CGWindowListCopyWindowInfo(.optionIncludingWindow, id) as? [[CFString: Any]] ?? []
+ return list.compactMap(WindowInfo.init).first { $0.id == id }
+ }
+
+ init?(_ d: [CFString: Any]) {
+ guard let id = d[kCGWindowNumber] as? CGWindowID, let pid = d[kCGWindowOwnerPID] as? pid_t,
+ let b = d[kCGWindowBounds] as? [String: Any], let rect = CGRect(dictionaryRepresentation: b as CFDictionary)
+ else { return nil }
+ self.id = id
+ self.pid = pid
+ app = d[kCGWindowOwnerName] as? String ?? ""
+ title = d[kCGWindowName] as? String ?? ""
+ bounds = rect
+ layer = d[kCGWindowLayer] as? Int ?? 0
+ onScreen = d[kCGWindowIsOnscreen] as? Bool ?? false
+ }
+}
+
+protocol CaptureSource: AnyObject {
+ /// The picture, its presentation time, and when the Mac composited it (µs, host clock).
+ var onFrame: ((CVPixelBuffer, CMTime, Int64) -> Void)? { get set }
+ var onEnded: ((String) -> Void)? { get set }
+ var onChange: (() -> Void)? { get set } // title or size changed
+ /// True once the window or display is gone for good.
+ var gone: Bool { get }
+ /// Points on the Mac's global display space that the picture covers.
+ var frameRect: CGRect { get }
+ var title: String { get }
+ var app: String { get }
+ var pid: pid_t? { get }
+ func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void)
+ /// A smaller or larger picture from now on (the long side, in pixels).
+ func setMaxLong(_ maxLong: Int)
+ func stop()
+ func pointer(x: Double, y: Double, text: String?) // for the test pattern
+}
+
+extension CaptureSource {
+ func pointer(x: Double, y: Double, text: String?) {}
+}
+
+/// ScreenCaptureKit, for a window or a display.
+final class SCKSource: NSObject, CaptureSource, SCStreamOutput, SCStreamDelegate {
+ let source: Source
+ var onFrame: ((CVPixelBuffer, CMTime, Int64) -> Void)?
+ var onEnded: ((String) -> Void)?
+ private(set) var frameRect = CGRect.zero
+ private(set) var title = ""
+ private(set) var app = ""
+ private(set) var pid: pid_t?
+ private var stream: SCStream?
+ private var config = SCStreamConfiguration()
+ private var maxLong = 1920
+ private var scale = 2.0
+ private var poll: DispatchSourceTimer?
+ private let queue = DispatchQueue(label: "frame-mac-view.capture", qos: .userInteractive)
+ /// Set by stop(), on `queue`; a start still enumerating windows checks it
+ /// before it starts capturing, so a viewer that left early leaves nothing on.
+ private var cancelled = false
+ /// The window or display no longer exists, so retrying can't help.
+ private(set) var gone = false
+ var onChange: (() -> Void)? // title or size changed
+ /// For a display: capture only this part of it (display points, top-left
+ /// origin). Set before start().
+ var crop: CGRect?
+
+ init(_ source: Source) { self.source = source }
+
+ /// What's captured, in global points: the display, or the cropped part of it.
+ private func displayRect(_ id: CGDirectDisplayID) -> CGRect {
+ let b = CGDisplayBounds(id)
+ guard let c = crop else { return b }
+ return c.offsetBy(dx: b.minX, dy: b.minY).intersection(b)
+ }
+
+ func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
+ self.maxLong = maxLong
+ SCShareableContent.getExcludingDesktopWindows(true, onScreenWindowsOnly: false) { [self] content, error in
+ queue.async { self.begin(content, error, fps: fps, completion: completion) }
+ }
+ }
+
+ private func begin(_ content: SCShareableContent?, _ error: Error?, fps: Int, completion: @escaping (String?) -> Void) {
+ if cancelled { return }
+ guard let content else {
+ return completion("Screen Recording isn't allowed for Frame Control (\(error?.localizedDescription ?? "no content"))")
+ }
+ let filter: SCContentFilter
+ switch source {
+ case .window(let id):
+ guard let w = content.windows.first(where: { $0.windowID == id }) else {
+ gone = true
+ return completion("that window has closed")
+ }
+ filter = SCContentFilter(desktopIndependentWindow: w)
+ title = w.title ?? ""
+ app = w.owningApplication?.applicationName ?? ""
+ pid = w.owningApplication?.processID
+ frameRect = w.frame
+ case .display(let id):
+ guard let d = content.displays.first(where: { $0.displayID == id }) else {
+ gone = true
+ return completion("that display isn't connected")
+ }
+ filter = SCContentFilter(display: d, excludingWindows: [])
+ title = displayName(id)
+ app = "Mac"
+ frameRect = displayRect(id)
+ if crop != nil { config.sourceRect = frameRect.offsetBy(dx: -CGDisplayBounds(id).minX, dy: -CGDisplayBounds(id).minY) }
+ case .test, .separate:
+ return completion("not a ScreenCaptureKit source")
+ }
+ scale = Double(filter.pointPixelScale)
+ // A display's own mode knows best: ScreenCaptureKit can still say 1
+ // for a virtual display that has only just switched to HiDPI.
+ if case .display(let id) = source, let mode = CGDisplayCopyDisplayMode(id), mode.width > 0 {
+ scale = max(scale, Double(mode.pixelWidth) / Double(mode.width))
+ }
+ let (w, h) = fitSize(width: frameRect.width * scale, height: frameRect.height * scale, maxLong: maxLong)
+ config.width = w
+ config.height = h
+ config.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
+ config.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+ config.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2
+ config.colorSpaceName = CGColorSpace.sRGB
+ config.showsCursor = true
+ config.queueDepth = 5
+ config.scalesToFit = true
+ config.preservesAspectRatio = true
+ config.capturesAudio = false
+ let stream = SCStream(filter: filter, configuration: config, delegate: self)
+ do {
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
+ } catch {
+ return completion("couldn't capture: \(error.localizedDescription)")
+ }
+ self.stream = stream
+ stream.startCapture { error in
+ self.queue.async {
+ if self.cancelled {
+ stream.stopCapture { _ in }
+ return
+ }
+ if let error { return completion("couldn't capture: \(error.localizedDescription)") }
+ self.startPolling()
+ completion(nil)
+ }
+ }
+ }
+
+ func stop() {
+ queue.async {
+ self.cancelled = true
+ self.poll?.cancel()
+ self.poll = nil
+ self.stream?.stopCapture { _ in }
+ self.stream = nil
+ }
+ }
+
+ /// Windows move, resize, retitle and close, and displays change mode;
+ /// ScreenCaptureKit doesn't say.
+ private func startPolling() {
+ if case .display(let id) = source { return pollDisplay(id) }
+ guard case .window(let id) = source else { return }
+ let t = DispatchSource.makeTimerSource(queue: queue)
+ t.schedule(deadline: .now() + 1, repeating: 1)
+ t.setEventHandler { [weak self] in
+ guard let self else { return }
+ guard let info = WindowInfo.find(id) else {
+ self.stop()
+ self.onEnded?("the window closed")
+ return
+ }
+ var changed = false
+ if !info.title.isEmpty, info.title != self.title { self.title = info.title; changed = true }
+ let old = self.frameRect
+ self.frameRect = info.bounds
+ if abs(old.width - info.bounds.width) > 1 || abs(old.height - info.bounds.height) > 1 {
+ let (w, h) = fitSize(width: info.bounds.width * self.scale, height: info.bounds.height * self.scale, maxLong: self.maxLong)
+ self.config.width = w
+ self.config.height = h
+ self.stream?.updateConfiguration(self.config) { _ in }
+ changed = true
+ }
+ if changed { self.onChange?() }
+ }
+ t.resume()
+ poll = t
+ }
+
+ func setMaxLong(_ n: Int) {
+ queue.async {
+ self.maxLong = n
+ guard self.stream != nil else { return }
+ let (w, h) = fitSize(width: self.frameRect.width * self.scale, height: self.frameRect.height * self.scale, maxLong: n)
+ guard w != self.config.width || h != self.config.height else { return }
+ self.config.width = w
+ self.config.height = h
+ self.stream?.updateConfiguration(self.config) { _ in }
+ }
+ }
+
+ private func pollDisplay(_ id: CGDirectDisplayID) {
+ let t = DispatchSource.makeTimerSource(queue: queue)
+ t.schedule(deadline: .now() + 0.5, repeating: 1)
+ t.setEventHandler { [weak self] in
+ guard let self, let mode = CGDisplayCopyDisplayMode(id), mode.width > 0 else { return }
+ let bounds = self.displayRect(id), scale = Double(mode.pixelWidth) / Double(mode.width)
+ let (w, h) = fitSize(width: bounds.width * scale, height: bounds.height * scale, maxLong: self.maxLong)
+ self.frameRect = bounds
+ guard w != self.config.width || h != self.config.height else { return }
+ self.scale = scale
+ self.config.width = w
+ self.config.height = h
+ self.stream?.updateConfiguration(self.config) { _ in }
+ self.onChange?()
+ }
+ t.resume()
+ poll = t
+ }
+
+ func stream(_ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType) {
+ guard type == .screen, sample.isValid, let pb = CMSampleBufferGetImageBuffer(sample) else { return }
+ // Only complete frames carry new pixels; idle and blank ones don't.
+ let info = (CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]])?.first
+ if let raw = info?[.status] as? Int, let status = SCFrameStatus(rawValue: raw), status != .complete {
+ return
+ }
+ let pts = CMSampleBufferGetPresentationTimeStamp(sample)
+ // When WindowServer composited it: the moment it showed on the Mac.
+ let shown = (info?[.displayTime] as? UInt64).map(hostUs) ?? hostUs(pts)
+ onFrame?(pb, pts, shown)
+ }
+
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
+ onEnded?("capture stopped: \(error.localizedDescription)")
+ }
+}
+
+func displayName(_ id: CGDirectDisplayID) -> String {
+ for screen in NSScreen.screens {
+ if (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == id {
+ return screen.localizedName
+ }
+ }
+ return "Display \(id)"
+}
+
+/// A moving test card: bars, a clock and a frame counter, plus a dot where the
+/// viewer's pointer is and the last key it sent, so input can be checked too.
+final class TestSource: CaptureSource {
+ var onFrame: ((CVPixelBuffer, CMTime, Int64) -> Void)?
+ var onEnded: ((String) -> Void)?
+ var onChange: (() -> Void)?
+ let gone = false
+ let frameRect = CGRect(x: 0, y: 0, width: 1280, height: 720)
+ let title = "Test pattern"
+ let app = "Frame Control"
+ let pid: pid_t? = nil
+ private var timer: DispatchSourceTimer?
+ private var pool: CVPixelBufferPool?
+ private var n = 0
+ private var dot: (Double, Double)?
+ private var lastText = "Point or type in the headset"
+ private let queue = DispatchQueue(label: "frame-mac-view.test")
+ private var size = (1280, 720)
+
+ func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
+ size = fitSize(width: 1280, height: 720, maxLong: maxLong)
+ makePool()
+ let t = DispatchSource.makeTimerSource(queue: queue)
+ t.schedule(deadline: .now(), repeating: 1.0 / Double(fps))
+ t.setEventHandler { [weak self] in self?.draw() }
+ t.resume()
+ timer = t
+ completion(nil)
+ }
+
+ func stop() {
+ timer?.cancel()
+ timer = nil
+ }
+
+ func setMaxLong(_ n: Int) {
+ queue.async {
+ self.size = fitSize(width: 1280, height: 720, maxLong: n)
+ self.makePool()
+ }
+ }
+
+ private func makePool() {
+ let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferWidthKey: size.0, kCVPixelBufferHeightKey: size.1,
+ kCVPixelBufferIOSurfacePropertiesKey: [:] as CFDictionary]
+ pool = nil
+ CVPixelBufferPoolCreate(nil, nil, attrs as CFDictionary, &pool)
+ }
+
+ func pointer(x: Double, y: Double, text: String?) {
+ queue.async {
+ if x >= 0 { self.dot = (x, y) }
+ if let text { self.lastText = text }
+ }
+ }
+
+ private func draw() {
+ guard let pool else { return }
+ var out: CVPixelBuffer?
+ CVPixelBufferPoolCreatePixelBuffer(nil, pool, &out)
+ guard let pb = out else { return }
+ CVPixelBufferLockBaseAddress(pb, [])
+ defer { CVPixelBufferUnlockBaseAddress(pb, []) }
+ let (w, h) = size
+ guard let ctx = CGContext(data: CVPixelBufferGetBaseAddress(pb), width: w, height: h, bitsPerComponent: 8,
+ bytesPerRow: CVPixelBufferGetBytesPerRow(pb), space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue)
+ else { return }
+ let colors: [(CGFloat, CGFloat, CGFloat)] = [(0.75, 0.75, 0.75), (0.75, 0.75, 0), (0, 0.75, 0.75), (0, 0.75, 0),
+ (0.75, 0, 0.75), (0.75, 0, 0), (0, 0, 0.75)]
+ let bw = CGFloat(w) / CGFloat(colors.count)
+ for (i, c) in colors.enumerated() {
+ ctx.setFillColor(red: c.0, green: c.1, blue: c.2, alpha: 1)
+ ctx.fill(CGRect(x: CGFloat(i) * bw, y: CGFloat(h) * 0.35, width: bw + 1, height: CGFloat(h) * 0.65))
+ }
+ ctx.setFillColor(red: 0.08, green: 0.09, blue: 0.11, alpha: 1)
+ ctx.fill(CGRect(x: 0, y: 0, width: w, height: Int(Double(h) * 0.35)))
+ // A bar sweeping once a second shows motion and dropped frames.
+ let x = CGFloat(n % 60) / 60 * CGFloat(w)
+ ctx.setFillColor(red: 1, green: 1, blue: 1, alpha: 1)
+ ctx.fill(CGRect(x: x, y: CGFloat(h) * 0.35, width: max(4, CGFloat(w) / 120), height: CGFloat(h) * 0.65))
+ let f = DateFormatter()
+ f.dateFormat = "HH:mm:ss.SSS"
+ let label = "Frame Control test pattern \(f.string(from: Date())) frame \(n)\n\(lastText)"
+ let text = NSAttributedString(string: label, attributes: [
+ .font: NSFont.monospacedSystemFont(ofSize: CGFloat(h) / 24, weight: .medium),
+ .foregroundColor: NSColor.white,
+ ])
+ let ns = NSGraphicsContext(cgContext: ctx, flipped: false)
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = ns
+ text.draw(at: CGPoint(x: CGFloat(w) * 0.03, y: CGFloat(h) * 0.08))
+ NSGraphicsContext.restoreGraphicsState()
+ if let (px, py) = dot {
+ let r = CGFloat(h) / 40
+ ctx.setFillColor(red: 1, green: 0.2, blue: 0.2, alpha: 1)
+ ctx.fillEllipse(in: CGRect(x: CGFloat(px) * CGFloat(w) - r, y: (1 - CGFloat(py)) * CGFloat(h) - r, width: 2 * r, height: 2 * r))
+ }
+ n += 1
+ onFrame?(pb, CMClockGetTime(CMClockGetHostTimeClock()), nowUs())
+ }
+}
diff --git a/mac/frame-mac-view/Sources/Controller.swift b/mac/frame-mac-view/Sources/Controller.swift
new file mode 100644
index 0000000..2cb4b3b
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Controller.swift
@@ -0,0 +1,234 @@
+// Adapts each stream to its network, latency first. The viewer acknowledges
+// every frame as it arrives ("rx"); from those acks the controller knows how
+// long frames take to get through and how fast the link delivers them.
+//
+// - The gate: a new frame is sent only while the oldest unacknowledged one is
+// younger than the path's usual round trip plus a little slack. So frames
+// never queue up in SSH, TCP or the Wi-Fi driver; while the link is stuck
+// the newest picture waits and goes out as soon as it moves again.
+// - The bitrate: when frames start queueing (the round trip grows) or the
+// gate has to hold frames back, it drops to a bit under what the link
+// actually delivered; once things are clear it probes up again slowly, never
+// above the quality setting's bitrate (the ceiling).
+// - The tier: as the bitrate falls, fewer frames per second (60, 45, 30), then
+// a smaller picture (75%, then 50% of the panel's pixels).
+// See docs/mac-in-headset.md ("Adapting to the network"). Thread-safe.
+import Foundation
+
+final class RateController {
+ struct Tier: Equatable {
+ let fps: Int
+ let scale: Double // of the picture's long side
+ }
+
+ static let tiers = [Tier(fps: 60, scale: 1), Tier(fps: 45, scale: 1), Tier(fps: 30, scale: 1),
+ Tier(fps: 30, scale: 0.75), Tier(fps: 30, scale: 0.5)]
+ /// A tier is used while the target bitrate is at least this share of the ceiling.
+ static let floors = [0.45, 0.28, 0.16, 0.08, 0]
+ static let enabled = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ADAPT"] != "0"
+
+ let maxFps: Int
+ private let lock = NSLock()
+ private var ceiling = 0 // bits/s at full size and frame rate
+ private(set) var target = 0
+ private(set) var tier = 0
+ private var unacked: [(seq: UInt32, sent: Int64, bytes: Int)] = []
+ /// Round trips (send -> ack arrives here), for the baseline: the lowest
+ /// in the last 10 s is the path without any queue.
+ private var rtts: [(t: Int64, v: Int64)] = []
+ private var acked: [(t: Int64, bytes: Int)] = [] // the last second
+ private var sentLog: [(t: Int64, bytes: Int)] = []
+ private var captures: [Int64] = [] // the last second
+ private var frameBytes = 0 // average recent frame, kept while the gate holds everything back
+ private var held = 0 // frames the gate held back since the last update
+ private var lastSignal = false
+ private var sawAck = false
+ private var lastDecrease: Int64 = 0
+ private var lastIncrease: Int64 = 0
+ private var belowSince: Int64 = 0, aboveSince: Int64 = 0
+ /// What changed, for the timeline: (time, event).
+ private(set) var events: [(Int64, String)] = []
+
+ init(maxFps: Int) { self.maxFps = maxFps }
+
+ private func locked(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
+
+ /// The quality setting's bitrate at full size; the first call also starts there.
+ func setCeiling(_ bps: Int) {
+ locked {
+ if target == 0 || target > bps { target = bps }
+ ceiling = bps
+ }
+ }
+
+ var fps: Int { locked { fpsLocked } }
+ private var fpsLocked: Int { min(maxFps, RateController.tiers[tier].fps) }
+ var scale: Double { locked { RateController.tiers[tier].scale } }
+ var baseRtt: Int64 { locked { baseline() } }
+
+ private func baseline() -> Int64 { rtts.map(\.v).min() ?? 0 }
+
+ /// How late a frame may be before the gate holds the next one: one frame
+ /// interval, plus room for the jitter this link normally has (1.5 times
+ /// its recent spread), so ordinary Wi-Fi jitter doesn't cost frames but a
+ /// real queue does. Updated in update().
+ private var slack: Int64 = 40_000
+
+ /// Whether a frame may be sent now without queueing behind earlier ones.
+ /// `counts`: a held frame is a sign of congestion (not when merely
+ /// re-checking whether a held frame can go yet).
+ func maySend(now: Int64, counts: Bool = true) -> Bool {
+ locked {
+ guard RateController.enabled, sawAck else { return true } // not heard from the viewer yet
+ // Unacknowledged for 2 s: gone with a reconnection, not in a queue.
+ unacked.removeAll { now - $0.sent > 2_000_000 }
+ guard let oldest = unacked.first else { return true }
+ // Age is what bounds latency. The count only stops a burst, and it
+ // allows a full round trip of frames, so a long but clear path
+ // (100 ms away) still gets every frame.
+ let interval = Int64(1_000_000 / max(1, fpsLocked))
+ let window = max(3, Int((baseline() + slack) / interval) + 1)
+ if unacked.count < window, now - oldest.sent <= baseline() + slack { return true }
+ if counts { held += 1 }
+ return false
+ }
+ }
+
+ /// A picture was captured (sent or not): with the frame sizes, what this
+ /// stream would send if the link allowed.
+ func captured(at t: Int64) {
+ locked {
+ captures.append(t)
+ if captures.count > 256 { captures.removeFirst(captures.count - 256) }
+ }
+ }
+
+ func sent(seq: UInt32, bytes: Int, at t: Int64) {
+ locked {
+ // Bounded even if the viewer never acknowledges (an old viewer, or
+ // the controller is off).
+ unacked.append((seq, t, bytes))
+ if unacked.count > 512 { unacked.removeFirst(unacked.count - 512) }
+ sentLog.append((t, bytes))
+ if sentLog.count > 1024 { sentLog.removeFirst(sentLog.count - 1024) }
+ }
+ }
+
+ /// The viewer has frame `seq`. Returns true if that may let a held frame go.
+ func acked(seq: UInt32, at now: Int64) -> Bool {
+ locked {
+ sawAck = true
+ guard let i = unacked.firstIndex(where: { $0.seq == seq }) else { return false }
+ let f = unacked[i]
+ unacked.removeSubrange(0...i) // TCP delivers in order: earlier ones arrived too
+ rtts.append((now, now - f.sent))
+ acked.append((now, f.bytes))
+ return true
+ }
+ }
+
+ /// Called every 100 ms. Returns the new bitrate target, or nil if the
+ /// controller is off.
+ func update(now: Int64) -> Int? {
+ locked {
+ rtts.removeAll { now - $0.t > 10_000_000 }
+ acked.removeAll { now - $0.t > 500_000 }
+ sentLog.removeAll { now - $0.t > 500_000 }
+ unacked.removeAll { now - $0.sent > 2_000_000 }
+ captures.removeAll { now - $0 > 1_000_000 }
+ guard RateController.enabled, ceiling > 0 else { return nil }
+ let base = baseline()
+ let spread = rtts.filter { now - $0.t < 2_000_000 }.map(\.v).sorted()
+ let jitter = spread.isEmpty ? 0 : spread[spread.count * 9 / 10] - base
+ let interval = Int64(1_000_000 / max(1, fpsLocked))
+ slack = interval + min(max(jitter * 3 / 2, 25_000), 80_000)
+ let recent = rtts.filter { now - $0.t < 300_000 }.map(\.v).sorted()
+ let queueing = recent.isEmpty ? 0 : recent[recent.count / 2] - base
+ let oldestAge = unacked.first.map { now - $0.sent } ?? 0
+ let stuck = oldestAge > base + 100_000
+ let delivered = acked.reduce(0) { $0 + $1.bytes } * 16 // bits/s over the last half second
+ let sending = sentLog.reduce(0) { $0 + $1.bytes } * 16
+ // Demand: captures per second (up to the tier's rate) times the
+ // average frame. A test card or a mostly still window wants far
+ // less than its budget; when the link hiccups, cutting its bitrate
+ // can't help, and it would only look link-limited afterwards.
+ if !sentLog.isEmpty { frameBytes = sentLog.reduce(0) { $0 + $1.bytes } / sentLog.count }
+ let demand = min(captures.count, fpsLocked) * frameBytes * 8
+ // Delay alone isn't our queue: Wi-Fi jitters by itself. It only
+ // counts while this stream uses a good part of its budget (so its
+ // own data could be what's queueing). Frames the gate had to hold,
+ // or one stuck in flight, show demand the link isn't carrying
+ // whatever was sent (the gate itself keeps what's sent low).
+ let busy = sending >= target / 2
+ // Twice in a row (200 ms), so one late ack doesn't count.
+ let signal = (busy && queueing > 40_000) || held >= 3 || stuck
+ let congested = signal && lastSignal
+ lastSignal = signal
+ let heldNow = held
+ held = 0
+ let floorBps = 300_000
+ if congested, now - lastDecrease > 300_000 {
+ // Down to a bit under what got through: at least a fifth off, at
+ // most half (a stall delivers nothing, but the link is still there).
+ let measured = Int(Double(delivered) * 0.9)
+ var next = max(floorBps, min(target * 4 / 5, max(measured, target / 2)))
+ // App-limited (it wants about half its budget or less): never
+ // below twice what it wants, however many cuts in a row. The
+ // extra quarter is hysteresis, so frame sizes wobbling at the
+ // floor don't switch the protection off.
+ if demand > 0, demand * 2 <= target * 5 / 4 { next = max(next, min(target, demand * 2)) }
+ target = next
+ lastDecrease = now
+ events.append((now, "down to \(target / 1000) kbit/s: queue \(queueing / 1000) ms, held \(heldNow), "
+ + "oldest \(oldestAge / 1000) ms, base \(base / 1000) ms, sent \(sending / 1000) got \(delivered / 1000) "
+ + "wants \(demand / 1000)"))
+ } else if !congested, now - lastDecrease > 1_000_000, now - lastIncrease > 250_000, target < ceiling,
+ sending > target * 6 / 10 || now - lastDecrease > 3_000_000 {
+ // Clear for a second and using what it has: probe up.
+ target = min(ceiling, Int(Double(target) * 1.1) + 50_000)
+ lastIncrease = now
+ }
+ // Fewer frames or pixels only help a stream that fills its budget;
+ // a small one (a still window, a test card) keeps its tier.
+ retier(now: now, linkLimited: sending >= target * 7 / 10)
+ if events.count > 200 { events.removeFirst(events.count - 200) }
+ return target
+ }
+ }
+
+ /// Steps down quickly, straight to the tier the bitrate supports, and back
+ /// up one tier at a time only when there's clearly room (hysteresis).
+ private func retier(now: Int64, linkLimited: Bool) {
+ let share = Double(target) / Double(max(ceiling, 1))
+ if tier < RateController.tiers.count - 1, share < RateController.floors[tier], linkLimited {
+ if belowSince == 0 { belowSince = now }
+ if now - belowSince > 500_000 {
+ tier = RateController.floors.firstIndex { share >= $0 } ?? RateController.tiers.count - 1
+ belowSince = 0
+ events.append((now, "tier \(tier)"))
+ }
+ } else {
+ belowSince = 0
+ }
+ if tier > 0, share > RateController.floors[tier - 1] * 1.25 {
+ if aboveSince == 0 { aboveSince = now }
+ if now - aboveSince > 2_000_000 {
+ tier -= 1
+ aboveSince = 0
+ events.append((now, "tier \(tier)"))
+ }
+ } else {
+ aboveSince = 0
+ }
+ }
+
+ func state() -> [String: Any] {
+ locked {
+ ["target": target, "ceiling": ceiling, "tier": tier, "fps": min(maxFps, RateController.tiers[tier].fps),
+ "scale": RateController.tiers[tier].scale, "baseRtt": Double(baseline()) / 1000,
+ "inFlight": unacked.count, "slack": Double(slack) / 1000, "adapt": RateController.enabled]
+ }
+ }
+
+ func eventList() -> [[String: Any]] { locked { events.map { ["t": $0.0, "e": $0.1] } } }
+}
diff --git a/mac/frame-mac-view/Sources/Encoder.swift b/mac/frame-mac-view/Sources/Encoder.swift
new file mode 100644
index 0000000..d3b507a
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Encoder.swift
@@ -0,0 +1,217 @@
+// VideoToolbox encoding: H.264 for the normal path (hardware, low-latency rate
+// control, no B-frames, Annex B output that WebCodecs takes without a
+// description), or JPEG stills for viewers that can't decode H.264.
+import CoreMedia
+import Foundation
+import VideoToolbox
+
+enum Codec: String {
+ case h264, jpeg
+}
+
+final class Encoder {
+ let codec: Codec
+ let fps: Int
+ let bitsPerPixel: Double
+ private(set) var width = 0
+ private(set) var height = 0
+ private var session: VTCompressionSession?
+ private var forceKey = true
+ private var lastPts = CMTime.invalid
+ private var lastGiven = CMTime.invalid
+ /// Bits per second to aim for; nil means from the size, frame rate and
+ /// bits per pixel. Changing it takes effect on the next frame.
+ private var target: Int?
+ private(set) var bitrate = 0
+ /// Called on VideoToolbox's thread with one access unit (or JPEG) per
+ /// frame, and the sequence number it was submitted with.
+ var onFrame: ((Data, Bool, CMTime, UInt32) -> Void)?
+ var onError: ((String) -> Void)?
+ /// Called once per frame handed to VideoToolbox, however it went.
+ var onDone: ((UInt32) -> Void)?
+ /// Experiment switches (FRAME_MAC_VIEW_ENCODER, comma-separated), so a
+ /// benchmark can compare them without a rebuild.
+ static let options = Set((ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_ENCODER"] ?? "")
+ .split(separator: ",").map(String.init))
+
+ init(codec: Codec, fps: Int, bitsPerPixel: Double) {
+ self.codec = codec
+ self.fps = fps
+ self.bitsPerPixel = bitsPerPixel
+ }
+
+ /// The bitrate the size and quality setting would give.
+ func defaultBitrate(width w: Int, height h: Int) -> Int {
+ Int(min(max(Double(w * h * fps) * bitsPerPixel, 2_000_000), 60_000_000))
+ }
+
+ /// Live, without a new keyframe. Call on the encoding queue.
+ func setBitrate(_ bps: Int?) {
+ target = bps
+ guard codec == .h264, let s = session else { return }
+ let b = bps ?? defaultBitrate(width: width, height: height)
+ guard b != bitrate else { return }
+ bitrate = b
+ VTSessionSetProperty(s, key: kVTCompressionPropertyKey_AverageBitRate, value: b as CFTypeRef)
+ // A hard ceiling too: no more than 200 ms' worth of bits in any 200 ms,
+ // so a keyframe can't hold the link for long.
+ if Encoder.options.contains("cap") {
+ VTSessionSetProperty(s, key: kVTCompressionPropertyKey_DataRateLimits,
+ value: [b / 8 / 5, 0.2] as CFArray)
+ }
+ }
+
+ deinit { invalidate() }
+
+ func requestKeyFrame() { forceKey = true }
+
+ func invalidate() {
+ if let s = session {
+ VTCompressionSessionCompleteFrames(s, untilPresentationTimeStamp: .invalid)
+ VTCompressionSessionInvalidate(s)
+ }
+ session = nil
+ }
+
+ private func makeSession(width w: Int, height h: Int) -> Bool {
+ invalidate()
+ var spec: [CFString: Any] = [:]
+ if codec == .h264 { spec[kVTVideoEncoderSpecification_EnableLowLatencyRateControl] = true }
+ if Encoder.options.contains("hw") { spec[kVTVideoEncoderSpecification_RequireHardwareAcceleratedVideoEncoder] = true }
+ var s: VTCompressionSession?
+ let type = codec == .h264 ? kCMVideoCodecType_H264 : kCMVideoCodecType_JPEG
+ func create(_ spec: [CFString: Any]) -> OSStatus {
+ VTCompressionSessionCreate(allocator: nil, width: Int32(w), height: Int32(h), codecType: type,
+ encoderSpecification: spec as CFDictionary, imageBufferAttributes: nil,
+ compressedDataAllocator: nil, outputCallback: nil, refcon: nil,
+ compressionSessionOut: &s)
+ }
+ var err = create(spec)
+ // Low-latency rate control needs Apple's hardware encoder; without it
+ // (some VMs), plain real-time encoding still works.
+ if err != noErr, !spec.isEmpty { err = create([:]) }
+ guard err == noErr, let s else {
+ onError?("couldn't start the \(codec.rawValue) encoder (VideoToolbox \(err))")
+ return false
+ }
+ func set(_ key: CFString, _ value: Any) { VTSessionSetProperty(s, key: key, value: value as CFTypeRef) }
+ set(kVTCompressionPropertyKey_RealTime, true)
+ set(kVTCompressionPropertyKey_ColorPrimaries, kCVImageBufferColorPrimaries_ITU_R_709_2)
+ set(kVTCompressionPropertyKey_TransferFunction, kCVImageBufferTransferFunction_ITU_R_709_2)
+ set(kVTCompressionPropertyKey_YCbCrMatrix, kCVImageBufferYCbCrMatrix_ITU_R_709_2)
+ if codec == .h264 {
+ set(kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_ConstrainedHigh_AutoLevel)
+ set(kVTCompressionPropertyKey_AllowFrameReordering, false)
+ set(kVTCompressionPropertyKey_ExpectedFrameRate, fps)
+ if Encoder.options.contains("nodelay") { set(kVTCompressionPropertyKey_MaxFrameDelayCount, 0) }
+ if Encoder.options.contains("speed") { set(kVTCompressionPropertyKey_PrioritizeEncodingSpeedOverQuality, true) }
+ // A keyframe every 10 s at most, so a viewer that lost one recovers
+ // even if it never asks. Viewers ask for one when they start.
+ set(kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, 10)
+ } else {
+ set(kVTCompressionPropertyKey_Quality, 0.8)
+ }
+ VTCompressionSessionPrepareToEncodeFrames(s)
+ session = s
+ lastPts = .invalid
+ lastGiven = .invalid
+ width = w
+ height = h
+ forceKey = true
+ bitrate = 0
+ setBitrate(target)
+ return true
+ }
+
+ /// False if the frame never reached VideoToolbox (then onDone won't come).
+ @discardableResult
+ func encode(_ pb: CVPixelBuffer, pts given: CMTime, seq: UInt32) -> Bool {
+ // The encoder's own timeline: strictly increasing (a resent picture
+ // stamped "now" can be followed by a capture stamped a moment earlier),
+ // and never more than two frames on from the last one. Rate control
+ // budgets bits by elapsed time, so after a pause (the link held frames
+ // back, or nothing changed) one frame would otherwise get a quarter
+ // second's worth of bits: 200 KB that then hold a slow link for half a second.
+ let w = CVPixelBufferGetWidth(pb), h = CVPixelBufferGetHeight(pb)
+ if session == nil || w != width || h != height {
+ guard makeSession(width: w, height: h) else { return false } // a new timeline too
+ }
+ guard let s = session else { return false }
+ var pts = given
+ if lastPts.isValid, lastGiven.isValid {
+ let gap = CMTimeSubtract(given, lastGiven)
+ let most = CMTime(value: 2, timescale: CMTimeScale(fps))
+ let step = CMTimeCompare(gap, most) > 0 ? most : gap
+ pts = CMTimeAdd(lastPts, CMTimeMaximum(step, CMTime(value: 1, timescale: 1_000_000)))
+ }
+ lastGiven = given
+ var props: CFDictionary?
+ if forceKey {
+ props = [kVTEncodeFrameOptionKey_ForceKeyFrame: true] as CFDictionary
+ forceKey = false
+ }
+ let codec = self.codec
+ lastPts = pts
+ let status = VTCompressionSessionEncodeFrame(s, imageBuffer: pb, presentationTimeStamp: pts, duration: .invalid,
+ frameProperties: props, infoFlagsOut: nil) { [weak self] status, _, sample in
+ guard let self else { return }
+ defer { self.onDone?(seq) }
+ guard status == noErr, let sample else { return }
+ if codec == .jpeg {
+ if let data = Self.bytes(sample) { self.onFrame?(data, true, pts, seq) }
+ } else if let (data, key) = Self.annexB(sample) {
+ self.onFrame?(data, key, pts, seq)
+ }
+ }
+ if status != noErr { forceKey = true }
+ return status == noErr
+ }
+
+ private static func bytes(_ sample: CMSampleBuffer) -> Data? {
+ guard let block = CMSampleBufferGetDataBuffer(sample) else { return nil }
+ var length = 0
+ var ptr: UnsafeMutablePointer?
+ guard CMBlockBufferGetDataPointer(block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &length,
+ dataPointerOut: &ptr) == noErr, let ptr else { return nil }
+ return Data(bytes: ptr, count: length)
+ }
+
+ /// AVCC sample -> Annex B access unit, with SPS and PPS before keyframes.
+ static func annexB(_ sample: CMSampleBuffer) -> (Data, Bool)? {
+ guard let avcc = bytes(sample) else { return nil }
+ var key = true
+ if let atts = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[CFString: Any]],
+ let first = atts.first, first[kCMSampleAttachmentKey_NotSync] as? Bool == true {
+ key = false
+ }
+ let start: [UInt8] = [0, 0, 0, 1]
+ var out = Data()
+ if key, let fmt = CMSampleBufferGetFormatDescription(sample) {
+ var count = 0
+ CMVideoFormatDescriptionGetH264ParameterSetAtIndex(fmt, parameterSetIndex: 0, parameterSetPointerOut: nil,
+ parameterSetSizeOut: nil, parameterSetCountOut: &count,
+ nalUnitHeaderLengthOut: nil)
+ for i in 0..?
+ var n = 0
+ if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(fmt, parameterSetIndex: i, parameterSetPointerOut: &p,
+ parameterSetSizeOut: &n, parameterSetCountOut: nil,
+ nalUnitHeaderLengthOut: nil) == noErr, let p {
+ out.append(contentsOf: start)
+ out.append(p, count: n)
+ }
+ }
+ }
+ let bytes = [UInt8](avcc)
+ var i = 0
+ while i + 4 <= bytes.count {
+ let n = Int(bytes[i]) << 24 | Int(bytes[i + 1]) << 16 | Int(bytes[i + 2]) << 8 | Int(bytes[i + 3])
+ i += 4
+ guard n > 0, i + n <= bytes.count else { break }
+ out.append(contentsOf: start)
+ out.append(contentsOf: bytes[i..<(i + n)])
+ i += n
+ }
+ return (out, key)
+ }
+}
diff --git a/mac/frame-mac-view/Sources/Input.swift b/mac/frame-mac-view/Sources/Input.swift
new file mode 100644
index 0000000..0f51efc
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Input.swift
@@ -0,0 +1,168 @@
+// Turns the viewer's pointer and key events into real Mac input. Needs the
+// Accessibility permission ("control your computer"); without it macOS drops
+// the events silently, so the agent reports the permission to the viewer.
+import AppKit
+import ApplicationServices
+import Foundation
+
+@_silgen_name("_AXUIElementGetWindow")
+private func _AXUIElementGetWindow(_ element: AXUIElement, _ id: UnsafeMutablePointer) -> AXError
+
+enum Input {
+ static let source = CGEventSource(stateID: .hidSystemState)
+ /// What each viewer (session id) is holding down, so one panel closing
+ /// lets go of its own keys and buttons and nobody else's. Main thread.
+ private static var buttonsHeld: [Int: Set] = [:]
+ private static var keysHeld: [Int: Set] = [:]
+
+ private static var lastDown: (time: TimeInterval, point: CGPoint, button: Int, count: Int)?
+
+ static var allowed: Bool { AXIsProcessTrusted() }
+
+ /// Brings a window to the front so a click lands on it, not on whatever
+ /// covers it, and typing goes to it.
+ static func focus(window id: CGWindowID, pid: pid_t) {
+ if frontWindow() == id { return }
+ let app = AXUIElementCreateApplication(pid)
+ var value: CFTypeRef?
+ if AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) == .success,
+ let windows = value as? [AXUIElement] {
+ for w in windows {
+ var wid: CGWindowID = 0
+ if _AXUIElementGetWindow(w, &wid) == .success, wid == id {
+ AXUIElementPerformAction(w, kAXRaiseAction as CFString)
+ AXUIElementSetAttributeValue(w, kAXMainAttribute as CFString, kCFBooleanTrue)
+ break
+ }
+ }
+ }
+ AXUIElementSetAttributeValue(app, kAXFrontmostAttribute as CFString, kCFBooleanTrue)
+ NSRunningApplication(processIdentifier: pid)?.activate()
+ }
+
+ static func frontWindow() -> CGWindowID? {
+ WindowInfo.all().first { $0.layer == 0 }?.id
+ }
+
+ /// `button` uses the browser's numbering: 0 left, 1 middle, 2 right.
+ static func mouse(_ kind: String, button: Int, at p: CGPoint, owner: Int) {
+ let b: CGMouseButton = button == 2 ? .right : button == 1 ? .center : .left
+ let type: CGEventType
+ switch kind {
+ case "down":
+ type = b == .left ? .leftMouseDown : b == .right ? .rightMouseDown : .otherMouseDown
+ buttonsHeld[owner, default: []].insert(button)
+ case "up":
+ type = b == .left ? .leftMouseUp : b == .right ? .rightMouseUp : .otherMouseUp
+ buttonsHeld[owner]?.remove(button)
+ // Another viewer still holding it keeps it down.
+ if buttonsHeld.values.contains(where: { $0.contains(button) }) { return }
+ default: // a drag is whatever this viewer is holding
+ let mine = buttonsHeld[owner] ?? []
+ if mine.contains(0) { type = .leftMouseDragged }
+ else if mine.contains(2) { type = .rightMouseDragged }
+ else if mine.contains(1) { type = .otherMouseDragged }
+ else { type = .mouseMoved }
+ }
+ let mine = buttonsHeld[owner] ?? []
+ let held: CGMouseButton = mine.contains(0) ? .left : mine.contains(2) ? .right : mine.contains(1) ? .center : .left
+ guard let e = CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: p,
+ mouseButton: kind == "move" ? held : b) else { return }
+ if kind == "down" || kind == "up" {
+ let now = ProcessInfo.processInfo.systemUptime
+ var count = 1
+ if kind == "down" {
+ if let l = lastDown, l.button == button, now - l.time < NSEvent.doubleClickInterval,
+ abs(l.point.x - p.x) < 5, abs(l.point.y - p.y) < 5 { count = l.count + 1 }
+ lastDown = (now, p, button, count)
+ } else if let l = lastDown, l.button == button {
+ count = l.count
+ }
+ e.setIntegerValueField(.mouseEventClickState, value: Int64(count))
+ }
+ e.post(tap: .cghidEventTap)
+ }
+
+ static func scroll(dx: Double, dy: Double, at p: CGPoint, owner: Int) {
+ mouse("move", button: 0, at: p, owner: owner)
+ // Browsers report pixels with +y meaning "scroll down"; macOS's +y is up.
+ guard let e = CGEvent(scrollWheelEvent2Source: source, units: .pixel, wheelCount: 2,
+ wheel1: Int32(-dy.rounded()), wheel2: Int32(-dx.rounded()), wheel3: 0) else { return }
+ e.location = p
+ e.post(tap: .cghidEventTap)
+ }
+
+ /// Lets go of every button and key this viewer is holding down, so a
+ /// dropped connection can't leave, say, Shift or ⌘ stuck on.
+ static func releaseAll(owner: Int) {
+ let p = CGEvent(source: nil)?.location ?? .zero
+ let buttons = buttonsHeld.removeValue(forKey: owner) ?? []
+ let keys = keysHeld.removeValue(forKey: owner) ?? []
+ // Only what no other viewer is still holding.
+ for b in buttons where !buttonsHeld.values.contains(where: { $0.contains(b) }) {
+ let type: CGEventType = b == 2 ? .rightMouseUp : b == 1 ? .otherMouseUp : .leftMouseUp
+ CGEvent(mouseEventSource: source, mouseType: type, mouseCursorPosition: p,
+ mouseButton: b == 2 ? .right : b == 1 ? .center : .left)?.post(tap: .cghidEventTap)
+ }
+ for k in keys where !keysHeld.values.contains(where: { $0.contains(k) }) {
+ CGEvent(keyboardEventSource: source, virtualKey: k, keyDown: false)?.post(tap: .cghidEventTap)
+ }
+ }
+
+ static func key(code: String, key: String, down: Bool, mods: [String], owner: Int) {
+ var flags = CGEventFlags()
+ if mods.contains("shift") { flags.insert(.maskShift) }
+ if mods.contains("ctrl") { flags.insert(.maskControl) }
+ if mods.contains("alt") { flags.insert(.maskAlternate) }
+ if mods.contains("meta") { flags.insert(.maskCommand) }
+ if let vk = keyCodes[code] {
+ guard let e = CGEvent(keyboardEventSource: source, virtualKey: vk, keyDown: down) else { return }
+ if down {
+ keysHeld[owner, default: []].insert(vk)
+ } else {
+ keysHeld[owner]?.remove(vk)
+ if keysHeld.values.contains(where: { $0.contains(vk) }) { return } // still held elsewhere
+ }
+ e.flags = flags
+ e.post(tap: .cghidEventTap)
+ } else if down, !key.isEmpty, key.count <= 4, key.unicodeScalars.allSatisfy({ $0.value >= 0x20 }) {
+ // A key the table doesn't know (a non-US layout, the headset's
+ // on-screen keyboard): type its character instead.
+ text(key)
+ }
+ }
+
+ static func text(_ s: String) {
+ let units = Array(s.utf16)
+ for chunk in stride(from: 0, to: units.count, by: 16) {
+ let part = Array(units[chunk.. macOS virtual key code (ANSI positions).
+ static let keyCodes: [String: CGKeyCode] = [
+ "KeyA": 0x00, "KeyS": 0x01, "KeyD": 0x02, "KeyF": 0x03, "KeyH": 0x04, "KeyG": 0x05, "KeyZ": 0x06, "KeyX": 0x07,
+ "KeyC": 0x08, "KeyV": 0x09, "IntlBackslash": 0x0A, "KeyB": 0x0B, "KeyQ": 0x0C, "KeyW": 0x0D, "KeyE": 0x0E,
+ "KeyR": 0x0F, "KeyY": 0x10, "KeyT": 0x11, "Digit1": 0x12, "Digit2": 0x13, "Digit3": 0x14, "Digit4": 0x15,
+ "Digit6": 0x16, "Digit5": 0x17, "Equal": 0x18, "Digit9": 0x19, "Digit7": 0x1A, "Minus": 0x1B, "Digit8": 0x1C,
+ "Digit0": 0x1D, "BracketRight": 0x1E, "KeyO": 0x1F, "KeyU": 0x20, "BracketLeft": 0x21, "KeyI": 0x22,
+ "KeyP": 0x23, "Enter": 0x24, "KeyL": 0x25, "KeyJ": 0x26, "Quote": 0x27, "KeyK": 0x28, "Semicolon": 0x29,
+ "Backslash": 0x2A, "Comma": 0x2B, "Slash": 0x2C, "KeyN": 0x2D, "KeyM": 0x2E, "Period": 0x2F, "Tab": 0x30,
+ "Space": 0x31, "Backquote": 0x32, "Backspace": 0x33, "Escape": 0x35, "MetaRight": 0x36, "MetaLeft": 0x37,
+ "ShiftLeft": 0x38, "CapsLock": 0x39, "AltLeft": 0x3A, "ControlLeft": 0x3B, "ShiftRight": 0x3C,
+ "AltRight": 0x3D, "ControlRight": 0x3E, "F17": 0x40, "NumpadDecimal": 0x41, "NumpadMultiply": 0x43,
+ "NumpadAdd": 0x45, "NumLock": 0x47, "NumpadDivide": 0x4B, "NumpadEnter": 0x4C, "NumpadSubtract": 0x4E,
+ "F18": 0x4F, "F19": 0x50, "NumpadEqual": 0x51, "Numpad0": 0x52, "Numpad1": 0x53, "Numpad2": 0x54,
+ "Numpad3": 0x55, "Numpad4": 0x56, "Numpad5": 0x57, "Numpad6": 0x58, "Numpad7": 0x59, "F20": 0x5A,
+ "Numpad8": 0x5B, "Numpad9": 0x5C, "F5": 0x60, "F6": 0x61, "F7": 0x62, "F3": 0x63, "F8": 0x64, "F9": 0x65,
+ "F11": 0x67, "F13": 0x69, "F16": 0x6A, "F14": 0x6B, "F10": 0x6D, "ContextMenu": 0x6E, "F12": 0x6F,
+ "F15": 0x71, "Insert": 0x72, "Help": 0x72, "Home": 0x73, "PageUp": 0x74, "Delete": 0x75, "F4": 0x76,
+ "End": 0x77, "F2": 0x78, "PageDown": 0x79, "F1": 0x7A, "ArrowLeft": 0x7B, "ArrowRight": 0x7C,
+ "ArrowDown": 0x7D, "ArrowUp": 0x7E, "OSLeft": 0x37, "OSRight": 0x36,
+ ]
+}
diff --git a/mac/frame-mac-view/Sources/Separate.swift b/mac/frame-mac-view/Sources/Separate.swift
new file mode 100644
index 0000000..72c02be
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Separate.swift
@@ -0,0 +1,295 @@
+// "Separate" mode: a streamed window gets a virtual display of its own. The
+// window is moved onto it and sized to fill it, and the whole display is
+// captured, so its menus, sheets, popovers and tooltips come along, nothing
+// can cover it, and clicks land on it without raising anything. On stop the
+// window goes back where it was. See docs/mac-window-separation.md.
+import AppKit
+import ApplicationServices
+import CoreMedia
+import Foundation
+
+@_silgen_name("_AXUIElementGetWindow")
+private func axWindowID(_ element: AXUIElement, _ id: UnsafeMutablePointer) -> AXError
+
+/// One of our virtual displays, HiDPI (2 pixels per point). Main thread only.
+final class VirtualDisplay {
+ static let vendor: UInt32 = 0xF0C0
+ /// How often macOS composites our displays (FRAME_MAC_VIEW_VD_HZ to experiment).
+ static let refreshRate = Double(ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_VD_HZ"] ?? "") ?? 60
+ private var display: CGVirtualDisplay?
+ let id: CGDirectDisplayID
+ /// Removal is deferred while the Mac's screen sleeps, and an identity
+ /// can't be reused until it's gone, so each display gets a fresh serial.
+ private static var serial = (UInt32(truncatingIfNeeded: ProcessInfo.processInfo.processIdentifier) & 0xFFFF) << 12
+
+ init?(name: String, width: Int, height: Int) {
+ var made: CGVirtualDisplay?
+ for _ in 0..<8 where made == nil {
+ VirtualDisplay.serial &+= 1
+ let d = CGVirtualDisplayDescriptor()
+ d.queue = DispatchQueue.main
+ d.name = name
+ d.maxPixelsWide = UInt32(2 * width)
+ d.maxPixelsHigh = UInt32(2 * height)
+ d.sizeInMillimeters = CGSize(width: Double(width) * 0.2646, height: Double(height) * 0.2646)
+ d.vendorID = VirtualDisplay.vendor
+ d.productID = 0x5E9A
+ d.serialNum = VirtualDisplay.serial
+ d.terminationHandler = { _, _ in }
+ guard let vd = CGVirtualDisplay(descriptor: d) else { continue }
+ let s = CGVirtualDisplaySettings()
+ s.hiDPI = 1
+ let hz = VirtualDisplay.refreshRate
+ s.modes = [CGVirtualDisplayMode(width: UInt32(2 * width), height: UInt32(2 * height), refreshRate: hz)!,
+ CGVirtualDisplayMode(width: UInt32(width), height: UInt32(height), refreshRate: hz)!]
+ if vd.apply(s), vd.displayID != 0 { made = vd }
+ }
+ guard let made else { return nil }
+ display = made
+ id = made.displayID
+ // Pick the HiDPI mode: `width` points drawn with twice the pixels.
+ let modes = (CGDisplayCopyAllDisplayModes(id, [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary)
+ as? [CGDisplayMode]) ?? []
+ if let hidpi = modes.first(where: {
+ $0.width == width && $0.height == height && $0.pixelWidth == 2 * width && $0.refreshRate == VirtualDisplay.refreshRate
+ }) ?? modes.first(where: { $0.width == width && $0.height == height && $0.pixelWidth == 2 * width }) {
+ var cfg: CGDisplayConfigRef?
+ CGBeginDisplayConfiguration(&cfg)
+ CGConfigureDisplayWithDisplayMode(cfg, id, hidpi, nil)
+ CGCompleteDisplayConfiguration(cfg, .forSession)
+ }
+ }
+
+ var bounds: CGRect { CGDisplayBounds(id) }
+
+ var isHiDPI: Bool { CGDisplayCopyDisplayMode(id).map { $0.pixelWidth > $0.width } ?? false }
+
+ var screen: NSScreen? {
+ NSScreen.screens.first {
+ ($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == id
+ }
+ }
+
+ /// Where a window may go, in global (top-left origin) points: the display
+ /// minus its menu bar and anything else macOS reserves there.
+ var usableRect: CGRect {
+ let b = bounds
+ guard let s = screen else { return b }
+ let top = s.frame.maxY - s.visibleFrame.maxY, bottom = s.visibleFrame.minY - s.frame.minY
+ let left = s.visibleFrame.minX - s.frame.minX, right = s.frame.maxX - s.visibleFrame.maxX
+ return CGRect(x: b.minX + left, y: b.minY + top, width: b.width - left - right, height: b.height - top - bottom)
+ }
+
+ func release() { display = nil }
+
+ static func isOurs(_ id: CGDirectDisplayID) -> Bool { CGDisplayVendorNumber(id) == vendor }
+}
+
+/// A window through the Accessibility API.
+struct AXWindow {
+ let element: AXUIElement
+
+ init?(windowID: CGWindowID, pid: pid_t) {
+ let app = AXUIElementCreateApplication(pid)
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value) == .success,
+ let windows = value as? [AXUIElement] else { return nil }
+ guard let match = windows.first(where: { w in
+ var id: CGWindowID = 0
+ return axWindowID(w, &id) == .success && id == windowID
+ }) else { return nil }
+ element = match
+ }
+
+ var frame: CGRect? {
+ var p: CFTypeRef?, s: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &p) == .success,
+ AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &s) == .success else { return nil }
+ var point = CGPoint.zero, size = CGSize.zero
+ AXValueGetValue(p as! AXValue, .cgPoint, &point)
+ AXValueGetValue(s as! AXValue, .cgSize, &size)
+ return CGRect(origin: point, size: size)
+ }
+
+ var isFullScreen: Bool {
+ var v: CFTypeRef?
+ return AXUIElementCopyAttributeValue(element, "AXFullScreen" as CFString, &v) == .success && (v as? Bool) == true
+ }
+
+ /// Moving to another display: position first (so the size fits there),
+ /// then size, then position again in case the size change moved it.
+ func setFrame(_ r: CGRect) {
+ var origin = r.origin, size = r.size
+ let pos = AXValueCreate(.cgPoint, &origin)!, sz = AXValueCreate(.cgSize, &size)!
+ AXUIElementSetAttributeValue(element, kAXPositionAttribute as CFString, pos)
+ AXUIElementSetAttributeValue(element, kAXSizeAttribute as CFString, sz)
+ AXUIElementSetAttributeValue(element, kAXPositionAttribute as CFString, pos)
+ }
+}
+
+/// A window on a display of its own, captured as that display.
+final class SeparateSource: CaptureSource {
+ let windowID: CGWindowID
+ var onFrame: ((CVPixelBuffer, CMTime, Int64) -> Void)?
+ var onEnded: ((String) -> Void)?
+ var onChange: (() -> Void)?
+ private(set) var title = ""
+ private(set) var app = ""
+ private(set) var pid: pid_t?
+ private(set) var gone = false
+ private var display: VirtualDisplay?
+ private var inner: SCKSource?
+ private var window: AXWindow?
+ private var original: CGRect?
+ private var crop: CGRect? // display points, when only part of the display is captured
+ private var poll: DispatchSourceTimer?
+ private var stopped = false
+ private let queue = DispatchQueue(label: "frame-mac-view.separate")
+
+ init(windowID: CGWindowID) { self.windowID = windowID }
+
+ var frameRect: CGRect { inner?.frameRect ?? .zero }
+ var displayID: CGDirectDisplayID? { display?.id }
+
+ func start(maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
+ guard AXIsProcessTrusted() else {
+ return completion("Separate windows need the Accessibility permission (to move the window)")
+ }
+ guard let info = WindowInfo.find(windowID) else {
+ gone = true
+ return completion("that window has closed")
+ }
+ title = info.title
+ app = info.app
+ pid = info.pid
+ DispatchQueue.main.async { [self] in
+ guard !stopped else { return }
+ guard let w = AXWindow(windowID: windowID, pid: info.pid), let frame = w.frame else {
+ return completion("couldn't reach that window through Accessibility")
+ }
+ if w.isFullScreen { return completion("take the window out of full screen first") }
+ // A display the window's size, plus room for the menu bar, in
+ // points; within what a panel can show sharply.
+ let width = min(max(Int(frame.width.rounded()), 640), 2560) / 2 * 2
+ let height = min(max(Int(frame.height.rounded()) + 40, 480), 1600) / 2 * 2
+ guard let vd = VirtualDisplay(name: app.isEmpty ? "Frame Control" : "\(app) (Frame)", width: width, height: height) else {
+ return completion("macOS wouldn't create a display for this window")
+ }
+ display = vd
+ window = w
+ original = frame
+ // The new screen shows up in NSScreen a moment later; its menu bar
+ // decides where the window can go.
+ placeWindow(attempt: 0, maxLong: maxLong, fps: fps, completion: completion)
+ }
+ }
+
+ private func placeWindow(attempt: Int, maxLong: Int, fps: Int, completion: @escaping (String?) -> Void) {
+ guard !stopped, let vd = display, let w = window else { return }
+ // Wait for the screen to appear, and for its HiDPI mode, so the
+ // first frames are captured sharp.
+ if vd.screen == nil || !vd.isHiDPI, attempt < 30 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ self.placeWindow(attempt: attempt + 1, maxLong: maxLong, fps: fps, completion: completion)
+ }
+ return
+ }
+ let target = vd.usableRect
+ w.setFrame(target)
+ guard let now = w.frame, vd.bounds.intersects(now) else {
+ restore()
+ return completion("macOS didn't let the window move to its own display (Stage Manager can prevent this)")
+ }
+ let sck = SCKSource(.display(vd.id))
+ // A window that keeps its own size (Calculator, some dialogs) sits in
+ // the display's top-left corner: send only that corner, menu bar
+ // included, with room around it for menus, not a panel of wallpaper.
+ let b = vd.bounds
+ if now.width < target.width - 8 || now.height < target.height - 8 {
+ let w = min(b.width, max(now.maxX - b.minX, 480)), h = min(b.height, max(now.maxY - b.minY, 360))
+ crop = CGRect(x: 0, y: 0, width: (w / 2).rounded(.up) * 2, height: (h / 2).rounded(.up) * 2)
+ sck.crop = crop
+ }
+ sck.onFrame = { [weak self] pb, pts, shown in self?.onFrame?(pb, pts, shown) }
+ sck.onEnded = { [weak self] reason in self?.onEnded?(reason) }
+ inner = sck
+ sck.start(maxLong: maxLong, fps: fps) { [weak self] error in
+ // Back on main, where stop() runs too, so a viewer that left during
+ // startup can't leave a capture running.
+ DispatchQueue.main.async {
+ guard let self, !self.stopped else { return sck.stop() }
+ if let error {
+ self.restore()
+ return completion(error)
+ }
+ self.startPolling()
+ completion(nil)
+ }
+ }
+ }
+
+ /// The window can close or be retitled; ScreenCaptureKit won't say.
+ private func startPolling() {
+ let t = DispatchSource.makeTimerSource(queue: queue)
+ t.schedule(deadline: .now() + 1, repeating: 1)
+ t.setEventHandler { [weak self] in
+ guard let self else { return }
+ guard let info = WindowInfo.find(self.windowID) else {
+ self.gone = true
+ self.onEnded?("the window closed")
+ return
+ }
+ if !info.title.isEmpty, info.title != self.title {
+ self.title = info.title
+ self.onChange?()
+ }
+ // Displays added at the same moment can rearrange each other, and
+ // someone may drag the window away: put it back on its display.
+ if !info.bounds.isEmpty {
+ DispatchQueue.main.async { self.keepOnDisplay(info.bounds) }
+ }
+ }
+ t.resume()
+ poll = t
+ }
+
+ private func keepOnDisplay(_ now: CGRect) {
+ guard !stopped, let vd = display, let w = window else { return }
+ let b = vd.bounds
+ if let c = crop {
+ // Only part of the display is sent: the window must stay inside it.
+ guard !c.offsetBy(dx: b.minX, dy: b.minY).insetBy(dx: -2, dy: -2).contains(now) else { return }
+ } else {
+ guard !b.contains(CGPoint(x: now.midX, y: now.minY + 10)) else { return }
+ }
+ let target = vd.usableRect
+ w.setFrame(CGRect(origin: target.origin, size: now.size.width < target.width - 8 ? now.size : target.size))
+ }
+
+ /// Lifecycle state (stopped, inner, poll, window, display) is only touched on main.
+ func stop() {
+ DispatchQueue.main.async { [self] in
+ stopped = true
+ poll?.cancel()
+ poll = nil
+ inner?.stop()
+ restore()
+ }
+ }
+
+ /// Puts the window back where it was, then removes the display. In that
+ /// order: removing a display first would let macOS pick where it goes.
+ private func restore() {
+ if let w = window, let r = original, WindowInfo.find(windowID) != nil { w.setFrame(r) }
+ window = nil
+ original = nil
+ let vd = display
+ display = nil
+ // Give WindowServer a moment to move the window before the display goes.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { vd?.release() }
+ }
+
+ func setMaxLong(_ n: Int) { DispatchQueue.main.async { self.inner?.setMaxLong(n) } }
+
+ func pointer(x: Double, y: Double, text: String?) {}
+}
diff --git a/mac/frame-mac-view/Sources/Server.swift b/mac/frame-mac-view/Sources/Server.swift
new file mode 100644
index 0000000..11c58dc
--- /dev/null
+++ b/mac/frame-mac-view/Sources/Server.swift
@@ -0,0 +1,269 @@
+// A small HTTP/1.1 + WebSocket server on Network.framework, loopback only.
+// Enough for the agent's JSON endpoints, the viewer page and one WebSocket per
+// stream; not a general web server.
+import CryptoKit
+import Foundation
+import Network
+
+struct Request {
+ let method: String
+ let path: String
+ let query: [String: String]
+ let headers: [String: String] // lower-cased names
+}
+
+final class Server {
+ let queue = DispatchQueue(label: "frame-mac-view.server")
+ private let listener: NWListener
+ private let handle: (Request, HTTPConnection) -> Void
+
+ init(port: UInt16, handle: @escaping (Request, HTTPConnection) -> Void) throws {
+ let tcp = NWProtocolTCP.Options()
+ tcp.noDelay = true
+ let params = NWParameters(tls: nil, tcp: tcp)
+ // Port 0 lets the system pick; `port` then says which.
+ params.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: NWEndpoint.Port(rawValue: port) ?? .any)
+ params.allowLocalEndpointReuse = true
+ listener = try NWListener(using: params)
+ self.handle = handle
+ }
+
+ var port: UInt16? { listener.port?.rawValue }
+
+ func start(ready: @escaping (Error?) -> Void) {
+ listener.stateUpdateHandler = { state in
+ switch state {
+ case .ready: ready(nil)
+ case .failed(let e): ready(e)
+ default: break
+ }
+ }
+ listener.newConnectionHandler = { [weak self] conn in
+ guard let self else { return }
+ HTTPConnection(conn, queue: self.queue, handle: self.handle).start()
+ }
+ listener.start(queue: queue)
+ }
+}
+
+final class HTTPConnection {
+ let conn: NWConnection
+ let queue: DispatchQueue
+ private let handle: (Request, HTTPConnection) -> Void
+ private var buffer = Data()
+ private var retained: HTTPConnection? // alive until the response is sent
+
+ init(_ conn: NWConnection, queue: DispatchQueue, handle: @escaping (Request, HTTPConnection) -> Void) {
+ self.conn = conn
+ self.queue = queue
+ self.handle = handle
+ }
+
+ func start() {
+ retained = self
+ conn.start(queue: queue)
+ readHead()
+ }
+
+ private func readHead() {
+ conn.receive(minimumIncompleteLength: 1, maximumLength: 16384) { [self] data, _, done, error in
+ if let data { buffer.append(data) }
+ if let end = buffer.range(of: Data("\r\n\r\n".utf8)) {
+ guard let req = Self.parse(buffer[.. 16384 {
+ close()
+ } else {
+ readHead()
+ }
+ }
+ }
+
+ static func parse(_ head: Data) -> Request? {
+ guard let text = String(data: head, encoding: .utf8) else { return nil }
+ let lines = text.components(separatedBy: "\r\n")
+ let parts = lines[0].split(separator: " ")
+ guard parts.count >= 2, let url = URLComponents(string: String(parts[1])) else { return nil }
+ var headers: [String: String] = [:]
+ for line in lines.dropFirst() {
+ guard let colon = line.firstIndex(of: ":") else { continue }
+ headers[line[.. WebSocket? {
+ guard req.headers["upgrade"]?.lowercased() == "websocket", let key = req.headers["sec-websocket-key"] else {
+ respond(400, text: "expected a WebSocket upgrade")
+ return nil
+ }
+ let accept = Data(Insecure.SHA1.hash(data: Data((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").utf8))).base64EncodedString()
+ let head = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: \(accept)\r\n\r\n"
+ conn.send(content: Data(head.utf8), completion: .contentProcessed { _ in })
+ let ws = WebSocket(conn, queue: queue)
+ retained = nil
+ return ws
+ }
+}
+
+/// Server side of RFC 6455. Sends are counted until the network stack has
+/// taken them, so the stream can skip frames instead of queueing seconds of
+/// video on a slow link.
+final class WebSocket {
+ static let maxMessage: UInt64 = 1 << 20
+ let conn: NWConnection
+ let queue: DispatchQueue
+ var onText: ((String) -> Void)?
+ var onClose: (() -> Void)?
+ private var buffer = Data()
+ private var fragments = Data()
+ private var fragmentOpcode: UInt8 = 0
+ private var closed = false
+ private var retained: WebSocket?
+ private let lock = NSLock()
+ private var _pending = 0
+ var onDrain: (() -> Void)?
+
+ /// Bytes handed to the connection that it hasn't sent yet. Any thread.
+ var pendingBytes: Int { lock.lock(); defer { lock.unlock() }; return _pending }
+
+ init(_ conn: NWConnection, queue: DispatchQueue) {
+ self.conn = conn
+ self.queue = queue
+ }
+
+ func start() {
+ retained = self
+ read()
+ }
+
+ func sendText(_ s: String) { send(opcode: 1, Data(s.utf8)) }
+ func sendJSON(_ obj: Any) {
+ if let d = try? JSONSerialization.data(withJSONObject: obj), let s = String(data: d, encoding: .utf8) { sendText(s) }
+ }
+ /// `taken` runs once the network stack has taken the whole frame.
+ func sendBinary(_ d: Data, taken: (() -> Void)? = nil) { send(opcode: 2, d, taken: taken) }
+
+ func send(opcode: UInt8, _ payload: Data, taken: (() -> Void)? = nil) {
+ var frame = Data([0x80 | opcode])
+ let n = payload.count
+ if n < 126 {
+ frame.append(UInt8(n))
+ } else if n < 65536 {
+ frame.append(126)
+ frame.append(contentsOf: [UInt8(n >> 8), UInt8(n & 0xff)])
+ } else {
+ frame.append(127)
+ for shift in stride(from: 56, through: 0, by: -8) { frame.append(UInt8((UInt64(n) >> UInt64(shift)) & 0xff)) }
+ }
+ frame.append(payload)
+ let size = frame.count
+ lock.lock(); _pending += size; lock.unlock()
+ conn.send(content: frame, completion: .contentProcessed { [weak self] _ in
+ guard let self else { return }
+ self.lock.lock(); self._pending -= size; self.lock.unlock()
+ taken?()
+ self.onDrain?()
+ })
+ }
+
+ func close() {
+ guard !closed else { return }
+ closed = true
+ send(opcode: 8, Data([0x03, 0xe8])) // 1000, normal closure
+ conn.send(content: nil, isComplete: true, completion: .contentProcessed { [weak self] _ in self?.conn.cancel() })
+ finish()
+ }
+
+ private func finish() {
+ let cb = onClose
+ onClose = nil
+ onText = nil
+ onDrain = nil
+ cb?()
+ retained = nil
+ }
+
+ private func read() {
+ conn.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, done, error in
+ guard let self, !self.closed else { return }
+ if let data { self.buffer.append(data) }
+ self.parse()
+ if error != nil || done {
+ self.closed = true
+ self.conn.cancel()
+ self.finish()
+ } else if !self.closed {
+ self.read()
+ }
+ }
+ }
+
+ private func parse() {
+ while buffer.count >= 2 {
+ let b = [UInt8](buffer.prefix(14))
+ let fin = b[0] & 0x80 != 0, opcode = b[0] & 0x0f, masked = b[1] & 0x80 != 0
+ // Lengths are unsigned and clients only send small control
+ // messages, so anything big (or a 64-bit length with the top bit
+ // set) is refused before it's turned into an Int.
+ var len64 = UInt64(b[1] & 0x7f), off = 2
+ if len64 == 126 {
+ guard b.count >= 4 else { return }
+ len64 = UInt64(b[2]) << 8 | UInt64(b[3]); off = 4
+ } else if len64 == 127 {
+ guard b.count >= 10 else { return }
+ len64 = 0
+ for i in 2..<10 { len64 = len64 << 8 | UInt64(b[i]) }
+ off = 10
+ }
+ guard len64 <= WebSocket.maxMessage else { return close() }
+ let len = Int(len64)
+ let maskOff = off
+ if masked { off += 4 }
+ guard buffer.count >= off + len else { return }
+ let start = buffer.startIndex
+ var payload = Data(buffer[(start + off)..<(start + off + len)])
+ if masked {
+ let mask = [UInt8](buffer[(start + maskOff)..<(start + maskOff + 4)])
+ payload.withUnsafeMutableBytes { p in
+ for i in 0.. Int64 { hostUs(mach_absolute_time()) }
+
+/// A mach_absolute_time value (as in SCStreamFrameInfo.displayTime) in microseconds.
+func hostUs(_ ticks: UInt64) -> Int64 { Int64(ticks / 1000 * UInt64(timebase.numer) / UInt64(timebase.denom)) }
+
+/// A host-clock CMTime (ScreenCaptureKit's presentation times) in microseconds.
+func hostUs(_ t: CMTime) -> Int64 { t.isValid ? Int64(CMTimeGetSeconds(t) * 1_000_000) : nowUs() }
+
+/// One frame's journey. Zero means "didn't happen" or "not reported yet".
+struct FrameRecord {
+ var seq: UInt32 = 0
+ var key = false
+ var bytes = 0
+ var width = 0, height = 0
+ var capture: Int64 = 0 // the Mac composited it (display time)
+ var arrived: Int64 = 0 // ScreenCaptureKit handed it to us
+ var encodeStart: Int64 = 0
+ var encodeEnd: Int64 = 0
+ var sent: Int64 = 0 // handed to the WebSocket
+ var wire: Int64 = 0 // the socket took it
+ var received: Int64 = 0 // viewer, agent clock
+ var decoded: Int64 = 0
+ var drawn: Int64 = 0
+ var vsync: Int64 = 0 // the viewer's next animation frame after drawing it
+ var echo: UInt32 = 0 // the first frame after input `echo`
+ var tier = 0
+ var bitrate = 0
+
+ var json: [String: Any] {
+ ["s": seq, "k": key ? 1 : 0, "b": bytes, "w": width, "h": height, "cap": capture, "arr": arrived,
+ "e0": encodeStart, "e1": encodeEnd, "snd": sent, "wire": wire, "rx": received, "dec": decoded,
+ "drw": drawn, "vs": vsync, "echo": echo, "tier": tier, "br": bitrate]
+ }
+}
+
+/// One input event from the viewer and what came of it.
+struct InputRecord {
+ var id: UInt32
+ var kind: String
+ var viewer: Int64 // the viewer's event time, agent clock
+ var injected: Int64 = 0 // posted as a CGEvent
+ var frame: UInt32 = 0 // first frame captured after it (0: none yet)
+ var capture: Int64 = 0
+
+ var json: [String: Any] {
+ ["id": id, "kind": kind, "tv": viewer, "inj": injected, "frame": frame, "cap": capture]
+ }
+}
+
+func percentile(_ sorted: [Double], _ p: Double) -> Double? {
+ guard !sorted.isEmpty else { return nil }
+ let i = min(sorted.count - 1, max(0, Int((p * Double(sorted.count - 1)).rounded())))
+ return sorted[i]
+}
+
+/// Frames and inputs of one stream, kept for the last few thousand frames.
+/// Any thread.
+final class StreamStats {
+ static let capacity = 4096
+ private let lock = NSLock()
+ private var frames = [FrameRecord?](repeating: nil, count: StreamStats.capacity)
+ private var inputs: [UInt32: InputRecord] = [:]
+ private var inputOrder: [UInt32] = []
+ private(set) var nextSeq: UInt32 = 1
+ var captured = 0 // frames ScreenCaptureKit delivered
+ var skipped = 0 // held back from encoding (link, encoder, pacing or gate busy); only the newest may go later
+ var viewerDropped = 0 // not shown by the viewer (behind, or waiting for a keyframe)
+ var rtt: Double = 0 // ms, the viewer's best recent ping
+ var clockSynced = false
+ var decoder = "" // what the viewer reports about its decoder
+
+ func withLock(_ f: () -> T) -> T { lock.lock(); defer { lock.unlock() }; return f() }
+
+ func newFrame(_ r: FrameRecord) -> UInt32 {
+ withLock {
+ var r = r
+ r.seq = nextSeq
+ nextSeq &+= 1
+ frames[Int(r.seq) % StreamStats.capacity] = r
+ return r.seq
+ }
+ }
+
+ func update(_ seq: UInt32, _ f: (inout FrameRecord) -> Void) {
+ withLock {
+ let i = Int(seq) % StreamStats.capacity
+ guard var r = frames[i], r.seq == seq else { return }
+ f(&r)
+ frames[i] = r
+ }
+ }
+
+ func frame(_ seq: UInt32) -> FrameRecord? {
+ withLock {
+ let r = frames[Int(seq) % StreamStats.capacity]
+ return r?.seq == seq ? r : nil
+ }
+ }
+
+ func addInput(_ r: InputRecord) {
+ withLock {
+ inputs[r.id] = r
+ inputOrder.append(r.id)
+ if inputOrder.count > 512 { inputs[inputOrder.removeFirst()] = nil }
+ }
+ }
+
+ func updateInput(_ id: UInt32, _ f: (inout InputRecord) -> Void) {
+ withLock { if var r = inputs[id] { f(&r); inputs[id] = r } }
+ }
+
+ /// Frames with seq > `since` that were sent at least `settle` µs ago, so
+ /// the viewer has had time to report on them.
+ func settled(since: UInt32, settle: Int64 = 1_500_000) -> [FrameRecord] {
+ let cutoff = nowUs() - settle
+ return withLock {
+ frames.compactMap { $0 }.filter { $0.seq > since && $0.sent > 0 && $0.sent < cutoff }.sorted { $0.seq < $1.seq }
+ }
+ }
+
+ func inputList() -> [InputRecord] { withLock { inputOrder.compactMap { inputs[$0] } } }
+
+ /// Percentiles over the last `window` µs, for /status and the overlay.
+ func summary(window: Int64 = 2_000_000) -> [String: Any] {
+ let now = nowUs(), from = now - window
+ let recent = withLock { frames.compactMap { $0 }.filter { $0.sent > from } }
+ func ms(_ pick: (FrameRecord) -> (Int64, Int64)) -> [String: Double] {
+ let v = recent.compactMap { r -> Double? in
+ let (a, b) = pick(r)
+ return a > 0 && b > 0 ? Double(b - a) / 1000 : nil
+ }.sorted()
+ guard !v.isEmpty else { return [:] }
+ return ["p50": (percentile(v, 0.5)! * 10).rounded() / 10, "p95": (percentile(v, 0.95)! * 10).rounded() / 10]
+ }
+ let secs = Double(window) / 1_000_000
+ let shown = recent.filter { $0.vsync > 0 }.count
+ let bytes = recent.reduce(0) { $0 + $1.bytes }
+ var s: [String: Any] = [
+ "capture": ms { ($0.capture, $0.arrived) },
+ "queue": ms { ($0.arrived, $0.encodeStart) },
+ "encode": ms { ($0.encodeStart, $0.encodeEnd) },
+ "network": ms { ($0.encodeEnd, $0.received) },
+ "decode": ms { ($0.received, $0.decoded) },
+ "draw": ms { ($0.decoded, $0.vsync) },
+ "total": ms { ($0.capture, $0.vsync) },
+ "fps": (Double(shown) / secs * 10).rounded() / 10,
+ "sentFps": (Double(recent.count) / secs * 10).rounded() / 10,
+ "mbps": (Double(bytes * 8) / secs / 1e5).rounded() / 10,
+ "rtt": (rtt * 10).rounded() / 10,
+ "synced": clockSynced,
+ ]
+ withLock {
+ s["captured"] = captured
+ s["skipped"] = skipped
+ s["dropped"] = viewerDropped
+ s["decoder"] = decoder
+ let done = inputOrder.suffix(20).compactMap { inputs[$0] }.compactMap { i -> Double? in
+ guard i.frame != 0, let f = frames[Int(i.frame) % StreamStats.capacity], f.seq == i.frame, f.vsync > 0
+ else { return nil }
+ return Double(f.vsync - i.viewer) / 1000
+ }.sorted()
+ if !done.isEmpty { s["input"] = ["p50": percentile(done, 0.5)!, "n": done.count] }
+ }
+ return s
+ }
+}
diff --git a/mac/frame-mac-view/Sources/main.swift b/mac/frame-mac-view/Sources/main.swift
new file mode 100644
index 0000000..5eb0f70
--- /dev/null
+++ b/mac/frame-mac-view/Sources/main.swift
@@ -0,0 +1,755 @@
+// frame-mac-view: streams Mac windows or displays to viewers on the Steam
+// Frame and plays their pointer and key input back on the Mac.
+//
+// frame-mac-view serve --port 47811 --page ui/mac-view.html (token in FRAME_MAC_VIEW_TOKEN)
+// frame-mac-view windows | displays | permissions (JSON on stdout)
+// frame-mac-view request-permissions (shows macOS's prompts)
+//
+// It listens on 127.0.0.1 only. Frame Control reaches it from the Frame
+// through an SSH reverse tunnel, and every request must carry the token.
+//
+// HTTP. Frame Control's key (?k=, from FRAME_MAC_VIEW_TOKEN) never leaves the
+// Mac; the Frame gets a single-use ticket per viewer instead.
+// GET /ping, /view open: tunnel check, viewer page
+// GET /stream?src=...&t=TICKET|r=KEY WebSocket (&codec=h264|jpeg&max=&fps=&bpp=)
+// GET /status, /windows, /displays ?k=: permissions, streams, sources
+// POST /ticket?src=... ?k=: a ticket for one viewer of src
+// POST /close[?src=...] ?k=: end those streams, close their windows
+// POST /permissions ?k=: show macOS's permission prompts
+// GET /stats[?id=&since=] ?k=: per-frame timing records (for benchmarks)
+// POST /bench?src=&action=... ?k=: ask viewers of src to type, click or show their overlay
+// src is window:, display: or test.
+import AppKit
+import ApplicationServices
+import Foundation
+import IOKit.pwr_mgt
+import ScreenCaptureKit
+import Security
+
+let version = "1"
+
+func windowsJSON() -> [[String: Any]] {
+ let me = ProcessInfo.processInfo.processIdentifier
+ let skip: Set = ["Window Server", "Dock", "Control Centre", "Control Center", "Notification Centre",
+ "Notification Center", "Spotlight", "SystemUIServer", "Wallpaper", "WindowManager"]
+ return WindowInfo.all().filter {
+ $0.layer == 0 && $0.pid != me && !skip.contains($0.app) && $0.bounds.width >= 120 && $0.bounds.height >= 80
+ }.map {
+ ["id": $0.id, "src": "window:\($0.id)", "pid": $0.pid, "app": $0.app, "title": $0.title,
+ "w": Int($0.bounds.width), "h": Int($0.bounds.height)]
+ }
+}
+
+func displaysJSON() -> [[String: Any]] {
+ var ids = [CGDirectDisplayID](repeating: 0, count: 16)
+ var n: UInt32 = 0
+ // Online, not active: a display that's asleep is still one you can stream.
+ CGGetOnlineDisplayList(16, &ids, &n)
+ return ids.prefix(Int(n)).filter {
+ CGDisplayMirrorsDisplay($0) == kCGNullDirectDisplay && !VirtualDisplay.isOurs($0) // not a separated window's
+ }.map { id in
+ let b = CGDisplayBounds(id)
+ return ["id": id, "src": "display:\(id)", "name": displayName(id), "w": Int(b.width), "h": Int(b.height),
+ "main": CGDisplayIsMain(id) != 0]
+ }
+}
+
+func permissionsJSON() -> [String: Any] {
+ ["screen": CGPreflightScreenCaptureAccess(), "accessibility": AXIsProcessTrusted()]
+}
+
+func printJSON(_ obj: Any) {
+ let d = (try? JSONSerialization.data(withJSONObject: obj, options: [.sortedKeys, .prettyPrinted])) ?? Data()
+ FileHandle.standardOutput.write(d + Data("\n".utf8))
+}
+
+/// A number from a JSON message, whatever its JSON type.
+func num(_ v: Any?) -> Double? { (v as? NSNumber)?.doubleValue }
+
+/// One viewer watching one source.
+final class Session {
+ let id: Int
+ let source: Source
+ let capture: CaptureSource
+ let encoder: Encoder
+ let ws: WebSocket
+ let codec: Codec
+ let reconnectKey: String
+ let stats = StreamStats()
+ let controller: RateController
+ private var appliedScale = 1.0
+ private var lastSubmit: Int64 = 0
+ private var paceScheduled = false
+ private let lock = NSLock()
+ /// A captured picture waiting to be encoded (or the last one encoded).
+ private struct Picture {
+ let pb: CVPixelBuffer
+ let pts: CMTime
+ let capture: Int64
+ let arrived: Int64
+ var echo: UInt32 // the input this picture is the first reply to
+ }
+ private var last: Picture?
+ private var skipped = false
+ private var stopped = false
+ private var inFlight = 0
+ /// Input posted to the Mac whose effect hasn't been captured yet: the
+ /// next picture composited after `after` is tagged with it.
+ private var pendingEcho: (id: UInt32, after: Int64)?
+ private var statsTimer: DispatchSourceTimer?
+ /// Frames already queued for the network; beyond this, or with two frames
+ /// already in the encoder, new frames are skipped (before encoding, so no
+ /// reference frame goes missing) and the newest picture is sent once
+ /// things catch up. Only that newest picture is kept meanwhile.
+ let maxPending: Int
+ static let maxInFlight = 2
+ var onEnd: ((Session) -> Void)?
+ var onAck: (() -> Void)?
+ var onFinished: ((String) -> Void)?
+ private let encodeQueue = DispatchQueue(label: "frame-mac-view.encode", qos: .userInteractive)
+
+ init(id: Int, source: Source, capture: CaptureSource, ws: WebSocket, codec: Codec, fps: Int, bitsPerPixel: Double,
+ reconnectKey: String) {
+ self.id = id
+ self.source = source
+ self.capture = capture
+ self.ws = ws
+ self.codec = codec
+ self.reconnectKey = reconnectKey
+ encoder = Encoder(codec: codec, fps: fps, bitsPerPixel: bitsPerPixel)
+ controller = RateController(maxFps: fps)
+ maxPending = codec == .jpeg ? 3 << 20 : 1 << 20
+ }
+
+ /// Frames come faster than the current tier's frame rate: this one waits,
+ /// and goes when its turn comes (unless a newer one replaces it).
+ /// Call with `lock` held.
+ private func paced(now: Int64) -> Bool {
+ let fps = controller.fps
+ guard fps < controller.maxFps, lastSubmit > 0 else { return false }
+ let interval = Int64(1_000_000 / fps), due = lastSubmit + interval * 9 / 10
+ guard now < due else { return false }
+ if !paceScheduled {
+ paceScheduled = true
+ encodeQueue.asyncAfter(deadline: .now() + .microseconds(Int(due - now))) { [weak self] in
+ guard let self else { return }
+ self.lock.lock(); self.paceScheduled = false; self.lock.unlock()
+ self.resendIfRoom()
+ }
+ }
+ return true
+ }
+
+ /// Encodes `pb` unless the link or the encoder is busy, in which case it
+ /// becomes the picture to send next.
+ private func offer(_ pb: CVPixelBuffer, pts: CMTime, capture shown: Int64) {
+ let arrived = nowUs()
+ stats.withLock { stats.captured += 1 }
+ controller.captured(at: arrived)
+ lock.lock()
+ // A reply to input stays with whichever picture ends up being sent.
+ var echo = skipped ? last?.echo ?? 0 : 0
+ if let p = pendingEcho, shown >= p.after {
+ echo = p.id
+ pendingEcho = nil
+ }
+ let picture = Picture(pb: pb, pts: pts, capture: shown, arrived: arrived, echo: echo)
+ last = picture
+ let busy = stopped || ws.pendingBytes > maxPending || inFlight >= Session.maxInFlight
+ || paced(now: arrived) || !controller.maySend(now: arrived)
+ if busy { skipped = true } else { inFlight += 1; last?.echo = 0; lastSubmit = arrived }
+ lock.unlock()
+ if busy { stats.withLock { stats.skipped += 1 } } else { submit(picture) }
+ }
+
+ private func submit(_ p: Picture) {
+ encodeQueue.async {
+ var r = FrameRecord()
+ r.capture = p.capture
+ r.arrived = p.arrived
+ r.echo = p.echo
+ r.bitrate = self.encoder.bitrate
+ r.tier = self.controller.tier
+ r.encodeStart = nowUs()
+ let seq = self.stats.newFrame(r)
+ if p.echo != 0 { self.stats.updateInput(p.echo) { $0.frame = seq; $0.capture = p.capture } }
+ if !self.encoder.encode(p.pb, pts: p.pts, seq: seq) { self.finished() }
+ }
+ }
+
+ /// An encode finished (or failed): send the newest skipped picture if
+ /// there's room now.
+ private func finished() {
+ lock.lock()
+ inFlight = max(0, inFlight - 1)
+ lock.unlock()
+ resendIfRoom()
+ }
+
+ private func resendIfRoom() {
+ lock.lock()
+ var next: Picture?
+ let now = nowUs()
+ if !stopped, skipped, ws.pendingBytes <= maxPending / 2, inFlight < Session.maxInFlight, let l = last,
+ !paced(now: now), controller.maySend(now: now, counts: false) {
+ next = l
+ skipped = false
+ inFlight += 1
+ last?.echo = 0
+ lastSubmit = now
+ }
+ lock.unlock()
+ // Stamped now: VideoToolbox needs increasing times, and the capture
+ // time stays in the record, so the wait counts as latency.
+ if let p = next {
+ submit(Picture(pb: p.pb, pts: CMClockGetTime(CMClockGetHostTimeClock()), capture: p.capture,
+ arrived: p.arrived, echo: p.echo))
+ }
+ }
+
+ func start(maxLong: Int, fps: Int) {
+ encoder.onFrame = { [weak self] data, key, pts, seq in
+ guard let self else { return }
+ let t = nowUs()
+ // The quality setting's bitrate, at full size, is the most it gets.
+ if self.appliedScale == 1 {
+ self.controller.setCeiling(self.encoder.defaultBitrate(width: self.encoder.width, height: self.encoder.height))
+ }
+ self.controller.sent(seq: seq, bytes: data.count + 17, at: t)
+ var echo: UInt32 = 0
+ let (w, h) = (self.encoder.width, self.encoder.height)
+ self.stats.update(seq) {
+ $0.encodeEnd = t
+ $0.sent = t
+ $0.bytes = data.count
+ $0.key = key
+ $0.width = w
+ $0.height = h
+ echo = $0.echo
+ }
+ // Header: flags (1 = keyframe), pts µs, sequence number, and the
+ // input this frame is the first reply to; all big-endian.
+ var msg = Data(capacity: data.count + 17)
+ msg.append(key ? 1 : 0)
+ var us = UInt64(max(0, CMTimeGetSeconds(pts)) * 1_000_000).bigEndian
+ msg.append(Data(bytes: &us, count: 8))
+ var s = seq.bigEndian, e = echo.bigEndian
+ msg.append(Data(bytes: &s, count: 4))
+ msg.append(Data(bytes: &e, count: 4))
+ msg.append(data)
+ let stats = self.stats
+ self.ws.sendBinary(msg) { stats.update(seq) { $0.wire = nowUs() } }
+ }
+ encoder.onDone = { [weak self] _ in self?.finished() }
+ encoder.onError = { [weak self] message in self?.ws.sendJSON(["t": "error", "message": message]) }
+ capture.onFrame = { [weak self] pb, pts, shown in self?.offer(pb, pts: pts, capture: shown) }
+ capture.onEnded = { [weak self] reason in
+ guard let self else { return }
+ self.ws.sendJSON(["t": "closed", "reason": reason])
+ self.onFinished?(self.source.key)
+ self.end()
+ }
+ ws.onDrain = { [weak self] in self?.resendIfRoom() }
+ ws.onText = { [weak self] text in self?.handle(text) }
+ ws.onClose = { [weak self] in self?.end() }
+ ws.start()
+ // Reconnect with this (kept in the page's memory, never on a command line).
+ ws.sendJSON(["t": "hello", "r": reconnectKey])
+ capture.onChange = { [weak self] in self?.sendInfo() }
+ // The numbers, for the viewer's overlay.
+ // Adapt ten times a second; the numbers go to the viewer once a second.
+ let t = DispatchSource.makeTimerSource(queue: encodeQueue)
+ t.schedule(deadline: .now() + 0.1, repeating: 0.1)
+ var ticks = 0
+ t.setEventHandler { [weak self] in
+ guard let self, !self.isStopped else { return }
+ self.adapt(maxLong: maxLong)
+ ticks += 1
+ guard ticks % 10 == 0 else { return }
+ var s = self.stats.summary()
+ s.merge(self.controller.state()) { a, _ in a }
+ s["t"] = "stats"
+ s["bitrate"] = self.encoder.bitrate
+ s["size"] = "\(self.encoder.width)×\(self.encoder.height)"
+ self.ws.sendJSON(s)
+ }
+ t.resume()
+ statsTimer = t
+ capture.start(maxLong: maxLong, fps: fps) { [weak self] error in
+ guard let self else { return }
+ if let error {
+ if self.capture.gone {
+ // Final, like a window closing mid-stream: the viewer closes
+ // and its key is revoked, rather than retrying for ever.
+ self.ws.sendJSON(["t": "closed", "reason": error])
+ self.onFinished?(self.source.key)
+ } else {
+ self.ws.sendJSON(["t": "error", "message": error])
+ }
+ self.end()
+ return
+ }
+ self.sendInfo()
+ }
+ }
+
+ /// Applies the controller's bitrate and tier. On the encoding queue.
+ private func adapt(maxLong: Int) {
+ guard let bps = controller.update(now: nowUs()) else { return }
+ encoder.setBitrate(bps)
+ let scale = controller.scale
+ if scale != appliedScale {
+ appliedScale = scale
+ capture.setMaxLong(max(320, Int(Double(maxLong) * scale)))
+ }
+ resendIfRoom() // a lower tier may let a held frame go now
+ }
+
+ /// While someone is typing or clicking, the viewer sends a tiny message this
+ /// often (ms, 0 = off), so the Frame's Wi-Fi doesn't doze between the input
+ /// and the frame that answers it (power saving is on there).
+ static let warmMs = Int(ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_WARM"] ?? "") ?? 0
+
+ func sendInfo() {
+ ws.sendJSON(["t": "info", "src": source.key, "title": capture.title, "app": capture.app, "codec": codec.rawValue,
+ "input": source == .test || Input.allowed, "warm": Session.warmMs,
+ "aspect": Double(capture.frameRect.width / max(capture.frameRect.height, 1))])
+ }
+
+ var isStopped: Bool { lock.lock(); defer { lock.unlock() }; return stopped }
+
+ func end() {
+ lock.lock()
+ let was = stopped
+ stopped = true
+ last = nil
+ lock.unlock()
+ guard !was else { return }
+ statsTimer?.cancel()
+ capture.stop()
+ encodeQueue.async { self.encoder.invalidate() }
+ let owner = id
+ DispatchQueue.main.async { Input.releaseAll(owner: owner) }
+ ws.close()
+ onEnd?(self)
+ }
+
+ /// A point in the picture (0...1 each way) -> Mac global coordinates.
+ private func point(_ x: Double, _ y: Double) -> CGPoint {
+ let r = capture.frameRect
+ return CGPoint(x: r.minX + min(max(x, 0), 1) * r.width, y: r.minY + min(max(y, 0), 1) * r.height)
+ }
+
+ /// Input with an id (from the viewer) has been posted: the next picture
+ /// the Mac composites is its first chance to show.
+ private func injected(_ m: [String: Any], kind: String) {
+ guard let iid = (m["i"] as? NSNumber)?.uint32Value, iid != 0 else { return }
+ let now = nowUs()
+ stats.addInput(InputRecord(id: iid, kind: kind, viewer: Int64(num(m["tv"]) ?? 0), injected: now))
+ lock.lock(); pendingEcho = (iid, now); lock.unlock()
+ }
+
+ /// Timing reports from the viewer, in the agent's clock.
+ private func report(_ t: String, _ m: [String: Any]) -> Bool {
+ switch t {
+ case "ping":
+ ws.sendJSON(["t": "pong", "c": m["c"] ?? 0, "a": nowUs()])
+ case "rx":
+ guard let s = (m["s"] as? NSNumber)?.uint32Value else { break }
+ if let r = num(m["r"]) { stats.update(s) { $0.received = Int64(r) } }
+ if controller.acked(seq: s, at: nowUs()) { resendIfRoom() }
+ case "fd":
+ for f in m["f"] as? [[NSNumber]] ?? [] where f.count >= 4 {
+ stats.update(f[0].uint32Value) {
+ $0.decoded = f[1].int64Value
+ $0.drawn = f[2].int64Value
+ $0.vsync = f[3].int64Value
+ }
+ }
+ let dropped = Int(num(m["drop"]) ?? 0)
+ stats.withLock { stats.viewerDropped += dropped }
+ case "w":
+ break // keep-warm filler, see sendInfo
+ case "clock":
+ stats.withLock {
+ stats.rtt = num(m["rtt"]) ?? 0
+ stats.clockSynced = true
+ if let d = m["dec"] as? String { stats.decoder = d }
+ }
+ default:
+ return false
+ }
+ return true
+ }
+
+ /// Asks the viewer to do something for a benchmark (type, click, show its overlay).
+ func bench(_ m: [String: Any]) { ws.sendJSON(m.merging(["t": "bench"]) { a, _ in a }) }
+
+ private func handle(_ text: String) {
+ guard !isStopped, let d = text.data(using: .utf8),
+ let m = try? JSONSerialization.jsonObject(with: d) as? [String: Any], let t = m["t"] as? String else { return }
+ if report(t, m) { return }
+ let x = m["x"] as? Double ?? -1, y = m["y"] as? Double ?? -1
+ if t == "ack" {
+ onAck?()
+ onAck = nil
+ return
+ }
+ if t == "key-frame" {
+ encodeQueue.async { self.encoder.requestKeyFrame() } // before the resend, same queue
+ lock.lock(); skipped = last != nil; lock.unlock()
+ resendIfRoom()
+ return
+ }
+ if source == .test {
+ let desc: String?
+ switch t {
+ case "m": desc = (m["e"] as? String) == "move" ? nil : "mouse \(m["e"] ?? "") button \(m["b"] ?? 0)"
+ case "wheel": desc = "wheel \(m["dx"] ?? 0), \(m["dy"] ?? 0)"
+ case "k": desc = (m["e"] as? String) == "down" ? "key \(m["code"] ?? "") \"\(m["key"] ?? "")\"" : nil
+ case "text": desc = "text \"\(m["s"] ?? "")\""
+ default: desc = nil
+ }
+ capture.pointer(x: x, y: y, text: desc)
+ if desc != nil { injected(m, kind: t) }
+ return
+ }
+ DispatchQueue.main.async { [self] in
+ guard !isStopped else { return } // Stop revokes input at once
+ switch t {
+ case "m":
+ let kind = m["e"] as? String ?? "move", b = m["b"] as? Int ?? 0
+ let p = point(x, y)
+ if kind == "down", case .window(let wid) = source, let pid = capture.pid {
+ Input.focus(window: wid, pid: pid)
+ }
+ Input.mouse(kind, button: b, at: p, owner: id)
+ if kind == "down" { injected(m, kind: "click") }
+ case "wheel":
+ Input.scroll(dx: m["dx"] as? Double ?? 0, dy: m["dy"] as? Double ?? 0, at: point(x, y), owner: id)
+ injected(m, kind: "wheel")
+ case "k":
+ let down = (m["e"] as? String) == "down"
+ Input.key(code: m["code"] as? String ?? "", key: m["key"] as? String ?? "",
+ down: down, mods: m["mods"] as? [String] ?? [], owner: id)
+ if down { injected(m, kind: "key") }
+ case "text":
+ if let s = m["s"] as? String, s.count <= 4096 { Input.text(s); injected(m, kind: "text") }
+ case "release":
+ Input.releaseAll(owner: id)
+ case "focus":
+ if case .window(let wid) = source, let pid = capture.pid { Input.focus(window: wid, pid: pid) }
+ default: break
+ }
+ }
+ }
+}
+
+func randomKey() -> String {
+ var bytes = [UInt8](repeating: 0, count: 24)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
+}
+
+func sameSecret(_ a: String, _ b: String) -> Bool {
+ guard !a.isEmpty, a.utf8.count == b.utf8.count else { return false }
+ return zip(a.utf8, b.utf8).reduce(0, { $0 | ($1.0 ^ $1.1) }) == 0
+}
+
+final class Agent {
+ /// Frame Control's own key. It stays on the Mac: the Frame only ever sees
+ /// single-use tickets and per-stream reconnect keys, both tied to one source.
+ let token: String
+ let page: URL?
+ var sessions: [Int: Session] = [:] { didSet { keepDisplayAwake(!sessions.isEmpty) } }
+ var nextId = 1
+ let lock = NSLock()
+ private var assertion: IOPMAssertionID = 0
+ /// ticket -> (source, expiry, reconnect key once redeemed). A ticket opens
+ /// one viewer within a minute; it may be redeemed again, for the same key,
+ /// only until that viewer confirms it has the key ("ack").
+ private var tickets: [String: (src: String, expiry: Date, key: String?)] = [:]
+ /// reconnect key -> source, until Stop for that source.
+ private var reconnectKeys: [String: String] = [:]
+ /// Sources that ended for good (the window closed), for Frame Control.
+ private var finished = Set()
+
+ /// Ends every stream, then exits once separated windows are back on the
+ /// Mac's own screens (they're moved back, then their displays go 0.3 s later).
+ /// Idempotent: SIGTERM and the parent going away can both call it, and a
+ /// session already ended by /close may still be putting its window back.
+ private var quitting = false
+ func quit() {
+ lock.lock()
+ let all = Array(sessions.values), again = quitting
+ quitting = true
+ lock.unlock()
+ guard !again else { return }
+ for s in all { s.ws.sendJSON(["t": "close"]); s.end() }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { exit(0) }
+ }
+
+ /// While anyone watches, keep the Mac's display on: a sleeping display
+ /// stops being drawn, so there'd be nothing to capture (and it would lock).
+ private func keepDisplayAwake(_ on: Bool) {
+ if on, assertion == 0 {
+ IOPMAssertionCreateWithName(kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
+ IOPMAssertionLevel(kIOPMAssertionLevelOn),
+ "Frame Control is showing this Mac in a Steam Frame" as CFString, &assertion)
+ } else if !on, assertion != 0 {
+ IOPMAssertionRelease(assertion)
+ assertion = 0
+ }
+ }
+
+ init(token: String, page: URL?) {
+ self.token = token
+ self.page = page
+ }
+
+ /// Whether this request may open a stream of `src`: Frame Control's key,
+ /// an unused ticket for that source, or a live reconnect key for it.
+ private func mayStream(_ req: Request, src: String) -> String? {
+ lock.lock()
+ defer { lock.unlock() }
+ let now = Date()
+ tickets = tickets.filter { $0.value.expiry > now }
+ if sameSecret(req.query["k"] ?? "", token) { return randomKey() }
+ if let t = req.query["t"], let entry = tickets[t], entry.src == src {
+ // A retry before the viewer got its key gets the same key back.
+ let key = entry.key ?? randomKey()
+ tickets[t] = (src, entry.expiry, key)
+ reconnectKeys[key] = src
+ return key
+ }
+ if let r = req.query["r"], reconnectKeys[r] == src { return r }
+ return nil
+ }
+
+ /// The viewer has its reconnect key, so the ticket that led to it can't
+ /// be used again (also when the ack comes over a reconnection).
+ func acknowledged(key: String) {
+ lock.lock(); tickets = tickets.filter { $0.value.key != key }; lock.unlock()
+ }
+
+ func handle(_ req: Request, _ c: HTTPConnection) {
+ // Open to anything that reaches the port: a liveness check for the
+ // tunnel, and the viewer page, which holds no secrets.
+ switch (req.method, req.path) {
+ case ("GET", "/ping"): return c.respond(200, text: "frame-mac-view")
+ case ("GET", "/view"):
+ guard let page, let body = try? Data(contentsOf: page) else { return c.respond(404, text: "no viewer page") }
+ return c.respond(200, body: body, type: "text/html; charset=utf-8")
+ case ("GET", "/stream"):
+ guard let src = Source(req.query["src"] ?? "") else { return c.respond(400, text: "bad src") }
+ guard let key = mayStream(req, src: src.key) else { return c.respond(403, text: "forbidden") }
+ return stream(req, c, src: src, key: key)
+ default: break
+ }
+ guard sameSecret(req.query["k"] ?? req.headers["x-token"] ?? "", token) else {
+ return c.respond(403, text: "forbidden")
+ }
+ switch (req.method, req.path) {
+ case ("GET", "/status"):
+ lock.lock()
+ let list = sessions.values.map { s -> [String: Any] in
+ var e: [String: Any] = ["id": s.id, "src": s.source.key, "title": s.capture.title, "app": s.capture.app,
+ "stats": s.stats.summary(), "bitrate": s.encoder.bitrate,
+ "controller": s.controller.state()]
+ if let d = (s.capture as? SeparateSource)?.displayID { e["display"] = d }
+ return e
+ }
+ lock.unlock()
+ var s = permissionsJSON()
+ s["version"] = version
+ s["streams"] = list
+ lock.lock(); s["finished"] = Array(finished); lock.unlock()
+ c.respond(json: s)
+ case ("GET", "/windows"):
+ c.respond(json: ["windows": windowsJSON(), "screen": CGPreflightScreenCaptureAccess()])
+ case ("GET", "/displays"):
+ c.respond(json: ["displays": displaysJSON()])
+ case ("POST", "/ticket"):
+ guard let src = Source(req.query["src"] ?? "") else { return c.respond(400, text: "bad src") }
+ let t = randomKey()
+ lock.lock()
+ tickets[t] = (src.key, Date().addingTimeInterval(60), nil)
+ finished.remove(src.key)
+ lock.unlock()
+ c.respond(json: ["ticket": t])
+ case ("POST", "/close"):
+ // Tell viewers to close their windows, but don't rely on them:
+ // the sessions end here and can't reconnect.
+ let src = req.query["src"]
+ lock.lock()
+ let matching = sessions.values.filter { src == nil || $0.source.key == src }
+ reconnectKeys = reconnectKeys.filter { src != nil && $0.value != src }
+ tickets = tickets.filter { src != nil && $0.value.src != src } // not yet used ones too
+ lock.unlock()
+ for s in matching { s.ws.sendJSON(["t": "close"]) }
+ DispatchQueue.global().asyncAfter(deadline: .now() + 0.3) { for s in matching { s.end() } }
+ c.respond(json: ["closed": matching.count])
+ case ("GET", "/stats"):
+ // Frames the viewer has had time to report on, oldest first.
+ let since = UInt32(req.query["since"] ?? "") ?? 0
+ let settle = Int64(req.query["settle"] ?? "") ?? 1_500_000
+ lock.lock()
+ let list = sessions.values.filter { req.query["id"] == nil || "\($0.id)" == req.query["id"] }
+ lock.unlock()
+ c.respond(json: ["now": nowUs(), "streams": list.map { s -> [String: Any] in
+ ["id": s.id, "src": s.source.key, "frames": s.stats.settled(since: since, settle: settle).map(\.json),
+ "inputs": s.stats.inputList().map(\.json), "summary": s.stats.summary(),
+ "captured": s.stats.withLock { s.stats.captured }, "controller": s.controller.state(),
+ "events": s.controller.eventList()]
+ }])
+ case ("POST", "/bench"):
+ lock.lock()
+ let list = sessions.values.filter { $0.source.key == req.query["src"] }
+ lock.unlock()
+ var m: [String: Any] = [:]
+ for (k, v) in req.query where k != "k" && k != "src" { m[k] = Double(v) ?? v as Any }
+ for s in list { s.bench(m) }
+ c.respond(json: ["sent": list.count])
+ case ("GET", "/snapshot"):
+ // One JPEG of a display (for checks and thumbnails): ?display=ID
+ guard let id = UInt32(req.query["display"] ?? "") else { return c.respond(400, text: "display=ID") }
+ snapshot(display: id) { data, error in
+ if let data { c.respond(200, body: data, type: "image/jpeg") } else { c.respond(500, text: error ?? "failed") }
+ }
+ case ("POST", "/permissions"):
+ DispatchQueue.main.async { requestPermissions() }
+ c.respond(json: permissionsJSON())
+ default:
+ c.respond(404, text: "not found")
+ }
+ }
+
+ private func stream(_ req: Request, _ c: HTTPConnection, src: Source, key: String) {
+ let codec = Codec(rawValue: req.query["codec"] ?? "h264") ?? .h264
+ let maxLong = min(max(Int(req.query["max"] ?? "") ?? 1920, 320), 3840)
+ let fps = min(max(Int(req.query["fps"] ?? "") ?? 60, 5), 120)
+ let bpp = min(max(Double(req.query["bpp"] ?? "") ?? 0.1, 0.02), 0.5)
+ guard let ws = c.upgrade(req) else { return }
+ let capture: CaptureSource
+ switch src {
+ case .test: capture = TestSource()
+ case .separate(let id): capture = SeparateSource(windowID: id)
+ default: capture = SCKSource(src)
+ }
+ lock.lock()
+ // One live viewer per key: a reconnection (or a second use of an
+ // unacknowledged ticket) replaces the one before.
+ let replaced = sessions.values.filter { $0.reconnectKey == key }
+ let session = Session(id: nextId, source: src, capture: capture, ws: ws, codec: codec, fps: fps,
+ bitsPerPixel: bpp, reconnectKey: key)
+ nextId += 1
+ sessions[session.id] = session
+ lock.unlock()
+ for old in replaced { old.end() }
+ session.onEnd = { [weak self] s in
+ guard let self else { return }
+ self.lock.lock(); self.sessions[s.id] = nil; self.lock.unlock()
+ }
+ // The source is gone (its window closed): its viewers can't come back.
+ session.onFinished = { [weak self] src in
+ guard let self else { return }
+ self.lock.lock()
+ self.finished.insert(src)
+ self.reconnectKeys = self.reconnectKeys.filter { $0.value != src }
+ self.lock.unlock()
+ }
+ session.onAck = { [weak self] in self?.acknowledged(key: key) }
+ session.start(maxLong: maxLong, fps: fps)
+ }
+}
+
+func snapshot(display id: CGDirectDisplayID, completion: @escaping (Data?, String?) -> Void) {
+ SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { content, error in
+ guard let d = content?.displays.first(where: { $0.displayID == id }) else {
+ return completion(nil, error?.localizedDescription ?? "no such display")
+ }
+ let filter = SCContentFilter(display: d, excludingWindows: [])
+ let cfg = SCStreamConfiguration()
+ cfg.width = Int(Double(d.width) * Double(filter.pointPixelScale))
+ cfg.height = Int(Double(d.height) * Double(filter.pointPixelScale))
+ cfg.showsCursor = true
+ SCScreenshotManager.captureImage(contentFilter: filter, configuration: cfg) { image, error in
+ guard let image else { return completion(nil, error?.localizedDescription ?? "no image") }
+ let rep = NSBitmapImageRep(cgImage: image)
+ completion(rep.representation(using: .jpeg, properties: [.compressionFactor: 0.85]), nil)
+ }
+ }
+}
+
+func requestPermissions() {
+ if !CGPreflightScreenCaptureAccess() { CGRequestScreenCaptureAccess() }
+ if !AXIsProcessTrusted() {
+ AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary)
+ }
+}
+
+func argument(_ name: String, in args: [String]) -> String? {
+ guard let i = args.firstIndex(of: name), i + 1 < args.count else { return nil }
+ return args[i + 1]
+}
+
+let args = Array(CommandLine.arguments.dropFirst())
+switch args.first {
+case "windows":
+ printJSON(["windows": windowsJSON(), "screen": CGPreflightScreenCaptureAccess()])
+case "displays":
+ printJSON(["displays": displaysJSON()])
+case "permissions":
+ printJSON(permissionsJSON())
+case "request-permissions":
+ requestPermissions()
+ printJSON(permissionsJSON())
+case "serve":
+ let port = UInt16(argument("--port", in: args) ?? "") ?? 0
+ let token = ProcessInfo.processInfo.environment["FRAME_MAC_VIEW_TOKEN"] ?? ""
+ guard !token.isEmpty else {
+ FileHandle.standardError.write(Data("frame-mac-view: set FRAME_MAC_VIEW_TOKEN\n".utf8))
+ exit(2)
+ }
+ let page = argument("--page", in: args).map { URL(fileURLWithPath: $0) }
+ let agent = Agent(token: token, page: page)
+ // Quit when the parent goes away (it holds our stdin open), or on SIGTERM,
+ // but first end every stream, so separated windows go back where they
+ // were before their displays disappear.
+ if args.contains("--exit-on-eof") {
+ DispatchQueue.global().async {
+ while FileHandle.standardInput.availableData.count > 0 {}
+ agent.quit()
+ }
+ }
+ signal(SIGTERM, SIG_IGN)
+ let sigterm = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
+ sigterm.setEventHandler { agent.quit() }
+ sigterm.resume()
+ let server: Server
+ do {
+ server = try Server(port: port) { req, c in agent.handle(req, c) }
+ } catch {
+ FileHandle.standardError.write(Data("frame-mac-view: \(error)\n".utf8))
+ exit(1)
+ }
+ server.start { [server] error in
+ if let error {
+ FileHandle.standardError.write(Data("frame-mac-view: can't listen on 127.0.0.1:\(port): \(error)\n".utf8))
+ exit(1)
+ }
+ print("frame-mac-view listening on 127.0.0.1:\(server.port ?? port)")
+ fflush(stdout)
+ }
+ // A real (background, no Dock icon) AppKit event loop, not just a run
+ // loop: without it this process never hears that displays were added or
+ // changed mode, so NSScreen and CGDisplayCopyDisplayMode stay stale for
+ // the displays Separate mode creates.
+ let app = NSApplication.shared
+ app.setActivationPolicy(.prohibited)
+ withExtendedLifetime((server, sigterm)) { app.run() }
+default:
+ FileHandle.standardError.write(Data("usage: frame-mac-view serve|windows|displays|permissions|request-permissions\n".utf8))
+ exit(2)
+}
diff --git a/mac/frame-mac-view/build.sh b/mac/frame-mac-view/build.sh
new file mode 100755
index 0000000..f391a58
--- /dev/null
+++ b/mac/frame-mac-view/build.sh
@@ -0,0 +1,11 @@
+#!/bin/sh
+# Builds the Mac streaming agent into mac/bin/frame-mac-view (arm64, macOS 14+).
+# Frame Control runs it for "Mac in the headset"; the Electron app bundles it.
+set -eu
+here=$(cd "$(dirname "$0")" && pwd)
+out=${1:-$here/../bin/frame-mac-view}
+mkdir -p "$(dirname "$out")"
+xcrun swiftc -O -swift-version 5 -target arm64-apple-macos14.0 \
+ -import-objc-header "$here/Sources/CGVirtualDisplay.h" \
+ -o "$out" "$here"/Sources/*.swift
+echo "built $out"
diff --git a/mac/frame-mac-view/lab.sh b/mac/frame-mac-view/lab.sh
new file mode 100755
index 0000000..11440d4
--- /dev/null
+++ b/mac/frame-mac-view/lab.sh
@@ -0,0 +1,50 @@
+#!/bin/sh
+# Wraps the agent in "Frame Mac View Lab.app" for testing from a terminal or
+# an agent session: macOS then asks for Screen Recording and Accessibility
+# for this app rather than for the terminal. Signed with an Apple Development
+# identity if there is one, so the permissions survive rebuilds.
+# mac/frame-mac-view/lab.sh build the app into mac/bin/
+# mac/frame-mac-view/lab.sh serve also start it (token, port and log in ~/Library/Caches/frame-mac-view-lab,
+# readable only by you: the token opens every window and injects input)
+# mac/frame-mac-view/lab.sh serve-only restart it without rebuilding
+set -eu
+here=$(cd "$(dirname "$0")" && pwd)
+app="$here/../bin/Frame Mac View Lab.app"
+if [ "${1:-}" != serve-only ]; then
+ mkdir -p "$app/Contents/MacOS"
+ sh "$here/build.sh" "$app/Contents/MacOS/frame-mac-view" >/dev/null
+ cat > "$app/Contents/Info.plist" <
+
+
+CFBundleIdentifier com.saphid.frame-mac-view-lab
+CFBundleExecutable frame-mac-view
+CFBundleName Frame Mac View Lab
+CFBundlePackageType APPL
+CFBundleShortVersionString 1
+LSUIElement
+NSScreenCaptureUsageDescription Tests streaming Mac windows into the Steam Frame.
+
+PLIST
+ id=$(security find-identity -v -p codesigning 2>/dev/null | awk '/Apple Development/ {print $2; exit}')
+ codesign -f -s "${id:--}" "$app" >/dev/null 2>&1
+ echo "built $app (signed ${id:-ad hoc})"
+fi
+if [ "${1:-}" = serve ] || [ "${1:-}" = serve-only ]; then
+ pkill -f "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view" 2>/dev/null || true
+ dir="$HOME/Library/Caches/frame-mac-view-lab"
+ mkdir -p "$dir" && chmod 700 "$dir"
+ umask 077
+ token=$(openssl rand -hex 16)
+ rm -f "$dir/token" "$dir/port" "$dir/log"
+ echo "$token" > "$dir/token"
+ : > "$dir/log"
+ # Experiment switches pass through: FRAME_MAC_VIEW_ENCODER (Encoder.swift),
+ # _VD_HZ (Separate.swift), _ADAPT (Controller.swift), _WARM (main.swift).
+ open -n "$app" --env "FRAME_MAC_VIEW_TOKEN=$token" --env "FRAME_MAC_VIEW_ENCODER=${FRAME_MAC_VIEW_ENCODER:-}" --env "FRAME_MAC_VIEW_VD_HZ=${FRAME_MAC_VIEW_VD_HZ:-}" \
+ --env "FRAME_MAC_VIEW_ADAPT=${FRAME_MAC_VIEW_ADAPT:-}" --env "FRAME_MAC_VIEW_WARM=${FRAME_MAC_VIEW_WARM:-}" --stdout "$dir/log" --stderr "$dir/log" \
+ --args serve --port 0 --page "$here/../../ui/mac-view.html"
+ for _ in 1 2 3 4 5 6 7 8 9 10; do grep -q listening "$dir/log" && break; sleep 0.3; done
+ sed -n 's/.*127\.0\.0\.1:\([0-9]*\).*/\1/p' "$dir/log" | head -n 1 > "$dir/port"
+ echo "lab on 127.0.0.1:$(cat "$dir/port")"
+fi
diff --git a/scripts/assistant-on-frame.py b/scripts/assistant-on-frame.py
new file mode 100644
index 0000000..64e88ba
--- /dev/null
+++ b/scripts/assistant-on-frame.py
@@ -0,0 +1,100 @@
+#!/usr/bin/env python3
+"""Open Frame Control's assistant as a Chromium panel. Ctrl-C closes it and its SSH tunnel.
+
+Start ui/server.py first. Requires the platform Chromium Flatpak and zsh on the
+computer (the existing panel launcher). No model endpoint or key is configured.
+"""
+import argparse
+import os
+from pathlib import Path
+import re
+import shlex
+import signal
+import shutil
+import subprocess
+import sys
+import uuid
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--port', type=int, default=47810, help='local Frame Control port')
+ parser.add_argument('--frame-port', type=int, default=47812, help='Frame loopback tunnel port')
+ args = parser.parse_args()
+ alias = os.environ.get('FRAME_ALIAS', 'frame')
+ if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]*', alias) or any(not 1 <= p <= 65535 for p in (args.port, args.frame_port)):
+ parser.error('Invalid alias or port')
+ if not shutil.which('zsh'):
+ parser.error('The panel launcher requires zsh on this computer')
+ sys.path.insert(0, str(ROOT / 'ui'))
+ from frame_mcp import Client
+ Client('http://127.0.0.1:' + str(args.port), os.environ.get('FRAME_UI_KEY', '1')).request('/api/host')
+ profile = '/tmp/frame-control-assistant-' + uuid.uuid4().hex
+ log_path = ''
+ signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
+ tunnel = subprocess.Popen(['ssh', '-N', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
+ '-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
+ '-o', 'ServerAliveCountMax=2', '-R',
+ f'127.0.0.1:{args.frame_port}:127.0.0.1:{args.port}', alias])
+ try:
+ # Check the forwarded page before starting a browser; no arbitrary sleeps.
+ probe = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
+ 'curl --retry 5 --retry-connrefused --retry-delay 1 --max-time 10 -fsS ' +
+ shlex.quote(f'http://127.0.0.1:{args.frame_port}/assistant')],
+ stdout=subprocess.DEVNULL, timeout=30)
+ if probe.returncode or tunnel.poll() is not None:
+ raise RuntimeError('Could not forward Frame Control to the Frame')
+ launched = subprocess.run(['zsh', str(ROOT / 'scripts/panel-on-frame.sh'), '--name', 'Frame Control Assistant',
+ 'org.chromium.Chromium', '--user-data-dir=' + profile, '--no-first-run',
+ '--disable-background-networking', '--disable-sync',
+ f'--app=http://127.0.0.1:{args.frame_port}/assistant'], check=True, timeout=45, stdout=subprocess.PIPE, text=True)
+ print(launched.stdout, end='', flush=True)
+ match = re.search(r'log (/tmp/panel-on-frame\.[A-Za-z0-9]+)', launched.stdout)
+ if match:
+ log_path = match.group(1)
+ print('Assistant panel open. Ctrl-C closes this panel and its tunnel.', flush=True)
+ tunnel.wait()
+ raise RuntimeError('SSH tunnel ended')
+ except KeyboardInterrupt:
+ return 0
+ finally:
+ tunnel.terminate()
+ try:
+ tunnel.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ tunnel.kill()
+ tunnel.wait()
+ # Only this unique browser profile, never a shared Chromium instance.
+ cleanup = '''import os, pathlib, signal, shutil, sys, time
+profile = sys.argv[1]
+needle = ('--user-data-dir=' + profile).encode()
+owned = []
+for p in pathlib.Path('/proc').iterdir():
+ try:
+ if p.name.isdigit() and p.stat().st_uid == os.getuid() and needle in (p / 'cmdline').read_bytes().split(b'\\0'):
+ owned.append(int(p.name))
+ except OSError:
+ pass
+for sig in (signal.SIGTERM, signal.SIGKILL):
+ for pid in owned:
+ try: os.kill(pid, sig)
+ except ProcessLookupError: pass
+ time.sleep(.3)
+shutil.rmtree(profile, ignore_errors=True)
+if sys.argv[2]:
+ pathlib.Path(sys.argv[2]).unlink(missing_ok=True)
+'''
+ result = subprocess.run(['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', alias,
+ 'python3 - ' + shlex.quote(profile) + ' ' + shlex.quote(log_path)], input=cleanup, text=True, timeout=20)
+ if result.returncode:
+ print('Cleanup failed; close the assistant panel and remove ' + profile + ' on the Frame.', file=sys.stderr)
+
+
+if __name__ == '__main__':
+ try:
+ sys.exit(main())
+ except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
+ print(str(exc), file=sys.stderr)
+ sys.exit(1)
diff --git a/scripts/connect.sh b/scripts/connect.sh
index 00bcb41..477020b 100755
--- a/scripts/connect.sh
+++ b/scripts/connect.sh
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
fi
}
-# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
+# Takes the lock Frame Control uses to edit ~/.ssh/config (ui/frame_devices.py), so a
+# running app and this script never write over each other's change.
write_config() {
+ local lockfd="" rc
+ zmodload zsh/system 2>/dev/null
+ touch "$CONFIG.frame-control.lock" 2>/dev/null
+ zsystem flock -t 30 -f lockfd "$CONFIG.frame-control.lock" 2>/dev/null || lockfd=""
+ write_config_locked; rc=$?
+ [[ -n "$lockfd" ]] && zsystem flock -u "$lockfd"
+ return $rc
+}
+
+# Checks each step itself: pair_with_devkit calls this from an `elif`, where set -e is off.
+write_config_locked() {
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
- local tmp
+ local tmp new="$CONFIG.frame-control.$$"
tmp=$(mktemp) || return 1
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
# value it sees per option, so this block must precede any other "Host frame"
@@ -126,7 +138,8 @@ write_config() {
print -r -- "Host *"
print -r -- "$END_MARK"
cat "$tmp"
- } > "$CONFIG" || { print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
+ } > "$new" && chmod 600 "$new" && mv -f "$new" "$CONFIG" \
+ || { rm -f "$new"; print -u2 "!! Writing $CONFIG failed; its previous contents are in $tmp"; return 1; }
rm -f "$tmp"
}
diff --git a/scripts/keep-awake.sh b/scripts/keep-awake.sh
new file mode 100755
index 0000000..f2c6815
--- /dev/null
+++ b/scripts/keep-awake.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env zsh
+# Mac-side: stop the Steam Frame from going to sleep while an agent works on it.
+#
+# The Frame sleeps when Steam's own idle timer runs out ("Sleep after
+# inactivity": 60 min on AC, 15 min on battery by default). SSH activity
+# doesn't count as input, and asleep the Frame is off the network. `on` sets
+# both timers to Never through Steam's UI (DevTools on 127.0.0.1:8080, via
+# ui/frame_steam.py) and holds a logind sleep inhibitor as a user unit.
+# `off` drops the inhibitor and restores the timers `on` saved.
+#
+# Usage:
+# scripts/keep-awake.sh on
+# scripts/keep-awake.sh off
+# scripts/keep-awake.sh status
+set -euo pipefail
+
+FRAME_ALIAS=${FRAME_ALIAS:-frame}
+# Frame Control passes the headset it has chosen: its address and pinned identity.
+ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
+HERE=${0:A:h}
+cmd=${1:-status}
+case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
+
+ssh "${ssh_opts[@]}" -o ConnectTimeout=8 "$FRAME_ALIAS" \
+ 'mkdir -p ~/.cache/frame-control && cat > ~/.cache/frame-control/frame_steam.py' < "$HERE/../ui/frame_steam.py"
+
+# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
+# timers are client settings system_idle_suspend_{ac,battery}_sec (0 = Never),
+# written the way Steam's settings page does (steamui module exporting the
+# SetSetting wrapper). logind refuses an inhibitor from an SSH session
+# ("Interactive authentication required") but allows one from a user unit.
+ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
+import json, os, subprocess, sys
+sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
+from frame_steam import Page
+
+cmd = sys.argv[1]
+saved_path = os.path.expanduser("~/.cache/frame-control/keep-awake.json")
+unit = "fc-keep-awake"
+keys = ("system_idle_suspend_ac_sec", "system_idle_suspend_battery_sec")
+
+if cmd == "off": # release the lock first, even if Steam's UI is down
+ subprocess.run(["systemctl", "--user", "stop", unit], stderr=subprocess.DEVNULL)
+page = Page()
+def read():
+ return {k: page.eval(f"settingsStore.clientSettings.{k}") for k in keys}
+def write(values):
+ page.eval("""(async () => { let req;
+ webpackChunksteamui.push([[Symbol()], {}, r => { req = r }]);
+ const mod = Object.keys(req.m).map(id => req.m[id].toString().includes("Settings.SetSetting") ? req(id) : null).find(Boolean);
+ const set = Object.values(mod).find(f => typeof f == "function" && f.toString().includes("SetSetting("));
+ for (const [k, v] of Object.entries(%s)) await set(k, v);
+ await new Promise(r => setTimeout(r, 1000)); })()""" % json.dumps(values))
+def inhibitor():
+ return subprocess.run(["systemctl", "--user", "is-active", "-q", unit]).returncode == 0
+
+if cmd == "on":
+ current = read()
+ if not os.path.exists(saved_path):
+ with open(saved_path, "w") as f:
+ json.dump(current, f)
+ write({k: 0 for k in keys})
+ if not inhibitor():
+ subprocess.run(["systemd-run", "--user", "-q", f"--unit={unit}",
+ "--description=Frame Control: keep the Frame awake",
+ "systemd-inhibit", "--what=sleep:idle:handle-suspend-key:handle-power-key",
+ "--who=Frame Control", "--why=Keep the Frame awake while an agent works on it",
+ "--mode=block", "sleep", "infinity"], check=True)
+elif cmd == "off":
+ if os.path.exists(saved_path): # no backup: leave the timers as they are
+ with open(saved_path) as f:
+ write(json.load(f))
+ os.remove(saved_path)
+
+print(json.dumps({"timers": read(), "inhibitor": inhibitor()}))
+EOF
diff --git a/scripts/macview-bench.py b/scripts/macview-bench.py
new file mode 100755
index 0000000..cffa8b5
--- /dev/null
+++ b/scripts/macview-bench.py
@@ -0,0 +1,847 @@
+#!/usr/bin/env python3
+"""Benchmark "Mac in the headset" on the real Frame, so every change to the
+stream is judged by numbers (docs/mac-in-headset.md, "Measuring").
+
+ scripts/macview-bench.py run [--scenario test,scroll,type] [--net 8] [--label x]
+ scripts/macview-bench.py compare bench/results/A.json bench/results/B.json
+ scripts/macview-bench.py ab --arm off:FRAME_MAC_VIEW_ADAPT=0 --arm on: --repeat 3 --scenario scroll
+
+`run` uses the lab agent (mac/frame-mac-view/lab.sh serve), which has
+Screen Recording and Accessibility. It opens a viewer on the Frame the way
+Frame Control does, drives a fixed scenario, and reads the agent's per-frame
+records: when the Mac composited each frame, when it was encoded and sent,
+and when the viewer received, decoded and drew it (the viewer's clock is
+synced to the Mac's). "content" is Mac composited -> drawn in the viewer;
+what the Frame's compositor adds after that is reported ("present",
+"fps_shown") but not graded, because an unworn Frame throttles panels to
+36 fps (15 in standby) after a few seconds whatever they draw. It writes a summary to bench/results/ and exits 1 if a
+target is missed ("bad"), or if --baseline is given and a key number got
+more than 10% worse.
+
+Wi-Fi changes from minute to minute, so single runs taken at different times
+can differ by more than a change does. `ab` restarts the lab agent with each
+arm's settings (environment variables read by the agent), runs the arms
+interleaved, and prints each metric's median and range per arm.
+
+Nobody needs to wear the headset. The scroll and type scenarios open a
+throwaway Chrome window (its own profile in /tmp) on the Mac.
+
+Network conditions come from a relay on the Mac between the agent and the
+tunnel, so they need no sudo:
+ --net 8 8 Mbit/s the whole run
+ --net 50@0,8@10,50@20 50 Mbit/s, 8 from 10 s, back to 50 from 20 s
+ --delay 30 30 ms more one-way delay
+The relay holds at most --buffer ms of data at the current rate (default
+250 ms, like a bloated Wi-Fi queue), then pushes back on the agent.
+
+Stdlib only.
+"""
+import argparse
+import asyncio
+import datetime
+import json
+import os
+import platform
+import re
+import statistics
+import subprocess
+import sys
+import threading
+import time
+import urllib.request
+from pathlib import Path
+from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponents keeps +
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+import frame_macview # noqa: E402
+
+RESULTS = ROOT / "bench" / "results"
+PAGES = ROOT / "bench" / "pages"
+CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+CHROME_PROFILE = "/tmp/fmv-bench-chrome"
+LAB_DIR = Path.home() / "Library" / "Caches" / "frame-mac-view-lab" # lab.sh serve
+
+# The acceptance bar (the handoff's targets): (feels local, acceptable) per
+# metric; worse than acceptable is "bad". Latencies in ms.
+TARGETS = {
+ "content_p50": (25, 60), "content_p95": (40, 100),
+ "input_p50": (50, 100), "input_p95": (70, 150),
+ "fps": (58, 45), # higher is better
+ "late_pct": (1, 5), "stall_max": (100, 250),
+ "adapt_s": (1, 3),
+}
+HIGHER_IS_BETTER = {"fps"}
+# Content that changes every frame, so fps and stalls mean something.
+MOVING = {"test", "scroll"}
+
+
+def ms(a, b):
+ return (b - a) / 1000 if a and b else None
+
+
+def pct(values, p):
+ v = sorted(x for x in values if x is not None)
+ if not v:
+ return None
+ return round(v[min(len(v) - 1, max(0, round(p * (len(v) - 1))))], 1)
+
+
+def dist(values):
+ v = [x for x in values if x is not None]
+ return {"p50": pct(v, 0.5), "p95": pct(v, 0.95), "p99": pct(v, 0.99), "n": len(v)} if v else {"n": 0}
+
+
+# ---- the network relay ----
+
+class Relay:
+ """Agent <-> tunnel, shaping the agent-to-Frame direction: a bottleneck
+ of `rate` Mbit/s with a queue of `buffer_ms`, plus `delay` ms each way."""
+
+ def __init__(self, target_port, schedule, delay_ms=0, buffer_ms=250):
+ self.target_port = target_port
+ self.schedule = schedule # [(seconds from start, Mbit/s or None)]
+ self.delay = delay_ms / 1000
+ self.buffer_ms = buffer_ms
+ self.rate = schedule[0][1] if schedule else None
+ self.next_free = 0.0
+ self.port = None
+ self.loop = asyncio.new_event_loop()
+ # Connections reset when a run tears the tunnel down; that's expected.
+ self.loop.set_exception_handler(lambda loop, ctx: None if isinstance(ctx.get("exception"), ConnectionError)
+ else loop.default_exception_handler(ctx))
+ self.started = None
+ self.queued_max = 0
+
+ def start(self):
+ ready = threading.Event()
+
+ def run():
+ asyncio.set_event_loop(self.loop)
+ server = self.loop.run_until_complete(asyncio.start_server(self._client, "127.0.0.1", 0))
+ self.port = server.sockets[0].getsockname()[1]
+ ready.set()
+ self.loop.run_forever()
+
+ threading.Thread(target=run, daemon=True).start()
+ ready.wait(5)
+
+ def begin(self):
+ """Start the schedule's clock (when the measured part begins)."""
+ self.started = time.monotonic()
+ for at, rate in self.schedule:
+ self.loop.call_soon_threadsafe(self.loop.call_later, at, self._set_rate, rate)
+
+ def _set_rate(self, rate):
+ self.rate = rate
+
+ def rate_at(self, t):
+ r = None
+ for at, rate in self.schedule:
+ if t >= at:
+ r = rate
+ return r
+
+ async def _client(self, reader, writer):
+ try:
+ up_r, up_w = await asyncio.open_connection("127.0.0.1", self.target_port)
+ except OSError:
+ writer.close()
+ return
+ await asyncio.gather(self._shaped(up_r, writer), self._plain(reader, up_w), return_exceptions=True)
+ for w in (writer, up_w):
+ w.close()
+
+ async def _plain(self, reader, writer): # Frame -> agent: delay only
+ if not self.delay:
+ while data := await reader.read(65536):
+ writer.write(data)
+ await writer.drain()
+ writer.close()
+ return
+ # Each chunk leaves `delay` after it arrived, while reading goes on:
+ # a delay line, not a pause (which would add up behind a busy stream).
+ queue = asyncio.Queue()
+
+ async def send():
+ while (item := await queue.get())[1] is not None:
+ wait = item[0] - self.loop.time()
+ if wait > 0:
+ await asyncio.sleep(wait)
+ writer.write(item[1])
+ await writer.drain()
+ writer.close()
+
+ async def pump():
+ try:
+ while data := await reader.read(65536):
+ await queue.put((self.loop.time() + self.delay, data))
+ finally: # EOF or a reset: either way the sender finishes and closes
+ queue.put_nowait((0, None))
+
+ sender, pumper = asyncio.ensure_future(send()), asyncio.ensure_future(pump())
+ done, _ = await asyncio.wait({sender, pumper}, return_when=asyncio.FIRST_COMPLETED)
+ if sender in done: # the agent's side went away: stop reading, so the connection closes
+ pumper.cancel()
+ writer.close()
+ return
+ await sender # the viewer's side ended: deliver what's queued first
+
+ async def _shaped(self, reader, writer): # agent -> Frame
+ queue = asyncio.Queue()
+ state = {"bytes": 0}
+ room = asyncio.Event()
+ room.set()
+
+ async def send():
+ while True:
+ at, data = await queue.get()
+ if data is None:
+ return
+ wait = at - self.loop.time()
+ if wait > 0:
+ await asyncio.sleep(wait)
+ writer.write(data)
+ await writer.drain()
+ state["bytes"] -= len(data)
+ room.set()
+
+ sender = asyncio.ensure_future(send())
+ while data := await reader.read(16384):
+ now = self.loop.time()
+ depart = now
+ if self.rate:
+ depart = max(now, self.next_free) + len(data) * 8 / (self.rate * 1e6)
+ self.next_free = depart
+ state["bytes"] += len(data)
+ self.queued_max = max(self.queued_max, state["bytes"])
+ await queue.put((depart + self.delay, data))
+ # A full queue pushes back, as a real bottleneck's buffer does.
+ while self.rate and state["bytes"] > self.rate * 1e6 / 8 * self.buffer_ms / 1000:
+ room.clear()
+ await room.wait()
+ await queue.put((0, None))
+ await sender
+
+
+# ---- the lab agent, through Frame Control's own code ----
+
+class LabView(frame_macview.MacView):
+ """MacView against the already-running lab agent, with the tunnel
+ pointing at `tunnel_port` (the relay, or the agent itself)."""
+
+ def __init__(self, tunnel_ssh, run, frame, agent_port, token, tunnel_port):
+ super().__init__(tunnel_ssh, run, frame)
+ self.agent_port = agent_port
+ self.token = token
+ self.port = tunnel_port
+
+ class Alive:
+ def poll(self):
+ return None
+
+ self.agent = Alive()
+
+ def ensure_agent(self):
+ pass
+
+ def call(self, path, method="GET", **query):
+ url = f"http://127.0.0.1:{self.agent_port}{path}?{urlencode({**query, 'k': self.token}, quote_via=quote)}"
+ req = urllib.request.Request(url, method=method, data=b"" if method == "POST" else None)
+ with urllib.request.urlopen(req, timeout=15) as r:
+ return json.load(r)
+
+
+def ssh_runner(base):
+ def run(remote, stdin=None, timeout=30):
+ r = subprocess.run([*base, remote], input=stdin, capture_output=True, text=True, timeout=timeout)
+ if r.returncode:
+ e = RuntimeError((r.stderr or r.stdout).strip())
+ e.stdout = r.stdout
+ raise e
+ return r.stdout
+ return run
+
+
+# ---- CPU on both ends ----
+
+FRAME_CPU = r"""
+hz=$(getconf CLK_TCK)
+while :; do
+ t=$(date +%s.%N)
+ for g in viewer:frame-control/mac-view tailscaled:tailscaled sshd:'^sshd' gamescope:'^gamescope'; do
+ name=${g%%:*} pat=${g#*:} sum=0
+ for p in $(pgrep -f "$pat"); do
+ s=$(awk '{print $14+$15}' /proc/$p/stat 2>/dev/null) && sum=$((sum + s))
+ done
+ echo "$t $name $sum $hz"
+ done
+ echo "$t total $(awk '/^cpu /{print $2+$3+$4+$6+$7+$8}' /proc/stat) $hz"
+ sleep 2
+done
+"""
+
+
+class CpuSampler:
+ def __init__(self, frame_ssh, agent_pid):
+ self.agent_pid = agent_pid
+ self.mac = [] # (t, cpu seconds)
+ self.frame = {} # name -> [(t, ticks, hz)]
+ self.proc = subprocess.Popen([*frame_ssh, "bash -s"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL, text=True)
+ self.proc.stdin.write(FRAME_CPU)
+ self.proc.stdin.close()
+ threading.Thread(target=self._read, daemon=True).start()
+ self.stop_flag = False
+ threading.Thread(target=self._mac, daemon=True).start()
+
+ def _read(self):
+ for line in self.proc.stdout:
+ parts = line.split()
+ if len(parts) == 4:
+ self.frame.setdefault(parts[1], []).append((float(parts[0]), int(parts[2]), int(parts[3])))
+
+ def _mac(self):
+ while not self.stop_flag:
+ out = subprocess.run(["ps", "-o", "cputime=", "-p", str(self.agent_pid)], capture_output=True,
+ text=True).stdout.strip()
+ m = re.match(r"(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)", out)
+ if m:
+ h, mnt, s = m.groups()
+ self.mac.append((time.monotonic(), int(h or 0) * 3600 + int(mnt) * 60 + float(s)))
+ time.sleep(2)
+
+ def stop(self):
+ self.stop_flag = True
+ self.proc.terminate()
+
+ def summary(self):
+ def rate(samples):
+ if len(samples) < 2:
+ return None
+ (t0, a0, *h0), (t1, a1, *_) = samples[0], samples[-1]
+ hz = h0[0] if h0 else 1
+ return round((a1 - a0) / hz / (t1 - t0) * 100, 1)
+ out = {"mac_agent_pct": rate(self.mac)}
+ for name, s in self.frame.items():
+ out[f"frame_{name}_pct"] = rate(s)
+ if "frame_total_pct" in out and out["frame_total_pct"] is not None:
+ out["frame_total_pct"] = round(out["frame_total_pct"] / 8, 1) # 8 cores -> share of the whole
+ return out
+
+
+# ---- scenarios ----
+
+def chrome_window(page, size=(1280, 820)):
+ """Opens a page in a throwaway Chrome app window; returns (proc, title)."""
+ if not Path(CHROME).exists():
+ raise SystemExit("Google Chrome is needed for the scroll and type scenarios.")
+ proc = subprocess.Popen([CHROME, f"--user-data-dir={CHROME_PROFILE}", "--no-first-run",
+ "--no-default-browser-check", f"--window-size={size[0]},{size[1]}",
+ "--window-position=80,80", f"--app=file://{PAGES / page}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ return proc
+
+
+def find_window(mv, title, timeout=15):
+ end = time.time() + timeout
+ while time.time() < end:
+ for w in mv.call("/windows").get("windows", []):
+ if title in (w.get("title") or ""):
+ return w
+ time.sleep(0.5)
+ raise SystemExit(f"window {title!r} didn't appear")
+
+
+def git(*a):
+ return subprocess.run(["git", *a], cwd=ROOT, capture_output=True, text=True).stdout.strip()
+
+
+def parse_schedule(net):
+ if not net or net == "none":
+ return []
+ out = []
+ for part in net.split(","):
+ rate, _, at = part.partition("@")
+ out.append((float(at or 0), None if rate in ("", "none", "0") else float(rate)))
+ return sorted(out)
+
+
+def run_scenario(args, scenario, agent_port, token, frame_ssh):
+ schedule = parse_schedule(args.net)
+ relay = None
+ tunnel_port = agent_port
+ if schedule or args.delay:
+ relay = Relay(agent_port, schedule, args.delay, args.buffer)
+ relay.start()
+ tunnel_port = relay.port
+ tunnel_ssh = list(frame_ssh[:-1]) + args.ssh_opt
+ mv = LabView(tunnel_ssh, ssh_runner(frame_ssh), frame_ssh[-1], agent_port, token, tunnel_port)
+ mv.prefer_usb = args.usb # otherwise the path is --host's (or ssh's default)
+ if args.browser_flag is not None:
+ mv.browser_flags = [f for f in args.browser_flag if f]
+ chrome = None
+ src = "test"
+ try:
+ if scenario in ("scroll", "type"):
+ chrome = chrome_window(f"{scenario}.html")
+ w = find_window(mv, f"fmv-bench {scenario}")
+ src = f"{args.mode}:{w['id']}"
+ t = time.time()
+ shown = mv.show(src, args.quality, 1280, 820)
+ show_s = round(time.time() - t, 2)
+ # Wait for frames, then measure from a clean start.
+ stream = None
+ end = time.time() + 20
+ while time.time() < end and not stream:
+ for s in mv.call("/status").get("streams", []):
+ if s["src"] == src and s.get("stats", {}).get("fps", 0) > 0:
+ stream = s
+ time.sleep(0.5)
+ if not stream:
+ raise SystemExit(f"{scenario}: no frames reached the viewer within 20 s")
+ time.sleep(args.warmup)
+ first = mv.call("/stats", id=stream["id"], settle=0)["streams"][0]
+ since = max([f["s"] for f in first["frames"]] or [0])
+ captured_before = first["captured"]
+ start_mac_us = mv.call("/stats", id=stream["id"])["now"]
+ agent_pid = int(subprocess.run(["pgrep", "-f", "Frame Mac View Lab.app/Contents/MacOS/frame-mac-view"],
+ capture_output=True, text=True).stdout.split()[0])
+ cpu = CpuSampler(frame_ssh, agent_pid)
+ if relay:
+ relay.begin()
+ expected = 0 # input events the harness asked the viewer for
+ if scenario == "type":
+ mv.call("/bench", method="POST", src=src, action="click", x=0.5, y=0.3)
+ time.sleep(0.5)
+ # Steady typing, then slow typing that lets the link go idle.
+ fast = "the quick brown fox jumps over the lazy dog "
+ n_fast = max(10, int((args.duration * 0.6) / 0.2))
+ mv.call("/bench", method="POST", src=src, action="type", text=(fast * (n_fast // len(fast) + 1))[:n_fast],
+ interval=200)
+ expected += n_fast
+ time.sleep(n_fast * 0.2 + 0.5)
+ slow_n = max(3, int(args.duration * 0.4 / 1.5))
+ mv.call("/bench", method="POST", src=src, action="type", text=("idle typing test " * (slow_n // 17 + 1))[:slow_n],
+ interval=1500)
+ expected += slow_n
+ time.sleep(slow_n * 1.5 + 0.5)
+ else:
+ # Clicks on the test pattern measure the input path; the scroll
+ # page just scrolls.
+ end = time.time() + args.duration
+ while time.time() < end:
+ if scenario == "test":
+ mv.call("/bench", method="POST", src=src, action="click", x=0.3, y=0.5)
+ expected += 1
+ time.sleep(0.5)
+ at_end = mv.call("/stats", id=stream["id"], since=2**32 - 1)
+ end_mac_us = at_end["now"]
+ captured_end = at_end["streams"][0]["captured"] if at_end["streams"] else None
+ time.sleep(2) # let the viewer report the last frames
+ streams = mv.call("/stats", id=stream["id"], since=since)["streams"]
+ if not streams:
+ raise SystemExit(f"{scenario}: the stream ended during the run (did the Frame go to sleep?)")
+ data = streams[0]
+ cpu.stop()
+ if args.raw:
+ Path(f"{args.raw}-{scenario}.json").write_text(json.dumps(data))
+ result = summarize(scenario, data, start_mac_us, end_mac_us, args, relay, schedule, expected)
+ result["controller"] = data.get("controller", {})
+ result["events"] = [{"t": round((e["t"] - start_mac_us) / 1e6, 2), "e": e["e"]}
+ for e in data.get("events", []) if e["t"] >= start_mac_us]
+ result["captured"] = (captured_end if captured_end is not None else data["captured"]) - captured_before
+ result["source_fps"] = round(result["captured"] / max(result["duration_s"], 1), 1)
+ result["cpu"] = cpu.summary()
+ result["viewer"] = data["summary"].get("decoder", "")
+ result["show_s"] = show_s
+ result["panel"] = shown.get("panel")
+ result["src"] = src
+ result["route"] = mv.route
+ return result
+ finally:
+ try:
+ mv.stop(src)
+ except Exception: # noqa: BLE001 - best effort
+ pass
+ mv.closing = True # or its supervisor reopens the tunnel
+ if mv.tunnel and mv.tunnel.poll() is None:
+ mv.tunnel.terminate()
+ mv.tunnel.wait(5)
+ if relay:
+ relay.loop.call_soon_threadsafe(relay.loop.stop)
+ if chrome:
+ chrome.terminate()
+ time.sleep(3) # Stop ends the Frame's viewer browser 2 s later
+
+
+def composited(f):
+ """When the Mac had the frame: its display time, or when ScreenCaptureKit
+ handed it over if that was earlier (display time can be a few ms ahead of
+ delivery, which would make latency look lower than it is)."""
+ return min(f["cap"], f["arr"]) if f["arr"] else f["cap"]
+
+
+def summarize(scenario, data, start_us, end_us, args, relay, schedule, expected=0):
+ frames = [f for f in data["frames"] if start_us <= f["cap"] <= end_us]
+ inputs = [i for i in data["inputs"] if i["tv"] and i["tv"] >= start_us]
+ by_seq = {f["s"]: f for f in data["frames"]}
+ drawn = [f for f in frames if f["drw"]]
+ shown = [f for f in frames if f["vs"]]
+ # The whole measured interval, so a stall at either end still counts.
+ duration = (end_us - start_us) / 1e6
+ stages = {
+ "capture": [ms(f["cap"], f["arr"]) for f in frames],
+ "queue": [ms(f["arr"], f["e0"]) for f in frames],
+ "encode": [ms(f["e0"], f["e1"]) for f in frames],
+ "socket": [ms(f["snd"], f["wire"]) for f in frames],
+ "network": [ms(f["e1"], f["rx"]) for f in frames],
+ "decode": [ms(f["rx"], f["dec"]) for f in frames],
+ "draw": [ms(f["dec"], f["drw"]) for f in frames],
+ # Waiting for the browser's next frame: set by the Frame's compositor,
+ # which throttles panels nobody is looking at (see "Measuring").
+ "present": [ms(f["drw"], f["vs"]) for f in frames],
+ "content": [ms(composited(f), f["drw"]) for f in frames],
+ "content_shown": [ms(composited(f), f["vs"]) for f in frames],
+ }
+ out = {"scenario": scenario, "frames_sent": len(frames), "frames_drawn": len(drawn),
+ "frames_shown": len(shown), "duration_s": round(duration, 1)}
+ out["stages_ms"] = {k: dist(v) for k, v in stages.items()}
+ # Smoothness: gaps between frames reaching the viewer's canvas.
+ # Smoothness counts only what was drawn inside the interval; a frame
+ # captured just before the end but drawn after it still counts for latency.
+ dr = [start_us] + sorted(f["drw"] for f in drawn if f["drw"] <= end_us) + [end_us]
+ gaps = [(b - a) / 1000 for a, b in zip(dr, dr[1:]) if b > a]
+ target_fps = frame_macview.QUALITY[args.quality]["fps"]
+ out["fps"] = round(sum(f["drw"] <= end_us for f in drawn) / duration, 1) if duration else 0
+ out["fps_shown"] = round(sum(0 < f["vs"] <= end_us for f in shown) / duration, 1) if duration else 0
+ out["late_pct"] = round(100 * sum(g > 1.5 * 1000 / target_fps for g in gaps) / len(gaps), 2) if gaps else None
+ out["stall_max"] = round(max(gaps), 1) if gaps else None
+ out["stalls_over_100ms"] = sum(g > 100 for g in gaps)
+ out["mbps"] = round(sum(f["b"] for f in frames) * 8 / duration / 1e6, 2) if duration else 0
+ out["keyframes"] = sum(f["k"] for f in frames)
+ out["size"] = f"{frames[-1]['w']}x{frames[-1]['h']}" if frames else ""
+ out["captured"] = data.get("captured")
+ out["viewer_never_drawn"] = len(frames) - len(drawn)
+ # Input: the viewer's event -> the first frame after it drawn (and shown).
+ lat, lat_shown = [], []
+ parts = {"uplink": [], "mac": [], "back": []}
+ for i in inputs:
+ f = by_seq.get(i["frame"])
+ if f and f["drw"]:
+ lat.append(ms(i["tv"], f["drw"]))
+ lat_shown.append(ms(i["tv"], f["vs"]))
+ parts["uplink"].append(ms(i["tv"], i["inj"]))
+ parts["mac"].append(ms(i["inj"], composited(f)))
+ parts["back"].append(ms(composited(f), f["drw"]))
+ out["input_ms"] = dist(lat)
+ out["input_shown_ms"] = dist(lat_shown)
+ out["input_parts_ms"] = {k: dist(v) for k, v in parts.items()}
+ out["inputs"] = {"asked": expected, "sent": len(inputs), "seen": len(lat)}
+ # A per-second timeline, for adaptation.
+ timeline = []
+ t0 = start_us
+ for sec in range(int(duration) + 1):
+ a, b = t0 + sec * 1_000_000, t0 + (sec + 1) * 1_000_000
+ fs = [f for f in frames if a <= f["cap"] < b]
+ if not fs:
+ continue
+ timeline.append({
+ "t": sec, "fps": sum(1 for f in fs if f["drw"]), "mbps": round(sum(f["b"] for f in fs) * 8 / 1e6, 2),
+ "content_p50": pct([ms(composited(f), f["drw"]) for f in fs], 0.5),
+ "content_p95": pct([ms(composited(f), f["drw"]) for f in fs], 0.95),
+ "bitrate": fs[-1].get("br"), "tier": fs[-1].get("tier"), "w": fs[-1]["w"],
+ "net": relay.rate_at(sec) if relay else None,
+ })
+ out["timeline"] = timeline
+ out["adapt"] = adaptation(timeline, schedule, duration)
+ out["content_p50"] = out["stages_ms"]["content"].get("p50")
+ out["content_p95"] = out["stages_ms"]["content"].get("p95")
+ out["input_p50"] = out["input_ms"].get("p50")
+ out["input_p95"] = out["input_ms"].get("p95")
+ out["grades"] = grade(out, scenario, args.quality)
+ return out
+
+
+def adaptation(timeline, schedule, duration=float("inf")):
+ """After each drop in the link's rate: how long until latency was within
+ bounds again and stayed there for 3 s. The bound is the acceptable p95
+ (100 ms), or 1.5 times the p95 before the drop if that was higher: on a
+ slower link each frame takes longer to send, so latency can't return to
+ what it was on the fast one."""
+ steps = []
+ for n, ((at, rate), (_, before)) in enumerate(zip(schedule[1:], schedule)):
+ # Settling must happen while this rate lasts, not after the link recovers.
+ until = min(schedule[n + 2][0] if n + 2 < len(schedule) else float("inf"), duration) # or the run ends
+ if before is not None and (rate is None or rate >= before):
+ continue
+ pre = [s["content_p95"] for s in timeline if at - 4 <= s["t"] < at and s["content_p95"]]
+ base = max(float(TARGETS["content_p95"][1]), 1.5 * statistics.median(pre) if pre else 0)
+ after = [s for s in timeline if s["t"] >= at]
+ worst = max([s["content_p95"] or 0 for s in after[:10]] or [0])
+ settled = None
+ for i, s in enumerate(after):
+ window = after[i:i + 3]
+ # Three populated seconds in a row: a second with no frames at all
+ # is a stall, not a pass.
+ if (len(window) == 3 and [w["t"] for w in window] == [s["t"], s["t"] + 1, s["t"] + 2] and s["t"] + 3 <= until
+ and all((w["content_p95"] or 1e9) <= base for w in window)):
+ settled = s["t"] - at
+ break
+ steps.append({"at": at, "to_mbit": rate, "p95_bound": round(base, 1), "worst_p95": worst,
+ "settled_s": settled})
+ return steps
+
+
+def grade(out, scenario, quality="balanced"):
+ g = {}
+ checks = ["content_p50", "content_p95"]
+ asked = max(out["inputs"].get("asked", 0), out["inputs"]["sent"])
+ if asked:
+ checks += ["input_p50", "input_p95"]
+ # Input that never reaches the Mac, or never shows up on screen, is a
+ # failure, not a gap in the data.
+ seen = out["inputs"]["seen"] / asked
+ g["input_replies"] = "local" if seen >= 0.95 else "acceptable" if seen >= 0.8 else "bad"
+ if scenario in MOVING:
+ checks += ["fps", "late_pct", "stall_max"]
+ for k in checks:
+ v = out.get(k)
+ if v is None:
+ g[k] = "missing"
+ continue
+ good, ok = TARGETS[k]
+ if k == "fps": # the targets are for 60 fps; Light and Compatible ask for fewer
+ want = frame_macview.QUALITY[quality]["fps"]
+ good, ok = good * want / 60, ok * want / 60
+ if k in HIGHER_IS_BETTER:
+ g[k] = "local" if v >= good else "acceptable" if v >= ok else "bad"
+ else:
+ g[k] = "local" if v <= good else "acceptable" if v <= ok else "bad"
+ for step in out["adapt"]:
+ s = step["settled_s"]
+ good, ok = TARGETS["adapt_s"]
+ g[f"adapt@{step['at']:g}s"] = "bad" if s is None or s > ok else "local" if s <= good else "acceptable"
+ return g
+
+
+# ---- compare ----
+
+KEYS = [("content_p50", "content p50 ms"), ("content_p95", "content p95 ms"), ("input_p50", "input p50 ms"),
+ ("input_p95", "input p95 ms"), ("fps", "fps"), ("late_pct", "late %"), ("stall_max", "max gap ms"),
+ ("fps_shown", "fps shown"), ("mbps", "Mbit/s")]
+STAGE_KEYS = ["capture", "queue", "encode", "socket", "network", "decode", "draw", "present"]
+
+
+def load(path):
+ return json.loads(Path(path).read_text())
+
+
+def compare(a, b, threshold=0.10):
+ """Prints before/after per scenario; returns the regressions."""
+ regressions = []
+ print(f"A: {a['label']} ({a['commit']}, {a['date']})\nB: {b['label']} ({b['commit']}, {b['date']})")
+ for sc in b["scenarios"]:
+ ra = next((x for x in a["scenarios"] if x["scenario"] == sc["scenario"]), None)
+ if not ra:
+ continue
+ print(f"\n{sc['scenario']:<10} {'A':>10} {'B':>10} {'change':>9}")
+ rows = [(k, n, ra.get(k), sc.get(k)) for k, n in KEYS]
+ rows += [(f"stage.{s}", f" {s} p50", ra["stages_ms"].get(s, {}).get("p50"), sc["stages_ms"].get(s, {}).get("p50"))
+ for s in STAGE_KEYS]
+ for k, name, va, vb in rows:
+ change = ""
+ if isinstance(va, (int, float)) and isinstance(vb, (int, float)) and va:
+ d = (vb - va) / abs(va)
+ change = f"{d:+.0%}"
+ worse = d < -threshold if k == "fps" else d > threshold
+ if worse and k in ("content_p50", "content_p95", "input_p50", "input_p95", "fps") and abs(vb - va) > 2:
+ regressions.append(f"{sc['scenario']} {name}: {va} -> {vb}")
+ change += " worse"
+ print(f"{name:<18} {fmt(va):>10} {fmt(vb):>10} {change:>9}")
+ return regressions
+
+
+def fmt(v):
+ return "–" if v is None else f"{v:g}" if isinstance(v, (int, float)) else str(v)
+
+
+# ---- main ----
+
+def add_run_args(r):
+ r.add_argument("--scenario", default="test,scroll,type")
+ r.add_argument("--duration", type=float, default=20)
+ r.add_argument("--warmup", type=float, default=3)
+ r.add_argument("--quality", default="balanced", choices=list(frame_macview.QUALITY))
+ r.add_argument("--mode", default="separate", choices=["separate", "window"], help="how Mac windows are shown")
+ r.add_argument("--net", default="", help="Mbit/s, or a schedule like 50@0,8@10,50@20")
+ r.add_argument("--delay", type=float, default=0, help="extra one-way delay, ms")
+ r.add_argument("--buffer", type=float, default=250, help="bottleneck queue, ms at the current rate")
+ r.add_argument("--frame", default="frame", help="ssh host of the Frame")
+ r.add_argument("--host", default="", help="connect to this address instead (e.g. the Frame's LAN IP)")
+ r.add_argument("--usb", action="store_true", help="use the Frame's USB-C network when plugged in, as Frame Control does")
+ r.add_argument("--ssh-opt", action="append", default=[], help="extra ssh option for the tunnel, e.g. -oIPQoS=ef")
+ r.add_argument("--browser-flag", action="append", default=None,
+ help="Chromium flag for the viewer (replaces Frame Control's defaults; '' for none)")
+ r.add_argument("--label", default="")
+ r.add_argument("--out", default="")
+ r.add_argument("--raw", default="", help="also write the agent's raw records here")
+
+
+def lab_agent():
+ try:
+ agent_port = int((LAB_DIR / "port").read_text())
+ token = (LAB_DIR / "token").read_text().strip()
+ urllib.request.urlopen(f"http://127.0.0.1:{agent_port}/ping", timeout=3)
+ return agent_port, token
+ except (OSError, ValueError):
+ raise SystemExit("Start the lab agent first: mac/frame-mac-view/lab.sh serve")
+
+
+def restart_lab(env):
+ """The lab agent again, without rebuilding, with these experiment switches."""
+ subprocess.run(["sh", str(ROOT / "mac" / "frame-mac-view" / "lab.sh"), "serve-only"],
+ env={**os.environ, **env}, check=True, capture_output=True)
+ time.sleep(1)
+
+
+def frame_ssh_for(args):
+ frame_ssh = ["ssh", "-o", "BatchMode=yes"]
+ if args.host:
+ # Another address for the same Frame: its host key must still match.
+ alias = next((line.split()[1] for line in subprocess.run(["ssh", "-G", args.frame], capture_output=True,
+ text=True).stdout.splitlines() if line.startswith("hostname ")), args.frame)
+ frame_ssh += ["-o", f"HostName={args.host}", "-o", f"HostKeyAlias={alias}"]
+ frame_ssh.append(args.frame)
+ return frame_ssh
+
+
+def run_suite(args, frame_ssh, quiet=False):
+ agent_port, token = lab_agent()
+ results = []
+ for sc in args.scenario.split(","):
+ if not quiet:
+ print(f"-- {sc} ...", flush=True)
+ res = run_scenario(args, sc, agent_port, token, frame_ssh)
+ if not quiet:
+ print(f" content p50/p95 {res['content_p50']}/{res['content_p95']} ms, input p50/p95 "
+ f"{res['input_p50']}/{res['input_p95']} ms, {res['fps']} fps (shown {res['fps_shown']}), "
+ f"late {res['late_pct']}%, max gap {res['stall_max']} ms, {res['mbps']} Mbit/s, {res['size']}",
+ flush=True)
+ print(f" stages p50: " + ", ".join(f"{k} {v.get('p50')}" for k, v in res["stages_ms"].items()), flush=True)
+ print(f" grades: {res['grades']} cpu: {res['cpu']}\n viewer: {res['viewer']}", flush=True)
+ for e in res["events"][:12]:
+ print(f" {e['t']:6.2f}s {e['e']}", flush=True)
+ results.append(res)
+ return results
+
+
+def document(args, frame_ssh, results, **extra):
+ build = subprocess.run([*frame_ssh, "grep -m1 BUILD_ID /etc/os-release"], capture_output=True, text=True).stdout
+ # Whether the headset is in standby (it throttles panels even harder then).
+ standby = subprocess.run([*frame_ssh, "grep -h standby ~/.local/share/Steam/logs/vrserver.txt | tail -n 1"],
+ capture_output=True, text=True).stdout.strip()
+ commit = git("rev-parse", "--short", "HEAD") + ("+dirty" if git("status", "--porcelain", "--", "mac", "ui") else "")
+ return {
+ "label": args.label or args.cmd, "date": datetime.datetime.now().isoformat(timespec="seconds"), "commit": commit,
+ "config": {"quality": args.quality, "mode": args.mode, "net": args.net or "none", "delay_ms": args.delay,
+ "buffer_ms": args.buffer, "host": args.host or args.frame, "usb": args.usb, "ssh_opts": args.ssh_opt,
+ "encoder": os.environ.get("FRAME_MAC_VIEW_ENCODER", ""), "duration_s": args.duration,
+ "browser_flags": args.browser_flag if args.browser_flag is not None else frame_macview.BROWSER_FLAGS},
+ "frame_build": build.strip().partition("=")[2], "mac": platform.mac_ver()[0], "headset": standby[-40:],
+ "scenarios": results, **extra,
+ }
+
+
+def write(doc, args):
+ RESULTS.mkdir(parents=True, exist_ok=True)
+ name = f"{doc['date'][:10]}-{doc['commit'].replace('+', '-')}-{re.sub(r'[^a-zA-Z0-9_.-]+', '-', doc['label'])}.json"
+ out = Path(args.out) if args.out else RESULTS / name
+ out.write_text(json.dumps(doc, indent=1))
+ print(f"wrote {out}")
+ return out
+
+
+AB_KEYS = ["content_p50", "content_p95", "input_p50", "input_p95", "fps", "late_pct", "stall_max", "mbps"]
+
+
+def ab(args, frame_ssh):
+ """Arms interleaved (A B, B A, ...) so a Wi-Fi link that changes over
+ minutes affects both alike; medians and ranges per arm."""
+ arms = []
+ for a in args.arm:
+ name, _, envs = a.partition(":")
+ arms.append((name, dict(e.split("=", 1) for e in envs.split(",") if e)))
+ runs = {name: [] for name, _ in arms}
+ for rep in range(args.repeat):
+ for name, env in (arms if rep % 2 == 0 else arms[::-1]):
+ print(f"-- repeat {rep + 1}, arm {name} {env}", flush=True)
+ restart_lab(env)
+ runs[name].append(run_suite(args, frame_ssh, quiet=True))
+ table = {}
+ for sc in args.scenario.split(","):
+ print(f"\n{sc:<14}" + "".join(f"{name:>26}" for name, _ in arms))
+ for k in AB_KEYS:
+ row = []
+ for name, _ in arms:
+ vals = [r[k] for suite in runs[name] for r in suite if r["scenario"] == sc and r.get(k) is not None]
+ table.setdefault(sc, {}).setdefault(k, {})[name] = vals
+ row.append(f"{statistics.median(vals):g} ({min(vals):g}-{max(vals):g})" if vals else "–")
+ print(f"{k:<14}" + "".join(f"{c:>26}" for c in row))
+ restart_lab({}) # back to the defaults
+ return runs, table
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ sub = p.add_subparsers(dest="cmd", required=True)
+ r = sub.add_parser("run")
+ add_run_args(r)
+ r.add_argument("--baseline", default="", help="fail on >10%% regressions against this result")
+ a = sub.add_parser("ab", help="compare lab-agent settings, interleaved: --arm name:ENV=V,ENV2=V ...")
+ add_run_args(a)
+ a.add_argument("--arm", action="append", required=True)
+ a.add_argument("--repeat", type=int, default=3)
+ c = sub.add_parser("compare")
+ c.add_argument("a")
+ c.add_argument("b")
+ args = p.parse_args()
+ # The agent keeps the last 4096 frames (Stats.swift) and results are read
+ # at the end: at 60 fps that's 68 s, less warm-up and the reporting wait.
+ if args.cmd != "compare" and args.host and args.usb:
+ p.error("--host and --usb pick the path two ways; use one")
+ if args.cmd != "compare" and args.warmup + args.duration > 60:
+ p.error("--warmup plus --duration can be at most 60 s (the agent keeps 4096 frames)")
+
+ if args.cmd == "compare":
+ regs = compare(load(args.a), load(args.b))
+ if regs:
+ print("\nworse:\n " + "\n ".join(regs))
+ sys.exit(1 if regs else 0)
+
+ lab_agent()
+ frame_ssh = frame_ssh_for(args)
+ # Keep the Mac's screen awake: virtual displays aren't removed while it sleeps.
+ runs = 1 if args.cmd == "run" else args.repeat * len(args.arm)
+ awake = subprocess.Popen(["caffeinate", "-d", "-u", "-t", str(int(runs * (args.duration * 4 + 60) + 120))])
+ try:
+ if args.cmd == "ab":
+ arm_runs, table = ab(args, frame_ssh)
+ write(document(args, frame_ssh, [], arms=args.arm, runs=arm_runs, table=table), args)
+ return
+ results = run_suite(args, frame_ssh)
+ finally:
+ awake.terminate()
+ doc = document(args, frame_ssh, results)
+ write(doc, args)
+ bad = [f"{r['scenario']} {k}" for r in results for k, v in r["grades"].items() if v in ("bad", "missing")]
+ regs = compare(load(args.baseline), doc) if args.baseline else []
+ if bad:
+ print("missed targets: " + ", ".join(bad))
+ if regs:
+ print("worse than baseline:\n " + "\n ".join(regs))
+ sys.exit(1 if bad or regs else 0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/panel-on-frame.sh b/scripts/panel-on-frame.sh
index 781767a..2ce218c 100755
--- a/scripts/panel-on-frame.sh
+++ b/scripts/panel-on-frame.sh
@@ -21,6 +21,8 @@
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
+# Frame Control passes the headset it has chosen: its address and pinned identity.
+ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}})
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
@@ -109,4 +111,4 @@ EOF
)
b64=$(print -rn -- "$remote" | base64)
-ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
+ssh "${ssh_opts[@]}" "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"
diff --git a/scripts/publish-release.sh b/scripts/publish-release.sh
new file mode 100755
index 0000000..82b5c10
--- /dev/null
+++ b/scripts/publish-release.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+# Publish a tested draft release so running copies of Frame Control offer it
+# (docs/releasing.md). Checks every installer is attached with a SHA-256
+# digest first, since the app's updater refuses assets without one, then
+# attaches update.json, the manifest the updater reads.
+# Usage: scripts/publish-release.sh v0.4.0
+set -eu
+tag="${1:?usage: $0 vX.Y.Z}"
+repo=saphid/frame-control
+expected="Frame-Control-mac-arm64.dmg Frame-Control-mac-arm64.zip Frame-Control-Setup-x64.exe
+Frame-Control-win-x64.zip Frame-Control-linux-x86_64.AppImage Frame-Control-linux-arm64.AppImage
+Frame-Control-linux-amd64.deb Frame-Control-linux-arm64.deb"
+
+info=$(gh release view "$tag" -R "$repo" --json isDraft,isPrerelease,assets)
+version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$(dirname "$0")/../app/package.json")
+[ "v$version" = "$tag" ] || echo "note: app/package.json here says $version (the release was built from the tag)"
+
+missing=""
+for name in $expected; do
+ digest=$(printf '%s' "$info" | python3 -c 'import json,sys
+d=json.load(sys.stdin); n=sys.argv[1]
+print(next((a.get("digest") or "" for a in d["assets"] if a["name"]==n), "absent"))' "$name")
+ case "$digest" in
+ sha256:*) echo "ok $name" ;;
+ absent) echo "MISSING $name"; missing=1 ;;
+ *) echo "NO HASH $name"; missing=1 ;;
+ esac
+done
+[ -z "$missing" ] || { echo "not publishing: fix the assets above" >&2; exit 1; }
+
+# update.json: what running copies read (app/updater.js), from github.com's
+# latest/download link rather than the rate-limited REST API.
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+gh release view "$tag" -R "$repo" --json tagName,url,body,assets | python3 -c 'import json,sys
+d=json.load(sys.stdin)
+names=set(sys.argv[1].split())
+print(json.dumps({"version": d["tagName"].lstrip("v"), "page": d["url"], "notes": d["body"][:4000],
+ "assets": [{"name": a["name"], "size": a["size"], "digest": a["digest"]}
+ for a in d["assets"] if a["name"] in names]}, indent=1))' "$expected" > "$tmp/update.json"
+gh release upload "$tag" -R "$repo" "$tmp/update.json" --clobber
+echo "ok update.json"
+
+gh release edit "$tag" -R "$repo" --draft=false --prerelease=false --latest
+echo "published $tag; running copies will offer it at their next check"
diff --git a/scripts/push-vr-video.sh b/scripts/push-vr-video.sh
index 93cb5ec..2833fd9 100755
--- a/scripts/push-vr-video.sh
+++ b/scripts/push-vr-video.sh
@@ -1,65 +1,8 @@
#!/usr/bin/env zsh
-# Mac-side: upload VR videos to the Steam Frame so DeoVR can play them in 3D.
-#
-# Files go to ~/Videos/VR on the Frame. The script links that folder into
-# DeoVR's Proton prefix as C:\users\steamuser\Videos\VR, so DeoVR's file
-# browser finds it under Videos. (It's also reachable as Z:\home\steamos\Videos\VR.)
-# Uploads resume if interrupted.
-#
-# Name files so DeoVR picks the projection: include _180 or _360 (or _fisheye190,
-# _mkx200, _rf52 ...) plus the stereo layout (_LR / _SBS side by side, _TB over-
-# under), e.g. "beach_180_LR.mp4". You can also change it in DeoVR's player.
-#
-# Usage:
-# scripts/push-vr-video.sh FILE_OR_DIR... # upload
-# scripts/push-vr-video.sh --launch FILE... # upload, then start DeoVR
-# scripts/push-vr-video.sh --launch # just start DeoVR
-# scripts/push-vr-video.sh --list # what's on the Frame
+# Upload media for Frame Control's own OpenVR player (no external player).
+# Usage: scripts/push-vr-video.sh [--launch] [--layout auto|mono|sbs|ou|full-sbs|full-ou] [--theatre] FILE...
+# scripts/push-vr-video.sh --list | --stop
+# Files go to ~/Videos/FrameControl//; --launch accepts exactly one file.
+# Directories, automatic DeoVR launching and Proton-prefix links are no longer used.
set -euo pipefail
-
-FRAME_ALIAS=${FRAME_ALIAS:-frame}
-DEOVR_APPID=837380
-REMOTE_DIR="Videos/VR"
-PREFIX_VIDEOS=".local/share/Steam/steamapps/compatdata/$DEOVR_APPID/pfx/drive_c/users/steamuser/Videos"
-
-launch=0 list=0
-while (( $# )); do
- case "$1" in
- -h|--help) sed -n '2,17p' "$0"; exit 0 ;;
- --launch) launch=1; shift ;;
- --list) list=1; shift ;;
- --) shift; break ;;
- -*) print -u2 "push-vr-video: unknown option $1"; exit 2 ;;
- *) break ;;
- esac
-done
-(( $# || launch || list )) || { sed -n '2,17p' "$0" >&2; exit 2; }
-
-for f in "$@"; do
- [[ -e "$f" ]] || { print -u2 "push-vr-video: no such file: $f"; exit 2; }
-done
-
-# Create the folder and link it into DeoVR's prefix (the prefix exists once
-# DeoVR has run). Refresh a stale link, but never replace a real directory.
-if (( $# || launch )); then
- ssh "$FRAME_ALIAS" "mkdir -p ~/$REMOTE_DIR
-p=~/$PREFIX_VIDEOS
-if [ -d \"\$p\" ] && { [ -L \"\$p/VR\" ] || [ ! -e \"\$p/VR\" ]; }; then ln -sfn ~/$REMOTE_DIR \"\$p/VR\"
-elif [ -d \"\$p/VR\" ]; then echo \"warning: \$p/VR is a real folder, so uploads won't show under DeoVR's Videos; browse Z:\\\\home\\\\steamos\\\\Videos\\\\VR instead\" >&2; fi
-[ -d \"\$p\" ] || echo 'note: DeoVR has not run yet; use Z:\\home\\steamos\\Videos\\VR or run this again after starting it once' >&2"
-fi
-
-if (( $# )); then
- # -L: send what a symlink points at; a Mac-side link would dangle on the Frame
- rsync -aL --partial --progress -- "$@" "$FRAME_ALIAS:$REMOTE_DIR/"
-fi
-
-if (( list )); then
- ssh "$FRAME_ALIAS" "cd ~/$REMOTE_DIR && ls -lhR"
-fi
-
-if (( launch )); then
- ssh "$FRAME_ALIAS" "command -v steam >/dev/null || { echo 'steam not found on the Frame' >&2; exit 1; }
-steam steam://rungameid/$DEOVR_APPID /dev/null 2>&1 &"
- print "DeoVR starting on the Frame. Open Local files / the file browser → Videos → VR."
-fi
+exec python3 "${0:A:h}/../ui/frame_media_cli.py" "$@"
diff --git a/tests/assistant_ui.cjs b/tests/assistant_ui.cjs
new file mode 100644
index 0000000..6848ba3
--- /dev/null
+++ b/tests/assistant_ui.cjs
@@ -0,0 +1,43 @@
+// Run the actual page script with a tiny DOM/fetch fixture; no browser dependency.
+const fs = require('node:fs');
+const vm = require('node:vm');
+const assert = require('node:assert/strict');
+const elements = new Map();
+const events = new Map();
+const requests = [];
+const element = id => {
+ if (!elements.has(id)) elements.set(id, {value:'', checked:false, disabled:false, textContent:'',
+ addEventListener(){}, reset(){}});
+ return elements.get(id);
+};
+const context = {
+ document:{getElementById:element}, location:{hash:''}, URLSearchParams,
+ window:{addEventListener:(name, fn) => events.set(name, fn)},
+ fetch:(path, options) => new Promise(resolve => requests.push({path, options, resolve})),
+};
+const html = fs.readFileSync(process.argv[2], 'utf8');
+vm.runInNewContext(html.match(/"
+ },
+ "license": "MIT",
+ "authorName": "Example Developer",
+ "icon": {
+ "de": {
+ "name": "/org.example.app/de/icon.png"
+ },
+ "en-US": {
+ "name": "/org.example.app/en-US/icon.png"
+ }
+ },
+ "featureGraphic": {
+ "fr": {
+ "name": "/org.example.app/fr/featureGraphic.png"
+ }
+ },
+ "screenshots": {
+ "phone": {
+ "de": [
+ {
+ "name": "/org.example.app/de/phoneScreenshots/1.png"
+ }
+ ],
+ "en-US": [
+ {
+ "name": "/org.example.app/en-US/phoneScreenshots/1.png"
+ },
+ {
+ "name": "/org.example.app/en-US/phoneScreenshots/2.png"
+ },
+ {
+ "name": "/org.example.app/en-US/phoneScreenshots/3.png"
+ },
+ {
+ "name": "/org.example.app/en-US/phoneScreenshots/4.png"
+ }
+ ]
+ },
+ "sevenInch": {
+ "fr": [
+ {
+ "name": "/org.example.app/fr/sevenInchScreenshots/1.png"
+ },
+ {
+ "name": "/org.example.app/fr/sevenInchScreenshots/2.png"
+ },
+ {
+ "name": "/org.example.app/fr/sevenInchScreenshots/3.png"
+ },
+ {
+ "name": "/org.example.app/fr/sevenInchScreenshots/4.png"
+ }
+ ]
+ }
+ }
+ },
+ "versions": {
+ "1": {
+ "manifest": {
+ "versionName": "1",
+ "versionCode": 1,
+ "usesSdk": {
+ "minSdkVersion": 21
+ },
+ "nativecode": []
+ },
+ "file": {
+ "name": "/example1.apk",
+ "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
+ "size": 51
+ },
+ "added": 1700000000000
+ },
+ "2": {
+ "manifest": {
+ "versionName": "2",
+ "versionCode": 2,
+ "usesSdk": {
+ "minSdkVersion": 30
+ },
+ "nativecode": [
+ "arm64-v8a"
+ ]
+ },
+ "file": {
+ "name": "/example2.apk",
+ "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
+ "size": 51
+ },
+ "added": 1700000000000
+ },
+ "3": {
+ "manifest": {
+ "versionName": "3",
+ "versionCode": 3,
+ "usesSdk": {
+ "minSdkVersion": 31
+ },
+ "nativecode": []
+ },
+ "file": {
+ "name": "/example3.apk",
+ "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
+ "size": 51
+ },
+ "added": 1700000000000
+ },
+ "4": {
+ "manifest": {
+ "versionName": "4",
+ "versionCode": 4,
+ "usesSdk": {
+ "minSdkVersion": 21
+ },
+ "nativecode": [
+ "x86_64"
+ ]
+ },
+ "file": {
+ "name": "/example4.apk",
+ "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
+ "size": 51
+ },
+ "added": 1700000000000
+ }
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/fdroid/entry.jar b/tests/fixtures/fdroid/entry.jar
new file mode 100644
index 0000000..b73864a
Binary files /dev/null and b/tests/fixtures/fdroid/entry.jar differ
diff --git a/tests/fixtures/fdroid/example.apk b/tests/fixtures/fdroid/example.apk
new file mode 100644
index 0000000..e53f5b7
--- /dev/null
+++ b/tests/fixtures/fdroid/example.apk
@@ -0,0 +1 @@
+Fixture APK payload, deliberately not installable.
diff --git a/tests/fixtures/fdroid/fingerprint.txt b/tests/fixtures/fdroid/fingerprint.txt
new file mode 100644
index 0000000..494e9b0
--- /dev/null
+++ b/tests/fixtures/fdroid/fingerprint.txt
@@ -0,0 +1 @@
+0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
diff --git a/tests/fixtures/fdroid/index-v1.jar b/tests/fixtures/fdroid/index-v1.jar
new file mode 100644
index 0000000..6ea58d2
Binary files /dev/null and b/tests/fixtures/fdroid/index-v1.jar differ
diff --git a/tests/fixtures/fdroid/index-v2.json b/tests/fixtures/fdroid/index-v2.json
new file mode 100644
index 0000000..6d443c6
--- /dev/null
+++ b/tests/fixtures/fdroid/index-v2.json
@@ -0,0 +1 @@
+{"repo": {"name": {"en-US": "Fixture"}}, "packages": {"org.example.app": {"metadata": {"name": {"en-US": "Example"}, "summary": {"en-US": "Offline fixture"}, "license": "MIT"}, "versions": {"1": {"manifest": {"versionName": "1", "versionCode": 1, "usesSdk": {"minSdkVersion": 21}, "nativecode": []}, "file": {"name": "/example1.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "2": {"manifest": {"versionName": "2", "versionCode": 2, "usesSdk": {"minSdkVersion": 30}, "nativecode": ["arm64-v8a"]}, "file": {"name": "/example2.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "3": {"manifest": {"versionName": "3", "versionCode": 3, "usesSdk": {"minSdkVersion": 31}, "nativecode": []}, "file": {"name": "/example3.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}, "4": {"manifest": {"versionName": "4", "versionCode": 4, "usesSdk": {"minSdkVersion": 21}, "nativecode": ["x86_64"]}, "file": {"name": "/example4.apk", "sha256": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79", "size": 51}, "added": 1700000000000}}}}}
\ No newline at end of file
diff --git a/tests/fixtures/fdroid/izzy-entry.jar b/tests/fixtures/fdroid/izzy-entry.jar
new file mode 100644
index 0000000..c518648
Binary files /dev/null and b/tests/fixtures/fdroid/izzy-entry.jar differ
diff --git a/tests/fixtures/library/README.md b/tests/fixtures/library/README.md
new file mode 100644
index 0000000..04d4f03
--- /dev/null
+++ b/tests/fixtures/library/README.md
@@ -0,0 +1,17 @@
+# Library fixtures
+
+`icon.png` is a synthetic 2×2 RGBA fixture with opaque, half-transparent and
+transparent pixels. `steam-responses.json` includes the Open Saber Plus
+shortcut ID/name and home path read from the Frame's existing metadata on
+2026-09-28; the `configure` response is synthetic, matching our helper's
+contract. Tests never contact the network.
+
+The existing fakeframe CEF shim models artwork and collection methods from
+SteamTracking's `ClientExtracted/steamui/chunk~2dcc5aaf7.js` and
+SteamDeckHomebrew/decky-frontend-lib's `src/globals/steam-client/App.ts`, read
+2026-09-28. These methods were not captured from this headset: Steam's client
+was unavailable. The Node-based test runs the actual generated JavaScript
+against that fixture; it is skipped when Node is absent.
+
+`icon.jpg` is the same synthetic icon converted with macOS `sips` to exercise
+JPEG SOF parsing. `sips` is not used by the product or tests.
diff --git a/tests/fixtures/library/check-renderer.js b/tests/fixtures/library/check-renderer.js
new file mode 100644
index 0000000..1e2e06f
--- /dev/null
+++ b/tests/fixtures/library/check-renderer.js
@@ -0,0 +1,21 @@
+const fs=require('fs'),vm=require('vm'),assert=require('assert');
+const stub=require(process.cwd()+'/tests/fakeframe/rootfs/usr/local/lib/fakeframe/canvas_stub');
+(async()=>{
+ const surface=stub.surface(),ctx=vm.createContext(surface);
+ vm.runInContext(fs.readFileSync('frame/android/library_artwork.js','utf8'),ctx);
+ const result=await ctx.renderLibraryArtwork({label:'Example Game',images:{icon:['png','fixture']}});
+ assert.deepEqual(Object.keys(result.images),['grid','wide','hero','logo','icon']);
+ for(const [slot,size] of Object.entries({grid:[600,900],wide:[920,430],hero:[3840,1240],logo:[1280,480],icon:[256,256]})) {
+ assert.equal(result.images[slot][0],'png');
+ const b=Buffer.from(result.images[slot][1],'base64');assert.equal(b.readUInt32BE(16),size[0]);assert.equal(b.readUInt32BE(20),size[1]);
+ }
+ // A photo scene is JPEG (Steam's 12 MiB limit at hero size); the logo stays transparent PNG.
+ const photo=await ctx.renderLibraryArtwork({label:'Photo',images:{hero:['jpg','fixture'],banner:['jpg','fixture']}});
+ for(const slot of ['wide','hero'])assert.equal(photo.images[slot][0],'jpg');
+ for(const slot of ['grid','logo','icon'])assert.equal(photo.images[slot][0],'png');
+ const hero=surface.canvases.find(c=>c.width===3840);assert.equal(hero.text.length,0);
+ const logo=surface.canvases.find(c=>c.width===1280);assert(logo.text.length);assert.equal(logo.draws,0);
+ const before=surface.canvases.length;await ctx.renderLibraryArtwork({label:'No Icon',images:{}});
+ assert.equal(surface.canvases.slice(before).find(c=>c.width===3840).text.length,0);
+ console.log('five dimensions, textless hero, title logo: OK');
+})().catch(e=>{console.error(e);process.exit(1)});
diff --git a/tests/fixtures/library/icon.jpg b/tests/fixtures/library/icon.jpg
new file mode 100644
index 0000000..ea4d67c
Binary files /dev/null and b/tests/fixtures/library/icon.jpg differ
diff --git a/tests/fixtures/library/icon.png b/tests/fixtures/library/icon.png
new file mode 100644
index 0000000..2e2af85
Binary files /dev/null and b/tests/fixtures/library/icon.png differ
diff --git a/tests/fixtures/library/steam-responses.json b/tests/fixtures/library/steam-responses.json
new file mode 100644
index 0000000..8b2c2c7
--- /dev/null
+++ b/tests/fixtures/library/steam-responses.json
@@ -0,0 +1,12 @@
+{
+ "home": "/home/steamos",
+ "shortcuts": [
+ {
+ "appid": 3346865537,
+ "name": "Open Saber Plus"
+ }
+ ],
+ "configure": {
+ "warnings": []
+ }
+}
diff --git a/tests/fixtures/more_sources/README.md b/tests/fixtures/more_sources/README.md
new file mode 100644
index 0000000..8f33165
--- /dev/null
+++ b/tests/fixtures/more_sources/README.md
@@ -0,0 +1,23 @@
+Recorded 2026-09-28 from public publisher endpoints using FrameControl/0.1 or
+`gh api`. JSON fixtures are reduced to fields consumed by the adapters; API
+values are unchanged. No token, cookies or signed download URL is included.
+
+- `*-releases.json`: `/repos/{repo}/releases?per_page=10`, first two releases,
+ for KhronosGroup/OpenXR-SDK-Source, icosa-foundation/open-brush and
+ SgtBilko76/SuperTux-3D (one release).
+- `topic.json`: `/search/repositories?q=topic:openxr+archived:false&sort=stars&per_page=3`.
+- `itch-feed.txt`: `https://itch.io/games/free/platform-android/tag-openxr.xml`.
+- `itch-robots.txt`: `https://itch.io/robots.txt`.
+- `itch-author-robots.txt`: `https://godotvr.itch.io/robots.txt`.
+
+The synthetic ZIP in tests is only a transport/integrity fixture, not an
+installable APK. Actual APK parsing was verified separately on the downloaded
+Khronos Vulkan sample; see docs/apk-sources.md.
+
+Artwork follow-up: `topic.json` now retains `owner.avatar_url` from authenticated
+repository API reads. `artwork-check.json` records HTTPS response status,
+Content-Type and image magic checks for all curated URLs and three topic
+results. All curated URLs returned real images; LWJGL's social preview returned
+HTTP 429. This fixture is evidence of a point-in-time check, not an uptime test.
+Open Brush screenshots came from the Steam appdetails response for app 1634870,
+linked by its README. SuperTux's README links the recorded upstream screenshot.
diff --git a/tests/fixtures/more_sources/artwork-check.json b/tests/fixtures/more_sources/artwork-check.json
new file mode 100644
index 0000000..d16be22
--- /dev/null
+++ b/tests/fixtures/more_sources/artwork-check.json
@@ -0,0 +1,120 @@
+[
+ {
+ "url": "https://avatars.githubusercontent.com/u/2757344?v=4",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://avatars.githubusercontent.com/u/784805?v=4",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://avatars.githubusercontent.com/u/94376830?v=4",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://opengraph.githubassets.com/1/KhronosGroup/OpenXR-SDK-Source",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://opengraph.githubassets.com/1/LWJGL/lwjgl3",
+ "error": "HTTP Error 429: Too Many Requests"
+ },
+ {
+ "url": "https://opengraph.githubassets.com/1/bjornbytes/lovr",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://opengraph.githubassets.com/1/sahibzada-allahyar/YC-Killer",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/KhronosGroup/OpenXR-SDK-Source/3ed64d0f9bb680f24b80a085091e5c8fab38f7b7/src/tests/hello_xr/android_resources/vulkan/mipmap-xxxhdpi/ic_helloxr_launcher.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/SgtBilko76/SuperTux-3D/1955493ee6f1000e048c58db40d4904df827210e/data/images/engine/icons/supertux-256x256.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/Assets/Resources/DefaultImages/OpenBrushLogo.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/icosa-foundation/open-brush/56acbce831c7e9f257bfee9e21853da99773787b/open-brush.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_0a9c208e26a43cf34879c2ca361d4c8f18af8cba.1920x1080.jpg",
+ "status": 200,
+ "content_type": "image/jpeg",
+ "image": true
+ },
+ {
+ "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_19b25b86ef55c0d8769a65135d60eaae8fa40553.1920x1080.jpg",
+ "status": 200,
+ "content_type": "image/jpeg",
+ "image": true
+ },
+ {
+ "url": "https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/1634870/ss_785ea37d63378146dfe0f0ffa3f1d5c155ca978f.1920x1080.jpg",
+ "status": 200,
+ "content_type": "image/jpeg",
+ "image": true
+ },
+ {
+ "url": "https://www.supertux.org/images/0_7_0/github_preview.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/icon.png",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://opengraph.githubassets.com/1/arpruss/OpenSaberPlus",
+ "status": 200,
+ "content_type": "image/png",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_1.gif",
+ "status": 200,
+ "content_type": "image/gif",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_2.gif",
+ "status": 200,
+ "content_type": "image/gif",
+ "image": true
+ },
+ {
+ "url": "https://raw.githubusercontent.com/arpruss/OpenSaberPlus/8c5295cdaadf02ea7418b0c6cbea20240ba913af/doc/images/OS0.4.0_3.gif",
+ "status": 200,
+ "content_type": "image/gif",
+ "image": true
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/more_sources/brush-releases.json b/tests/fixtures/more_sources/brush-releases.json
new file mode 100644
index 0000000..c17f800
--- /dev/null
+++ b/tests/fixtures/more_sources/brush-releases.json
@@ -0,0 +1,88 @@
+[
+ {
+ "tag_name": "2.32.29",
+ "draft": false,
+ "prerelease": true,
+ "published_at": "2026-09-26T17:49:28Z",
+ "assets": [
+ {
+ "id": 591143285,
+ "name": "OpenBrush_Android_2.32.29.apk",
+ "size": 314602794,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Android_2.32.29.apk",
+ "digest": "sha256:f20361b830803a2bf53e2af648bba59ee17bc4615bde534dbf6c4f0012bbd291"
+ },
+ {
+ "id": 591143287,
+ "name": "OpenBrush_Desktop_2.32.29.zip",
+ "size": 380735034,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Desktop_2.32.29.zip",
+ "digest": "sha256:3b680b07ca0b8def579fe194916aa7db4d007c3094c4dea818124776098c9b9a"
+ },
+ {
+ "id": 591143282,
+ "name": "OpenBrush_Linux_2.32.29.zip",
+ "size": 364906490,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Linux_2.32.29.zip",
+ "digest": "sha256:e380934f77d2b1441179424193a75f7b4b5793b34761c04cbf2d87bad9f8fc16"
+ },
+ {
+ "id": 591143283,
+ "name": "OpenBrush_Mac_2.32.29.dmg",
+ "size": 373196471,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Mac_2.32.29.dmg",
+ "digest": "sha256:5d544d0a64bed1cae65173fcfa7ada069d4f81b42385e806e99cc4427ef3513c"
+ },
+ {
+ "id": 591143286,
+ "name": "OpenBrush_Quest_2.32.29.apk",
+ "size": 314603546,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.29/OpenBrush_Quest_2.32.29.apk",
+ "digest": "sha256:57cd7b9067689060451494e55dc06276f934a992f5cbbd44d965069903937ba6"
+ }
+ ]
+ },
+ {
+ "tag_name": "2.32.28",
+ "draft": false,
+ "prerelease": true,
+ "published_at": "2026-09-26T14:42:27Z",
+ "assets": [
+ {
+ "id": 590837298,
+ "name": "OpenBrush_Android_2.32.28.apk",
+ "size": 314603450,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Android_2.32.28.apk",
+ "digest": "sha256:1a3af1a194c4348ef67c8ea61d9a8258e2490cdfe1f760cbc3c69f2978a6a082"
+ },
+ {
+ "id": 590837301,
+ "name": "OpenBrush_Desktop_2.32.28.zip",
+ "size": 380738236,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Desktop_2.32.28.zip",
+ "digest": "sha256:c2de5424bc022951f0e0bdbd652ede549a1e60d9cfbf41c730ea43d16d97262f"
+ },
+ {
+ "id": 590837297,
+ "name": "OpenBrush_Linux_2.32.28.zip",
+ "size": 364907046,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Linux_2.32.28.zip",
+ "digest": "sha256:29daf410347b3ca36e47808e31172270df8040ba7decc83be2bdcd51de895e06"
+ },
+ {
+ "id": 590837296,
+ "name": "OpenBrush_Mac_2.32.28.dmg",
+ "size": 373195966,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Mac_2.32.28.dmg",
+ "digest": "sha256:2f352d766450c993fdcae8a8552878a6a976d32d675246b63c81bb1b326fd20d"
+ },
+ {
+ "id": 590837295,
+ "name": "OpenBrush_Quest_2.32.28.apk",
+ "size": 314604234,
+ "browser_download_url": "https://github.com/icosa-foundation/open-brush/releases/download/2.32.28/OpenBrush_Quest_2.32.28.apk",
+ "digest": "sha256:9a3af6a6e838dd8bd201e549c5570f67db794df940e960390aeeae93235d702e"
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/more_sources/itch-author-robots.txt b/tests/fixtures/more_sources/itch-author-robots.txt
new file mode 100644
index 0000000..5dacc48
--- /dev/null
+++ b/tests/fixtures/more_sources/itch-author-robots.txt
@@ -0,0 +1,12 @@
+User-agent: Mediapartners-Google
+Disallow:
+
+User-agent: *
+Disallow: /*/download/
+Disallow: /*/rh/
+Disallow: /*/rp/
+Disallow: /-/
+
+Sitemap: https://itch.io/sitemap.xml
+
+# vim: set ft=robots:
diff --git a/tests/fixtures/more_sources/itch-feed.txt b/tests/fixtures/more_sources/itch-feed.txt
new file mode 100644
index 0000000..87a9e72
--- /dev/null
+++ b/tests/fixtures/more_sources/itch-feed.txt
@@ -0,0 +1,11 @@
+Top free games for Android tagged openxr - itch.io https://itch.io/games/free/platform-android/tag-openxrhttps://leandrodreamer.itch.io/open-saber Open Saber [Free] [Rhythm] [Windows] [Linux] [Android] Open Saber https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif $0.00 USD https://leandrodreamer.itch.io/open-saber]]> Thu, 07 Sep 2023 02:11:50 GMT Thu, 07 Sep 2023 02:11:50 GMT Wed, 08 Jan 2025 02:24:29 GMT yes https://absyo.itch.io/off-nominal Off Nominal [Free] [Puzzle] [Windows] [Linux] [Android] Off Nominal https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png $0.00 USD https://absyo.itch.io/off-nominal]]> Fri, 25 Sep 2026 19:54:48 GMT Fri, 25 Sep 2026 19:54:48 GMT Sun, 27 Sep 2026 23:25:20 GMT yes yes yes https://somar-project.itch.io/somar-project Somar-project [Free] [Educational] [Android] Somar-project https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png $0.00 USD https://somar-project.itch.io/somar-project]]> Wed, 26 Mar 2025 17:17:58 GMT Wed, 26 Mar 2025 17:17:58 GMT Fri, 28 Mar 2025 15:52:59 GMT yes https://5imon.itch.io/buggenesis Bug Genesis VR [Free] [Interactive Fiction] [Windows] [Android] Bug Genesis VR https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg $0.00 USD https://5imon.itch.io/buggenesis]]> Sun, 25 May 2025 20:22:49 GMT Sun, 25 May 2025 20:22:49 GMT Sun, 25 May 2025 20:37:39 GMT yes yes https://benmclean.itch.io/wolfsharp WolfSharp [Free] [Shooter] [Windows] [Linux] [Android] WolfSharp https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png $0.00 USD https://benmclean.itch.io/wolfsharp]]> Sat, 25 Jul 2026 12:16:01 GMT Sat, 25 Jul 2026 12:16:01 GMT Sat, 25 Jul 2026 13:45:38 GMT yes yes yes https://mimekunst.itch.io/winter-solitude-vr Winter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android] Winter Solitude VR https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png $0.00 USD https://mimekunst.itch.io/winter-solitude-vr]]> Tue, 19 Dec 2023 19:19:59 GMT Tue, 19 Dec 2023 19:19:59 GMT Wed, 20 Dec 2023 22:49:23 GMT yes yes yes yes https://salmondev.itch.io/evil-miner-vr EVIL MINER VR [Free] [Other] [Windows] [Android] EVIL MINER VR https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png $0.00 USD https://salmondev.itch.io/evil-miner-vr]]> Fri, 13 Jun 2025 16:48:01 GMT Fri, 13 Jun 2025 16:48:01 GMT Sun, 15 Jun 2025 15:19:53 GMT yes yes https://robinhuud.itch.io/winter-challenge-north-pole-defense North Pole Defense VR [Free] [Action] [Android] North Pole Defense VR https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png $0.00 USD https://robinhuud.itch.io/winter-challenge-north-pole-defense]]> Thu, 16 Dec 2021 01:33:33 GMT Thu, 16 Dec 2021 01:33:33 GMT Thu, 16 Dec 2021 01:51:29 GMT yes https://envemos.itch.io/ambly-native-xr Ambly Native XR [Free] [Linux] [Android] Ambly Native XR https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg $0.00 USD https://envemos.itch.io/ambly-native-xr]]> Wed, 22 Jul 2026 18:38:55 GMT Wed, 22 Jul 2026 18:38:55 GMT Fri, 07 Aug 2026 16:04:20 GMT yes yes https://andyman404.itch.io/glow-up-garden Glow Up Garden (VR) [Free] [Action] [Windows] [Android] Glow Up Garden (VR) https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg $0.00 USD https://andyman404.itch.io/glow-up-garden]]> Mon, 03 Jun 2024 20:36:53 GMT Mon, 03 Jun 2024 20:36:53 GMT Tue, 04 Jun 2024 04:20:37 GMT yes yes
\ No newline at end of file
diff --git a/tests/fixtures/more_sources/itch-robots.txt b/tests/fixtures/more_sources/itch-robots.txt
new file mode 100644
index 0000000..3b8cbfc
--- /dev/null
+++ b/tests/fixtures/more_sources/itch-robots.txt
@@ -0,0 +1,11 @@
+User-agent: *
+Disallow: /embed/
+Disallow: /embed-upload/
+Disallow: /search
+Disallow: /checkout/
+Disallow: /game/download/
+Disallow: /bundle/download/
+Disallow: /register-for-purchase/
+Disallow: /email-feedback/
+
+Sitemap: https://itch.io/sitemap.xml
diff --git a/tests/fixtures/more_sources/khronos-releases.json b/tests/fixtures/more_sources/khronos-releases.json
new file mode 100644
index 0000000..5de075d
--- /dev/null
+++ b/tests/fixtures/more_sources/khronos-releases.json
@@ -0,0 +1,410 @@
+[
+ {
+ "tag_name": "release-1.1.63",
+ "draft": false,
+ "prerelease": false,
+ "published_at": "2026-09-02T21:22:32Z",
+ "assets": [
+ {
+ "id": 541779948,
+ "name": "apilayer_api_dump-1.1.63.aar",
+ "size": 5120886,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar",
+ "digest": "sha256:9cab975cc8df3a99f6530f47a1fbabfa0527109a138f5a3ef5150672a658c61c"
+ },
+ {
+ "id": 541779969,
+ "name": "apilayer_api_dump-1.1.63.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.aar.asc",
+ "digest": "sha256:419ec65d3f526c617176f272d8a481488181ade017cb05ef42205cb2c5a86f05"
+ },
+ {
+ "id": 541779983,
+ "name": "apilayer_api_dump-1.1.63.pom",
+ "size": 1462,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom",
+ "digest": "sha256:9a5b3e470830ae471fe317bc63f43b33bc063458239238fe27e234232c45ff28"
+ },
+ {
+ "id": 541780002,
+ "name": "apilayer_api_dump-1.1.63.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_api_dump-1.1.63.pom.asc",
+ "digest": "sha256:7cbf9f1af504ca68fc36e0985dadb74d1fbf4d2bbdcde3e00bfe9ea6168fc4a8"
+ },
+ {
+ "id": 541780126,
+ "name": "apilayer_best_practices_validation-1.1.63.aar",
+ "size": 484596,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar",
+ "digest": "sha256:0c56cb3dc0b093b28f4e5490820d3cb3f7b201b48444134f347455d4ee99abd2"
+ },
+ {
+ "id": 541780150,
+ "name": "apilayer_best_practices_validation-1.1.63.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.aar.asc",
+ "digest": "sha256:7eb9ab3efe939c367e4661d5a75836c1d9e0799ab627e99211253546935defa7"
+ },
+ {
+ "id": 541780162,
+ "name": "apilayer_best_practices_validation-1.1.63.pom",
+ "size": 1487,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom",
+ "digest": "sha256:97d410936f5f051ab2a73cdac196c744ac56b2dde6279a5e46e944ed61316147"
+ },
+ {
+ "id": 541780192,
+ "name": "apilayer_best_practices_validation-1.1.63.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_best_practices_validation-1.1.63.pom.asc",
+ "digest": "sha256:7f9fa0cd33627c4ecc2a4410e53dedadb5731b3cddae59a3c0d4fcd467b3472d"
+ },
+ {
+ "id": 541780025,
+ "name": "apilayer_core_validation-1.1.63.aar",
+ "size": 6386964,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar",
+ "digest": "sha256:9663ce94a5076b6707502cf5503bac456987ccf1f39a3f7c8f350d4521db8647"
+ },
+ {
+ "id": 541780057,
+ "name": "apilayer_core_validation-1.1.63.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.aar.asc",
+ "digest": "sha256:c6e75df848ca6d436fe24f680bde73a9e69972b67eef46e49aba4b77dec366e9"
+ },
+ {
+ "id": 541780090,
+ "name": "apilayer_core_validation-1.1.63.pom",
+ "size": 1455,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom",
+ "digest": "sha256:6aa9337f5e645baf489c05e562cd074dc696bad87db70a0cdd661dffc63b2c89"
+ },
+ {
+ "id": 541780108,
+ "name": "apilayer_core_validation-1.1.63.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/apilayer_core_validation-1.1.63.pom.asc",
+ "digest": "sha256:0fe8ded9fdf0660bf28a544ae405b9b58682a8d9648dacedf4e4ceef2f827869"
+ },
+ {
+ "id": 541777706,
+ "name": "hello_xr-OpenGLES-release-1.1.63.apk",
+ "size": 9557049,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-OpenGLES-release-1.1.63.apk",
+ "digest": "sha256:7c96022ac002cb0c72e3cd14c11a817767b7b5dbdf5a1d1c85d0c083b5719056"
+ },
+ {
+ "id": 541777527,
+ "name": "hello_xr-Vulkan-release-1.1.63.apk",
+ "size": 9557441,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/hello_xr-Vulkan-release-1.1.63.apk",
+ "digest": "sha256:f24bbe8ba6f6339fca658628868ba8189cbc33390d6ac508f69d76fb67b5fa34"
+ },
+ {
+ "id": 541800362,
+ "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz",
+ "size": 4857593,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz",
+ "digest": "sha256:a3b97a36f11abe256a7ea1668a0a468aac9b738e94bea6b468f0ae31ad537a46"
+ },
+ {
+ "id": 541800378,
+ "name": "OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR-SDK-Source-release-1.1.63.tar.gz.asc",
+ "digest": "sha256:4f97028306ae219f599e9960adbf9bb072e8da2bfbedffd1f0de312516a61f87"
+ },
+ {
+ "id": 541821806,
+ "name": "OpenXR.Loader.1.1.63.nupkg",
+ "size": 1916162,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg",
+ "digest": "sha256:4e5a50a8807ef66f25180ff224e7d8150b594aa8ee4b07590f9ade55a8e98703"
+ },
+ {
+ "id": 541821827,
+ "name": "OpenXR.Loader.1.1.63.nupkg.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/OpenXR.Loader.1.1.63.nupkg.asc",
+ "digest": "sha256:9d66a6e958f7c2d5c4a0a4aef8df90a7beecab13547440c9fa2069979eab7ac7"
+ },
+ {
+ "id": 541779892,
+ "name": "openxr_loader_for_android-1.1.63-sources.jar",
+ "size": 1141287,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar",
+ "digest": "sha256:6f964ad09c4afa3f42f451cada86e61503392b018660b22dd34b61dfbf71a555"
+ },
+ {
+ "id": 541779920,
+ "name": "openxr_loader_for_android-1.1.63-sources.jar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63-sources.jar.asc",
+ "digest": "sha256:b98cba20f5c3b202b887307cb19196f4459f895413e55b68823a606f50d045fa"
+ },
+ {
+ "id": 541779720,
+ "name": "openxr_loader_for_android-1.1.63.aar",
+ "size": 4170279,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar",
+ "digest": "sha256:622419d2f6741c3443a3beb4779af0764318edd01830de967f24c741ebcded73"
+ },
+ {
+ "id": 541779762,
+ "name": "openxr_loader_for_android-1.1.63.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.aar.asc",
+ "digest": "sha256:3bb68b26d7def68b4fe8506bf09f302116290c2de9cf91fdfdba753978bff5ed"
+ },
+ {
+ "id": 541779849,
+ "name": "openxr_loader_for_android-1.1.63.pom",
+ "size": 1598,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom",
+ "digest": "sha256:c98f38fa8acf4cf1bd8bcb40774f9815ae6ed9da94c8a7be2ed9db7815c85404"
+ },
+ {
+ "id": 541779867,
+ "name": "openxr_loader_for_android-1.1.63.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_for_android-1.1.63.pom.asc",
+ "digest": "sha256:1c2625f5b889c7ed967c8a6010294ca94bbd96091d879b2fc989c6f40f9a6af1"
+ },
+ {
+ "id": 541793000,
+ "name": "openxr_loader_macos-1.1.63.zip",
+ "size": 826298,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip",
+ "digest": "sha256:b243eebcdfa8683d17ccc8cfbfb9036be94b3f5a22b3eb73c39da0877694a3ab"
+ },
+ {
+ "id": 541793027,
+ "name": "openxr_loader_macos-1.1.63.zip.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_macos-1.1.63.zip.asc",
+ "digest": "sha256:3e95172aabc4bd2537a7125060abbb8efbdd0cee19bdf1bf30cbd9736b7dcbd2"
+ },
+ {
+ "id": 541784717,
+ "name": "openxr_loader_windows-1.1.63.zip",
+ "size": 31961521,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip",
+ "digest": "sha256:01c631aeabbfe0879540f77ef833416c532a20746285b494630160c23588b771"
+ },
+ {
+ "id": 541784760,
+ "name": "openxr_loader_windows-1.1.63.zip.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.63/openxr_loader_windows-1.1.63.zip.asc",
+ "digest": "sha256:e9384e2a94d82c5059d1d83c57d0da585056d95e393255048b0955b0ce69b3fc"
+ }
+ ]
+ },
+ {
+ "tag_name": "release-1.1.62",
+ "draft": false,
+ "prerelease": false,
+ "published_at": "2026-08-01T01:32:30Z",
+ "assets": [
+ {
+ "id": 500510340,
+ "name": "apilayer_api_dump-1.1.62.aar",
+ "size": 4800443,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar",
+ "digest": "sha256:2a7c2d1bb14d94dfed8c1aaf170a9dda649756477fbcba4a143c76d08a50bed8"
+ },
+ {
+ "id": 500510366,
+ "name": "apilayer_api_dump-1.1.62.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.aar.asc",
+ "digest": "sha256:7ab53e3c1fcaabf49561737fe8ed5df9ad9a5a761615f05e1d5eed2799e85c5f"
+ },
+ {
+ "id": 500510378,
+ "name": "apilayer_api_dump-1.1.62.pom",
+ "size": 1462,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom",
+ "digest": "sha256:fcd4358d7582ce0787b96aacb9840afc086a28499767a6aa7491dbb682bcc921"
+ },
+ {
+ "id": 500510385,
+ "name": "apilayer_api_dump-1.1.62.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_api_dump-1.1.62.pom.asc",
+ "digest": "sha256:4832ce5c8835d1880ae5adbc535b774d50f8c86f9870650a50fbf3b9993ec5fa"
+ },
+ {
+ "id": 500510464,
+ "name": "apilayer_best_practices_validation-1.1.62.aar",
+ "size": 482607,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar",
+ "digest": "sha256:6d1a369ba367049aff23ae554b284ccc17cb3be8832869de0c1d151228a26502"
+ },
+ {
+ "id": 500510477,
+ "name": "apilayer_best_practices_validation-1.1.62.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.aar.asc",
+ "digest": "sha256:658ecbb7f871e97ed0d659ed55b27ca6ff6cc6e1853699498ee7b1d5583d7313"
+ },
+ {
+ "id": 500510480,
+ "name": "apilayer_best_practices_validation-1.1.62.pom",
+ "size": 1487,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom",
+ "digest": "sha256:571d7c5587075e83ca0a4d2382d87515de614ab8c34416a82a1b9b1a7eb5f9c0"
+ },
+ {
+ "id": 500510489,
+ "name": "apilayer_best_practices_validation-1.1.62.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_best_practices_validation-1.1.62.pom.asc",
+ "digest": "sha256:342d0ed7080e92399dbc8648df4fe9378086718f1abc73f79f3a52de211ed36e"
+ },
+ {
+ "id": 500510395,
+ "name": "apilayer_core_validation-1.1.62.aar",
+ "size": 5910024,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar",
+ "digest": "sha256:5692ccd5563a0963c4d842614af11d7c280960687a221643a46266ef968c4d70"
+ },
+ {
+ "id": 500510422,
+ "name": "apilayer_core_validation-1.1.62.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.aar.asc",
+ "digest": "sha256:1e39ff48d4cd87231d931515968317c717a6811da84585650f0623c49329ec41"
+ },
+ {
+ "id": 500510432,
+ "name": "apilayer_core_validation-1.1.62.pom",
+ "size": 1455,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom",
+ "digest": "sha256:1917e5cee9b979c9032c7819c386ea62e4498c30ffbbcf0c25578ebcd0c1e441"
+ },
+ {
+ "id": 500510453,
+ "name": "apilayer_core_validation-1.1.62.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/apilayer_core_validation-1.1.62.pom.asc",
+ "digest": "sha256:8aed84009daa5e388b7d179ab90837e9537b4f762a1676aab922e03dc633ed18"
+ },
+ {
+ "id": 497398718,
+ "name": "hello_xr-OpenGLES-release-1.1.62.apk",
+ "size": 9548745,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-OpenGLES-release-1.1.62.apk",
+ "digest": "sha256:da5e421795b801684cab50156c572422b73cd98fc8c75aeeb968962b43a2ab46"
+ },
+ {
+ "id": 497398704,
+ "name": "hello_xr-Vulkan-release-1.1.62.apk",
+ "size": 9549137,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/hello_xr-Vulkan-release-1.1.62.apk",
+ "digest": "sha256:a154b2353983f8c0cb1827ddf51d7e80fc105085253fe524502f35c5ddac3284"
+ },
+ {
+ "id": 500514717,
+ "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz",
+ "size": 4834887,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz",
+ "digest": "sha256:977073d7f4c0d1af8ab975f57e4b6ffd1c4e9209be66075812b890576e0e1e5f"
+ },
+ {
+ "id": 500514735,
+ "name": "OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR-SDK-Source-release-1.1.62.tar.gz.asc",
+ "digest": "sha256:3ea4d6e47da6a8b3480931636af3e85eb2e0cddaf582153ee97973a8b05a5214"
+ },
+ {
+ "id": 500514501,
+ "name": "OpenXR.Loader.1.1.62.nupkg",
+ "size": 1902954,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg",
+ "digest": "sha256:6bb16b4dbe3c11f29605def2def5b7d1177784c0403dc9a24bc2e13d7eb82604"
+ },
+ {
+ "id": 500514512,
+ "name": "OpenXR.Loader.1.1.62.nupkg.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/OpenXR.Loader.1.1.62.nupkg.asc",
+ "digest": "sha256:386dfd33e9c2db9c9c37d881b77eec9b74d181fda394b47c473b2bce37fd7005"
+ },
+ {
+ "id": 500510319,
+ "name": "openxr_loader_for_android-1.1.62-sources.jar",
+ "size": 1110593,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar",
+ "digest": "sha256:b83318394b30bb129b069dd854de2b1e21df4376dda1a7fa342aeaff17a71d3d"
+ },
+ {
+ "id": 500510327,
+ "name": "openxr_loader_for_android-1.1.62-sources.jar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62-sources.jar.asc",
+ "digest": "sha256:838b5f5a23329eb7f0e13afde7a7d6ae73b439c7cbf6d3ec0af3e65efd7d5bd6"
+ },
+ {
+ "id": 500510263,
+ "name": "openxr_loader_for_android-1.1.62.aar",
+ "size": 4158815,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar",
+ "digest": "sha256:c03c689fed9a48f9394af953660982c998b00f6d2d2d8d150bc3f890c75a7465"
+ },
+ {
+ "id": 500510280,
+ "name": "openxr_loader_for_android-1.1.62.aar.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.aar.asc",
+ "digest": "sha256:883ccab775776c65ee35bf3120553f6a3f0f47de2dd661e074469e98d3caea46"
+ },
+ {
+ "id": 500510292,
+ "name": "openxr_loader_for_android-1.1.62.pom",
+ "size": 1598,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom",
+ "digest": "sha256:9b1047158a416984fd6d60c472da09bb324aec74709f83a1516435917fa05064"
+ },
+ {
+ "id": 500510305,
+ "name": "openxr_loader_for_android-1.1.62.pom.asc",
+ "size": 215,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_for_android-1.1.62.pom.asc",
+ "digest": "sha256:9dd7d3f79ad76a48f709f55d8c205892ae30f70b10a44c4afbd51f50603c4478"
+ },
+ {
+ "id": 500514048,
+ "name": "openxr_loader_macos-1.1.62.zip",
+ "size": 817438,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip",
+ "digest": "sha256:400bf9ab932d04cf8a315fe8e63d5cd9824015b64ad2e5d72b2ffc086c54313c"
+ },
+ {
+ "id": 500514061,
+ "name": "openxr_loader_macos-1.1.62.zip.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_macos-1.1.62.zip.asc",
+ "digest": "sha256:034a3575bbee545926dc59558aa5d62ea9fc2de9e23c426ac640cbb68bd8db88"
+ },
+ {
+ "id": 500513308,
+ "name": "openxr_loader_windows-1.1.62.zip",
+ "size": 30975472,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip",
+ "digest": "sha256:800ec772e2f9448a26ab9f579f4914d984346dd9d0d7c007841abe21d2c8ff2f"
+ },
+ {
+ "id": 500513351,
+ "name": "openxr_loader_windows-1.1.62.zip.asc",
+ "size": 870,
+ "browser_download_url": "https://github.com/KhronosGroup/OpenXR-SDK-Source/releases/download/release-1.1.62/openxr_loader_windows-1.1.62.zip.asc",
+ "digest": "sha256:8117563bfc5092895e17112366cb954ca78f84964e5c09526dfd69656c1713fd"
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/more_sources/topic.json b/tests/fixtures/more_sources/topic.json
new file mode 100644
index 0000000..2a87d01
--- /dev/null
+++ b/tests/fixtures/more_sources/topic.json
@@ -0,0 +1,28 @@
+{
+ "items": [
+ {
+ "full_name": "LWJGL/lwjgl3",
+ "name": "lwjgl3",
+ "description": "LWJGL is a Java library that enables cross-platform access to popular native APIs useful in the development of graphics (OpenGL, Vulkan, bgfx), audio (OpenAL, Opus), parallel computing (OpenCL, CUDA) and XR (OpenVR, LibOVR, OpenXR) applications.",
+ "owner": {
+ "avatar_url": "https://avatars.githubusercontent.com/u/2757344?v=4"
+ }
+ },
+ {
+ "full_name": "sahibzada-allahyar/YC-Killer",
+ "name": "YC-Killer",
+ "description": "A library of enterprise-grade AI agents designed to democratize artificial intelligence and provide free, open-source alternatives to overvalued Y Combinator startups.",
+ "owner": {
+ "avatar_url": "https://avatars.githubusercontent.com/u/94376830?v=4"
+ }
+ },
+ {
+ "full_name": "bjornbytes/lovr",
+ "name": "lovr",
+ "description": "Lua Virtual Reality Framework",
+ "owner": {
+ "avatar_url": "https://avatars.githubusercontent.com/u/784805?v=4"
+ }
+ }
+ ]
+}
diff --git a/tests/fixtures/more_sources/tux-releases.json b/tests/fixtures/more_sources/tux-releases.json
new file mode 100644
index 0000000..2d482fa
--- /dev/null
+++ b/tests/fixtures/more_sources/tux-releases.json
@@ -0,0 +1,17 @@
+[
+ {
+ "tag_name": "Beta0.2",
+ "draft": false,
+ "prerelease": true,
+ "published_at": "2026-09-23T14:51:47Z",
+ "assets": [
+ {
+ "id": 583977968,
+ "name": "SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
+ "size": 294152462,
+ "browser_download_url": "https://github.com/SgtBilko76/SuperTux-3D/releases/download/Beta0.2/SuperTux-Beta0.2-quest-pico-arm64-v8a.apk",
+ "digest": "sha256:63287e5d6f1866193e0d4730bf4a2ba87fd2fbdbe6317bd6bd24b96a8ddc9963"
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/sidequest-policy.json b/tests/fixtures/sidequest-policy.json
new file mode 100644
index 0000000..dd08588
--- /dev/null
+++ b/tests/fixtures/sidequest-policy.json
@@ -0,0 +1,18 @@
+{
+ "recorded": "2026-09-28",
+ "robots": {
+ "url": "https://sidequestvr.com/robots.txt",
+ "user_agent": "*",
+ "crawl_delay": 3,
+ "disallow": ["/search/", "/user/*", "/sideload/*"],
+ "sitemap": "https://sidequestvr.com/sitemap_index.xml"
+ },
+ "api_robots": {"url": "https://api.sidequestvr.com/robots.txt", "status": 403},
+ "terms": {
+ "url": "https://sidequestvr.com/terms",
+ "bundle": "https://sidequestvr.com/main-4MMXZRXL.js",
+ "prohibited_activities_i": "copy, distribute, or disclose any part of the Service in any medium, including without limitation by any automated or non-automated scraping",
+ "prohibited_activities_xi": "access any content on the Service through any technology or means other than those provided or authorized by the Service"
+ },
+ "note": "Policy evidence, not a fabricated API response. No app metadata or download fixture was collected after discovering the restriction."
+}
diff --git a/tests/page/ctrl_gestures.mjs b/tests/page/ctrl_gestures.mjs
new file mode 100644
index 0000000..871b8b8
--- /dev/null
+++ b/tests/page/ctrl_gestures.mjs
@@ -0,0 +1,244 @@
+// Control on the live view (tap, drag, hold, scroll, type), run in node against the real
+// functions from ui/index.html with a fake canvas and a fake server.
+import { readFileSync } from "fs";
+const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
+const grab = name => {
+ const one = src.match(new RegExp(`\\n((?:async )?function ${name}\\(.*\\}\\n)`)); // one-line function
+ if (one) return one[1];
+ const m = src.match(new RegExp(`(?:\\nconst ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`));
+ if (!m) throw new Error("not found: " + name);
+ return m[0];
+};
+const NAMES = ["panelKey", "ctrlAimedAt", "ctrlSameTarget", "isMoveEvent", "isRelease", "ctrlKeepable", "TAP_MOVE", "ctrlAim", "ctrlKeyEvent", "ctrlTouchCancel", "ctrlSend", "ctrlFlush", "ctrlMoveTo", "ctrlMoveBy", "ctrlSchedule",
+ "ctrlFlushMoves", "ctrlButton", "ctrlClick", "ctrlRelease", "ctrlFraction", "ctrlTouchDown", "centroid",
+ "ctrlTouchMove", "ctrlTouchUp", "ctrlTap", "ctrlText"];
+const code = NAMES.map(grab).join("");
+const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
+const tick = (ms = 0) => new Promise(r => setTimeout(r, ms));
+
+function page({ mode = "abs", rect = { left: 0, top: 0, width: 640, height: 360 }, api } = {}) {
+ const target = { panel: { window: 42, display: ":1" } };
+ const ctrl = { on: true, queue: [], sending: false, state: "ready", message: "", move: null, rel: [0, 0], raf: 0,
+ held: new Set(), keys: new Set(), pointers: new Map(), g: null, retry: null };
+ const sent = [];
+ const canvas = { width: 1280, height: 720, getBoundingClientRect: () => rect };
+ const env = {
+ ctrl, $: () => canvas, ctrlMode: () => mode, ctrlShow: () => {}, toast: () => {},
+ ctrlTarget: () => (mode !== "abs" ? { ok: true } : target.panel ? { ok: true, panel: target.panel } : { why: "gone" }),
+ api: api || (async (path, body) => { sent.push(...body.events); return { state: "ready", sent: true }; }),
+ requestAnimationFrame: cb => { setTimeout(cb, 0); return 1; },
+ navigator: {},
+ };
+ const fns = new Function(...Object.keys(env), `let ctrlWarned = false;\n${code}
+ return { ctrlTouchDown, ctrlTouchMove, ctrlTouchUp, ctrlTouchCancel, ctrlSend, ctrlText, ctrlButton, ctrlKeyEvent, ctrlRelease, ctrlFlushMoves };`)(...Object.values(env));
+ const at = (id, x, y) => ({ pointerId: id, clientX: x, clientY: y });
+ return { ctrl, sent, target, ...fns, at };
+}
+
+// A tap lands where it was tapped: pointer there first, then the click.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
+ await tick(10);
+ const [move, down, up] = p.sent;
+ if (!(move.fx === 0.5 && Math.abs(move.fy - 0.25) < 1e-9 && move.window === 42 && move.display === ":1")) fail("tap position " + JSON.stringify(move));
+ if (!(down.window === 42 && down.display === ":1")) fail("a press names its panel " + JSON.stringify(down));
+ if (!(down.button === "left" && down.down && up.button === "left" && up.down === false && p.sent.length === 3))
+ fail("tap click " + JSON.stringify(p.sent));
+}
+// The panel is letterboxed in a taller view: taps map inside it, taps on the bars do nothing.
+{
+ const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } }); // 640x360 picture, 60px bars
+ p.ctrlTouchDown(p.at(1, 320, 150)); p.ctrlTouchUp(p.at(1, 320, 150));
+ p.ctrlTouchDown(p.at(2, 320, 10)); p.ctrlTouchUp(p.at(2, 320, 10));
+ await tick(10);
+ if (!(p.sent.length === 3 && Math.abs(p.sent[0].fy - 0.25) < 1e-9)) fail("letterbox " + JSON.stringify(p.sent));
+}
+// A drag is a mouse drag: down where it started, moves, up at the end.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 100, 100));
+ p.ctrlTouchMove(p.at(1, 140, 100)); await tick(5);
+ p.ctrlTouchMove(p.at(1, 200, 120)); await tick(5);
+ p.ctrlTouchUp(p.at(1, 200, 120)); await tick(10);
+ const kinds = p.sent.map(e => "fx" in e ? "move" : `${e.button}-${e.down ? "down" : "up"}`);
+ if (!(kinds[0] === "move" && kinds[1] === "left-down" && kinds.at(-1) === "left-up" && kinds.includes("move", 2)))
+ fail("drag " + kinds.join(","));
+ if (Math.abs(p.sent[0].fx - 100 / 640) > 1e-9) fail("drag starts where the finger went down");
+ if (p.ctrl.held.size) fail("drag left the button held");
+}
+// Press and hold is a right-click where the finger is.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 64, 36));
+ await tick(620);
+ p.ctrlTouchUp(p.at(1, 64, 36)); await tick(10);
+ const buttons = p.sent.filter(e => "button" in e).map(e => `${e.button}-${e.down}`);
+ if (buttons.join() !== "right-true,right-false") fail("hold " + buttons.join());
+ if (!(p.sent[0].fx === 0.1)) fail("hold position " + JSON.stringify(p.sent[0]));
+}
+// Two fingers scroll, and the content follows them (fingers up scrolls down).
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 200, 200));
+ p.ctrlTouchMove(p.at(1, 100, 180)); p.ctrlTouchMove(p.at(2, 200, 180));
+ p.ctrlTouchUp(p.at(1, 100, 180)); p.ctrlTouchUp(p.at(2, 200, 180));
+ await tick(10);
+ const dy = p.sent.filter(e => e.scroll).reduce((a, e) => a + e.scroll[1], 0);
+ if (!(dy === 40 && !p.sent.some(e => "button" in e))) fail("scroll " + JSON.stringify(p.sent));
+}
+// On the headset view it's a trackpad: drags move the pointer, taps click where it is.
+{
+ const p = page({ mode: "rel" });
+ p.ctrlTouchDown(p.at(1, 100, 100));
+ p.ctrlTouchMove(p.at(1, 110, 100)); p.ctrlTouchMove(p.at(1, 120, 105));
+ p.ctrlTouchUp(p.at(1, 120, 105)); await tick(10);
+ p.ctrlTouchDown(p.at(1, 50, 50)); p.ctrlTouchUp(p.at(1, 50, 50)); await tick(10);
+ const moved = p.sent.filter(e => "dx" in e).reduce((a, e) => [a[0] + e.dx, a[1] + e.dy], [0, 0]);
+ if (!(moved[0] === 32 && moved[1] === 8)) fail("trackpad move " + JSON.stringify(moved));
+ if (p.sent.some(e => "fx" in e)) fail("trackpad sent an absolute position");
+ if (p.sent.filter(e => e.button === "left").length !== 2) fail("trackpad tap " + JSON.stringify(p.sent));
+}
+// Text: plain ASCII only, in chunks the server takes.
+{
+ const p = page();
+ p.ctrlText("héllo " + "x".repeat(600));
+ await tick(10);
+ const text = p.sent.map(e => e.text).join("");
+ if (!(text === "hllo " + "x".repeat(600) && p.sent.every(e => e.text.length <= 500))) fail("text " + text.length);
+}
+// Not connected yet: clicks and keys wait with their position. The request fails: only releases wait.
+{
+ let calls = 0;
+ const p = page({ api: async () => { calls++; if (calls === 1) return { state: "starting", sent: false }; throw new Error("offline"); } });
+ p.ctrlSend([{ fx: 0.1, fy: 0.1, window: 42 }, { button: "left", down: true }, { key: 30, down: true }]);
+ await tick(5);
+ const q = p.ctrl.queue;
+ if (!(q.length === 3 && "fx" in q[0] && q[1].button === "left" && q[2].key === 30)) fail("kept while starting " + JSON.stringify(q));
+ clearTimeout(p.ctrl.retry);
+ p.ctrl.queue = []; p.ctrl.retryAt = 0;
+ p.ctrlSend([{ button: "left", down: false }, { key: 31, down: true }, { dx: 3, dy: 1 }]);
+ await tick(5);
+ if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].button === "left" && p.ctrl.queue[0].down === false))
+ fail("release kept on failure " + JSON.stringify(p.ctrl.queue));
+ if (calls !== 2) fail("retried in a tight loop: " + calls + " requests");
+ clearTimeout(p.ctrl.retry);
+}
+// Turning Control off (or the view losing focus) lets go of anything held.
+{
+ const p = page();
+ p.ctrlButton("left", true);
+ p.ctrlRelease(); await tick(10);
+ if (!(p.sent.at(-1).button === "left" && p.sent.at(-1).down === false && !p.ctrl.held.size)) fail("release " + JSON.stringify(p.sent));
+}
+// Keys held on the Frame are let go too.
+{
+ const p = page();
+ p.ctrlKeyEvent(42, true);
+ p.ctrlRelease(); await tick(10);
+ if (!(p.sent.at(-1).key === 42 && p.sent.at(-1).down === false && !p.ctrl.keys.size)) fail("key release " + JSON.stringify(p.sent));
+}
+// A long queue sheds moves and old scrolls, never a release.
+{
+ const p = page({ api: () => new Promise(() => {}) }); // stuck request
+ p.ctrlSend([{ dx: 1, dy: 1 }]);
+ p.ctrlSend([{ button: "left", down: false }]);
+ for (let i = 0; i < 320; i++) p.ctrlSend([{ scroll: [0, 1] }]);
+ if (!p.ctrl.queue.some(e => e.button === "left" && e.down === false)) fail("trim dropped a release");
+ if (p.ctrl.queue.length > 300) fail("trim kept " + p.ctrl.queue.length);
+}
+// Broken on the Frame side: no hammering, and only releases wait.
+{
+ let calls = 0;
+ const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
+ p.ctrlSend([{ button: "left", down: true }, { button: "left", down: false }]);
+ await tick(50);
+ if (calls !== 1) fail("error response reposted " + calls + " times");
+ if (!(p.ctrl.queue.length === 1 && p.ctrl.queue[0].down === false)) fail("error kept " + JSON.stringify(p.ctrl.queue));
+ clearTimeout(p.ctrl.retry);
+}
+// Connecting: a tap keeps its position, so it lands where it was made.
+{
+ const p = page({ api: async () => ({ state: "starting", sent: false }) });
+ p.ctrlTouchDown(p.at(1, 320, 90)); p.ctrlTouchUp(p.at(1, 320, 90));
+ await tick(10);
+ const q = p.ctrl.queue;
+ if (!("fx" in q[0] && q[1].button === "left")) fail("connecting tap " + JSON.stringify(q));
+ clearTimeout(p.ctrl.retry);
+}
+// Lifting one of two scrolling fingers doesn't jump the scroll or start a drag.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 100, 200)); p.ctrlTouchDown(p.at(2, 300, 200));
+ p.ctrlTouchUp(p.at(1, 100, 200));
+ p.ctrlTouchMove(p.at(2, 300, 199));
+ p.ctrlTouchUp(p.at(2, 300, 199)); await tick(10);
+ if (p.sent.length) fail("one finger left after scrolling " + JSON.stringify(p.sent));
+}
+// A cancelled touch isn't a tap.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 100, 100)); p.ctrlTouchCancel(p.at(1, 100, 100)); await tick(10);
+ if (p.sent.length) fail("cancel clicked " + JSON.stringify(p.sent));
+}
+// Press and hold on the bars around the picture does nothing.
+{
+ const p = page({ rect: { left: 0, top: 0, width: 640, height: 480 } });
+ p.ctrlTouchDown(p.at(1, 320, 10)); await tick(620); p.ctrlTouchUp(p.at(1, 320, 10)); await tick(10);
+ if (p.sent.length) fail("hold on the bars " + JSON.stringify(p.sent));
+}
+// Taps only reach the panel in use, and only once its picture is the one on screen.
+{
+ const fns = ["panelKey", "deskPanel", "ctrlTarget"].map(grab).join("");
+ const check = (desk, live = true) => new Function("desk", "live", "ctrlMode", fns + "; return ctrlTarget();")(desk, live, () => "abs");
+ const a = { display: ":1", window: 5 }, b = { display: ":0", window: 5 };
+ const base = { panels: [a, b], loaded: true, pick: "", focus: ":1/5", shown: ":1/5" };
+ if (!check(base).ok) fail("target: shown and in use");
+ if (check({ ...base, shown: ":0/5" }).ok) fail("target: the picture is another panel with the same id");
+ if (!/Capture or Live/.test(check({ ...base, shown: null }, false).why)) fail("target: stale picture message");
+ if (check({ ...base, pick: ":0/5", shown: ":0/5" }).ok) fail("target: a watched panel that isn't in use");
+ if (check({ ...base, focus: null }).ok) fail("target: nothing in use");
+}
+// Focus moves to another panel mid-gesture: the tap or hold does nothing.
+{
+ const p = page();
+ p.ctrlTouchDown(p.at(1, 100, 100));
+ p.target.panel = { window: 43, display: ":1" };
+ p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
+ p.target.panel = { window: 42, display: ":1" };
+ p.ctrlTouchDown(p.at(1, 100, 100));
+ p.target.panel = null;
+ await tick(620); p.ctrlTouchUp(p.at(1, 100, 100)); await tick(10);
+ if (p.sent.some(e => "button" in e)) fail("gesture outlived its panel " + JSON.stringify(p.sent));
+}
+// Trimming keeps a click together with its position.
+{
+ const p = page({ api: () => new Promise(() => {}) });
+ p.ctrlSend([{ dx: 1, dy: 0 }]); // in flight forever
+ for (let i = 0; i < 100; i++) p.ctrlSend([{ scroll: [0, 1] }]);
+ p.ctrlSend([{ fx: 0.5, fy: 0.5, window: 42, display: ":1" }, { button: "left", down: true }, { button: "left", down: false }]);
+ for (let i = 0; i < 198; i++) p.ctrlSend([{ scroll: [0, 1] }]);
+ const q = p.ctrl.queue, i = q.findIndex(e => e.button === "left" && e.down);
+ if (i < 1 || !("fx" in q[i - 1])) fail("trim split a click from its position");
+}
+// Backing off holds for new input too.
+{
+ let calls = 0;
+ const p = page({ api: async () => { calls++; return { state: "error", sent: false }; } });
+ p.ctrlSend([{ button: "left", down: false }]);
+ await tick(5);
+ for (let i = 0; i < 10; i++) { p.ctrlSend([{ key: 30, down: false }]); await tick(2); }
+ if (calls !== 1) fail("new input bypassed the backoff: " + calls + " requests");
+ clearTimeout(p.ctrl.retry);
+}
+// A long paste goes in several requests, so a release never waits behind all of it.
+{
+ const batches = [];
+ const p = page({ api: async (path, body) => { batches.push(body.events); return { state: "ready", sent: true }; } });
+ p.ctrlText("y".repeat(450));
+ p.ctrlButton("left", false);
+ await tick(30);
+ if (!batches.every(b => b.reduce((a, e) => a + (e.text?.length || 0), 0) <= 100)) fail("a batch carried too much text");
+ if (batches.length < 5) fail("paste went in " + batches.length + " requests");
+}
+console.log("control gestures ok");
diff --git a/tests/page/pad_queue.mjs b/tests/page/pad_queue.mjs
new file mode 100644
index 0000000..f8e748c
--- /dev/null
+++ b/tests/page/pad_queue.mjs
@@ -0,0 +1,48 @@
+// The page's input queue, run in node against the real functions from ui/index.html.
+import { readFileSync } from "fs";
+const src = readFileSync(new URL("../../ui/index.html", import.meta.url), "utf8");
+const grab = name => src.match(new RegExp(`(?:const ${name} = [^\\n]*\\n)|((?:async )?function ${name}\\([\\s\\S]*?\\n}\\n)`))[0];
+const code = ["isMove", "padSend", "padKeep", "padFlush"].map(grab).join("");
+const fail = msg => { console.log("FAIL " + msg); process.exit(1); };
+
+function page(api) {
+ const pad = { state: "ready", queue: [], sending: false };
+ const shown = [];
+ const fns = new Function("pad", "api", "padShow", code + "; return { padSend, padFlush };")(pad, api, s => { shown.push(s); pad.state = s.state; });
+ return { pad, shown, ...fns };
+}
+
+// While a request is in flight, events queue by these rules.
+{
+ const { pad, padSend } = page(() => new Promise(() => {})); // a request that never returns
+ const send = (state, e) => { pad.state = state; padSend(e); };
+ send("ready", { singlehold: true }); // goes out, and stays in flight
+ send("error", { dx: 3 }); // dropped: a stale move while broken
+ send("error", { singlerelease: true }); // kept
+ send("off", { singlerelease: true }); // off: nothing was held
+ send("pairing", { key: "a" }); // kept while reconnecting
+ send("pairing", { dx: 1 }); // dropped
+ for (let i = 0; i < 250; i++) send("pairing", { key: "x" });
+ send("pairing", { singlerelease: true }); // kept past the 200 cap
+ const q = pad.queue;
+ if (!(q[0].singlerelease && q[1].key === "a" && q.at(-1).singlerelease && !q.some(e => "dx" in e)
+ && q.filter(e => e.singlerelease).length === 2)) fail("queueing " + JSON.stringify(q.slice(0, 3)) + " len " + q.length);
+}
+
+// A request that fails, or reaches a link that had dropped, keeps its keys and releases.
+for (const [label, api] of [["request failed", async () => { throw new Error("offline"); }],
+ ["not sent", async () => ({ state: "starting", sent: false })]]) {
+ const { pad, padSend, shown } = page(api);
+ padSend({ dx: 5 }); // sent and lost: a move, fine to drop
+ await new Promise(r => setTimeout(r, 0));
+ pad.state = "ready";
+ padSend({ singlerelease: true });
+ padSend({ dx: 7 }); // queued while that request is in flight: stale once it fails
+ padSend({ key: "b" });
+ await new Promise(r => setTimeout(r, 0));
+ const q = pad.queue;
+ if (!(q.some(e => e.singlerelease) && q.some(e => e.key === "b") && !q.some(e => "dx" in e)))
+ fail(label + ": " + JSON.stringify(q));
+ if (!shown.length || shown.at(-1).state === "ready") fail(label + ": state not shown");
+}
+console.log("queue rules ok");
diff --git a/tests/sandbox.py b/tests/sandbox.py
new file mode 100644
index 0000000..5d2a3f0
--- /dev/null
+++ b/tests/sandbox.py
@@ -0,0 +1,16 @@
+"""Imported first by every test module: nothing a test does reaches this person's
+app data, their telemetry, or the shared compatibility database.
+
+Must run before any ui module is imported, since those read these at import time.
+"""
+import atexit
+import os
+import shutil
+import tempfile
+
+_dir = tempfile.mkdtemp(prefix="frame-control-tests-")
+atexit.register(shutil.rmtree, _dir, ignore_errors=True)
+os.environ["FRAME_CONTROL_DATA_DIR"] = _dir
+os.environ["FRAME_CONTROL_TELEMETRY"] = "0"
+# A maintainer's machine holds the database key; send anything that slips through nowhere.
+os.environ["FRAME_COMPAT_DB_URL"] = "http://127.0.0.1:9"
diff --git a/tests/search_preview.py b/tests/search_preview.py
new file mode 100644
index 0000000..cdd6bec
--- /dev/null
+++ b/tests/search_preview.py
@@ -0,0 +1,58 @@
+"""Local store preview. No device access; installation progress is simulated.
+
+FRAME_APK_SEARCH_DEMO=1 python3 tests/search_preview.py
+"""
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+import time
+from urllib.parse import urlparse
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+import server
+from apk_sources import _demo, _images, search
+
+
+class Preview(server.Handler):
+ def do_GET(self):
+ path = urlparse(self.path).path
+ if path == '/api/android':
+ self.send_json({'apps': []})
+ return
+ if path == '/api/android/reports':
+ self.send_json({'reports': [], 'shared': False})
+ return
+ if path not in ('/', '/index.html', '/api/search', '/api/sources', '/api/sources/details', '/api/job', '/api/host') and not path.startswith('/source-image/'):
+ self.send_json({'error': 'Headset disconnected', 'offline': True}, 503)
+ return
+ super().do_GET()
+
+ def do_POST(self):
+ if not self.local_request():
+ return
+ if urlparse(self.path).path == '/api/sources/install':
+ body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))))
+ def work(report):
+ for percent in (12, 28, 43, 67, 89):
+ report('Downloading', percent)
+ time.sleep(2)
+ report('Installing', None)
+ time.sleep(3)
+ return {'package': 'org.preview.' + body['id'], 'message': 'Preview installation complete'}
+ self.send_json(server.start_job('Preview installation', work, progress=True))
+ return
+ if urlparse(self.path).path == '/api/sources':
+ super().do_POST()
+ return
+ self.send_json({'error': 'Device access disabled in store preview'}, 403)
+
+
+if __name__ == '__main__':
+ if os.environ.get('FRAME_APK_SEARCH_DEMO') != '1':
+ sys.exit('Set FRAME_APK_SEARCH_DEMO=1')
+ for name, url in json.loads((_demo.FIXTURES / 'artwork' / 'urls.json').read_text()).items():
+ _images.remember(url, (_demo.FIXTURES / 'artwork' / name).read_bytes())
+ with tempfile.TemporaryDirectory(prefix='frame-store-preview-') as tmp:
+ search.settings_path = lambda: Path(tmp) / 'enabled.json'
+ server.ThreadingHTTPServer(('127.0.0.1', 8795), Preview).serve_forever()
diff --git a/tests/test_agent.py b/tests/test_agent.py
new file mode 100644
index 0000000..931eccb
--- /dev/null
+++ b/tests/test_agent.py
@@ -0,0 +1,274 @@
+"""MCP protocol, exact-action approvals and explicit assistant data sharing."""
+import io
+import json
+import os
+import shutil
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import threading
+import unittest
+from unittest import mock
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+import frame_agent as agent
+import frame_assistant as assistant
+import frame_mcp as mcp
+import server
+
+
+class Approvals(unittest.TestCase):
+ def test_requires_human_decision_exact_action_and_single_use(self):
+ gate = agent.Approvals()
+ action = {'name': 'power', 'arguments': {'action': 'reboot'}}
+ token = gate.request(action)['confirmation']
+ with self.assertRaises(ValueError):
+ gate.consume(token, action)
+ gate.decide(token, True)
+ with self.assertRaises(ValueError):
+ gate.consume(token, {'name': 'power', 'arguments': {'action': 'poweroff'}})
+ gate.consume(token, action)
+ with self.assertRaises(ValueError):
+ gate.consume(token, action)
+
+ def test_expiry_rejection_and_non_boolean_approval(self):
+ gate = agent.Approvals()
+ token = gate.request({})['confirmation']
+ gate.decide(token, 'true')
+ with self.assertRaises(ValueError):
+ gate.inspect(token)
+ token = gate.request({})['confirmation']
+ with mock.patch.object(agent.time, 'monotonic', return_value=float('inf')):
+ with self.assertRaises(ValueError):
+ gate.decide(token, True)
+
+ def test_concurrent_consumption_executes_once(self):
+ gate = agent.Approvals()
+ token = gate.request({})['confirmation']
+ gate.decide(token, True)
+ results = []
+ def consume():
+ try:
+ gate.consume(token, {})
+ results.append(True)
+ except ValueError:
+ results.append(False)
+ threads = [threading.Thread(target=consume) for _ in range(8)]
+ for thread in threads: thread.start()
+ for thread in threads: thread.join()
+ self.assertEqual(results.count(True), 1)
+
+ def test_action_never_runs_before_approval(self):
+ with mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'flatpak') as install:
+ body = {'name': 'install', 'arguments': {'id': 'org.example.App'}}
+ result = agent.call(server, body)
+ install.assert_not_called()
+ body['confirmation'] = result['confirmation']
+ with self.assertRaises(ValueError): agent.call(server, body)
+ agent.approvals.decide(body['confirmation'], True)
+ agent.call(server, body)
+ install.assert_called_once_with({'id': 'org.example.App', 'action': 'install'})
+ with self.assertRaises(ValueError): agent.call(server, body)
+
+ def test_file_content_change_invalidates_approval(self):
+ with tempfile.TemporaryDirectory() as tmp, mock.patch.object(agent, 'approvals', agent.Approvals()), mock.patch.object(server, 'push_file') as push:
+ path = Path(tmp) / 'note.txt'
+ path.write_text('first')
+ body = {'name': 'send_file', 'arguments': {'path': str(path)}}
+ result = agent.call(server, body)
+ agent.approvals.decide(result['confirmation'], True)
+ body['confirmation'] = result['confirmation']
+ path.write_text('second')
+ with self.assertRaises(ValueError): agent.call(server, body)
+ push.assert_not_called()
+
+ def test_no_arbitrary_commands_or_arguments(self):
+ for name, args in [('shell', {'command': 'true'}), ('panel', {'id': 'org.example.App', 'args': '--evil'}),
+ ('power', {'action': 'factory-reset'}), ('send_text', {'text': ''})]:
+ with self.assertRaises(ValueError): agent.call(server, {'name': name, 'arguments': args})
+
+
+class Assistant(unittest.TestCase):
+ def setUp(self):
+ self.received = []
+ owner = self
+ class Endpoint(BaseHTTPRequestHandler):
+ def log_message(self, *args): pass
+ def do_POST(self):
+ owner.received.append((dict(self.headers), json.loads(self.rfile.read(int(self.headers['Content-Length'])))))
+ if self.path == '/redirect':
+ self.send_response(302)
+ self.send_header('Location', '/other')
+ self.end_headers()
+ return
+ data = json.dumps({'choices': [{'message': {'content': ''}}]}).encode()
+ self.send_response(200)
+ self.send_header('Content-Length', str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ self.httpd = ThreadingHTTPServer(('127.0.0.1', 0), Endpoint)
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
+ self.thread.start()
+ self.body = {'endpoint': 'http://127.0.0.1:%d/chat' % self.httpd.server_port, 'model': 'local', 'prompt': 'Hello', 'consent': True}
+
+ def tearDown(self):
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ self.thread.join()
+
+ def test_no_opt_in_no_request_or_capture(self):
+ capture = mock.Mock()
+ for consent in (False, None, 'true', 1):
+ with self.assertRaises(ValueError): assistant.chat({**self.body, 'consent': consent, 'screenshot': True}, capture)
+ capture.assert_not_called()
+ self.assertEqual(self.received, [])
+
+ def test_text_only_keyless_and_optional_screenshot(self):
+ capture = mock.Mock(return_value=b'png')
+ self.assertIn('script', assistant.chat(self.body, capture)['reply'])
+ capture.assert_not_called()
+ headers, body = self.received[-1]
+ self.assertNotIn('Authorization', headers)
+ self.assertEqual(body['messages'], [{'role': 'user', 'content': 'Hello'}])
+ assistant.chat({**self.body, 'screenshot': True, 'key': 'test-key'}, capture)
+ capture.assert_called_once()
+ headers, body = self.received[-1]
+ self.assertEqual(headers['Authorization'], 'Bearer test-key')
+ self.assertEqual(body['messages'][0]['content'][1]['image_url']['url'], 'data:image/png;base64,cG5n')
+
+ def test_redirects_do_not_forward_context_or_credentials(self):
+ with self.assertRaises(ValueError):
+ assistant.chat({**self.body, 'endpoint': self.body['endpoint'].replace('/chat', '/redirect'), 'key': 'secret'}, mock.Mock())
+ self.assertEqual(len(self.received), 1)
+
+ def test_bad_urls_fail_before_capture(self):
+ for url in ('file:///etc/passwd', 'http://example.com/chat', 'https://user:pass@example.com', 'https://example.com?key=secret'):
+ capture = mock.Mock()
+ with self.assertRaises(ValueError): assistant.chat({**self.body, 'endpoint': url, 'screenshot': True}, capture)
+ capture.assert_not_called()
+
+
+class AssistantPage(unittest.TestCase):
+ @unittest.skipUnless(shutil.which('node'), 'Node is required for the page script regression')
+ def test_approval_navigation_races(self):
+ root = Path(__file__).resolve().parents[1]
+ result = subprocess.run(['node', str(root / 'tests/assistant_ui.cjs'), str(root / 'ui/assistant.html')],
+ capture_output=True, text=True, timeout=10)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+
+class Protocol(unittest.TestCase):
+ def test_stdio_initialize_list_call_errors_and_eof(self):
+ messages = [
+ {'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', 'params': {'protocolVersion': '2025-06-18'}},
+ {'jsonrpc': '2.0', 'method': 'notifications/initialized'},
+ {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'},
+ {'jsonrpc': '2.0', 'id': 3, 'method': 'tools/call', 'params': {'name': 'shell'}},
+ {'jsonrpc': '2.0', 'id': 4, 'method': 'ping'},
+ ]
+ result = subprocess.run([sys.executable, str(Path(mcp.__file__))], input='\n'.join(map(json.dumps, messages)) + '\n', text=True, capture_output=True, timeout=10)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ replies = list(map(json.loads, result.stdout.splitlines()))
+ self.assertEqual([r['id'] for r in replies], [1, 2, 3, 4])
+ self.assertEqual(replies[0]['result']['protocolVersion'], '2025-06-18')
+ self.assertIn('screenshot', [t['name'] for t in replies[1]['result']['tools']])
+ self.assertTrue(replies[2]['result']['isError'])
+
+ def test_mcp_cannot_approve_and_returns_review_url(self):
+ client = mock.Mock(url='http://127.0.0.1:47810')
+ client.request.return_value = {'approvalPath': '/assistant#confirm=token'}
+ result = mcp.call(client, 'power', {'action': 'reboot'})
+ self.assertIn('http://127.0.0.1:47810/assistant', result['content'][0]['text'])
+ with self.assertRaises(ValueError): mcp.call(client, 'approve', {'confirmation': 'token'})
+ with self.assertRaises(ValueError): mcp.call(client, 'status', {'path': '/api/open'})
+
+ def test_loopback_only_backend(self):
+ for url in ('https://example.com', 'http://127.0.0.1/api', 'http://secret@localhost:1234', 'file:///tmp/x'):
+ with self.assertRaises(ValueError): mcp.Client(url)
+
+
+class ManagedBackend(unittest.TestCase):
+ def test_private_backend_auth_and_cleanup(self):
+ from urllib.error import HTTPError, URLError
+ from urllib.request import urlopen
+ with mock.patch.dict(os.environ, {'FRAME_ALIAS': 'frame-control-test.invalid'}):
+ with mcp.backend() as client:
+ url = client.url
+ self.assertIn('os', client.request('/api/host'))
+ with self.assertRaises(HTTPError) as error:
+ urlopen(url + '/api/host', timeout=2)
+ self.assertEqual(error.exception.code, 403)
+ error.exception.close()
+ # A second client has its own backend and key.
+ with mcp.backend() as other:
+ self.assertNotEqual(client.url, other.url)
+ self.assertNotEqual(client.key, other.key)
+ self.assertIn('os', client.request('/api/host'))
+ with self.assertRaises(URLError):
+ urlopen(url + '/', timeout=2)
+
+ def test_private_ssh_socket_is_not_the_desktop_socket(self):
+ with mock.patch.object(server.frame_host, 'MUX', True), \
+ mock.patch.object(server.frame_host.os, 'getuid', return_value=501, create=True), \
+ mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
+ # Per headset (its tag), and per process for a private server.
+ self.assertEqual(server.frame_host.control_path('a1b2', private=False), '/tmp/frame-ui-501-a1b2-%C')
+ self.assertEqual(server.frame_host.control_path('a1b2', private=True), '/tmp/frame-ui-501-123-a1b2-%C')
+
+
+class ComputerState(unittest.TestCase):
+ def test_gamescope_triplets_and_empty_focus(self):
+ import frame_computer
+ parsed = frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 16, 42, 123, 32, 55, 999\nGAMESCOPE_FOCUSED_APP(CARDINAL) = \n')
+ self.assertEqual(parsed['windows'], [{'windowId': '0x10', 'appid': 42, 'pid': 123}, {'windowId': '0x20', 'appid': 55, 'pid': 999}])
+ self.assertIsNone(parsed['focusedApp'])
+ with self.assertRaises(ValueError):
+ frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = 1, 2')
+ with self.assertRaises(ValueError):
+ frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL) = untrusted')
+ with self.assertRaises(ValueError):
+ frame_computer.parse_windows('GAMESCOPE_FOCUSABLE_WINDOWS: no such atom on any window.')
+
+ def test_partial_snapshot_reports_failure_not_empty_success(self):
+ import frame_computer
+ with mock.patch.object(frame_computer.subprocess, 'run', side_effect=OSError('no display')), \
+ mock.patch.object(frame_computer, 'accessibility', side_effect=OSError('no AT-SPI')):
+ result = frame_computer.snapshot()
+ self.assertIn('windowError', result)
+ self.assertIn('accessibilityError', result)
+ self.assertFalse(result['inputEnabled'])
+ self.assertNotIn('windows', result)
+
+ def test_mcp_computer_state_is_read_only(self):
+ client = mock.Mock()
+ client.request.return_value = {'windows': []}
+ mcp.call(client, 'computer_state', {})
+ client.request.assert_called_once_with('/api/computer/state')
+ spec = next(t for t in mcp.TOOLS if t['name'] == 'computer_state')
+ self.assertTrue(spec['annotations']['readOnlyHint'])
+
+
+if __name__ == '__main__':
+ unittest.main()
+
+
+class ScriptsFollowTheHeadset(unittest.TestCase):
+ """keep_awake and panel tools run scripts that ssh on their own: they must reach the
+ headset the server is routed to, not whatever `frame` means in ~/.ssh/config."""
+
+ @unittest.skipUnless(shutil.which('zsh'), 'needs zsh')
+ def test_scripts_get_the_routed_alias_and_options(self):
+ import shlex
+ fake = mock.Mock(FRAME='frame-2', LOCAL=False, HERE=Path(__file__).resolve().parent.parent / 'ui',
+ SSH=['ssh', '-o', 'BatchMode=yes', '-o', 'HostName=192.0.2.2', '-o', 'HostKeyAlias=frame-control-ab'])
+ with mock.patch.object(agent.subprocess, 'run', return_value=mock.Mock(returncode=0, stdout='ok', stderr='')) as run:
+ agent.run_script(fake, 'keep-awake.sh', ['status'])
+ env = run.call_args.kwargs['env']
+ self.assertEqual(env['FRAME_ALIAS'], 'frame-2')
+ self.assertEqual(shlex.split(env['FRAME_SSH_OPTS']), fake.SSH[1:])
+ # and the script turns that back into the same argv
+ out = subprocess.run(['zsh', '-c', 'ssh_opts=(${(Q)${(z)FRAME_SSH_OPTS:-}}); print -l -- $ssh_opts'],
+ env={**os.environ, 'FRAME_SSH_OPTS': env['FRAME_SSH_OPTS']}, capture_output=True, text=True)
+ self.assertEqual(out.stdout.splitlines(), fake.SSH[1:])
diff --git a/tests/test_apk_artwork.py b/tests/test_apk_artwork.py
new file mode 100644
index 0000000..b036f90
--- /dev/null
+++ b/tests/test_apk_artwork.py
@@ -0,0 +1,101 @@
+import http.client
+import json
+from pathlib import Path
+import socket
+import sys
+import threading
+import unittest
+from unittest.mock import patch, Mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+from apk_sources import _images, search, SourceError
+import server
+
+PNG = (Path(__file__).parent / 'fixtures/apk-search/artwork/brush-icon.png').read_bytes()
+
+
+class ArtworkTests(unittest.TestCase):
+ def setUp(self):
+ with _images._lock:
+ _images._urls.clear()
+ _images._cache.clear()
+
+ def test_only_registered_source_images_are_fetchable(self):
+ with patch.object(_images, 'fetch') as fetch:
+ with self.assertRaisesRegex(SourceError, 'Unknown artwork'):
+ _images.image('https://example.com/arbitrary.png')
+ fetch.assert_not_called()
+ entry = {'images': {'icon': 'https://example.com/icon.png', 'banner': 'file:///tmp/private',
+ 'screenshots': ['https://example.com/shot.png', 'javascript:alert(1)']}}
+ art = _images.artwork(entry)
+ self.assertTrue(art['icon'].startswith('/source-image/'))
+ self.assertIsNone(art['banner'])
+ self.assertEqual(len(art['screenshots']), 1)
+ with patch.object(_images, 'fetch', return_value=(PNG, 'image/png')) as fetch:
+ self.assertEqual(_images.image(art['icon'].split('/')[-1]), (PNG, 'image/png'))
+ _images.image(art['icon'].split('/')[-1])
+ fetch.assert_called_once_with('https://example.com/icon.png')
+
+ def test_rejects_credentials_ports_and_non_http(self):
+ for url in ['file:///tmp/a.png', 'data:image/png;base64,AAAA', 'http://user:pass@example.com/a.png',
+ 'http://example.com:22/a.png', 'https://example.com:bad/a.png', '//example.com/a.png']:
+ self.assertIsNone(_images.register(url), url)
+
+ def test_blocks_private_loopback_and_mixed_dns_answers(self):
+ for ip in ['127.0.0.1', '10.0.0.1', '169.254.169.254', '::1', '192.168.1.1']:
+ with patch.object(socket, 'getaddrinfo', return_value=[(2,1,6,'',(ip,443))]), \
+ patch.object(socket, 'create_connection') as connect:
+ with self.assertRaisesRegex(SourceError, 'Private network'):
+ _images.fetch('https://example.com/private.png')
+ connect.assert_not_called()
+
+ def test_redirect_to_private_network_is_rejected(self):
+ response = Mock(status=302)
+ response.getheader.return_value = 'http://127.0.0.1/secret'
+ conn = Mock()
+ conn.getresponse.return_value = response
+ public = [(2,1,6,'',('93.184.216.34',80))]
+ private = [(2,1,6,'',('127.0.0.1',80))]
+ with patch.object(socket, 'getaddrinfo', side_effect=[public,private]), \
+ patch.object(socket, 'create_connection') as connect, \
+ patch.object(http.client, 'HTTPConnection', return_value=conn):
+ with self.assertRaisesRegex(SourceError, 'Private network'):
+ _images.fetch('http://example.com/a.png')
+ connect.assert_called_once_with(('93.184.216.34',80),timeout=10)
+
+ def test_non_images_and_oversized_images_are_rejected(self):
+ with self.assertRaises(SourceError):
+ _images.remember('https://example.com/a.svg', b' ')
+ with self.assertRaisesRegex(SourceError, 'too large'):
+ _images.remember('https://example.com/a.png', PNG[:8] + b'x' * _images.MAX_IMAGE)
+
+ def test_handles_are_bounded(self):
+ for i in range(4100):
+ _images.register('https://example.com/%d.png' % i)
+ self.assertEqual(len(_images._urls),4096)
+
+ def test_plain_language_verdict_is_evidence_based(self):
+ self.assertEqual(search.verdict({})['label'], 'Not yet checked on the Frame')
+ self.assertEqual(search.verdict({'min_sdk':24,'abis':[]})['label'], 'Ready to try on the Frame')
+ self.assertEqual(search.verdict({'min_sdk':24,'abis':[],'frame_tested':True})['label'], 'Works on the Frame')
+ self.assertIn('newer Android', search.verdict({'min_sdk':31})['label'])
+ self.assertIn('Meta Quest services', search.verdict({'requires_meta_services':True})['label'])
+ self.assertEqual(search.verdict({'engine':'VrApi'})['tone'],'blocked')
+ self.assertNotEqual(search.verdict({'frame_tested':True,'min_sdk':31})['tone'],'works')
+
+ def test_image_endpoint_does_not_allow_arbitrary_urls(self):
+ httpd = server.ThreadingHTTPServer(('127.0.0.1',0),server.Handler)
+ threading.Thread(target=httpd.serve_forever,daemon=True).start()
+ try:
+ path = _images.register('https://example.com/app.png')
+ _images.remember('https://example.com/app.png',PNG)
+ for url, expected in [(path,200),('/source-image/unknown',404)]:
+ c=http.client.HTTPConnection('127.0.0.1',httpd.server_port)
+ c.request('GET',url)
+ r=c.getresponse();data=r.read();c.close()
+ self.assertEqual(r.status,expected)
+ if expected==200:
+ self.assertEqual(data,PNG)
+ self.assertEqual(r.getheader('Content-Type'),'image/png')
+ finally:
+ httpd.shutdown();httpd.server_close()
diff --git a/tests/test_apk_more_sources.py b/tests/test_apk_more_sources.py
new file mode 100644
index 0000000..f1879b9
--- /dev/null
+++ b/tests/test_apk_more_sources.py
@@ -0,0 +1,235 @@
+import hashlib, io, json, os, sys, tempfile, time, unittest, urllib.error, urllib.request, zipfile
+from pathlib import Path
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+from apk_sources import SourceError, github, itch, _web
+
+FIX = Path(__file__).parent / 'fixtures' / 'more_sources'
+
+
+class PublisherSources(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.cache = patch.object(_web, 'cache', return_value=self.tmp.name)
+ self.cache.start()
+ self.addCleanup(self.cache.stop)
+ self.network = patch('urllib.request.OpenerDirector.open', side_effect=AssertionError('network in test'))
+ self.network.start()
+ self.addCleanup(self.network.stop)
+ _web._limited.clear()
+ self.addCleanup(_web._limited.clear)
+ self.root = Path(self.tmp.name) / 'caches' / 'apk-sources'
+ roots = patch.object(_web.frame_host, 'cache_dir', lambda *p: self.root.parent.joinpath(*p))
+ roots.start()
+ self.addCleanup(roots.stop)
+
+ def test_curated_search_is_offline(self):
+ self.assertEqual(github.search(github.sources()[0], 'hello')[0]['id'], 'KhronosGroup/OpenXR-SDK-Source')
+ self.assertEqual(github.search(github.sources()[0], '', 0), [])
+
+ def test_curated_artwork_has_recorded_image_evidence(self):
+ evidence = {r['url']: r for r in json.loads((FIX / 'artwork-check.json').read_text())}
+ entries = github.search(github.sources()[0], '')
+ self.assertEqual(len(entries), 4)
+ for entry in entries:
+ self.assertTrue(entry['summary'])
+ images = entry['images']
+ self.assertEqual(entry['icon'], images['icon'])
+ for url in [u for u in [images['icon'], images['banner']] if u] + images['screenshots']:
+ self.assertTrue(url.startswith('https://'))
+ self.assertEqual(evidence[url]['status'], 200)
+ self.assertTrue(evidence[url]['image'])
+ self.assertTrue(entries[1]['images']['screenshots'])
+ self.assertTrue(entries[2]['images']['screenshots'])
+
+ def test_topic_keeps_curated_artwork(self):
+ curated = github._curated()[1]
+ repo = {'full_name': curated['repo'], 'name': 'open-brush',
+ 'owner': {'avatar_url': 'https://avatars.githubusercontent.com/u/1'}}
+ with patch.object(github, '_api', return_value={'items': [repo]}):
+ entry = github.search(github.sources()[0], 'topic:openxr')[0]
+ self.assertEqual(entry['images'], curated['images'])
+ unknown = github.details(github.sources()[0], 'unknown/project')
+ self.assertEqual(unknown['icon'], 'https://github.com/unknown.png')
+ self.assertIsNone(unknown['images']['banner'])
+
+ def test_real_releases(self):
+ for key, repo in [('khronos', 'KhronosGroup/OpenXR-SDK-Source'),
+ ('brush', 'icosa-foundation/open-brush'), ('tux', 'SgtBilko76/SuperTux-3D')]:
+ with patch.object(github, '_api', return_value=json.loads((FIX / (key + '-releases.json')).read_text())):
+ e = github.details(github.sources()[0], repo)
+ self.assertTrue(e['downloadable'])
+ self.assertTrue(e['versions'][0]['name'].endswith('.apk'))
+ self.assertIsNone(e['version_code'])
+ search_entry = github.search(github.sources()[0], repo)[0]
+ self.assertEqual(e['images'], search_entry['images'])
+ self.assertEqual(e['icon'], e['images']['icon'])
+ with self.assertRaises(SourceError):
+ github.download(github.sources()[0], repo, 123)
+
+ def test_topic_results_need_approval(self):
+ data = json.loads((FIX / 'topic.json').read_text())
+ with patch.object(github, '_api', return_value=data):
+ entries = github.search(github.sources()[0], 'topic:openxr')
+ self.assertTrue(entries)
+ self.assertTrue(any(not e['downloadable'] for e in entries))
+ for entry, repo in zip(entries, data['items']):
+ self.assertEqual(entry['icon'], repo['owner']['avatar_url'])
+ self.assertEqual(entry['images']['icon'], entry['icon'])
+ self.assertIsNone(entry['images']['banner'])
+ self.assertEqual(entry['images']['screenshots'], [])
+ self.assertFalse(github.details(github.sources()[0], 'unknown/project')['downloadable'])
+ with self.assertRaises(SourceError):
+ github.search(github.sources()[0], 'topic:piracy')
+
+ def test_feed_free_android_only_and_deduplicated(self):
+ with patch.object(_web, 'read', return_value=(FIX / 'itch-feed.txt').read_bytes()):
+ entries = itch.search(itch.sources()[0], '')
+ self.assertEqual(len(entries), 9)
+ e = itch.details(itch.sources()[0], entries[0]['id'])
+ self.assertTrue(e['vr'])
+ self.assertTrue(e['images']['banner'])
+ for item in entries:
+ self.assertEqual(item['icon'], item['images']['icon'])
+ self.assertEqual(item['icon'], item['images']['banner'])
+ self.assertEqual(item['images']['screenshots'], [])
+ self.assertFalse(e['downloadable'])
+ self.assertEqual(itch.search(itch.sources()[0], 'off nominal')[0]['name'], 'Off Nominal')
+ with self.assertRaises(SourceError):
+ itch.download(itch.sources()[0], entries[0]['id'])
+ with self.assertRaises(SourceError):
+ itch._parse(itch.sources()[0], b'not xml')
+
+ def test_paid_and_unsafe_feed(self):
+ raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'$0.00', b'$1.00')
+ self.assertEqual(itch._parse(itch.sources()[0], raw), [])
+ raw = (FIX / 'itch-feed.txt').read_bytes().replace(b'https://absyo.itch.io', b'http://localhost')
+ self.assertFalse(any(e['name'] == 'Off Nominal' for e in itch._parse(itch.sources()[0], raw)))
+
+ def test_download_hash_and_cleanup(self):
+ b = io.BytesIO()
+ with zipfile.ZipFile(b, 'w') as z:
+ z.writestr('AndroidManifest.xml', b'fixture')
+ raw = b.getvalue()
+ digest = hashlib.sha256(raw).hexdigest()
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)):
+ result = _web.apk('https://github.com/owner/repo/file.apk', github.HOSTS, digest)
+ self.assertTrue(result['verified'])
+ self.assertEqual(Path(result['apk']).read_bytes(), raw)
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(raw)):
+ self.assertFalse(_web.apk('https://github.com/file.apk', github.HOSTS)['verified'])
+ for content, expected in [(raw, '0' * 64), (b'html challenge', None)]:
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(content)), self.assertRaises(SourceError):
+ _web.apk('https://github.com/file.apk', github.HOSTS, expected)
+ self.assertFalse(list(Path(self.tmp.name).glob('*.part')))
+
+ def test_origin_and_redirect(self):
+ for url in ['http://github.com/x', 'https://evil.test/x', 'https://user@github.com/x']:
+ with self.assertRaises(SourceError):
+ _web.checked_url(url, github.HOSTS)
+ req = urllib.request.Request('https://api.github.com/x', headers={'Authorization': 'Bearer secret'})
+ handler = _web.Redirect(('api.github.com', 'github.com'))
+ redirected = handler.redirect_request(req, None, 302, '', {}, 'https://github.com/x')
+ self.assertFalse(redirected.has_header('Authorization'))
+ with self.assertRaises(SourceError):
+ handler.redirect_request(req, None, 302, '', {}, 'https://evil.test/x')
+
+ def test_cache_rate_limit_and_invalid_json(self):
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(b'index')) as op:
+ self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index')
+ self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index')
+ self.assertEqual(op.call_count, 1)
+ error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None)
+ with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \
+ self.assertRaisesRegex(SourceError, 'FRAME_GITHUB_TOKEN'):
+ github._api('/x')
+ with patch.object(_web, 'open_url', side_effect=error), patch.object(_web.time, 'time', return_value=1e12):
+ self.assertEqual(_web.read('https://itch.io/test', ('itch.io',)), b'index') # throttled: stale copy
+ with patch.object(_web, 'read', return_value=b''), self.assertRaises(SourceError):
+ github._api('/x')
+
+ def test_prune_caps_apks_by_age_and_removes_orphans(self):
+ now = 1e9
+ self.root.mkdir(parents=True)
+ def make(folder, name, size, age):
+ path = Path(folder) / name
+ path.mkdir() if size is None else path.write_bytes(b'x' * size)
+ os.utime(str(path), (now - age, now - age))
+ return path
+ pub = self.tmp.name
+ oldest = make(self.root, 'a.apk', 40, 9000)
+ old = make(pub, 'b.apk', 40, 8000)
+ kept = make(self.root, 'c.apk', 40, 7200)
+ recent = make(pub, 'd.apk', 40, 60) # just downloaded: never pruned
+ orphan, busy = make(self.root, 'x.part', 5, 90000), make(pub, 'y.part', 5, 60)
+ listing, fresh = make(pub, 'l.data', 5, 8 * 86400), make(pub, 'm.data', 5, 3600)
+ tmpdir = make(self.root, 'tmpabc', None, 90000)
+ with patch.object(_web, 'APK_CAP', 100), patch.object(_web.time, 'time', return_value=now):
+ _web.prune()
+ self.assertEqual([p.exists() for p in (oldest, old, kept, recent)], [False, False, True, True])
+ self.assertEqual([p.exists() for p in (orphan, busy, listing, fresh, tmpdir)], [False, True, False, True, False])
+
+ def test_prune_rechecks_before_deleting_and_spares_apks_in_use(self):
+ self.root.mkdir(parents=True)
+ old = time.time() - 7200
+ reused, claimed, stale = self.root / 'a.apk', self.root / 'b.apk', self.root / 'c.apk'
+ for path in (reused, claimed, stale):
+ path.write_bytes(b'x' * 40)
+ _web.claim(claimed)
+ self.addCleanup(_web.release, claimed)
+ for path in (reused, claimed, stale):
+ os.utime(str(path), (old, old))
+ real = os.listdir
+ def listdir(folder):
+ if folder == self.tmp.name: # scanning the second folder: a download reuses a.apk meanwhile
+ self.assertTrue(_web.touch(reused))
+ return real(folder)
+ with patch.object(_web, 'APK_CAP', 10), patch.object(_web.os, 'listdir', listdir):
+ _web.prune()
+ self.assertEqual([p.exists() for p in (reused, claimed, stale)], [True, True, False])
+ _web.release(claimed)
+ with patch.object(_web, 'APK_CAP', 10):
+ _web.prune()
+ self.assertFalse(claimed.exists())
+ self.assertFalse(_web.touch(stale)) # a pruned APK is reported gone, so it's downloaded again
+
+ def test_backoff_honours_retry_after_per_host(self):
+ from apk_sources import SourceLimited
+ from email.utils import formatdate
+ now = [1e9]
+ clock = patch.object(_web.time, 'time', side_effect=lambda: now[0])
+ clock.start()
+ self.addCleanup(clock.stop)
+ error = urllib.error.HTTPError('https://itch.io/a', 429, 'slow down', {'Retry-After': '120'}, None)
+ with patch.object(_web, 'open_url', side_effect=error) as op:
+ with self.assertRaisesRegex(SourceLimited, '^itch.io is limiting requests; try again in 2 minutes$'):
+ itch.search(itch.sources()[0], '')
+ with self.assertRaises(SourceLimited) as caught: # other URLs on the host wait too
+ _web.read('https://itch.io/b', ('itch.io',), name='itch.io')
+ self.assertEqual(op.call_count, 1)
+ self.assertAlmostEqual(caught.exception.retry_after, 120)
+ with self.assertRaises(SourceLimited):
+ _web.apk('https://itch.io/c.apk', ('itch.io',))
+ self.assertEqual(op.call_count, 1)
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(b'fresh')):
+ self.assertEqual(_web.read('https://api.github.com/x', ('api.github.com',)), b'fresh') # other hosts unaffected
+ now[0] += 121
+ with patch.object(_web, 'open_url', return_value=io.BytesIO(b'feed')):
+ self.assertEqual(_web.read('https://itch.io/b', ('itch.io',)), b'feed')
+ cases = [({}, 600), ({'Retry-After': formatdate(now[0] + 300, usegmt=True)}, 300),
+ ({'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': str(int(now[0]) + 60)}, 60)]
+ for headers, expected in cases:
+ with self.subTest(headers=headers):
+ _web._limited.clear()
+ self.assertAlmostEqual(_web.throttle('https://h.test/x', headers), expected, delta=1)
+ self.assertAlmostEqual(_web.wait_time('https://h.test/y'), expected, delta=1)
+ error = urllib.error.HTTPError('https://api.github.com/x', 403, 'limited', {}, None)
+ with patch.object(_web, 'open_url', side_effect=error), patch.dict(os.environ, {'FRAME_GITHUB_TOKEN': ''}), \
+ self.assertRaisesRegex(SourceLimited, '^GitHub is limiting requests; try again in 10 minutes .*TOKEN'):
+ github._api('/y')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_apk_search.py b/tests/test_apk_search.py
new file mode 100644
index 0000000..20cd57e
--- /dev/null
+++ b/tests/test_apk_search.py
@@ -0,0 +1,350 @@
+import http.client
+import json
+from pathlib import Path
+import sys
+import tempfile
+import threading
+import time
+import types
+import unittest
+from unittest.mock import Mock, patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+from apk_sources import search, SourceError
+import server
+
+ENTRIES = json.loads((Path(__file__).parent / 'fixtures/apk-search/entries.json').read_text())
+
+
+def fake(source_id='one', fn=None):
+ return types.SimpleNamespace(KIND=source_id, sources=lambda: [dict(id=source_id, name=source_id,
+ enabled=True, trust='official', builtin=True)],
+ search=fn or (lambda s, q, limit=50: [e for e in ENTRIES if e['source'] == source_id]),
+ details=lambda s, i: ENTRIES[0],
+ download=Mock(return_value={'apk': '/fake.apk', 'obb': []}))
+
+
+class SettingsTest(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ p = patch.object(search, 'settings_path', return_value=Path(self.tmp.name) / 'enabled.json')
+ p.start()
+ self.addCleanup(p.stop)
+ for state in (search._running, search._pending, search._status, search._game_data):
+ state.clear()
+ from apk_sources import _web
+ self.claims = []
+ for name in ('claim', 'release'): # fake downloads aren't real files
+ p = patch.object(_web, name, side_effect=lambda path, name=name: self.claims.append((name, path)))
+ p.start()
+ self.addCleanup(p.stop)
+
+
+class SearchTests(SettingsTest):
+ def test_group_does_not_merge_distinct_or_unknown_packages(self):
+ result = search.group(ENTRIES)
+ self.assertEqual(len(result), 3)
+ self.assertEqual(sorted(len(a['offers']) for a in result), [1, 2, 2])
+ same_name = dict(ENTRIES[0], package=None)
+ self.assertEqual(len(search.group(ENTRIES[:1] + [same_name])), 2)
+
+ def test_rank_exact_and_installable_and_verified(self):
+ result = search.group(ENTRIES, 'Open Brush')
+ self.assertEqual(result[0]['package'], 'org.brush')
+ self.assertEqual(result[0]['offers'][0]['source'], 'one')
+ self.assertEqual(result[1]['package'], 'org.other')
+ self.assertEqual(len(search.group(ENTRIES, vr=False)), 1)
+ self.assertEqual(len(search.group(ENTRIES, installable=True)), 1)
+
+ def test_unknown_vr_counts_as_flat(self):
+ entries = [dict(ENTRIES[3], id='a', name='Flat', vr=False), dict(ENTRIES[3], id='b', name='Unknown', vr=None),
+ dict(ENTRIES[3], id='c', name='Headset', vr=True)]
+ self.assertEqual(sorted(a['name'] for a in search.group(entries, vr=False)), ['Flat', 'Unknown'])
+ self.assertEqual([a['name'] for a in search.group(entries, vr=True)], ['Headset'])
+
+ def test_browse_puts_unknown_fit_vr_first_and_blocked_last(self):
+ entries = [dict(source='s', id='flat', name='Flat', package='a.flat', vr=False, min_sdk=21, abis=[]),
+ dict(source='s', id='vr', name='Headset', package='a.vr', vr=True),
+ dict(source='s', id='bad', name='Blocked', package='a.bad', vr=True, min_sdk=34, abis=[])]
+ self.assertEqual([a['name'] for a in search.group(entries)], ['Headset', 'Flat', 'Blocked'])
+
+ def test_fit_unknown_and_native_free_and_vr_hints(self):
+ self.assertIsNone(search.fit({})['installable'])
+ self.assertTrue(search.fit({'min_sdk': 23, 'abis': []})['installable'])
+ self.assertFalse(search.fit({'min_sdk': 23, 'abis': ['x86']})['installable'])
+ self.assertIn('Legacy VrApi', search.fit({'engine': 'VrApi'})['reasons'][0])
+
+ def test_timeout_and_failure_leave_other_results(self):
+ release = threading.Event()
+ calls = []
+ def slow(s, q, limit=50):
+ calls.append(q)
+ release.wait(2)
+ return []
+ mods = [fake(), fake('slow', slow), fake('broken', Mock(side_effect=SourceError('offline')))]
+ try:
+ with patch.object(search, 'modules', return_value=(mods, [])):
+ started = time.monotonic()
+ result = search.search(timeout=.03)
+ self.assertLess(time.monotonic() - started, .3)
+ self.assertTrue(result['apps'])
+ self.assertEqual([s['status'] for s in result['sources']], ['ok', 'loading', 'error'])
+ self.assertEqual(search.search('other', timeout=.03)['sources'][1]['status'], 'loading')
+ search.search('newest', timeout=.03)
+ self.assertEqual(calls, [''])
+ queued = search._pending['slow']
+ release.set()
+ self.assertTrue(queued['event'].wait(2))
+ self.assertEqual(queued['entries'], [])
+ self.assertEqual(calls, ['', 'newest']) # 'other' was superseded, never run
+ finally:
+ release.set()
+
+ def test_query_arriving_as_a_search_finishes_is_not_stranded(self):
+ mod = fake()
+ source = mod.sources()[0]
+ arrived = []
+
+ class Event(threading.Event):
+ def set(self):
+ if not arrived: # a request lands just as the first search completes
+ arrived.append(None)
+ t = threading.Thread(target=lambda: arrived.append(search._launch(mod, source, 'second', 50)))
+ t.start()
+ t.join(.3) # blocks on search._lock if completion is published atomically
+ super().set()
+ with patch.object(search, 'threading', types.SimpleNamespace(Event=Event, Thread=threading.Thread)):
+ search._launch(mod, source, 'first', 50)
+ for _ in range(200):
+ if len(arrived) == 2:
+ break
+ time.sleep(.01)
+ self.assertTrue(arrived[1]['event'].wait(2))
+ self.assertEqual(arrived[1]['query'], ('second', 50))
+
+ def test_set_enabled_does_not_hold_search_lock_in_source(self):
+ free = []
+ def set_enabled(source_id, enabled):
+ t = threading.Thread(target=lambda: free.append(search._lock.acquire(timeout=1) and not search._lock.release()))
+ t.start()
+ t.join()
+ mod = fake()
+ mod.set_enabled = set_enabled
+ with patch.object(search, 'modules', return_value=([mod], [])):
+ search.set_enabled('one', False)
+ self.assertEqual(free, [True])
+
+ def test_stale_source_status(self):
+ mod = fake()
+ mod.stale = lambda source: True
+ with patch.object(search, 'modules', return_value=([mod], [])):
+ status = search.search(timeout=1)['sources'][0]
+ self.assertEqual((status['status'], status['stale']), ('ok', True))
+
+ def test_limited_source_status(self):
+ from apk_sources import SourceLimited
+ mods = [fake('busy', Mock(side_effect=SourceLimited('busy is limiting requests', 60)))]
+ with patch.object(search, 'modules', return_value=(mods, [])):
+ status = search.search(timeout=1)['sources'][0]
+ self.assertEqual((status['status'], status['error']), ('limited', 'busy is limiting requests'))
+
+ def test_disable_persists_and_prevents_queries_and_installs(self):
+ mod = fake()
+ with patch.object(search, 'modules', return_value=([mod], [])):
+ search.set_enabled('one', False)
+ self.assertFalse(search.sources()[0]['enabled'])
+ self.assertEqual(search.search()['apps'], [])
+ with self.assertRaisesRegex(SourceError, 'disabled'):
+ search.install('one', 'brush')
+
+ def test_install_passes_metadata_artwork_and_obb(self):
+ mod = fake()
+ mod.download.return_value.update(obb=['main.obb'], artwork={'hero': '/hero.png'}, icon_png=b'png')
+ def install(apk, name=None, icon_png=None, source=None, artwork=None):
+ self.assertEqual((apk, name, icon_png, source, artwork),
+ ('/fake.apk', 'Open Brush', b'png', 'one', {'hero': '/hero.png'}))
+ return {'package': 'org.brush'}
+ with patch.object(search, 'modules', return_value=([mod], [])), \
+ patch.object(server.frame_android, 'install_obb', create=True) as obb:
+ # An actual function exposes the future signature for inspection.
+ with patch.object(server.frame_android, 'install', install):
+ result = search.install('one', 'brush', 1)
+ # The app's instance isn't running right after install, so game data is a follow-up step.
+ obb.assert_not_called()
+ self.assertTrue(result['game_data'])
+ self.assertIn('Add game data', result['message'])
+ obb.return_value = {'package': 'org.brush', 'obb': []}
+ self.assertEqual(search.add_game_data('org.brush')['message'], 'Game data added')
+ obb.assert_called_once_with('org.brush', ['main.obb'])
+ with self.assertRaisesRegex(SourceError, 'install it again'):
+ search.add_game_data('org.brush')
+ mod.download.assert_called_once_with(mod.sources()[0] | {'status': 'not searched'}, 'brush', version_code=1)
+
+ def test_install_uses_source_image_urls_as_steam_artwork(self):
+ mod = fake()
+ plain = mod.details
+ mod.details = lambda source, entry_id: dict(plain(source, entry_id), images={
+ 'icon': 'https://img.example/icon.png', 'banner': 'https://img.example/banner.png',
+ 'screenshots': ['https://img.example/1.png', None]})
+ seen = {}
+ def install(apk, name=None, icon_png=None, source=None, artwork=None):
+ seen['artwork'] = artwork
+ return {'package': 'org.brush'}
+ with patch.object(search, 'modules', return_value=([mod], [])), \
+ patch.object(server.frame_android, 'install', install):
+ search.install('one', 'brush')
+ self.assertEqual(seen['artwork'], {'icon': 'https://img.example/icon.png',
+ 'banner': 'https://img.example/banner.png',
+ 'screenshots': ['https://img.example/1.png']})
+
+ def test_discovery_and_demo_are_opt_in(self):
+ module = fake()
+ with patch.object(search.pkgutil, 'iter_modules', return_value=[types.SimpleNamespace(name='example')]), \
+ patch.object(search.importlib, 'import_module', return_value=module), \
+ patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
+ self.assertEqual(search.modules(), ([module], []))
+ with patch.object(search.pkgutil, 'iter_modules', return_value=[]), \
+ patch.dict(search.os.environ, {'FRAME_APK_SEARCH_DEMO': '0'}):
+ self.assertEqual(search.modules(), ([], []))
+
+ def test_newest_compatible_then_official_offer(self):
+ first = dict(ENTRIES[0], verified=False, trust='community')
+ newer = dict(first, source='new', version_code=2, updated='2026-01-01')
+ official = dict(newer, source='official', trust='official')
+ incompatible = dict(newer, source='blocked', verified=True, min_sdk=40)
+ offers = search.group([first, incompatible, newer, official])[0]['offers']
+ self.assertEqual([e['source'] for e in offers], ['official', 'new', 'one', 'blocked'])
+
+ def test_missing_obb_support_stops_before_install(self):
+ mod = fake()
+ mod.download.return_value['obb'] = ['main.obb']
+ with patch.object(search, 'modules', return_value=([mod], [])), \
+ patch.object(server.frame_android, 'install') as install, \
+ patch.dict(server.frame_android.__dict__):
+ server.frame_android.__dict__.pop('install_obb', None)
+ with self.assertRaisesRegex(SourceError, 'OBB'):
+ search.install('one', 'brush')
+ install.assert_not_called()
+
+ def test_downloaded_apk_is_protected_from_pruning_while_installing(self):
+ mod = fake()
+ def install(apk, **kwargs):
+ self.assertEqual(self.claims, [('claim', '/fake.apk')])
+ raise server.frame_android.FrameError('adb failed')
+ with patch.object(search, 'modules', return_value=([mod], [])), \
+ patch.object(server.frame_android, 'install', install):
+ with self.assertRaises(server.frame_android.FrameError):
+ search.install('one', 'brush')
+ self.assertEqual(self.claims, [('claim', '/fake.apk'), ('release', '/fake.apk')])
+
+ def test_listing_cannot_download(self):
+ mod = fake()
+ mod.details = lambda s, i: dict(ENTRIES[0], downloadable=False)
+ with patch.object(search, 'modules', return_value=([mod], [])):
+ with self.assertRaisesRegex(SourceError, 'developer page'):
+ search.install('one', 'brush')
+ mod.download.assert_not_called()
+
+
+class EndpointTests(SettingsTest):
+ def setUp(self):
+ super().setUp()
+ self.mod = fake()
+ p = patch.object(search, 'modules', return_value=([self.mod], []))
+ p.start()
+ self.addCleanup(p.stop)
+ self.httpd = server.ThreadingHTTPServer(('127.0.0.1', 0), server.Handler)
+ threading.Thread(target=self.httpd.serve_forever, daemon=True).start()
+ self.addCleanup(self.httpd.server_close)
+ self.addCleanup(self.httpd.shutdown)
+
+ def request(self, method, path, body=None):
+ c = http.client.HTTPConnection('127.0.0.1', self.httpd.server_port)
+ c.request(method, path, json.dumps(body) if body is not None else None,
+ {'X-Frame-UI': '1', 'Content-Type': 'application/json'})
+ r = c.getresponse()
+ result = r.status, json.loads(r.read())
+ c.close()
+ return result
+
+ def test_http_search_and_validation(self):
+ self.assertEqual(self.request('GET', '/api/sources')[1]['sources'][0]['id'], 'one')
+ self.assertTrue(self.request('GET', '/api/search?q=Brush&vr=true')[1]['apps'])
+ self.assertEqual(self.request('GET', '/api/search?vr=invalid')[0], 400)
+ self.assertEqual(self.request('GET', '/api/search?source=missing')[0], 400)
+ for body in ({'source': 'one'}, {'source': 'one', 'id': 'brush', 'version_code': True}):
+ self.assertEqual(self.request('POST', '/api/sources/install', body)[0], 400)
+
+ def test_http_install_background_job(self):
+ with patch.object(server.frame_android, 'install', return_value={'package': 'org.brush'}) as install:
+ status, reply = self.request('POST', '/api/sources/install', {'source': 'one', 'id': 'brush'})
+ self.assertEqual(status, 200)
+ for _ in range(100):
+ job = self.request('GET', '/api/job?id=' + reply['job'])[1]
+ if job['done']:
+ break
+ time.sleep(.01)
+ self.assertTrue(job['done'])
+ self.assertIsNone(job['error'])
+ install.assert_called_once_with('/fake.apk', name='Open Brush', icon_png=None, source='one')
+
+ def test_details_endpoint_and_real_install_stages(self):
+ code, entry = self.request('GET', '/api/sources/details?source=one&id=brush')
+ self.assertEqual(code, 200)
+ self.assertEqual(entry['name'], 'Open Brush')
+ self.assertEqual(entry['verdict']['label'], 'Ready to try on the Frame')
+ self.assertIn('artwork', entry)
+ self.assertEqual(self.request('GET', '/api/sources/details?source=one')[0], 400)
+ stages = []
+ with patch.object(server.frame_android, 'install', return_value={'package':'org.brush'}):
+ search.install('one', 'brush', progress=lambda stage, percent: stages.append((stage,percent)))
+ self.assertEqual(stages, [('Downloading',None),('Installing',None)])
+
+ def test_http_repository_management(self):
+ self.assertEqual(self.request('POST', '/api/sources', {'action': 'enable', 'source': 'one', 'enabled': False})[0], 200)
+ code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
+ self.assertEqual(code, 400)
+ self.assertIn('not available', reply['error'])
+ mod = fake('fdroid')
+ added = {'id': 'fdroid-user-1', 'name': 'repo.example', 'fingerprint': 'ab' * 32, 'trust_on_first_use': True}
+ mod.add_repo, mod.remove_repo, mod.set_enabled = Mock(return_value=added), Mock(), Mock()
+ with patch.object(search, 'modules', return_value=([mod], [])):
+ code, reply = self.request('POST', '/api/sources', {'action': 'add', 'url': 'https://repo.example/repo'})
+ self.assertEqual(code, 200)
+ job = self.wait(reply['job'])
+ self.assertEqual(job['message'], 'Added repo.example. Trusted on first use: ' + 'AB' * 32)
+ self.assertEqual(job['result']['source']['fingerprint'], 'ab' * 32)
+ mod.add_repo.assert_called_once_with(url='https://repo.example/repo', fingerprint=None, name=None)
+ link = 'fdroidrepos://repo.example/repo?fingerprint=' + 'ab' * 32
+ mod.add_repo.return_value = dict(added, trust_on_first_use=False)
+ job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
+ self.assertEqual(job['message'], 'Added repo.example')
+ mod.add_repo.assert_called_with(url=link, fingerprint=None, name=None)
+ mod.add_repo.side_effect = SourceError('repository fingerprint mismatch')
+ job = self.wait(self.request('POST', '/api/sources', {'action': 'add', 'url': link})[1]['job'])
+ self.assertEqual(job['error'], 'repository fingerprint mismatch') # no "SourceError:" prefix
+ for url in ('http://repo.example/repo', 'fdroidrepo://repo.example/repo', 'https://u@repo.example/'):
+ self.assertEqual(self.request('POST', '/api/sources', {'action': 'add', 'url': url})[0], 400)
+ self.assertEqual(self.request('POST', '/api/sources', {'action': 'remove', 'source': 'fdroid'})[0], 200)
+ mod.remove_repo.assert_called_once_with(source_id='fdroid')
+
+ def test_http_add_game_data_job(self):
+ search._game_data['org.brush'] = ['/cache/main.1.org.brush.obb']
+ self.addCleanup(search._game_data.clear)
+ with patch.object(server.frame_android, 'install_obb', create=True,
+ side_effect=server.frame_android.FrameError('start this app instance before installing OBB data')):
+ job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job'])
+ self.assertEqual(job['error'], 'start this app instance before installing OBB data')
+ with patch.object(server.frame_android, 'install_obb', create=True, return_value={'package': 'org.brush'}) as obb:
+ job = self.wait(self.request('POST', '/api/sources', {'action': 'game-data', 'package': 'org.brush'})[1]['job'])
+ self.assertEqual(job['message'], 'Game data added')
+ obb.assert_called_once_with('org.brush', ['/cache/main.1.org.brush.obb'])
+
+ def wait(self, job_id):
+ for _ in range(200):
+ job = self.request('GET', '/api/job?id=' + job_id)[1]
+ if job['done']:
+ return job
+ time.sleep(.01)
+ self.fail('job did not finish')
diff --git a/tests/test_comfort.py b/tests/test_comfort.py
new file mode 100644
index 0000000..0b3af36
--- /dev/null
+++ b/tests/test_comfort.py
@@ -0,0 +1,257 @@
+"""Fake-Frame session clock/actions plus real helper serialization and sensor probes."""
+import os
+import shutil
+import json
+from pathlib import Path
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import Mock, patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+import frame_comfort as comfort
+import frame_status as status
+
+OPTIONS = {'action': 'start', 'minutes': 3, 'breakMinutes': 1, 'stillMinutes': 1,
+ 'batteryAlert': True, 'heatAlert': True}
+
+
+class SessionTests(unittest.TestCase):
+ def setUp(self):
+ self.s = comfort.new_session(OPTIONS, 0, 'boot-one')
+ self.warn, self.home = Mock(), Mock()
+
+ def step(self, now, **sample):
+ comfort.tick(self.s, now, sample, self.warn, self.home, read_clock=lambda: now)
+
+ def test_warning_then_home_never_closes_a_game(self):
+ self.step(119)
+ self.warn.assert_not_called()
+ self.step(120)
+ self.warn.assert_called_once()
+ self.step(179)
+ self.home.assert_not_called()
+ self.step(180)
+ self.home.assert_called_once()
+ self.assertFalse(self.s['active'])
+ self.step(181)
+ self.home.assert_called_once()
+
+ def test_late_wakeup_always_gets_a_full_warning_minute(self):
+ self.step(400)
+ self.home.assert_not_called()
+ self.step(459)
+ self.home.assert_not_called()
+ self.step(460)
+ self.home.assert_called_once()
+
+ def test_slow_warning_still_leaves_a_full_minute(self):
+ comfort.tick(self.s, 120, {}, self.warn, self.home, read_clock=lambda: 140)
+ self.assertEqual(self.s['warned'], 140)
+ self.step(180)
+ self.home.assert_not_called()
+ self.step(199)
+ self.home.assert_not_called()
+ self.step(200)
+ self.home.assert_called_once()
+
+ def test_failed_warning_never_stops_session(self):
+ self.warn.side_effect = RuntimeError('offline')
+ with self.assertRaises(RuntimeError):
+ self.step(200)
+ self.assertIsNone(self.s['warned'])
+ self.home.assert_not_called()
+ self.warn.side_effect = None
+ self.step(300)
+ self.step(359)
+ self.home.assert_not_called()
+ self.step(360)
+ self.home.assert_called_once()
+
+ def test_failed_home_stays_active_and_retries(self):
+ self.step(120)
+ self.home.side_effect = RuntimeError('Steam offline')
+ with self.assertRaises(RuntimeError):
+ self.step(180)
+ self.assertTrue(self.s['active'])
+ self.home.side_effect = None
+ self.step(185)
+ self.assertFalse(self.s['active'])
+
+ def test_cancel_prevents_all_actions(self):
+ self.s['active'] = False
+ self.step(999, battery={'percent': 1, 'status': 'Discharging'})
+ self.warn.assert_not_called()
+ self.home.assert_not_called()
+ self.assertEqual(self.s['events'], [])
+
+ def test_breaks_and_checkin_require_measured_activity(self):
+ self.step(20, activity=1)
+ self.step(40, activity=2)
+ self.step(60, activity=1)
+ self.assertEqual([e['kind'] for e in self.s['events']], ['break', 'still'])
+ self.step(70, activity=1)
+ self.assertEqual(len(self.s['events']), 2)
+ self.step(75, activity=3)
+ self.assertEqual(self.s['used'], 0)
+ self.assertFalse(self.s['stillSent'])
+
+ def test_unknown_activity_and_gaps_do_not_count_as_wear(self):
+ self.step(25)
+ self.assertEqual(self.s['used'], 0)
+ self.assertEqual(self.s['unavailable'], ['battery', 'temperature', 'activity'])
+ self.step(100, activity=1)
+ self.assertEqual(self.s['used'], 30)
+
+ def test_alerts_latch_and_rearm_without_battery_chatter(self):
+ low = {'percent': 10, 'status': 'Discharging'}
+ self.step(1, battery=low, thermal=['cpu'])
+ self.step(2, battery=low, thermal=['cpu'])
+ self.step(3)
+ self.assertEqual(len(self.s['events']), 2)
+ self.step(4, battery={'percent': 16, 'status': 'Discharging'}, thermal=[])
+ self.step(5, battery=low, thermal=[])
+ self.assertEqual(len(self.s['events']), 2)
+ self.step(6, battery={'percent': 22, 'status': 'Discharging'}, thermal=[])
+ self.step(7, battery=low, thermal=['cpu'])
+ self.assertEqual([e['kind'] for e in self.s['events']], ['battery', 'heat', 'battery', 'heat'])
+
+ def test_disabled_alerts_and_charging(self):
+ self.s['options']['heatAlert'] = False
+ self.step(1, battery={'percent': 2, 'status': 'Charging'}, thermal=['cpu'])
+ self.assertEqual(self.s['events'], [])
+
+ def test_invalid_options(self):
+ for key, value in [('minutes', 0), ('minutes', 241), ('minutes', True), ('minutes', 2.5),
+ ('breakMinutes', -1), ('stillMinutes', '1'), ('heatAlert', 1)]:
+ with self.subTest(key=key, value=value), self.assertRaises(ValueError):
+ comfort.validate({**OPTIONS, key: value})
+ for value in (None, [], {'action': 'shutdown'}):
+ with self.assertRaises(ValueError):
+ comfort.validate(value)
+
+ def test_restart_invalidates_session_and_stale_worker_is_explicit(self):
+ with patch.object(comfort, 'boot', return_value='boot-one'), patch.object(comfort.time, 'time', return_value=999):
+ current = comfort.current(self.s, 100)
+ self.assertIn('not responding', current['error'])
+ self.assertEqual(current['time'], 999)
+ with patch.object(comfort, 'boot', return_value='boot-two'):
+ result = comfort.current(self.s, 100)
+ self.assertFalse(result['active'])
+ self.assertIn('restarted', result['error'])
+
+ @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
+ def test_real_state_commands_share_one_session_and_cancel(self):
+ with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
+ patch.object(comfort, 'boot', return_value='boot-one'), \
+ patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn:
+ started = comfort.command(OPTIONS)
+ self.assertEqual(comfort.command({'action': 'status'})['id'], started['id'])
+ with self.assertRaises(ValueError):
+ comfort.command(OPTIONS)
+ self.assertFalse(comfort.command({'action': 'cancel'})['active'])
+ spawn.assert_called_once()
+ self.assertEqual((Path(tmp) / 'session.json').stat().st_mode & 0o777, 0o600)
+
+ @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
+ def test_cancel_clears_stale_worker_error(self):
+ with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
+ patch.object(comfort, 'boot', return_value='boot-one'), \
+ patch.object(comfort, 'clock', return_value=200):
+ with comfort.locked():
+ comfort.save(self.s)
+ self.assertIn('not responding', comfort.command({'action': 'status'})['error'])
+ cancelled = comfort.command({'action': 'cancel'})
+ self.assertFalse(cancelled['active'])
+ self.assertIsNone(cancelled['error'])
+ self.assertIsNone(comfort.command({'action': 'status'})['error'])
+
+ @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
+ def test_failed_spawn_leaves_session_inactive_and_retryable(self):
+ with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
+ patch.object(comfort, 'boot', return_value='boot-one'), \
+ patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen') as spawn:
+ spawn.side_effect = OSError('process limit')
+ with self.assertRaises(OSError):
+ comfort.command(OPTIONS)
+ failed = comfort.command({'action': 'status'})
+ self.assertFalse(failed['active'])
+ self.assertIn('Could not start', failed['error'])
+ spawn.side_effect = None
+ self.assertTrue(comfort.command(OPTIONS)['active'])
+
+ @unittest.skipUnless(os.name == "posix", "on-headset state uses POSIX flock")
+ def test_unreadable_state_is_preserved_and_can_be_replaced(self):
+ for contents in (b'{broken', b'\xff', b'null', b'[]', b'42', b'"x"'):
+ with self.subTest(contents=contents):
+ with tempfile.TemporaryDirectory() as tmp, patch.object(comfort, 'ROOT', Path(tmp)), \
+ patch.object(comfort, 'boot', return_value='boot-one'), \
+ patch.object(comfort, 'clock', return_value=0), patch.object(comfort.subprocess, 'Popen'):
+ (Path(tmp) / 'session.json').write_bytes(contents)
+ failed = comfort.command({'action': 'status'})
+ self.assertFalse(failed['active'])
+ self.assertIn('unreadable', failed['error'])
+ backups = list(Path(tmp).glob('session-unreadable-*.json'))
+ self.assertEqual(len(backups), 1)
+ self.assertEqual(backups[0].read_bytes(), contents)
+ self.assertTrue(comfort.command(OPTIONS)['active'])
+
+ def test_home_has_total_process_deadline_and_propagates_timeout(self):
+ with patch.object(comfort.subprocess, 'run', side_effect=subprocess.TimeoutExpired('home', 15)) as run:
+ with self.assertRaises(subprocess.TimeoutExpired):
+ comfort.home()
+ self.assertEqual(run.call_args.kwargs['timeout'], 15)
+ self.assertEqual(run.call_args.args[0][-1], '--home')
+
+ def test_native_warning_reports_failures_and_quotes_as_one_argument(self):
+ with patch.object(comfort.subprocess, 'run') as run:
+ run.return_value = subprocess.CompletedProcess([], 0, 'Notification succeeded', '')
+ comfort.notify('Save "now"; $(nothing)')
+ args = run.call_args.args[0]
+ self.assertEqual(args, [comfort.VRCMD, '--notify', 'Frame Control: Save "now"; $(nothing)'])
+ run.return_value.stdout = 'Notification failed with error 1'
+ with self.assertRaises(RuntimeError):
+ comfort.notify('test')
+
+ @unittest.skipUnless(shutil.which("node"), "Node exercises the fake Steam JS context")
+ def test_home_javascript_against_fake_steam_preserves_game(self):
+ # Same JS runs in Steam CDP. This fake records navigation and refuses any
+ # unexpected API call; it offers no shutdown or terminate-game primitive.
+ js = '''let running = [123], path = '/routes/library/app/123', visible = false;
+const location = {get pathname() {return path;}};
+const SteamUIStore = {Navigate(p) {path = '/routes' + p;}};
+const SteamClient = {OpenVR: {VROverlay: {
+ async ShowDashboard(key) {if (key !== 'valve.steam.gamepadui.main') throw Error(key); visible = true;},
+ async IsDashboardVisible() {return visible;}
+}}};
+'''
+ js += comfort.HOME_JS + '.then(result => console.log(JSON.stringify({result, running, visible})));'
+ r = subprocess.run(['node', '-e', js], capture_output=True, text=True, check=True)
+ result = json.loads(r.stdout)
+ self.assertEqual(result['running'], [123])
+ self.assertTrue(result['visible'])
+ self.assertEqual(result['result']['path'], '/routes/library/home')
+
+
+class SensorTests(unittest.TestCase):
+ def test_hot_trip_uses_its_own_zone_not_hottest_unrelated_chip(self):
+ values = {'/z/a/temp': '90000', '/z/a/trip_point_0_type': 'hot', '/z/a/trip_point_0_temp': '110000',
+ '/z/b/temp': '45000', '/z/b/trip_point_0_type': 'hot', '/z/b/trip_point_0_temp': '44000', '/z/b/type': 'battery'}
+ def glob(pattern):
+ if pattern.endswith('thermal_zone*'):
+ return ['/z/a', '/z/b']
+ return [pattern.replace('*', '0')]
+ with patch.object(status.glob, 'glob', side_effect=glob), patch.object(status, 'read', side_effect=values.get):
+ self.assertEqual(status.thermal_alerts(), [{'zone': 'battery', 'tempC': 45, 'limitC': 44}])
+
+ def test_missing_thermal_and_activity_are_unknown(self):
+ with patch.object(status.glob, 'glob', return_value=[]):
+ self.assertIsNone(status.thermal_alerts())
+ with patch.object(status, 'run', return_value='unavailable'):
+ self.assertIsNone(status.activity_level())
+ for malformed in ('{}', '[null, 42, "bad"]'):
+ with patch.object(status, 'run', return_value=malformed):
+ self.assertIsNone(status.activity_level())
+ with patch.object(status, 'run', return_value='[{"operation":"status","activity_level":3}]'):
+ self.assertEqual(status.activity_level(), 3)
diff --git a/tests/test_comfort_ui.py b/tests/test_comfort_ui.py
new file mode 100644
index 0000000..2d75b36
--- /dev/null
+++ b/tests/test_comfort_ui.py
@@ -0,0 +1,60 @@
+"""Run the actual shared page's comfort renderer against a minimal DOM/bridge."""
+import pathlib
+import shutil
+import subprocess
+import unittest
+
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+
+
+@unittest.skipUnless(shutil.which('node'), 'Node exercises the shared page JS')
+class ComfortUI(unittest.TestCase):
+ def test_notification_failure_survives_poll_until_success(self):
+ page = (ROOT / 'ui/index.html').read_text(encoding='utf-8')
+ code = page[page.index('let comfortBusy ='):page.index('async function pollComfort()')]
+ setup = r'''
+const assert = require('node:assert/strict');
+const elements = new Map();
+const $ = id => {
+ if (!elements.has(id)) elements.set(id, {textContent:'', hidden:true, disabled:false, type: 'number'});
+ return elements.get(id);
+};
+let denied = 0;
+const window = {frameApp:{notify:async()=>{denied++;throw Error('permission denied');}}};
+const log = ()=>{}, toast = ()=>{};
+'''
+ checks = r'''
+(async()=>{
+ const active = {id:'session-one',active:true,time:100,remaining:120,
+ options:{minutes:2,breakMinutes:1,stillMinutes:1,batteryAlert:true,heatAlert:true},
+ events:[{id:'event-one',kind:'battery',time:99,message:'Low battery'}]};
+ renderComfort(active); // initial history must not replay even a fresh event
+ assert.equal(denied,0);
+ assert.equal($('comfortAnnouncement').textContent,'');
+ active.events.push({id:'event-two',kind:'break',time:100,message:'Take a break'});
+ renderComfort(active);
+ await new Promise(resolve=>setImmediate(resolve));
+ assert.equal(denied,1);
+ assert.equal($('comfortAnnouncement').textContent,'Take a break');
+ assert.equal($('comfortNotificationStatus').hidden,false);
+ assert.match($('comfortNotificationStatus').textContent,/notification settings/);
+ renderComfort({...active,time:105}); // the next normal poll must not erase failure
+ assert.equal($('comfortNotificationStatus').hidden,false);
+ assert.equal($('sessionStart').disabled,true);
+ assert.equal($('sessionMinutes').disabled,true);
+ assert.equal($('sessionCancel').disabled,false);
+ let requests=0;
+ window.frameApp.notify=async()=>{requests++;};
+ renderComfort({...active,time:106});
+ assert.equal($('comfortAnnouncement').textContent,'Take a break');
+ assert.equal(requests,0); // polling does not replay an already-seen event
+ await localNotification('test',true);
+ assert.equal($('comfortNotificationStatus').hidden,true);
+ renderComfort({...active,active:false});
+ assert.equal($('sessionStart').disabled,false);
+ assert.equal($('sessionMinutes').disabled,false);
+ assert.equal($('sessionCancel').disabled,true);
+})().catch(e=>{console.error(e);process.exitCode=1;});
+'''
+ result = subprocess.run(['node', '-e', setup + code + checks], capture_output=True, text=True)
+ self.assertEqual(result.returncode, 0, result.stderr)
diff --git a/tests/test_compat_db.py b/tests/test_compat_db.py
index 7081d79..37208d5 100644
--- a/tests/test_compat_db.py
+++ b/tests/test_compat_db.py
@@ -2,6 +2,7 @@
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import os
import sys
import tempfile
diff --git a/tests/test_connect.py b/tests/test_connect.py
index b3366bd..0e8972a 100644
--- a/tests/test_connect.py
+++ b/tests/test_connect.py
@@ -3,6 +3,7 @@ steamos-devkit-service, the ~/.ssh/config block, and the mDNS output parsers.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import json
import socket
import sys
diff --git a/tests/test_devices.py b/tests/test_devices.py
new file mode 100644
index 0000000..31f3c61
--- /dev/null
+++ b/tests/test_devices.py
@@ -0,0 +1,400 @@
+"""frame_devices: the headset registry, importing ~/.ssh/config, address order, pinned
+host keys and input checks. Everything works in temporary folders.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import json
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_devices as fd # noqa: E402
+
+CONFIG = """Host lxso1
+ HostName 192.168.1.109
+
+# >>> steam-frame (frame) >>>
+Host frame
+ HostName frame.tail1234.ts.net
+ User steamos
+ IdentityFile ~/.ssh/id_ed25519_frame
+ IdentityFile ~/.ssh/id_rsa_frame_devkit
+ IdentitiesOnly yes
+ ServerAliveInterval 30
+Host *
+# <<< steam-frame (frame) <<<
+# >>> steam-frame (frame-2) >>>
+Host frame-2
+ HostName 192.168.1.60
+ Port 2222
+ User deck
+ IdentityFile ~/.ssh/id_ed25519_frame
+Host *
+# <<< steam-frame (frame-2) <<<
+Host *
+ ServerAliveInterval 60
+"""
+KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIID6kdLfZZmdTqS1snKfTESTKEYTESTKEYTESTKEYTESTKE"
+
+
+class Base(unittest.TestCase):
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp(prefix="frame-devices-"))
+ self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
+ self.ssh = self.dir / "ssh"
+ self.ssh.mkdir()
+ (self.ssh / "config").write_text(CONFIG)
+ old = os.environ.get("FRAME_CONTROL_SSH_DIR")
+ os.environ["FRAME_CONTROL_SSH_DIR"] = str(self.ssh)
+ self.addCleanup(lambda: os.environ.__setitem__("FRAME_CONTROL_SSH_DIR", old) if old
+ else os.environ.pop("FRAME_CONTROL_SSH_DIR", None))
+ self.reg = fd.Registry(self.dir / "devices.json")
+
+
+class Validation(unittest.TestCase):
+ def test_hosts(self):
+ for good in ("frame.local", "192.168.1.40", "fd7a:115c:a1e0::1234:5678", "fe80::1%en0", "frame-2.tail1234.ts.net"):
+ self.assertEqual(fd.check_host(good), good)
+ for bad in ("", " ", "-oProxyCommand=sh", "a b", "frame;id", "frame\nHost *", "frame..local", "$(id)",
+ "frame%en0", "x" * 300, None, 5, "frame/../x"):
+ with self.assertRaises(fd.DeviceError, msg=repr(bad)):
+ fd.check_host(bad)
+
+ def test_names(self):
+ self.assertEqual(fd.check_alias("frame-2"), "frame-2")
+ for bad in ("", "-F", "frame 2", "frame\n", "a" * 65, None):
+ with self.assertRaises(fd.DeviceError):
+ fd.check_alias(bad)
+ with self.assertRaises(fd.DeviceError):
+ fd.check_user(bad)
+ for bad in ("0", "65536", "x", None, "22; id"):
+ with self.assertRaises(fd.DeviceError):
+ fd.check_port(bad)
+ self.assertEqual(fd.check_port("2222"), 2222)
+ with self.assertRaises(fd.DeviceError):
+ fd.check_text("line\nbreak", "label")
+ with self.assertRaises(fd.DeviceError):
+ fd.check_kind("wifi")
+
+ def test_ipv6_zone_is_escaped_for_ssh(self):
+ self.assertEqual(fd.ssh_host("fe80::1%en0"), "fe80::1%%en0")
+
+
+class Migration(Base):
+ def test_blocks_are_parsed(self):
+ blocks = fd.parse_blocks(CONFIG)
+ self.assertEqual([b["alias"] for b in blocks], ["frame", "frame-2"])
+ self.assertEqual(blocks[0]["hostname"], "frame.tail1234.ts.net")
+ self.assertEqual(blocks[0]["identity_files"], ["~/.ssh/id_ed25519_frame", "~/.ssh/id_rsa_frame_devkit"])
+ self.assertEqual((blocks[1]["port"], blocks[1]["user"]), (2222, "deck"))
+
+ def test_existing_headsets_are_imported_once(self):
+ self.assertTrue(self.reg.sync_from_config(seed=False))
+ devices = self.reg.devices()
+ self.assertEqual([d["alias"] for d in devices], ["frame", "frame-2"])
+ frame, second = devices
+ self.assertEqual(frame["name"], "Steam Frame")
+ self.assertEqual(frame["addresses"][0]["host"], "frame.tail1234.ts.net")
+ self.assertEqual(frame["addresses"][0]["kind"], "tailscale")
+ self.assertEqual((second["user"], second["port"]), ("deck", 2222))
+ self.assertEqual(self.reg.active(), frame["id"])
+ self.assertFalse(self.reg.sync_from_config(seed=False)) # nothing new
+ # It's all on disk, in the documented shape.
+ data = json.loads((self.dir / "devices.json").read_text())
+ self.assertEqual(data["version"], 1)
+ self.assertEqual(len(data["devices"]), 2)
+ self.assertEqual(fd.Registry(self.dir / "devices.json").devices(), self.reg.devices())
+
+ def test_first_import_keeps_using_frame(self):
+ # Set Up Connection puts each new block first; the app used `frame` before.
+ blocks = CONFIG.split("# >>> steam-frame (frame-2) >>>")
+ head, first = blocks[0].split("# >>> steam-frame (frame) >>>")
+ second, tail = blocks[1].split("# <<< steam-frame (frame-2) <<<")
+ (self.ssh / "config").write_text(head + "# >>> steam-frame (frame-2) >>>" + second + "# <<< steam-frame (frame-2) <<<\n"
+ + "# >>> steam-frame (frame) >>>" + first + tail)
+ self.reg.sync_from_config(seed=False)
+ self.assertEqual([d["alias"] for d in self.reg.devices()], ["frame-2", "frame"])
+ self.assertEqual(self.reg.active(), self.reg.by_alias("frame")["id"])
+
+ @unittest.skipUnless(shutil.which("ssh"), "needs ssh")
+ def test_a_port_inherited_from_another_host_entry_is_kept(self):
+ (self.ssh / "config").write_text(CONFIG.replace("Host *\n ServerAliveInterval 60", "Host *\n Port 2222"))
+ self.reg.sync_from_config(seed=False)
+ self.assertEqual(self.reg.by_alias("frame")["port"], 2222) # what ssh itself would use
+ self.assertEqual(self.reg.by_alias("frame-2")["port"], 2222) # its own Port line
+ # Saving 22 must then say so in the block, or ssh would go on inheriting 2222.
+ self.assertTrue(fd.rewrite_block("frame", port=22))
+ self.assertEqual(fd.effective_port("frame", self.ssh / "config"), 22)
+ self.assertFalse(fd.rewrite_block("frame", port=22)) # and only once
+
+ def test_setup_finding_a_new_address_adds_it(self):
+ self.reg.sync_from_config(seed=False)
+ (self.ssh / "config").write_text(CONFIG.replace("HostName frame.tail1234.ts.net", "HostName 192.168.1.237"))
+ self.assertTrue(self.reg.sync_from_config(seed=False))
+ hosts = [a["host"] for a in self.reg.by_alias("frame")["addresses"]]
+ self.assertEqual(hosts, ["192.168.1.237", "frame.tail1234.ts.net"]) # the new one first
+
+ def test_setup_changing_the_login_updates_the_headset(self):
+ self.reg.sync_from_config(seed=False)
+ (self.ssh / "config").write_text(CONFIG.replace(" User steamos\n", " User deck\n Port 2200\n", 1))
+ self.assertTrue(self.reg.sync_from_config(seed=False))
+ d = self.reg.by_alias("frame")
+ self.assertEqual((d["user"], d["port"]), ("deck", 2200))
+
+ def test_removed_headset_stays_removed_until_setup_changes_it(self):
+ self.reg.sync_from_config(seed=False)
+ second = self.reg.by_alias("frame-2")
+ self.reg.remove_device(second["id"])
+ self.reg.sync_from_config(seed=False)
+ self.assertIsNone(self.reg.by_alias("frame-2"))
+ self.reg.undismiss("frame-2") # Set Up Connection run for it from the Devices tab
+ self.reg.sync_from_config(seed=False)
+ self.assertIsNotNone(self.reg.by_alias("frame-2"))
+
+ def test_corrupt_registry_is_ignored(self):
+ (self.dir / "bad.json").write_text("{not json")
+ self.assertEqual(fd.Registry(self.dir / "bad.json").devices(), [])
+ (self.dir / "evil.json").write_text(json.dumps({"devices": [
+ {"id": "x1", "alias": "-oProxyCommand=id", "addresses": []},
+ {"id": "x2", "alias": "ok", "addresses": [{"host": "a b", "kind": "lan"}, {"host": "frame.local", "kind": "mdns"}]}]}))
+ devices = fd.Registry(self.dir / "evil.json").devices()
+ self.assertEqual([d["alias"] for d in devices], ["ok"])
+ self.assertEqual([a["host"] for a in devices[0]["addresses"]], ["frame.local"])
+
+
+
+class SharedFile(Base):
+ """The desktop app and a standalone server can share devices.json."""
+
+ def test_one_server_never_saves_over_anothers_change(self):
+ self.reg.sync_from_config(seed=False)
+ other = fd.Registry(self.dir / "devices.json") # a second server, loaded now
+ d = self.reg.by_alias("frame")
+ self.reg.add_address(d["id"], "100.101.1.2", "tailscale")
+ other.record_success(d["id"], d["addresses"][0]["host"], "net-1", 12) # works from its older copy
+ hosts = [a["host"] for a in fd.Registry(self.dir / "devices.json").get(d["id"])["addresses"]]
+ self.assertIn("100.101.1.2", hosts)
+ self.assertIn("100.101.1.2", [a["host"] for a in other.get(d["id"])["addresses"]]) # and it sees it
+
+ def test_another_servers_choice_of_headset_doesnt_move_this_one(self):
+ self.reg.sync_from_config(seed=False)
+ other = fd.Registry(self.dir / "devices.json")
+ mine, theirs = self.reg.by_alias("frame")["id"], self.reg.by_alias("frame-2")["id"]
+ self.reg.set_active(mine)
+ other.set_active(theirs)
+ self.assertEqual(self.reg.active(), mine) # after a refresh
+ self.reg.add_address(mine, "192.0.2.9") # and after a change reloads the file
+ self.assertEqual(self.reg.active(), mine)
+ self.assertEqual(fd.Registry(self.dir / "devices.json").active(), mine) # last to save: next start
+
+class ConfigRewrite(Base):
+ def test_hostname_user_and_port_change_only_inside_the_block(self):
+ cfg = self.ssh / "config"
+ self.assertTrue(fd.rewrite_block("frame", hostname="192.168.1.237"))
+ text = cfg.read_text()
+ self.assertIn(" HostName 192.168.1.237\n", text)
+ self.assertEqual(text.replace("192.168.1.237", "frame.tail1234.ts.net"), CONFIG) # nothing else moved
+ self.assertFalse(fd.rewrite_block("frame", hostname="192.168.1.237")) # no change, no write
+ self.assertTrue(fd.rewrite_block("frame", port=2200, user="deck"))
+ block = fd.parse_blocks(cfg.read_text())[0]
+ self.assertEqual((block["port"], block["user"], block["hostname"]), (2200, "deck", "192.168.1.237"))
+ self.assertTrue(fd.rewrite_block("frame-2", port=22)) # back to the default: said explicitly
+ self.assertEqual(fd.parse_blocks(cfg.read_text())[1]["port"], 22)
+ self.assertIn(" Port 22\n", cfg.read_text())
+ self.assertIn("HostName 192.168.1.109", cfg.read_text()) # other hosts untouched
+ if os.name != "nt":
+ self.assertEqual(cfg.stat().st_mode & 0o777, 0o600)
+
+ def test_concurrent_edits_all_land(self):
+ import threading
+ def edit(alias, prefix):
+ for n in range(15):
+ fd.rewrite_block(alias, hostname=f"{prefix}.{n}")
+ threads = [threading.Thread(target=edit, args=("frame", "10.0.0")),
+ threading.Thread(target=edit, args=("frame-2", "10.0.1"))]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+ blocks = fd.parse_blocks((self.ssh / "config").read_text())
+ self.assertEqual([b["hostname"] for b in blocks], ["10.0.0.14", "10.0.1.14"])
+ self.assertEqual([p.name for p in self.ssh.iterdir() if "frame-control." in p.name and not p.name.endswith(".lock")], []) # no temp files left
+
+ def test_learning_skips_a_block_someone_changed(self):
+ # The connector learned an address, but Set Up Connection moved the block meanwhile.
+ self.assertFalse(fd.rewrite_block("frame", hostname="10.0.0.9",
+ expect={"hostname": "old.example", "user": None, "port": None}))
+ self.assertIn("HostName frame.tail1234.ts.net", (self.ssh / "config").read_text())
+ self.assertTrue(fd.rewrite_block("frame", hostname="10.0.0.9",
+ expect={"hostname": "frame.tail1234.ts.net", "user": "steamos", "port": 22}))
+
+ def test_zone_is_escaped_and_read_back(self):
+ fd.rewrite_block("frame", hostname="fe80::1%en0")
+ self.assertIn("HostName fe80::1%%en0", (self.ssh / "config").read_text())
+ self.assertEqual(fd.parse_blocks((self.ssh / "config").read_text())[0]["hostname"], "fe80::1%en0")
+
+ def test_missing_block_is_left_alone(self):
+ self.assertFalse(fd.rewrite_block("frame-9", hostname="10.0.0.1"))
+ self.assertFalse(fd.remove_block("frame-9"))
+ self.assertTrue(fd.remove_block("frame-2"))
+ self.assertEqual([b["alias"] for b in fd.parse_blocks((self.ssh / "config").read_text())], ["frame"])
+
+
+@unittest.skipUnless(shutil.which("ssh-keygen"), "needs ssh-keygen")
+class Pins(Base):
+ def test_seed_copies_the_trusted_key_under_the_device_alias(self):
+ (self.ssh / "known_hosts").write_text(f"frame.tail1234.ts.net {KEY}\nother.example {KEY}X\n")
+ self.assertFalse(fd.pinned("d1"))
+ self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"]))
+ self.assertTrue(fd.pinned("d1"))
+ self.assertEqual(fd.known_hosts("d1").read_text(), f"frame-control-d1 {KEY}\n")
+ self.assertTrue(fd.seed_pin("d1", ["frame.tail1234.ts.net"])) # idempotent
+ self.assertEqual(fd.known_hosts("d1").read_text().count("\n"), 1)
+ self.assertFalse(fd.seed_pin("d2", ["never-seen.example"]))
+ self.assertTrue(fd.forget_pin("d1"))
+ self.assertFalse(fd.pinned("d1"))
+ self.assertFalse(fd.forget_pin("d1"))
+
+ def test_each_headset_has_its_own_file(self):
+ (self.ssh / "known_hosts").write_text(f"a.local {KEY}\nb.local {KEY}\n")
+ fd.seed_pin("da", ["a.local"])
+ fd.seed_pin("db", ["b.local"])
+ fd.forget_pin("da")
+ self.assertTrue(fd.pinned("db")) # forgetting one can't touch another
+ self.assertNotEqual(fd.known_hosts("da"), fd.known_hosts("db"))
+
+ def test_hashed_and_non_default_port_entries(self):
+ kh = self.ssh / "known_hosts"
+ kh.write_text(f"[frame.local]:2222 {KEY}\n")
+ subprocess.run(["ssh-keygen", "-H", "-f", str(kh)], capture_output=True, check=True)
+ self.assertFalse(fd.seed_pin("d3", ["frame.local"])) # port 22: not that entry
+ self.assertTrue(fd.seed_pin("d3", ["frame.local"], port=2222))
+ self.assertIn(f"frame-control-d3 {KEY}", fd.known_hosts("d3").read_text())
+
+ def test_hashed_pins_are_found_and_forgotten(self):
+ target = fd.known_hosts("d4")
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(f"frame-control-d4 {KEY}\n")
+ subprocess.run(["ssh-keygen", "-H", "-f", str(target)], capture_output=True, check=True)
+ self.assertNotIn("frame-control-d4", target.read_text())
+ self.assertTrue(fd.pinned("d4"))
+ self.assertTrue(fd.forget_pin("d4"))
+ self.assertFalse(fd.pinned("d4"))
+
+ def test_known_hosts_option_uses_the_override(self):
+ self.assertEqual(fd.known_hosts_opt("d5"), str(self.ssh / "frame-control-hosts" / "d5"))
+ os.environ.pop("FRAME_CONTROL_SSH_DIR")
+ self.assertEqual(fd.known_hosts_opt("d5"), "~/.ssh/frame-control-hosts/d5") # no spaces to split on
+
+
+class Registry(Base):
+ def test_address_editing(self):
+ d = self.reg.add_device("frame-3", hosts=["192.168.1.40"])
+ a = self.reg.add_address(d["id"], "frame-3.local", label="mDNS")
+ self.assertEqual(a["kind"], "mdns")
+ self.reg.add_address(d["id"], "100.100.1.1", kind="tailscale", label="Tailscale")
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_address(d["id"], "frame-3.local") # already there
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_address(d["id"], "frame-3.local; id")
+ self.reg.move_address(d["id"], "100.100.1.1", -1)
+ self.reg.move_address(d["id"], "100.100.1.1", -1)
+ self.reg.move_address(d["id"], "100.100.1.1", -1) # already first: stays
+ hosts = lambda: [x["host"] for x in self.reg.get(d["id"])["addresses"]]
+ self.assertEqual(hosts(), ["100.100.1.1", "192.168.1.40", "frame-3.local"])
+ self.reg.record_success(d["id"], "192.168.1.40", "n-home", 3.2)
+ self.reg.update_address(d["id"], "192.168.1.40", label="Home")
+ self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # a label keeps what it learned
+ with self.assertRaises(fd.DeviceError):
+ self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41", label="bad\nlabel")
+ self.assertEqual(self.reg.get(d["id"])["addresses"][1]["networks"], ["n-home"]) # rejected: unchanged
+ self.reg.update_address(d["id"], "192.168.1.40", new_host="192.168.1.41")
+ moved = self.reg.get(d["id"])["addresses"][1]
+ self.assertEqual((moved["host"], moved["networks"], moved["last_ok"]), ("192.168.1.41", [], None))
+ self.reg.remove_address(d["id"], "192.168.1.41")
+ self.assertEqual(hosts(), ["100.100.1.1", "frame-3.local"])
+ with self.assertRaises(fd.DeviceError):
+ self.reg.remove_address(d["id"], "nope")
+
+ def test_devices(self):
+ a = self.reg.add_device("frame")
+ b = self.reg.add_device("frame-2", name="Office")
+ self.assertEqual(self.reg.active(), a["id"])
+ with self.assertRaises(fd.DeviceError):
+ self.reg.add_device("frame")
+ self.reg.set_active(b["id"])
+ self.assertEqual(self.reg.update_device(b["id"], name="Desk", user="deck", port="2222")["port"], 2222)
+ with self.assertRaises(fd.DeviceError):
+ self.reg.update_device(b["id"], user="bad user")
+ with self.assertRaises(fd.DeviceError):
+ self.reg.update_device(b["id"], user="steam", port="bad")
+ self.assertEqual(self.reg.get(b["id"])["user"], "deck") # a rejected edit changes nothing
+ self.reg.remove_device(b["id"])
+ self.assertEqual(self.reg.active(), a["id"])
+ self.assertFalse(self.reg.emptied())
+ self.reg.remove_device(a["id"])
+ self.assertTrue(self.reg.emptied()) # the connector then uses no headset at all
+ self.reg.add_device("frame-4")
+ self.assertFalse(self.reg.emptied())
+ with self.assertRaises(fd.DeviceError):
+ self.reg.get(b["id"])
+
+ def test_networks_get_names(self):
+ net = {"id": "n-1", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "ssid": None, "wifi": True}
+ self.assertEqual(self.reg.network_name(net), "Wi-Fi via 192.168.1.1")
+ self.reg.record_network(net)
+ self.reg.name_network("n-1", "Home Wi-Fi")
+ self.assertEqual(self.reg.network_name(net), "Home Wi-Fi")
+ self.assertEqual(self.reg.network_name(dict(net, id="n-2", ssid="Cafe")), "Cafe")
+ self.assertEqual(self.reg.network_name(None), "No network")
+ with self.assertRaises(fd.DeviceError):
+ self.reg.name_network("n-unknown", "x")
+
+
+class Order(unittest.TestCase):
+ def addr(self, host, kind, networks=()):
+ return {"host": host, "kind": kind, "networks": list(networks)}
+
+ def test_known_here_then_mdns_then_tailscale_then_the_rest(self):
+ addrs = [self.addr("10.1.1.5", "lan", ["n-office"]), self.addr("192.168.1.40", "lan"),
+ self.addr("100.64.1.2", "tailscale"), self.addr("frame.local", "mdns"),
+ self.addr("192.168.1.237", "lan", ["n-home"])]
+ order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", True)]
+ self.assertEqual(order, ["192.168.1.237", "frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5"])
+ # Tailscale off: its addresses go last.
+ order = [a["host"] for a, _ in fd.order_addresses(addrs, "n-home", False)]
+ self.assertEqual(order[-1], "100.64.1.2")
+ # On an unknown network nothing has worked yet; the user's order breaks ties.
+ ranked = fd.order_addresses(addrs, None, True)
+ self.assertEqual([a["host"] for a, _ in ranked], ["frame.local", "100.64.1.2", "192.168.1.40", "10.1.1.5", "192.168.1.237"])
+ self.assertEqual(ranked[0][1], "mDNS name")
+
+
+if __name__ == "__main__":
+ unittest.main()
+
+
+class RoutingLongLivedSsh(unittest.TestCase):
+ """The keyboard agent and the Mac view keep their own ssh open: switching headset
+ must end or retarget them, or input would go on reaching the old headset."""
+
+ def test_switching_headset_stops_input_and_retargets_the_mac_view(self):
+ import server
+ from unittest import mock
+ before = (server.FRAME, list(server.HOST_OPTS))
+ self.addCleanup(lambda: server.route(*before))
+ with mock.patch.object(server._input, "stop") as stop, \
+ mock.patch.object(server.macview, "retarget") as retarget:
+ server.route(server.FRAME, ["-o", "HostName=192.0.2.9"]) # same headset, new address
+ stop.assert_not_called()
+ server.route("frame-2", ["-o", "HostName=192.0.2.2"])
+ stop.assert_called_once()
+ retarget.assert_called_with("frame-2", ["-o", "HostName=192.0.2.2"])
diff --git a/tests/test_fdroid_sources.py b/tests/test_fdroid_sources.py
new file mode 100644
index 0000000..4285721
--- /dev/null
+++ b/tests/test_fdroid_sources.py
@@ -0,0 +1,537 @@
+"""Offline authenticated repository fixtures; no tests contact a server."""
+import io
+import json
+import os
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+import urllib.error
+import zipfile
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+from apk_sources import SourceError, SourceLimited, _web, fdroid
+
+FIXTURES = Path(__file__).parent / 'fixtures' / 'fdroid'
+PIN = (FIXTURES / 'fingerprint.txt').read_text().strip()
+URL = 'https://example.org/repo/'
+_KEY = []
+
+
+def signed_jar(member, content, digest='sha256'):
+ """A JAR signed like fdroidserver's (no CMS signed attributes) with a throwaway test key."""
+ import base64, hashlib
+ from frame_apk_sign import certificate, der, integer, sequence, signing_key
+ if not _KEY:
+ with tempfile.TemporaryDirectory() as tmp:
+ _KEY.append(signing_key(Path(tmp) / 'key.json'))
+ key = _KEY[0]
+ label = 'SHA1' if digest == 'sha1' else 'SHA-256'
+ b64 = lambda data: base64.b64encode(hashlib.new(digest, data).digest()).decode()
+ manifest = ('Manifest-Version: 1.0\r\n\r\nName: %s\r\n%s-Digest: %s\r\n\r\n' % (member, label, b64(content))).encode()
+ sf = ('Signature-Version: 1.0\r\n%s-Digest-Manifest: %s\r\n\r\n' % (label, b64(manifest))).encode()
+ oid, prefix = next((bytes.fromhex(o), bytes.fromhex(p)) for o, (d, p) in fdroid._DIGESTS.items() if d == digest)
+ alg = sequence(der(6, oid), der(5, b''))
+ size = (key['n'].bit_length() + 7) // 8
+ value = prefix + hashlib.new(digest, sf).digest()
+ padded = b'\0\1' + b'\xff' * (size - len(value) - 3) + b'\0' + value
+ signature = pow(int.from_bytes(padded, 'big'), key['d'], key['n']).to_bytes(size, 'big')
+ cert = certificate(key)
+ issuer = fdroid._der_parts(fdroid._der_parts(fdroid._der_parts(cert)[0][1])[0][1])[3][2]
+ signer = sequence(integer(1), sequence(issuer, integer(1)), alg,
+ sequence(der(6, bytes.fromhex('2a864886f70d010101')), der(5, b'')), der(4, signature))
+ signed = sequence(integer(1), der(0x31, alg), sequence(der(6, bytes.fromhex('2a864886f70d010701'))),
+ der(0xa0, cert), der(0x31, signer))
+ block = sequence(der(6, bytes.fromhex('2a864886f70d010702')), der(0xa0, signed))
+ stream = io.BytesIO()
+ with zipfile.ZipFile(stream, 'w') as z:
+ for name, data in (('META-INF/MANIFEST.MF', manifest), ('META-INF/TEST.SF', sf),
+ ('META-INF/TEST.RSA', block), (member, content)):
+ z.writestr(name, data)
+ return stream.getvalue(), fdroid.hashlib.sha256(cert).hexdigest()
+
+
+def entry_jar(timestamp, digest='sha256'):
+ entry = json.loads(zipfile.ZipFile(FIXTURES / 'entry.jar').read('entry.json'))
+ return signed_jar('entry.json', json.dumps(dict(entry, timestamp=timestamp)).encode(), digest)
+
+
+class Repositories(unittest.TestCase):
+ def setUp(self):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ self.root = Path(tmp.name)
+ for name, value in [('data_dir', lambda *p: self.root.joinpath('data', *p)),
+ ('cache_dir', lambda *p: self.root.joinpath('cache', *p))]:
+ mock = patch.object(fdroid.frame_host, name, value)
+ mock.start()
+ self.addCleanup(mock.stop)
+ net = patch.object(fdroid.urllib.request, 'build_opener', side_effect=AssertionError('network forbidden'))
+ net.start()
+ self.addCleanup(net.stop)
+ mock = self.fetch_patch = patch.object(fdroid, '_fetch', side_effect=self.fetch)
+ self.fetch_mock = mock.start()
+ self.addCleanup(mock.stop)
+ self.v1 = False
+ self.corrupt = None
+ self.files = {}
+ for state in (_web._limited, fdroid._stale, fdroid._retry_at, fdroid._refreshing):
+ state.clear()
+ self.addCleanup(state.clear)
+
+ def fetch(self, url, path, maximum):
+ name = url.rsplit('/', 1)[-1]
+ if self.v1 and name == 'entry.jar':
+ raise urllib.error.HTTPError(url, 404, 'missing', None, None)
+ payload = self.files.get(name) or (FIXTURES / ('example.apk' if name.endswith('.apk') else name)).read_bytes()
+ if name == self.corrupt:
+ payload += b'tampered'
+ Path(path).write_bytes(payload)
+
+ def add(self):
+ return fdroid.add_repo(URL, PIN)
+
+ def test_add_search_details_download_cache(self):
+ source = self.add()
+ self.assertEqual(source['fingerprint'], PIN)
+ self.assertFalse(source['trust_on_first_use'])
+ result = fdroid.search(source, 'example offline')
+ self.assertEqual(len(result), 1)
+ self.assertNotIn('versions', result[0])
+ self.assertEqual(result[0]['version_code'], 2)
+ self.assertEqual([v['version_code'] for v in fdroid.details(source, 'org.example.app')['versions']], [2, 1])
+ downloaded = fdroid.download(source, 'org.example.app', 1)
+ self.assertTrue(downloaded['verified'])
+ self.assertEqual(Path(downloaded['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
+ count = self.fetch_mock.call_count
+ os.utime(downloaded['apk'], (1, 1))
+ fdroid.download(source, 'org.example.app', 1)
+ self.assertEqual(self.fetch_mock.call_count, count)
+ self.assertGreater(Path(downloaded['apk']).stat().st_mtime, 1) # reuse counts as recent use
+
+ def test_wrong_pin_is_not_saved(self):
+ with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
+ fdroid.add_repo(URL, '0' * 64)
+ self.assertEqual(fdroid.user_repos(), [])
+
+ def test_tampered_index_is_not_saved(self):
+ self.corrupt = 'index-v2.json'
+ with self.assertRaisesRegex(SourceError, 'SHA-256'):
+ self.add()
+ self.assertEqual(fdroid.user_repos(), [])
+
+ def test_tampered_apk_is_not_cached(self):
+ source = self.add()
+ self.corrupt = 'example2.apk'
+ with self.assertRaisesRegex(SourceError, 'APK SHA-256'):
+ fdroid.download(source, 'org.example.app')
+ self.assertEqual(list(self.root.rglob('*.apk')), [])
+ self.assertEqual(list(self.root.rglob('*.part')), [])
+
+ def test_v1_fallback(self):
+ self.v1 = True
+ source = self.add()
+ self.assertEqual(fdroid.search(source, 'Example')[0]['version_code'], 1)
+ self.assertTrue(fdroid.download(source, 'org.example.app')['verified'])
+
+ def test_bad_v2_never_downgrades(self):
+ self.corrupt = 'index-v2.json'
+ with self.assertRaises(SourceError):
+ self.add()
+ self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
+
+ def test_transient_error_never_downgrades(self):
+ self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
+ with self.assertRaises(SourceError):
+ self.add()
+ self.assertEqual(self.fetch_mock.call_count, 1)
+
+ def test_tofu_preserves_pin_and_settings(self):
+ source = fdroid.add_repo('fdroidrepos://example.org/repo')
+ self.assertTrue(source['trust_on_first_use'])
+ self.assertEqual(source['fingerprint'], PIN)
+ fdroid.add_repo(URL)
+ self.assertEqual(len(fdroid.user_repos()), 1)
+ with self.assertRaisesRegex(SourceError, 'different pinned'):
+ fdroid.add_repo(URL, '0' * 64)
+ fdroid.set_enabled(source['id'], False)
+ self.assertEqual(fdroid.search(fdroid.user_repos()[0], ''), [])
+ with self.assertRaisesRegex(SourceError, 'disabled'):
+ fdroid.download(fdroid.user_repos()[0], 'org.example.app')
+ fdroid.set_enabled('fdroid', False)
+ self.assertFalse(fdroid.sources()[0]['enabled'])
+ fdroid.remove_repo(source['id'])
+ self.assertEqual(fdroid.user_repos(), [])
+ with self.assertRaises(SourceError):
+ fdroid.remove_repo('fdroid')
+
+ def test_urls(self):
+ self.assertEqual(fdroid._url(URL + '?fingerprint=' + PIN.upper()), (URL, PIN))
+ for url in ['http://example.org/repo', 'fdroidrepo://example.org', 'https://u:p@example.org', URL+'?other=x']:
+ with self.subTest(url=url), self.assertRaises(SourceError):
+ fdroid._url(url)
+ with self.assertRaisesRegex(SourceError, 'conflicting'):
+ fdroid._url(URL + '?fingerprint=' + PIN, '0' * 64)
+ self.assertEqual(fdroid._child(URL, '/app/en-US/phoneScreenshots/#0 a.png'),
+ URL + 'app/en-US/phoneScreenshots/%230%20a.png') # real F-Droid screenshot name
+ for name in ['../x.apk', '%2e%2e/x.apk', 'https://evil.org/a.apk', '//evil.org/../x', 'x?token=y', 'x\\y']:
+ with self.subTest(name=name), self.assertRaises(SourceError):
+ fdroid._child(URL, name)
+
+ def test_recorded_real_signature(self):
+ content, fingerprint = fdroid._jar(FIXTURES / 'izzy-entry.jar', 'entry.json', fdroid.IZZY_PIN, strong=True)
+ self.assertEqual(fingerprint, fdroid.IZZY_PIN)
+ self.assertIn('index', json.loads(content))
+
+ def test_tampering_each_signature_layer(self):
+ for member in ['entry.json', 'META-INF/MANIFEST.MF', 'META-INF/TEST.SF', 'META-INF/TEST.RSA']:
+ stream = io.BytesIO()
+ with zipfile.ZipFile(FIXTURES / 'entry.jar') as src, zipfile.ZipFile(stream, 'w') as dst:
+ for item in src.infolist():
+ data = src.read(item.filename)
+ if item.filename == member:
+ data = data[:-1] + bytes([data[-1] ^ 1])
+ dst.writestr(item.filename, data)
+ with self.subTest(member=member), self.assertRaises(SourceError):
+ fdroid._jar(io.BytesIO(stream.getvalue()), 'entry.json', PIN)
+
+ def test_duplicate_jar_member_rejected(self):
+ stream = io.BytesIO((FIXTURES / 'entry.jar').read_bytes())
+ import warnings
+ with warnings.catch_warnings():
+ warnings.simplefilter('ignore', UserWarning)
+ with zipfile.ZipFile(stream, 'a') as z:
+ z.writestr('entry.json', '{}')
+ stream.seek(0)
+ with self.assertRaisesRegex(SourceError, 'duplicate'):
+ fdroid._jar(stream, 'entry.json', PIN)
+
+ def test_corrupt_cache_refetches_verified_index(self):
+ source = self.add()
+ fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json').write_text('{')
+ self.assertEqual(len(fdroid.search(source, 'example')), 1)
+ self.assertEqual(self.fetch_mock.call_count, 4)
+
+ def test_artwork_v1_and_v2_survives_source_cache(self):
+ source = self.add()
+ for version in ('v1', 'v2'):
+ with self.subTest(version=version):
+ raw = FIXTURES / ('artwork-' + version + '.json')
+ if version == 'v1':
+ normalized = self.root / 'normalized.json'
+ fdroid._v1(raw.read_bytes(), normalized)
+ raw = normalized
+ apps = fdroid._reduce(raw, source)
+ cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
+ fdroid._write(cache, {'version': fdroid.CACHE_VERSION, 'url': URL,
+ 'fingerprint': PIN, 'apps': apps})
+ before = self.fetch_mock.call_count
+ result = fdroid.search(source, 'example offline')[0]
+ self.assertEqual(result['developer'], 'Example Developer')
+ self.assertEqual(result['summary'], 'Offline fixture & music. One line.')
+ self.assertEqual(result['icon'], URL + 'org.example.app/en-US/icon.png')
+ self.assertEqual(result['images'], {
+ 'icon': result['icon'],
+ 'banner': URL + 'org.example.app/fr/featureGraphic.png',
+ 'screenshots': [URL + 'org.example.app/en-US/phoneScreenshots/' + str(i) + '.png' for i in range(1, 5)] +
+ [URL + 'org.example.app/fr/sevenInchScreenshots/' + str(i) + '.png' for i in range(1, 3)]})
+ self.assertEqual(fdroid.details(source, result['id'])['images'], result['images'])
+ self.assertEqual(self.fetch_mock.call_count, before)
+
+ def test_missing_artwork_is_not_invented(self):
+ result = fdroid.search(self.add(), 'example')[0]
+ self.assertEqual(result['images'], {'icon': None, 'banner': None, 'screenshots': []})
+ self.assertIsNone(result['icon'])
+ self.assertIsNone(result['developer'])
+
+ def test_v1_legacy_icon_and_tablet_fallback(self):
+ index = json.loads((FIXTURES / 'artwork-v1.json').read_text())
+ app = index['apps'][0]
+ app['localized'] = {'fr': {'sevenInchScreenshots': ['tablet.png']}}
+ app['icon'] = 'legacy.1.png'
+ raw = self.root / 'legacy.json'
+ fdroid._v1(json.dumps(index).encode(), raw)
+ result = fdroid._reduce(raw, {'id': 'test', 'url': URL})['org.example.app']
+ self.assertEqual(result['icon'], URL + 'icons/legacy.1.png')
+ self.assertEqual(result['images']['screenshots'], [URL + 'org.example.app/fr/sevenInchScreenshots/tablet.png'])
+
+ def test_v2_legacy_screenshot_keys_and_limit(self):
+ meta = {'phoneScreenshots': {'fr': [{'name': '/phone/' + str(i) + '.png'} for i in range(8)]},
+ 'sevenInchScreenshots': {'en-US': [{'name': '/tablet.png'}]}}
+ images = fdroid._images(meta, URL)
+ self.assertEqual(images['screenshots'], [URL + 'phone/' + str(i) + '.png' for i in range(6)])
+ meta.pop('phoneScreenshots')
+ self.assertEqual(fdroid._images(meta, URL)['screenshots'], [URL + 'tablet.png'])
+
+ def test_old_cache_refreshes_for_artwork(self):
+ source = self.add()
+ path = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
+ saved = json.loads(path.read_text())
+ saved.pop('version')
+ for app in saved['apps'].values():
+ app.pop('images')
+ fdroid._write(path, saved)
+ self.assertIn('images', fdroid.search(source, 'example')[0])
+ self.assertEqual(self.fetch_mock.call_count, 4)
+
+ def test_slow_download_blocks_neither_settings_nor_other_repos(self):
+ import threading
+ source = self.add()
+ other = dict(source, id='other-repo')
+ started, release = threading.Event(), threading.Event()
+ def fetch(url, path, maximum):
+ if threading.current_thread().name == 'slow':
+ started.set()
+ release.wait(5)
+ self.fetch(url, path, maximum)
+ self.fetch_mock.side_effect = fetch
+ slow = threading.Thread(target=fdroid._load, args=(other, True), name='slow')
+ slow.start()
+ try:
+ self.assertTrue(started.wait(2))
+ results = []
+ # The settings lock is free and another repo still loads while this one downloads.
+ check = threading.Thread(target=lambda: results.append(
+ (fdroid.set_enabled('fdroid', False), len(fdroid._load(source, force=True)[0]))))
+ check.start()
+ check.join(2)
+ self.assertEqual(results, [(None, 1)])
+ finally:
+ release.set()
+ slow.join()
+
+ def test_rollback_to_older_index_is_refused(self):
+ self.files['entry.jar'], pin = entry_jar(2000)
+ source = fdroid.add_repo(URL)
+ self.assertEqual(source['fingerprint'], pin)
+ self.files['entry.jar'], _ = entry_jar(1000)
+ with self.assertRaisesRegex(SourceError, 'older'):
+ fdroid._load(source, force=True)
+ for timestamp in (2000, 3000): # unchanged and newer indexes are fine
+ self.files['entry.jar'], _ = entry_jar(timestamp)
+ self.assertEqual(len(fdroid._load(source, force=True)[0]), 1)
+ self.files['entry.jar'], _ = entry_jar(2000)
+ with self.assertRaisesRegex(SourceError, 'older'):
+ fdroid._load(source, force=True)
+ fdroid.remove_repo(source['id']) # a deliberate re-add starts over
+ self.assertEqual(fdroid.add_repo(URL)['fingerprint'], pin)
+
+ def test_concurrent_processes_cannot_publish_an_older_index_last(self):
+ # Threads with their own in-memory locks, as separate processes would have; each
+ # _state_file_lock() opens its own file description, so flock contends for real.
+ import threading
+ self.files['entry.jar'], _ = entry_jar(50)
+ source = fdroid.add_repo(URL)
+ jars = {'older': entry_jar(100)[0], 'newer': entry_jar(200)[0]}
+ def fetch(url, path, maximum):
+ name = threading.current_thread().name
+ if name in jars and url.endswith('entry.jar'):
+ Path(path).write_bytes(jars[name])
+ else:
+ self.fetch(url, path, maximum)
+ self.fetch_mock.side_effect = fetch
+ inside, go, order = threading.Event(), threading.Event(), []
+ real_write = fdroid._write
+ def write(path, value):
+ if path.name.endswith('.json') and 'apps' in value and threading.current_thread().name == 'older':
+ inside.set() # the older load has passed its locked recheck; hold it there
+ go.wait(5)
+ order.append(threading.current_thread().name)
+ real_write(path, value)
+ errors = {}
+ def load():
+ try:
+ fdroid._load(source, force=True)
+ except SourceError as e:
+ errors[threading.current_thread().name] = str(e)
+ with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()), \
+ patch.object(fdroid, '_write', write):
+ older = threading.Thread(target=load, name='older')
+ older.start()
+ self.assertTrue(inside.wait(5))
+ newer = threading.Thread(target=load, name='newer')
+ newer.start()
+ newer.join(.5)
+ self.assertTrue(newer.is_alive()) # blocked on the file lock, not publishing
+ go.set()
+ older.join(5)
+ newer.join(5)
+ self.assertEqual(errors, {})
+ self.assertEqual(order, ['older', 'older', 'newer', 'newer']) # cache+state, one load at a time
+ self.assertEqual(fdroid._state(source)['timestamp'], 200)
+
+ def test_overlapping_v1_load_cannot_replace_accepted_v2(self):
+ import threading
+ v1 = json.loads(zipfile.ZipFile(FIXTURES / 'index-v1.jar').read('index-v1.json'))
+ v1['repo'] = {'timestamp': 100}
+ self.files['index-v1.jar'], pin = signed_jar('index-v1.json', json.dumps(v1).encode())
+ self.files['entry.jar'], _ = entry_jar(100) # the same timestamp as the v1 index
+ source = dict(id='overlap', name='Overlap', url=URL, fingerprint=None)
+ self.v1 = True
+ inner = []
+ def fetch(url, path, maximum):
+ if url.endswith('index-v1.jar') and not inner:
+ inner.append(1) # the v1 load passed its fallback check; a v2 load finishes now
+ self.v1 = False
+ t = threading.Thread(target=lambda: inner.append(fdroid._load(source, force=True)))
+ with patch.object(fdroid, '_source_lock', lambda source_id: threading.Lock()):
+ t.start()
+ t.join(5)
+ self.v1 = True
+ self.fetch(url, path, maximum)
+ self.fetch_mock.side_effect = fetch
+ with self.assertRaisesRegex(SourceError, 'v2'):
+ fdroid._load(source, force=True)
+ self.assertEqual(inner[1][1], pin)
+ self.assertTrue(fdroid._state(source)['v2'])
+ self.v1 = False
+ count = self.fetch_mock.call_count
+ apps, _ = fdroid._load(dict(source, fingerprint=pin))
+ self.assertEqual((apps['org.example.app']['version_code'], self.fetch_mock.call_count), (2, count)) # v2 cache stayed
+
+ def test_apk_removed_during_cache_check_is_downloaded_again(self):
+ source = self.add()
+ first = fdroid.download(source, 'org.example.app', 1)
+ count = self.fetch_mock.call_count
+ real = fdroid._sha256
+ def removed_first(path):
+ if str(path) == first['apk'] and not hashed:
+ hashed.append(1)
+ os.remove(first['apk']) # deleted between the existence check and the open
+ return real(path)
+ hashed = []
+ with patch.object(fdroid, '_sha256', removed_first):
+ again = fdroid.download(source, 'org.example.app', 1)
+ self.assertEqual(self.fetch_mock.call_count, count + 1)
+ self.assertEqual(Path(again['apk']).read_bytes(), (FIXTURES / 'example.apk').read_bytes())
+
+ def test_cached_apk_is_touched_before_hashing(self):
+ source = self.add()
+ first = fdroid.download(source, 'org.example.app', 1)
+ os.utime(first['apk'], (1, 1))
+ count = self.fetch_mock.call_count
+ real = fdroid._sha256
+ def prune_first(path):
+ if str(path) == first['apk']:
+ with patch.object(_web, 'APK_CAP', 0):
+ _web.prune() # a pruner running now sees a just-used APK
+ return real(path)
+ with patch.object(fdroid, '_sha256', prune_first):
+ fdroid.download(source, 'org.example.app', 1)
+ self.assertEqual(self.fetch_mock.call_count, count)
+ self.assertTrue(Path(first['apk']).exists())
+
+ def test_cli_search_waits_for_background_refresh(self):
+ source = self.add()
+ self.expire(source)
+ before = self.fetch_mock.call_count
+ out = io.StringIO()
+ with patch.object(sys, 'argv', ['fdroid.py', 'search', source['id'], 'example']), \
+ patch('sys.stdout', out):
+ fdroid.main()
+ self.assertEqual(json.loads(out.getvalue())[0]['id'], 'org.example.app')
+ self.assertEqual(self.fetch_mock.call_count, before + 2) # the refresh finished before exit
+ self.assertFalse(fdroid.stale(source))
+ self.assertNotIn(source['id'], fdroid._refreshing)
+
+ def test_cli_error_still_waits_for_background_refresh(self):
+ import threading
+ source = self.add()
+ self.expire(source)
+ release = threading.Event()
+ def slow(url, path, maximum):
+ release.wait(5)
+ self.fetch(url, path, maximum)
+ self.fetch_mock.side_effect = slow
+ threading.Timer(.3, release.set).start()
+ with patch.object(sys, 'argv', ['fdroid.py', 'download', source['id'], 'org.missing']), \
+ patch('sys.stderr', io.StringIO()) as err, self.assertRaises(SystemExit):
+ fdroid.main()
+ self.assertIn('no Lepton-compatible version', err.getvalue())
+ self.assertTrue(release.is_set())
+ self.assertEqual(fdroid._refreshing, {}) # joined before exiting
+ self.assertFalse(fdroid.stale(source))
+
+ def test_no_v1_fallback_once_v2_accepted(self):
+ source = self.add()
+ self.v1 = True
+ with self.assertRaisesRegex(SourceError, 'v2'):
+ fdroid._load(source, force=True)
+ self.assertFalse(any(c.args[0].endswith('index-v1.jar') for c in self.fetch_mock.call_args_list))
+
+ def test_v1_then_v2_upgrade_is_allowed(self):
+ self.v1 = True
+ source = self.add()
+ self.v1 = False
+ self.assertEqual(fdroid._load(source, force=True)[0]['org.example.app']['version_code'], 2)
+
+ def test_sha1_only_entry_jar_rejected(self):
+ self.files['entry.jar'], _ = entry_jar(1, 'sha1')
+ with self.assertRaisesRegex(SourceError, 'SHA-1'):
+ fdroid.add_repo(URL)
+ self.assertEqual(fdroid.user_repos(), [])
+ stream = io.BytesIO(self.files['entry.jar'])
+ self.assertIn(b'index', fdroid._jar(stream, 'entry.json', None)[0]) # index-v1.jar may still use SHA-1
+
+ def test_rate_limited_host_backs_off(self):
+ source = dict(self.add(), name='My repo')
+ self.fetch_patch.stop()
+ error = urllib.error.HTTPError(URL, 429, 'slow down', {'Retry-After': '300'}, None)
+ with patch.object(fdroid.urllib.request, 'build_opener') as opener:
+ opener.return_value.open.side_effect = error
+ with self.assertRaisesRegex(SourceLimited, '^My repo is limiting requests; try again in 5 minutes$'):
+ fdroid._load(source, force=True)
+ with self.assertRaisesRegex(SourceLimited, 'My repo'):
+ fdroid.download(source, 'org.example.app', 1)
+ self.assertEqual(opener.return_value.open.call_count, 1)
+ self.fetch_mock = self.fetch_patch.start()
+
+ def expire(self, source):
+ cache = fdroid.frame_host.cache_dir('apk-sources', source['id'] + '.json')
+ old = cache.stat().st_mtime - fdroid.MAX_AGE - 1
+ fdroid.os.utime(str(cache), (old, old))
+
+ def test_expired_index_served_stale_while_refreshing(self):
+ source = self.add()
+ self.expire(source)
+ before = self.fetch_mock.call_count
+ release = __import__('threading').Event()
+ def slow(url, path, maximum):
+ release.wait(5)
+ self.fetch(url, path, maximum)
+ self.fetch_mock.side_effect = slow
+ self.assertEqual(len(fdroid.search(source, 'example')), 1) # immediately, from the old index
+ self.assertTrue(fdroid.stale(source))
+ refresh = fdroid._refreshing[source['id']]
+ fdroid.search(source, 'example')
+ self.assertIs(fdroid._refreshing.get(source['id']), refresh) # one refresh at a time
+ release.set()
+ refresh.join(5)
+ self.assertEqual(self.fetch_mock.call_count, before + 2)
+ self.assertFalse(fdroid.stale(source))
+
+ def test_failed_refresh_keeps_serving_stale_index(self):
+ source = self.add()
+ self.expire(source)
+ self.fetch_mock.side_effect = urllib.error.HTTPError(URL, 503, 'unavailable', None, None)
+ self.assertEqual(len(fdroid.search(source, 'example')), 1)
+ # A fast failed refresh may already have removed itself from the registry.
+ refresh = fdroid._refreshing.get(source['id'])
+ if refresh is not None:
+ refresh.join(5)
+ calls = self.fetch_mock.call_count
+ self.assertEqual(fdroid.details(source, 'org.example.app')['version_code'], 2)
+ self.assertTrue(fdroid.stale(source))
+ self.assertNotIn(source['id'], fdroid._refreshing) # failed refresh waits before retrying
+ self.assertEqual(self.fetch_mock.call_count, calls)
+
+ def test_cached_index_does_not_cross_pins(self):
+ source = self.add()
+ source['fingerprint'] = '0' * 64
+ with self.assertRaisesRegex(SourceError, 'fingerprint mismatch'):
+ fdroid.search(source, '')
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_frame_android_data.py b/tests/test_frame_android_data.py
new file mode 100644
index 0000000..175762c
--- /dev/null
+++ b/tests/test_frame_android_data.py
@@ -0,0 +1,293 @@
+import io
+import json
+import os
+from pathlib import Path
+import runpy
+import shlex
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import frame_android as android
+import frame_android_data as data
+
+REMOTE = runpy.run_path(str(data.REMOTE))
+PKG = 'org.example.game'
+META = {'package': PKG, 'instance': 2800000001}
+
+
+class ObbTests(unittest.TestCase):
+ def test_invalid_files_never_contact_frame(self):
+ with tempfile.TemporaryDirectory() as tmp, patch.object(android, 'ssh') as ssh:
+ for name in ('game.obb', 'main.1.org.other.game.obb', 'main.x.' + PKG + '.obb'):
+ path = Path(tmp) / name
+ path.write_bytes(b'content')
+ with self.assertRaises(android.FrameError):
+ data.install_obb(PKG, [path])
+ with self.assertRaises(android.FrameError):
+ data.install_obb('../game', [])
+ with self.assertRaises(android.FrameError):
+ data.install_obb(PKG, [])
+ ssh.assert_not_called()
+
+ def test_streams_to_correct_instance_and_checks_hash_before_rename(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / ('main.7.' + PKG + '.obb')
+ path.write_bytes(b'expansion payload')
+ calls = []
+ def stream(command, src=None, dst=None):
+ calls.append(command)
+ self.assertEqual(src.read(), b'expansion payload')
+ with patch.object(android, '_meta_or_fail', return_value=META), \
+ patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001\n'), \
+ patch.object(data, '_stream', side_effect=stream):
+ result = data.install_obb(PKG, [path])
+ self.assertTrue(result['verified'])
+ self.assertIn('podman exec -i lepton-steamlaunch-2800000001', calls[0])
+ self.assertIn('/sdcard/Android/obb/' + PKG, calls[0])
+ self.assertLess(calls[0].index('sha256sum'), calls[0].index('; mv'))
+ self.assertIn(result['obb'][0]['sha256'], calls[0])
+
+ def test_stopped_instance_and_failed_transfer(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / ('patch.7.' + PKG + '.obb')
+ path.write_bytes(b'patch')
+ with patch.object(android, '_meta_or_fail', return_value=META), \
+ patch.object(android, 'ssh', return_value=''), patch.object(data, '_stream') as stream:
+ with self.assertRaisesRegex(android.FrameError, 'start this app'):
+ data.install_obb(PKG, [path])
+ stream.assert_not_called()
+ with patch.object(subprocess, 'run', return_value=subprocess.CompletedProcess([], 1, b'', b'bad hash')):
+ with self.assertRaisesRegex(android.FrameError, 'bad hash'):
+ data._stream('command')
+
+
+ @unittest.skipUnless(os.name == 'posix' and shutil.which("sh") and shutil.which("shasum"),
+ "the OBB script runs on the Frame (Linux shell)")
+ def test_android_shell_publish_and_hash_failure(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ source = Path(tmp) / ('main.7.' + PKG + '.obb')
+ source.write_bytes(b'good expansion')
+ output = Path(tmp) / 'sdcard/Android/obb' / PKG / source.name
+ tools_dir = Path(tmp) / 'bin'
+ tools_dir.mkdir()
+ checksum = tools_dir / 'sha256sum'
+ checksum.write_text('#!/bin/sh\nexec shasum -a 256 "$@"\n')
+ checksum.chmod(0o700)
+ corrupt = False
+ def stream(command, src=None, dst=None):
+ script = shlex.split(command)[-1].replace('/sdcard/', tmp + '/sdcard/')
+ if corrupt:
+ source.write_bytes(b'corrupt expansion')
+ result = subprocess.run(['sh', '-c', script], stdin=src, capture_output=True,
+ env=dict(os.environ, PATH=str(tools_dir) + ':' + os.environ['PATH']))
+ if result.returncode:
+ raise android.FrameError('checksum failed')
+ with patch.object(android, '_meta_or_fail', return_value=META), \
+ patch.object(android, 'ssh', return_value='lepton-steamlaunch-2800000001'), \
+ patch.object(data, '_stream', side_effect=stream):
+ data.install_obb(PKG, [source])
+ self.assertEqual(output.read_bytes(), b'good expansion')
+ corrupt = True
+ with self.assertRaises(android.FrameError):
+ data.install_obb(PKG, [source])
+ self.assertEqual(output.read_bytes(), b'good expansion')
+ self.assertEqual(list(output.parent.glob('*.part')), [])
+
+
+@unittest.skipUnless(os.name == 'posix', 'app-data backups run on the Frame (Linux ownership and modes)')
+class BackupTests(unittest.TestCase):
+ def test_roundtrip_and_retains_previous_data(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / PKG
+ (source / 'files').mkdir(parents=True)
+ (source / 'files/save').write_bytes(b'original save')
+ archive = io.BytesIO()
+ REMOTE['backup'](root, PKG, META['instance'], archive)
+ (source / 'files/save').write_bytes(b'new save')
+ archive.seek(0)
+ # Current user's uid/gid in this local test; no elevated execution.
+ result = REMOTE['restore'](root, PKG, META['instance'], archive)
+ self.assertEqual((source / 'files/save').read_bytes(), b'original save')
+ self.assertEqual((Path(result['previous']) / 'files/save').read_bytes(), b'new save')
+
+ def test_symlinks_skipped_and_recorded_hardlinks_copied(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ source = root / PKG
+ (source / 'files').mkdir(parents=True)
+ (source / 'files/save').write_bytes(b'save')
+ os.link(str(source / 'files/save'), str(source / 'files/save-link'))
+ os.symlink('/data/app/lib', str(source / 'lib'))
+ os.symlink('save', str(source / 'files/alias'))
+ archive = root / 'backup.tar.gz'
+ with archive.open('wb') as output:
+ REMOTE['backup'](root, PKG, META['instance'], output)
+ result = REMOTE['inspect_archive'](archive, PKG, META['instance'])
+ self.assertEqual(result['skipped_links'], 2)
+ with tarfile.open(archive) as tar:
+ manifest = json.load(tar.extractfile('manifest.json'))
+ self.assertEqual(tar.extractfile('data/files/save-link').read(), b'save')
+ self.assertEqual(sorted((l['path'], l['target']) for l in manifest['skipped_links']),
+ [('data/files/alias', 'save'), ('data/lib', '/data/app/lib')])
+ with archive.open('rb') as src:
+ REMOTE['restore'](root, PKG, META['instance'], src)
+ self.assertFalse((source / 'lib').exists() or (source / 'lib').is_symlink())
+ self.assertEqual((source / 'files/save-link').read_bytes(), b'save')
+
+ @unittest.skipUnless(os.name == 'posix', 'restores run on the Frame (Linux flock)')
+ def test_overlapping_restores_keep_a_recovery_copy(self):
+ import threading
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / PKG).mkdir()
+ (root / PKG / 'save').write_bytes(b'backup')
+ archive = io.BytesIO()
+ REMOTE['backup'](root, PKG, META['instance'], archive)
+ (root / PKG / 'save').write_bytes(b'current')
+ REMOTE['restore'](root, PKG, META['instance'], io.BytesIO(archive.getvalue())) # an old copy to clean up
+ restore = REMOTE['restore']
+ real_rmtree, inside, go = shutil.rmtree, threading.Event(), threading.Event()
+ def rmtree(path, **kwargs):
+ if threading.current_thread().name == 'first':
+ inside.set() # swapped, now cleaning up; hold it here
+ go.wait(5)
+ real_rmtree(path, **kwargs)
+ results = {}
+ def run():
+ results[threading.current_thread().name] = restore(
+ root, PKG, META['instance'], io.BytesIO(archive.getvalue()))['previous']
+ with patch.dict(restore.__globals__, {'shutil': type('S', (), {'rmtree': staticmethod(rmtree),
+ 'copyfileobj': shutil.copyfileobj})}):
+ first = threading.Thread(target=run, name='first')
+ first.start()
+ self.assertTrue(inside.wait(5))
+ second = threading.Thread(target=run, name='second')
+ second.start()
+ second.join(.5)
+ self.assertTrue(second.is_alive()) # can't swap or clean up during the first's cleanup
+ go.set()
+ first.join(5)
+ second.join(5)
+ copies = sorted(root.glob('.' + PKG + '.before-restore-*'))
+ self.assertEqual(copies, [Path(results['second'])]) # the second restore's recovery copy survives
+ self.assertEqual((copies[0] / 'save').read_bytes(), b'backup')
+
+ def test_restore_keeps_only_latest_previous_copy(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / PKG).mkdir()
+ (root / PKG / 'save').write_bytes(b'one')
+ other = root / '.org.example.gameplus.before-restore-1' # another package's copy is left alone
+ other.mkdir()
+ archive = io.BytesIO()
+ REMOTE['backup'](root, PKG, META['instance'], archive)
+ previous = []
+ for _ in range(3):
+ archive.seek(0)
+ previous.append(REMOTE['restore'](root, PKG, META['instance'], archive)['previous'])
+ self.assertEqual(sorted(root.glob('.' + PKG + '.before-restore-*')), [Path(previous[-1])])
+ self.assertTrue(other.exists())
+
+ def make_archive(self, path, members, package=PKG):
+ with tarfile.open(path, 'w:gz') as archive:
+ payload = json.dumps({'format': 1, 'package': package, 'instance': META['instance']}).encode()
+ member = tarfile.TarInfo('manifest.json')
+ member.size = len(payload)
+ archive.addfile(member, io.BytesIO(payload))
+ root = tarfile.TarInfo('data')
+ root.type = tarfile.DIRTYPE
+ archive.addfile(root)
+ for name, kind in members:
+ member = tarfile.TarInfo(name)
+ member.type = kind
+ member.linkname = '/tmp/escape'
+ archive.addfile(member)
+
+ def test_rejects_wrong_package_traversal_links_devices_duplicates(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / 'bad.tar.gz'
+ cases = [('../escape', tarfile.REGTYPE), ('/absolute', tarfile.REGTYPE),
+ ('data/link', tarfile.SYMTYPE), ('data/link', tarfile.LNKTYPE),
+ ('data/device', tarfile.CHRTYPE), ('data', tarfile.DIRTYPE),
+ ('other/file', tarfile.REGTYPE), ('data/../escape', tarfile.REGTYPE)]
+ for member in cases:
+ self.make_archive(path, [member])
+ with self.assertRaises(ValueError, msg=str(member)):
+ REMOTE['inspect_archive'](path, PKG, META['instance'])
+ self.make_archive(path, [], package='org.other.game')
+ with self.assertRaisesRegex(ValueError, 'does not match'):
+ REMOTE['inspect_archive'](path, PKG, META['instance'])
+
+ def test_failed_backup_leaves_no_archive_and_existing_is_preserved(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / 'backup.tar.gz'
+ with patch.object(android, '_meta_or_fail', return_value=META), \
+ patch.object(data, '_stream', side_effect=android.FrameError('offline')):
+ with self.assertRaises(android.FrameError):
+ data.backup_data(PKG, path)
+ self.assertEqual(list(Path(tmp).iterdir()), [])
+ path.write_bytes(b'keep')
+ with self.assertRaisesRegex(android.FrameError, 'already exists'):
+ data.backup_data(PKG, path)
+ self.assertEqual(path.read_bytes(), b'keep')
+
+ def test_guard_does_not_hide_podman_failure(self):
+ command = data._data_command('backup', META)
+ self.assertIn('|| exit 1', command)
+ self.assertIn('stop the app', command)
+ self.assertIn('podman unshare python3', command)
+ self.assertNotIn('|| true', command)
+
+ def test_bad_restore_is_rejected_before_transfer(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / 'bad.tar.gz'
+ self.make_archive(path, [('../escape', tarfile.REGTYPE)])
+ with patch.object(android, '_meta_or_fail', return_value=META), patch.object(data, '_stream') as stream:
+ with self.assertRaises(android.FrameError):
+ data.restore_data(PKG, path)
+ stream.assert_not_called()
+
+
+ def test_successful_backup_is_private_and_inspectable(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / PKG).mkdir()
+ (root / PKG / 'save').write_bytes(b'checkpoint')
+ destination = root / 'backup.tar.gz'
+ def stream(command, src=None, dst=None):
+ REMOTE['backup'](root, PKG, META['instance'], dst)
+ with patch.object(android, '_meta_or_fail', return_value=META), \
+ patch.object(data, '_stream', side_effect=stream):
+ result = data.backup_data(PKG, destination)
+ self.assertEqual(destination.stat().st_mode & 0o777, 0o600)
+ self.assertEqual(result['sha256'], data._sha256(destination))
+ self.assertEqual(result['files'], 2)
+
+ def test_rejected_restore_keeps_existing_data(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ (root / PKG).mkdir()
+ (root / PKG / 'save').write_bytes(b'keep')
+ archive = root / 'bad.tar.gz'
+ self.make_archive(archive, [('data/link', tarfile.SYMTYPE)])
+ with archive.open('rb') as source, self.assertRaises(ValueError):
+ REMOTE['restore'](root, PKG, META['instance'], source)
+ self.assertEqual((root / PKG / 'save').read_bytes(), b'keep')
+ self.assertFalse(list(root.glob('.frame-restore-*')))
+ self.assertFalse(list(root.glob('.*.before-restore-*')))
+
+ def test_archive_root_must_be_a_directory(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = Path(tmp) / 'bad.tar.gz'
+ with tarfile.open(archive, 'w:gz') as target:
+ target.addfile(tarfile.TarInfo('data'))
+ with self.assertRaisesRegex(ValueError, 'directory'):
+ REMOTE['inspect_archive'](archive, PKG, META['instance'])
diff --git a/tests/test_frame_android_library.py b/tests/test_frame_android_library.py
new file mode 100644
index 0000000..cdf51c0
--- /dev/null
+++ b/tests/test_frame_android_library.py
@@ -0,0 +1,644 @@
+"""Offline artwork, Steam API and launcher supervision regressions."""
+import importlib.util
+import json
+import os
+from pathlib import Path
+import signal
+import struct
+import subprocess
+import sys
+import tempfile
+import time
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import frame_android as android
+import frame_artwork as art
+
+spec = importlib.util.spec_from_file_location('steam_shortcuts', ROOT / 'frame/android/steam_shortcuts.py')
+shortcuts = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(shortcuts)
+
+
+@unittest.skipIf(os.name == 'nt', 'POSIX launcher')
+class LauncherTests(unittest.TestCase):
+ def exercise(self, terminate, sig=signal.SIGTERM, blocked=None, orphan=False):
+ with tempfile.TemporaryDirectory() as tmp:
+ d = Path(tmp)
+ app = d / 'Applications/Android/org.test.app'
+ app.mkdir(parents=True)
+ (app / 'launch.sh').write_bytes((ROOT / 'frame/android/lepton-app.sh').read_bytes())
+ (app / 'app.apk').touch()
+ (app / 'instance.id').write_text('2800000001')
+ (app / 'shortcut.id').write_text('3346865537')
+ bin_dir = d / 'bin'
+ bin_dir.mkdir()
+ lepton = d / '.local/share/Steam/steamapps/common/Lepton/lepton'
+ lepton.parent.mkdir(parents=True)
+ def script(path, body):
+ path.write_text('#!' + sys.executable + '\n' + body)
+ path.chmod(0o755)
+ script(lepton, 'import os,time\nfrom pathlib import Path\n'
+ 'assert os.environ["SteamAppId"] == "2800000001"\n'
+ 'assert os.environ["LEPTON_ENV_SteamAppId"] == "3346865537"\n'
+ 'Path(os.environ["HOME"],"started").write_text(str(os.getpid()))\n'
+ 'try:\n os.fstat(9); Path(os.environ["HOME"],"inherited-lock").touch()\nexcept OSError: pass\n'
+ + ('time.sleep(30)\n' if terminate else 'raise SystemExit(23)\n'))
+ script(bin_dir / 'setsid', 'import os,sys\nos.setsid()\nos.execv(sys.argv[2],sys.argv[2:])\n')
+ script(bin_dir / 'flock', 'import os\nraise SystemExit(1 if os.environ.get("TEST_LOCKED") else 0)\n') # lock semantics belong to Linux; no flock on macOS
+ script(bin_dir / 'podman', 'import os,sys\nfrom pathlib import Path\n'
+ 'p=Path(os.environ["HOME"],"podman-calls")\n'
+ 'with p.open("a") as f: f.write(" ".join(sys.argv[1:])+"\\n")\n'
+ 'if sys.argv[1:2]==["inspect"] and os.environ.get("TEST_RUNNING"): print("true")\n')
+ env = {**os.environ, 'HOME': str(d), 'PATH': str(bin_dir) + os.pathsep + os.environ['PATH']}
+ if blocked:
+ env['TEST_' + blocked] = '1'
+ if orphan:
+ env['TEST_RUNNING'] = '1'
+ saved = d / '.local/share/Steam/steamapps/compatdata/2800000001/internal/save'
+ saved.parent.mkdir(parents=True)
+ saved.write_text('saved game')
+ proc = subprocess.Popen(['bash', str(app / 'launch.sh')], env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ try:
+ if blocked:
+ proc.communicate(timeout=5)
+ self.assertEqual(proc.returncode, 1)
+ self.assertFalse((d / 'started').exists())
+ calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
+ self.assertNotIn('stop ', calls)
+ return
+ deadline = time.monotonic() + 5
+ while not (d / 'started').exists() and proc.poll() is None and time.monotonic() < deadline:
+ time.sleep(.02)
+ self.assertTrue((d / 'started').exists(), 'launcher did not start Lepton')
+ self.assertFalse((d / 'inherited-lock').exists(), 'Lepton inherited the launch lock')
+ if terminate:
+ self.assertIsNone(proc.poll(), 'Steam-tracked wrapper exited during the session')
+ proc.send_signal(sig)
+ _, err = proc.communicate(timeout=5)
+ calls = (d / 'podman-calls').read_text() if (d / 'podman-calls').exists() else ''
+ self.assertIn('stop -t 5 lepton-steamlaunch-2800000001', calls, err.decode())
+ self.assertEqual(calls.count('stop -t 5'), 2 if orphan else 1)
+ self.assertEqual(proc.returncode, 128 + sig if terminate else 23)
+ self.assertEqual(saved.read_text(), 'saved game')
+ self.assertTrue((app / 'app.apk').exists())
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.communicate()
+ if (d / 'started').exists():
+ try:
+ os.kill(int((d / 'started').read_text()), signal.SIGKILL)
+ except ProcessLookupError:
+ pass
+
+ def test_steam_stop_cleans_container(self):
+ self.exercise(True)
+
+ def test_hangup_and_interrupt_cleanup(self):
+ # macOS's stock bash 3.2 doesn't run a SIGINT trap while blocked in `wait`;
+ # the Frame's bash (5.x) does, and that's where the launcher runs.
+ major = subprocess.run(['bash', '-c', 'echo ${BASH_VERSINFO[0]}'], capture_output=True, text=True).stdout.strip()
+ sigs = (signal.SIGHUP, signal.SIGINT) if major.isdigit() and int(major) >= 4 else (signal.SIGHUP,)
+ for sig in sigs:
+ with self.subTest(sig=sig):
+ self.exercise(True, sig)
+
+ def test_duplicate_launch_leaves_existing_session_alone(self):
+ self.exercise(False, blocked='LOCKED')
+
+ def test_orphaned_container_is_stopped_and_play_proceeds(self):
+ # Container running but the lock free: its launcher was SIGKILLed.
+ self.exercise(False, orphan=True)
+
+ def test_normal_exit_cleans_container_and_keeps_exit_code(self):
+ self.exercise(False)
+
+
+FIXTURES = ROOT / 'tests/fixtures/library'
+
+
+class ArtworkTests(unittest.TestCase):
+ def test_source_inputs_and_url(self):
+ from apk_sources import _images
+ data = (FIXTURES / 'icon.png').read_bytes()
+ with patch('frame_steamgriddb.lookup', return_value=({}, [])), \
+ patch.object(_images, 'fetch', return_value=(data, 'image/png')) as fetch:
+ images, warnings = art.prepare('Game', artwork={'banner': data, 'icon': 'https://example.org/icon.png'})
+ self.assertEqual(images['banner'], ('png', data))
+ self.assertEqual(images['icon'], ('png', data))
+ self.assertEqual(warnings, [])
+ self.assertEqual(fetch.call_args.args[0], 'https://example.org/icon.png')
+ self.assertIsNotNone(fetch.call_args.kwargs['deadline'])
+
+ def test_provider_precedence_and_bad_source_fallback(self):
+ data = (FIXTURES / 'icon.png').read_bytes()
+ jpg = (FIXTURES / 'icon.jpg').read_bytes()
+ with patch('frame_steamgriddb.lookup', return_value=({'hero': jpg}, [])):
+ images, warnings = art.prepare('Game', data, {'hero': data, 'wide': b'bad', 'screenshots': [b'bad', data]})
+ self.assertEqual(images['hero'], ('jpg', jpg))
+ self.assertEqual(images['icon'], ('png', data))
+ self.assertEqual(images['screenshot'], ('png', data))
+ self.assertNotIn('wide', images)
+ self.assertEqual(warnings, ['Source wide unavailable; using fallback art']) # one per slot, not per candidate
+
+ def test_any_source_failure_falls_back_to_generated_art(self):
+ import http.client
+ from apk_sources import _images
+ data = (FIXTURES / 'icon.png').read_bytes()
+ for error in (http.client.RemoteDisconnected('gone'), http.client.IncompleteRead(b''), AttributeError('x')):
+ with self.subTest(error=type(error).__name__), \
+ patch.object(_images, 'fetch', side_effect=error), \
+ patch('frame_steamgriddb.lookup', side_effect=error):
+ images, warnings = art.prepare('Game', data, {'banner': 'https://example.org/b.png'})
+ self.assertEqual(set(images), {'icon'})
+ self.assertEqual(len(warnings), 2)
+
+ def test_url_fetch_refuses_private_hosts_and_honours_deadline(self):
+ from apk_sources import _images, SourceError
+ local = [(2, 1, 6, '', ('127.0.0.1', 443))]
+ with patch.object(_images.socket, 'getaddrinfo', return_value=local), \
+ self.assertRaisesRegex(SourceError, 'Private'):
+ art.fetch('https://example.org/icon.png')
+ public = [(2, 1, 6, '', ('93.184.216.34', 443))]
+ with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
+ patch.object(_images.socket, 'create_connection') as connect, \
+ self.assertRaisesRegex(SourceError, 'too long'):
+ art.fetch('https://example.org/icon.png', deadline=time.monotonic() - 1)
+ connect.assert_not_called()
+ with self.assertRaises(SourceError):
+ art.fetch('file:///etc/passwd')
+
+ def trickle(self, head, seconds):
+ # A server that answers one byte every 20 ms, over a socketpair standing in for the network.
+ import socket
+ import threading
+ from apk_sources import _images
+ client, server = socket.socketpair()
+ def serve():
+ try:
+ server.recv(65536)
+ for byte in head + b'x' * 1000:
+ server.sendall(bytes([byte]))
+ time.sleep(0.02)
+ except OSError:
+ pass
+ finally:
+ server.close()
+ threading.Thread(target=serve, daemon=True).start()
+ public = [(2, 1, 6, '', ('93.184.216.34', 80))]
+ with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
+ patch.object(_images.socket, 'create_connection', return_value=client):
+ start = time.monotonic()
+ with self.assertRaisesRegex(_images.SourceError, 'too long'):
+ _images.get('http://example.org/a.png', deadline=start + seconds)
+ return time.monotonic() - start
+
+ def test_deadline_bounds_trickling_headers_and_body(self):
+ self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n', 0.15), 0.4)
+ self.assertLess(self.trickle(b'HTTP/1.1 200 OK\r\n', 0.15), 0.4) # headers never finish
+
+ def test_deadline_covers_a_stalled_tls_handshake(self):
+ import socket
+ from apk_sources import _images
+ client, server = socket.socketpair()
+ public = [(2, 1, 6, '', ('93.184.216.34', 443))]
+ try:
+ with patch.object(_images.socket, 'getaddrinfo', return_value=public), \
+ patch.object(_images.socket, 'create_connection', return_value=client):
+ start = time.monotonic()
+ with self.assertRaisesRegex(_images.SourceError, 'too long'):
+ _images.get('https://example.org/a.png', deadline=start + 0.25) # server never answers
+ self.assertLess(time.monotonic() - start, 0.45)
+ finally:
+ server.close()
+
+ def test_timed_out_lookups_are_capped(self):
+ import threading
+ from apk_sources import _images
+ gate = threading.Event()
+ try:
+ with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
+ errors = []
+ for _ in range(6):
+ try:
+ _images.get('https://example.org/a.png', deadline=time.monotonic() + 0.05)
+ except _images.SourceError as e:
+ errors.append(str(e))
+ self.assertEqual(sum('too long' in e for e in errors), 4)
+ 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
+
+ def test_resolver_slot_released_when_thread_cannot_start(self):
+ from apk_sources import _images
+ with patch.object(_images.threading.Thread, 'start', side_effect=RuntimeError("can't start new thread")):
+ 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()
+
+ def test_deadline_covers_name_resolution(self):
+ import threading
+ from apk_sources import _images
+ gate = threading.Event()
+ with patch.object(_images.socket, 'getaddrinfo', side_effect=lambda *a, **k: gate.wait(5) and []):
+ start = time.monotonic()
+ with self.assertRaisesRegex(_images.SourceError, 'too long'):
+ _images.get('https://example.org/a.png', deadline=start + 0.1)
+ self.assertLess(time.monotonic() - start, 0.4)
+ gate.set()
+ with self.assertRaisesRegex(_images.SourceError, 'too long'):
+ _images.get('https://example.org/a.png', deadline=time.monotonic() - 1)
+
+ def test_steamgriddb_uses_the_bounded_fetch_without_redirects(self):
+ import frame_steamgriddb as sgdb
+ from apk_sources import _images
+ with patch.object(_images, 'get', return_value=b'{"success": true, "data": [1]}') as get:
+ self.assertEqual(sgdb._get('/search/x', 'secret', time.monotonic() + 5), [1])
+ self.assertEqual(get.call_args.kwargs['redirects'], 0)
+ self.assertEqual(get.call_args.args[1]['Authorization'], 'Bearer secret')
+ self.assertLessEqual(get.call_args.kwargs['deadline'] - time.monotonic(), 5)
+
+ def test_supplied_jpeg(self):
+ data = (FIXTURES / 'icon.jpg').read_bytes()
+ self.assertEqual(art.image_type(data), 'jpg')
+ self.assertEqual(art.image_type(data + b'\0' * 64), 'jpg') # trailing padding after EOI
+ with self.assertRaises(ValueError):
+ art.image_type(data[:30])
+
+ FRAME = b'\x21\xf9\x04\x01\x00\x00\x00\x00' + b'\x2c' + struct.pack('IIBBBBB', w, h, depth, color, 0, 0, interlace)) + \
+ art.chunk(b'IEND', b'')
+ self.assertEqual(art.image_type(png(3840, 1240, 16, 6, 0)), 'png')
+ self.assertEqual(art.image_type(png(3840, 2160, 8, 2, 1)), 'png')
+ for bad, message in ((png(10000, 10, 8, 6, 0), 'dimensions'), (png(5000, 5000, 8, 6, 0), 'dimensions'),
+ (png(10, 10, 3, 6, 0), 'encoding'), (png(10, 10, 8, 5, 0), 'encoding')):
+ with self.subTest(message=message), self.assertRaisesRegex(ValueError, message):
+ art.image_type(bad)
+ broken = bytearray(png(10, 10, 8, 6, 0))
+ broken[20] ^= 1
+ with self.assertRaisesRegex(ValueError, 'checksum'):
+ art.image_type(bytes(broken))
+ with self.assertRaises(ValueError):
+ art.image_type(art.PNG + b'junk')
+
+ def test_bad_artwork_arguments(self):
+ for value in ({'bad': b'bad'}, ['hero']):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ art.prepare('Game', artwork=value)
+
+ def test_godot_project_icon(self):
+ import io
+ import zipfile
+ data = (FIXTURES / 'icon.png').read_bytes()
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, 'w') as archive:
+ archive.writestr('assets/icon.png', data)
+ with zipfile.ZipFile(buffer) as archive:
+ self.assertEqual(android.frame_apk._icon_png(archive, set(archive.namelist()), []), data)
+
+
+class InstallTests(unittest.TestCase):
+ def setUp(self):
+ self.responses = json.loads((FIXTURES / 'steam-responses.json').read_text())
+ self.info = {'package': 'org.test.vr', 'label': 'VR', 'version': '1', 'icon_png': None,
+ 'vr': True, 'launchable': True, 'repairable': False, 'abis': [], 'min_sdk': 24}
+ self.images = {slot: ('png', b'PNG ' + slot.encode()) for slot in art.SLOTS}
+ self.existing = {'package': 'org.test.vr', 'instance': 2800000001,
+ 'shortcut': 3346865537, 'label': 'Old name'}
+
+ def install(self, existing, tool=None):
+ def shortcut(*args, **kwargs):
+ if args[0] == 'add':
+ return '3346865537'
+ if args[0] == 'render':
+ return json.dumps({'paths': {slot: '/home/steamos/Applications/Android/org.test.vr/artwork/' + slot + '.png' for slot in art.SLOTS}})
+ if args[0] == 'list':
+ return json.dumps(self.responses['shortcuts'])
+ return json.dumps(self.responses['configure'])
+ with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
+ patch.object(android, 'read_meta', return_value=existing), \
+ patch.object(android, '_copy') as copy, \
+ patch.object(android, 'ssh', return_value=self.responses['home']) as ssh, \
+ patch.object(android, 'shortcut_tool', side_effect=tool or shortcut) as api, \
+ patch.object(android, '_write_meta') as meta:
+ result = android._install('game.apk', self.info, self.info['package'], False, 'New name', 'test')
+ return result, ssh, api, meta
+
+ def test_existing_shortcut_refreshes_name_vr_and_every_slot(self):
+ result, ssh, api, meta = self.install(self.existing)
+ calls = [c.args for c in api.call_args_list]
+ self.assertNotIn('add', [c[0] for c in calls])
+ configure = next(c for c in calls if c[0] == 'configure')
+ self.assertEqual(configure[1:3], ('3346865537', 'New name'))
+ self.assertEqual(configure[6], '1')
+ self.assertEqual(set(json.loads(configure[7])), set(art.SLOTS))
+ self.assertTrue(configure[5].endswith('/org.test.vr/artwork/icon.png'))
+ self.assertEqual(result['shortcut'], self.existing['shortcut'])
+ self.assertEqual(result['label'], 'New name')
+ self.assertEqual(result['library_warnings'], [])
+ self.assertEqual(len([c for c in ssh.call_args_list if isinstance(c.kwargs.get('input'), bytes)]), 5)
+ meta.assert_called_once()
+
+ def test_first_install_adds_shortcut(self):
+ _, _, api, _ = self.install(None)
+ self.assertEqual([c.args[0] for c in api.call_args_list], ['add', 'render', 'configure'])
+
+ def test_artwork_forwarded_through_patch(self):
+ artwork = {'hero': b'provided'}
+ with patch.object(android, 'apk_info', return_value={**self.info, 'repairable': True}), \
+ patch.object(android, 'xr_compat_files', return_value={}), \
+ patch.object(android, 'patch', return_value={'patched': ['launcher']}), \
+ patch.object(android, '_install', return_value={}) as install:
+ android.install('x.apk', artwork=artwork)
+ self.assertIs(install.call_args.args[-1], artwork)
+
+ def test_failed_new_install_removes_shortcut(self):
+ def tool(*args, **kwargs):
+ if args[0] == 'add':
+ return '3346865537'
+ if args[0] == 'render':
+ return json.dumps({'paths': {slot: '/tmp/' + slot + '.png' for slot in art.SLOTS}})
+ if args[0] == 'configure':
+ raise android.FrameError('write failed')
+ return '{}'
+ with patch.object(android.frame_artwork, 'prepare', return_value=(self.images, [])), \
+ patch.object(android, 'read_meta', return_value=None), \
+ patch.object(android, '_copy'), patch.object(android, 'ssh', return_value='/home/steamos') as ssh, \
+ patch.object(android, 'shortcut_tool', side_effect=tool) as api:
+ with self.assertRaisesRegex(android.FrameError, 'write failed'):
+ android._install('x.apk', self.info, 'org.test.vr', False, None, None)
+ self.assertIn(('remove', '3346865537'), [c.args for c in api.call_args_list])
+ self.assertTrue(any(c.args[0] == 'rm -rf Applications/Android/org.test.vr' for c in ssh.call_args_list))
+
+ def test_remove_keeps_data_when_requested_and_survives_steam_failure(self):
+ with patch.object(android, '_meta_or_fail', side_effect=lambda pkg: dict(self.existing)), \
+ patch.object(android, 'stop'), \
+ patch.object(android, 'shortcut_tool', return_value='{"warnings": []}') as api, \
+ patch.object(android, 'ssh') as ssh:
+ android.remove('org.test.vr', keep_data=True)
+ api.assert_called_once_with('remove', '3346865537')
+ ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr')
+ api.side_effect = android.FrameError('SharedJSContext not found: is the Steam client running?')
+ ssh.reset_mock()
+ result = android.remove('org.test.vr')
+ ssh.assert_called_once_with('rm -rf Applications/Android/org.test.vr '
+ '.local/share/Steam/steamapps/compatdata/2800000001 '
+ '.local/share/Steam/steamapps/shadercache/2800000001')
+ self.assertIn('Steam client running', result['library_warnings'][0])
+
+ def test_remove_waits_for_refresh_and_is_never_undone(self):
+ import threading
+ state = {'meta': dict(self.existing, flatscreen=False), 'shortcuts': {3346865537}}
+ in_refresh, release, added = threading.Event(), threading.Event(), []
+ def meta(pkg):
+ if not state['meta']:
+ raise android.FrameError(pkg + ' is not installed')
+ return dict(state['meta'])
+ def ssh(cmd, input=None, **kw):
+ if cmd.startswith('rm -rf Applications/Android/org.test.vr'):
+ state['meta'] = None
+ return json.dumps({'icon_png': ''}) if cmd == 'python3 -' else '/home/steamos'
+ def tool(*args, **kw):
+ if args[0] == 'list': return json.dumps([{'appid': a} for a in state['shortcuts']])
+ if args[0] == 'remove': state['shortcuts'].discard(int(args[1])); return '{"warnings": []}'
+ if args[0] == 'add': added.append(args); return '99'
+ if args[0] == 'render': return json.dumps({'paths': {s: '/tmp/' + s + '.png' for s in art.SLOTS}})
+ return '{"warnings": []}'
+ def prepare(*args, **kw):
+ in_refresh.set(); release.wait(5)
+ return self.images, []
+ with patch.object(android, '_meta_or_fail', side_effect=meta), patch.object(android, 'ssh', side_effect=ssh), \
+ patch.object(android, 'shortcut_tool', side_effect=tool), patch.object(android, 'stop'), \
+ patch.object(android, '_write_meta', side_effect=lambda d, m: state.__setitem__('meta', m)), \
+ patch.object(android.frame_artwork, 'prepare', side_effect=prepare):
+ refresh = threading.Thread(target=android.refresh_art, args=('org.test.vr',), kwargs={'fill_only': True})
+ refresh.start()
+ self.assertTrue(in_refresh.wait(5))
+ remove = threading.Thread(target=android.remove, args=('org.test.vr',))
+ remove.start()
+ remove.join(0.3)
+ self.assertTrue(remove.is_alive(), 'remove ran while a refresh was writing')
+ release.set(); refresh.join(5); remove.join(5)
+ self.assertIsNone(state['meta']); self.assertEqual(state['shortcuts'], set())
+ with self.assertRaisesRegex(android.FrameError, 'not installed'):
+ android.refresh_art('org.test.vr', fill_only=True) # a queued backfill after removal
+ self.assertEqual(added, [])
+
+ def test_stop_requests_steam_and_has_container_fallback(self):
+ with patch.object(android, '_meta_or_fail', return_value=self.existing), \
+ patch.object(android, 'shortcut_tool', side_effect=android.FrameError('offline')) as api, \
+ patch.object(android, 'ssh') as ssh:
+ android.stop('org.test.vr')
+ api.assert_called_once_with('stop', '3346865537')
+ self.assertIn('podman stop -t 5 lepton-steamlaunch-2800000001', ssh.call_args.args[0])
+
+
+class SteamAPITests(unittest.TestCase):
+ def test_artwork_api_enums_and_safe_serialization(self):
+ with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
+ shortcuts.configure(42, 'A "name"\n', '/path', '/start', '/icon', True,
+ {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
+ js = evaluate.call_args.args[0]
+ self.assertIn('SetShortcutIsVR(id, true)', js)
+ self.assertIn('SetShortcutName(id, "A \\"name\\"\\n")', js)
+ self.assertIn('SetCustomArtworkForApp(id, data, ext, type)', js)
+ self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('SetCustomArtworkForApp(id, data, ext, type)'))
+ self.assertEqual(shortcuts.ASSETS, {'grid': 0, 'hero': 1, 'logo': 2, 'wide': 3, 'icon': 4})
+ self.assertIn('NewUnsavedCollection(name, undefined, [app])', js)
+
+ def test_remove_clears_every_slot_before_shortcut(self):
+ with patch.object(shortcuts, 'evaluate', return_value={}) as evaluate:
+ shortcuts.remove(42)
+ js = evaluate.call_args.args[0]
+ self.assertIn('[0, 1, 2, 3]', js)
+ self.assertLess(js.index('ClearCustomArtworkForApp(id, type)'), js.index('RemoveShortcut(id)'))
+ self.assertIn('const wanted = []', js)
+ self.assertNotIn('throw', js) # tidy-up failures are warnings; RemoveShortcut always runs
+
+ def test_devkit_configure_leaves_vr_flag_and_uses_sideloaded(self):
+ slots = {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS}
+ with patch.object(shortcuts, 'evaluate', return_value={'warnings': []}) as evaluate:
+ shortcuts.configure(42, 'Game', '', '', '/icon', None, slots, {'category': 'Sideloaded'})
+ js = evaluate.call_args.args[0]
+ self.assertIn('if (null !== null && !fill)', js)
+ self.assertIn('const wanted = ["Sideloaded"]', js)
+ with patch.object(sys, 'argv', ['steam_shortcuts.py', 'configure', '42', 'Game', '', '', '/icon', '',
+ json.dumps(slots), '{}']), \
+ patch.object(shortcuts, 'configure', return_value={}) as configure, patch('builtins.print'):
+ shortcuts.main()
+ self.assertIsNone(configure.call_args.args[5])
+
+ def test_render_writes_jpeg_and_retries_oversized_photo_with_generated_art(self):
+ import base64
+ png = base64.b64encode((FIXTURES / 'icon.png').read_bytes()).decode()
+ jpg = base64.b64encode((FIXTURES / 'icon.jpg').read_bytes()).decode()
+ huge = base64.b64encode(b'\xff\xd8\xff' + b'\0' * (12 * 1024 * 1024)).decode()
+ calls = []
+ def evaluate(js, timeout=20):
+ calls.append((json.loads(js[js.rindex('renderLibraryArtwork(') + 21:-1]), timeout))
+ hero = ['jpg', huge] if len(calls) == 1 else ['png', png]
+ return {'images': {'grid': ['jpg', jpg], 'wide': ['jpg', jpg], 'hero': hero,
+ 'logo': ['png', png], 'icon': ['png', png]}, 'warnings': []}
+ with tempfile.TemporaryDirectory() as tmp:
+ for name in ('icon.png', 'hero.jpg'):
+ Path(tmp, 'source-' + name).write_bytes((FIXTURES / ('icon.jpg' if name.endswith('jpg') else 'icon.png')).read_bytes())
+ Path(tmp, 'hero.png').write_bytes(b'stale')
+ plan = Path(tmp, 'input.json')
+ plan.write_text(json.dumps({'label': 'Game', 'images': {'icon': str(Path(tmp, 'source-icon.png')),
+ 'hero': str(Path(tmp, 'source-hero.jpg'))}}))
+ with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
+ result = shortcuts.render(str(plan))
+ self.assertEqual(set(calls[0][0]['images']), {'icon', 'hero'})
+ self.assertEqual(set(calls[1][0]['images']), {'icon'})
+ self.assertEqual([c[1] for c in calls], [75, 75])
+ self.assertTrue(result['paths']['grid'].endswith('grid.jpg'))
+ self.assertTrue(result['paths']['hero'].endswith('hero.png'))
+ self.assertIn('generated art used', result['warnings'][0])
+ self.assertFalse(Path(tmp, 'hero.jpg').exists())
+ self.assertEqual(Path(tmp, 'grid.jpg').read_bytes(), (FIXTURES / 'icon.jpg').read_bytes())
+
+ def test_stop_uses_exact_64_bit_game_id_string(self):
+ with patch.object(sys, 'argv', ['steam_shortcuts.py', 'stop', '3346865537']), \
+ patch.object(shortcuts, 'evaluate') as evaluate, patch('builtins.print'):
+ shortcuts.main()
+ self.assertEqual(evaluate.call_args.args[0], 'SteamClient.Apps.TerminateApp("14374678025558032384", false)')
+
+
+class SteamContextTests(unittest.TestCase):
+ @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
+ def test_collection_lifecycle_and_native_artwork_calls(self):
+ steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Before'}], 'compat_tools': {},
+ 'collections': [{'name': 'Android', 'apps': [999]}]}
+ def evaluate(expression, timeout=20):
+ nonlocal steam
+ proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
+ input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
+ 'steam': steam}) + '\n',
+ text=True, capture_output=True, timeout=10, check=True)
+ reply = json.loads(proc.stdout)
+ self.assertNotIn('exceptionDetails', reply['result'])
+ steam = reply['steam']
+ return reply['result']['result'].get('value')
+ with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
+ result = shortcuts.configure(42, 'Game', '/exe', '/dir', '/icon', True,
+ {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
+ self.assertEqual(result['warnings'], [])
+ self.assertTrue(steam['shortcuts'][0]['vr'])
+ self.assertEqual(set(steam['shortcuts'][0]['artwork']), {'0', '1', '2', '3'})
+ self.assertEqual(steam['collections'], [{'name': 'Android', 'apps': [999, 42]},
+ {'name': 'Android VR', 'apps': [42]}])
+ shortcuts.configure(42, 'Renamed', '/exe', '/dir', '/icon', False,
+ {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS})
+ self.assertEqual(steam['shortcuts'][0]['name'], 'Renamed')
+ self.assertEqual(steam['collections'][1]['apps'], [])
+ with patch.object(sys, 'argv', ['steam_shortcuts.py', 'list']), patch('builtins.print') as out:
+ shortcuts.main()
+ self.assertEqual(json.loads(out.call_args.args[0]),
+ [{'appid': 42, 'name': 'Renamed', 'exe': '/exe', 'start_dir': '/dir'}])
+ shortcuts.remove(42)
+ self.assertEqual(steam['shortcuts'], [])
+ self.assertEqual(steam['collections'][0]['apps'], [999])
+
+ @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
+ def test_fill_only_keeps_customised_name_icon_and_art(self):
+ custom = {'0': {'data': 'mine-grid', 'ext': 'png'}, '1': {'data': 'mine-hero', 'ext': 'jpg'}}
+ steam = {'apps': [], 'compat_tools': {}, 'collections': [],
+ 'shortcuts': [{'appid': 42, 'name': 'My Name', 'icon': '/mine.png', 'vr': True, 'artwork': dict(custom)}]}
+ def evaluate(expression, timeout=20):
+ nonlocal steam
+ proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
+ input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
+ 'steam': steam}) + '\n',
+ text=True, capture_output=True, timeout=10, check=True)
+ reply = json.loads(proc.stdout)
+ self.assertNotIn('exceptionDetails', reply['result'])
+ steam = reply['steam']
+ return reply['result']['result'].get('value')
+ with tempfile.TemporaryDirectory() as home:
+ grid = Path(home, '.local/share/Steam/userdata/1/config/grid')
+ grid.mkdir(parents=True)
+ (grid / '42p.png').write_bytes(b'mine')
+ (grid / '42_hero.jpg').write_bytes(b'mine')
+ with patch.dict(os.environ, {'HOME': home, 'USERPROFILE': home}), patch.object(shortcuts, 'evaluate', side_effect=evaluate):
+ self.assertEqual(shortcuts.custom_art(42), {0, 1})
+ shortcuts.configure(42, 'Generated', '/exe', '/dir', '/generated.png', False,
+ {slot: str(FIXTURES / 'icon.png') for slot in art.SLOTS},
+ {'category': 'Sideloaded', 'fill_only': True})
+ s = steam['shortcuts'][0]
+ self.assertEqual((s['name'], s['icon'], s['vr']), ('My Name', '/mine.png', True))
+ self.assertEqual({k: s['artwork'][k] for k in ('0', '1')}, custom)
+ self.assertEqual(set(s['artwork']), {'0', '1', '2', '3'})
+
+ @unittest.skipUnless(__import__('shutil').which('node'), 'optional V8 fixture check requires node')
+ def test_remove_without_collections_or_artwork_api_still_removes(self):
+ steam = {'apps': [], 'shortcuts': [{'appid': 42, 'name': 'Game', 'exe': '"/home/steamos/devkit-game/G/g"',
+ 'start_dir': '/home/steamos/devkit-game/G'}], 'compat_tools': {}}
+ def evaluate(expression, timeout=20):
+ nonlocal steam
+ expression = ('delete globalThis.collectionStore;'
+ 'SteamClient.Apps.ClearCustomArtworkForApp = async () => { throw Error("busy"); };' + expression)
+ proc = subprocess.run(['node', str(ROOT / 'tests/fakeframe/rootfs/usr/local/lib/fakeframe/cef_shim.js')],
+ input=json.dumps({'id': 1, 'expression': expression, 'awaitPromise': True,
+ 'steam': steam}) + '\n',
+ text=True, capture_output=True, timeout=10, check=True)
+ reply = json.loads(proc.stdout)
+ self.assertNotIn('exceptionDetails', reply['result'])
+ steam = reply['steam']
+ return reply['result']['result'].get('value')
+ with patch.object(shortcuts, 'evaluate', side_effect=evaluate):
+ result = shortcuts.remove(42)
+ self.assertEqual(steam['shortcuts'], [])
+ self.assertEqual(len(result['warnings']), 5)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_frame_apk.py b/tests/test_frame_apk.py
index 1af14f9..ebe7b0b 100644
--- a/tests/test_frame_apk.py
+++ b/tests/test_frame_apk.py
@@ -1,4 +1,5 @@
"""frame_apk against a small APK built here: binary manifest plus resource table."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import os
import struct
diff --git a/tests/test_frame_apk_versions.py b/tests/test_frame_apk_versions.py
index 8cf7701..ea70373 100644
--- a/tests/test_frame_apk_versions.py
+++ b/tests/test_frame_apk_versions.py
@@ -1,4 +1,5 @@
"""Offline version lookup with small index-v2 fixtures."""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import io
import json
import os
@@ -242,6 +243,7 @@ class UploadVersionsTest(unittest.TestCase):
handler.rfile = io.BytesIO(b'x')
with patch.object(frame_android, 'apk_info', return_value=dict(info)), \
patch.object(versions, 'alternatives', side_effect=AssertionError('lookup during upload')) as lookup, \
+ patch.object(frame_android, 'install_hooks', []), \
patch.object(server, 'ensure_master') as ssh:
if mode == 'apkinfo':
reply = handler.upload()
@@ -256,5 +258,28 @@ class UploadVersionsTest(unittest.TestCase):
ssh.assert_not_called()
+ def test_blocked_uploads_are_reported_like_failed_installs(self):
+ import server
+ info = {'package': 'org.example.app', 'label': 'Example', 'version': '5',
+ 'version_code': 5, 'min_sdk': 33, 'abis': [], 'icon_png': None}
+ for apk_info, expected_info in ((dict(info), 'org.example.app'),
+ (frame_android.FrameError('not an APK'), None)):
+ handler = object.__new__(server.Handler)
+ handler.headers = {'X-Filename': 'app.apk', 'X-Mode': 'apk', 'Content-Length': '1'}
+ handler.rfile = io.BytesIO(b'x')
+ calls = []
+ patch_info = (patch.object(frame_android, 'apk_info', side_effect=apk_info)
+ if isinstance(apk_info, Exception) else
+ patch.object(frame_android, 'apk_info', return_value=apk_info))
+ with patch_info, patch.object(frame_android, 'install_hooks', [lambda *a: calls.append(a)]), \
+ patch.object(server, 'ensure_master'):
+ with self.assertRaises(server.Failure):
+ handler.upload()
+ self.assertEqual(len(calls), 1)
+ got_info, meta, error, _ = calls[0]
+ self.assertEqual((got_info or {}).get('package'), expected_info)
+ self.assertIsNone(meta)
+ self.assertIsInstance(error, frame_android.FrameError)
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_frame_apk_vr.py b/tests/test_frame_apk_vr.py
new file mode 100644
index 0000000..49a56ca
--- /dev/null
+++ b/tests/test_frame_apk_vr.py
@@ -0,0 +1,297 @@
+"""Local-only VR manifest, raw ZIP and independent signature checks."""
+import io
+import os
+from pathlib import Path
+import struct
+import subprocess
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+import zipfile
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+import frame_apk
+import frame_apk_vr as vr
+import frame_apk_sign as signing
+import frame_android
+from test_frame_apk import pool
+
+
+DEFAULT = 'android.intent.category.DEFAULT'
+
+
+def manifest(utf8=False, launcher=False, category=None, samsung=False, split=False, alias=False, splash=False):
+ strings = ['manifest', 'package', 'org.test.vr', 'application', 'activity', 'intent-filter',
+ 'action', 'category', 'name', vr.MAIN, category or next(iter(sorted(vr.VR))),
+ vr.LAUNCHER, 'http://schemas.android.com/apk/res/android', 'meta-data', 'value',
+ 'com.samsung.android.vr.application.mode', 'vr_only', 'activity-alias', DEFAULT, next(iter(sorted(vr.VR))), 'targetActivity', '.Splash', '.Game']
+ def start(tag, attrs=()):
+ body = struct.pack('" in c[0]]
+ self.assertEqual([c[1] for c in copies], [b"first", b"second"])
+ self.assertIn("a-1-1-aarch64.pkg.tar.zst.part", copies[0][0])
+
+ def test_refuses_a_damaged_bundle(self):
+ with self.assertRaises(self.server.Failure):
+ self.deliver(frame_has=False, damaged=True)
+
+ def test_a_failed_copy_is_removed(self):
+ calls = []
+
+ def ssh(remote, stdin=None, timeout=30, text=True):
+ calls.append(remote)
+ if "cat >" in remote and len([c for c in calls if "cat >" in c]) == 2:
+ raise self.server.Failure("Broken pipe")
+ return b"" if not text else ""
+ folder, packages = self.fake_bundle()
+ old = self.server.ssh, self.server.KDECONNECT, self.server.LOCAL
+ self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = ssh, folder, False
+ try:
+ with self.assertRaises(self.server.Failure):
+ self.server.InputAgent(packages=packages).deliver(lambda m: None)
+ finally:
+ self.server.ssh, self.server.KDECONNECT, self.server.LOCAL = old
+ self.assertTrue(calls[-1].startswith("rm -rf .local/share/frame-control/kdeconnect/incoming/"), calls[-1])
+
+ def lifecycle(self, agent_lines, deliver=None):
+ """An InputAgent whose ssh and agent are fakes. Returns it, the folders each launch
+ used, and the fake agents. A fake agent reports its lines, then keeps running
+ (unless its lines end with need-packages) until the test ends."""
+ launches, procs, done = [], [], threading.Event()
+ self.addCleanup(done.set)
+
+ class Agent(self.server.InputAgent):
+ def deliver(self, report, force=False):
+ if deliver:
+ deliver()
+ return "~/copied" if force else ""
+
+ def command(self, folder=""):
+ launches.append(folder)
+ return "agent"
+
+ class Proc:
+ stdin = None
+
+ def __init__(self, lines):
+ self.lines, self.ended = lines, threading.Event()
+ procs.append(self)
+
+ @property
+ def stdout(self):
+ yield from self.lines
+ if self.lines and b"need-packages" not in self.lines[-1]:
+ while not (done.is_set() or self.ended.is_set()):
+ self.ended.wait(0.02)
+ self.ended.set()
+
+ def wait(self):
+ self.ended.wait(5)
+ return 0
+
+ def poll(self):
+ return 0 if self.ended.is_set() else None
+
+ def terminate(self):
+ self.ended.set()
+
+ def popen(*a, **k):
+ return Proc(agent_lines.pop(0) if agent_lines else [b'{"state": "ready"}\n'])
+ old = self.server.ensure_master, self.server.subprocess.Popen
+ self.server.ensure_master, self.server.subprocess.Popen = lambda: None, popen
+ self.addCleanup(lambda: (setattr(self.server, "ensure_master", old[0]),
+ setattr(self.server.subprocess, "Popen", old[1])))
+ agent = Agent(packages=[("a.pkg.tar.zst", "0" * 64)])
+
+ def settle(): # nothing of this test may still be launching when the next one starts
+ agent.stop()
+ for _ in range(250):
+ if agent.launching is None and all(p.ended.is_set() for p in procs):
+ return
+ time.sleep(0.02)
+ self.addCleanup(settle)
+ return agent, launches, procs
+
+ def wait_for(self, check):
+ for _ in range(250):
+ if check():
+ return
+ time.sleep(0.02)
+ self.fail("timed out")
+
+ def test_agent_asking_for_packages_gets_them_and_starts_again(self):
+ agent, launches, procs = self.lifecycle([[b'{"state": "need-packages"}\n'], [b'{"state": "ready"}\n']])
+ agent.start()
+ self.wait_for(lambda: agent.status == {"state": "ready"})
+ self.assertEqual(launches, ["", "~/copied"])
+ self.assertIs(agent.proc, procs[1])
+
+ def test_asking_twice_is_an_error_not_a_loop(self):
+ need = [b'{"state": "need-packages"}\n']
+ agent, launches, _ = self.lifecycle([need, list(need)])
+ agent.start()
+ self.wait_for(lambda: agent.status.get("state") == "error")
+ self.assertEqual(launches, ["", "~/copied"])
+ self.assertIn("didn't reach", agent.status["message"])
+
+ def test_start_after_stop_during_the_copy_still_starts(self):
+ gate, entered = threading.Event(), threading.Event()
+ agent, launches, procs = self.lifecycle([], deliver=lambda: (entered.set(), gate.wait(5)))
+ agent.start()
+ self.assertTrue(entered.wait(5))
+ agent.stop()
+ entered.clear()
+ agent.start() # while the first launch is still copying
+ self.assertTrue(entered.wait(5), "the second start didn't launch")
+ gate.set()
+ self.wait_for(lambda: agent.status == {"state": "ready"})
+ time.sleep(0.1)
+ # The stopped launch started no agent; the new one is the one in use.
+ self.assertEqual(len(procs), 1)
+ self.assertIs(agent.proc, procs[0])
+ agent.stop()
+ self.wait_for(lambda: all(p.ended.is_set() for p in procs))
+
+ def test_retry_and_a_new_start_never_run_two_agents(self):
+ # A start() landing just as an agent that asked for the packages exits: exactly one
+ # of it and the retry launches, and stop() ends everything. The new start is held
+ # in its copy until the retry has decided, so neither can see the other's agent.
+ first, decided = [], threading.Event()
+ agent, launches, procs = self.lifecycle([[b'{"state": "need-packages"}\n'], [b'{"state": "ready"}\n']])
+ watch, launch, deliver = agent._watch, agent._launch, agent.deliver
+
+ def racing_watch(proc, errors, retry=False):
+ wanted, heard = watch(proc, errors, retry)
+ if wanted and not first:
+ first.append(threading.current_thread())
+ agent.start() # lands between the agent exiting and the retry
+ return wanted, heard
+
+ def held_deliver(report, force=False):
+ if force:
+ decided.set() # the retry went ahead
+ elif first and threading.current_thread() is not first[0]:
+ decided.wait(5) # the new start waits until the retry has decided
+ return deliver(report, force)
+
+ def first_launch(generation, force=False):
+ try:
+ launch(generation, force)
+ finally:
+ if first and threading.current_thread() is first[0]:
+ decided.set() # the retry declined
+ agent._watch, agent._launch, agent.deliver = racing_watch, first_launch, held_deliver
+ agent.start()
+ # Both have decided once the new start has launched its agent (it always does).
+ self.wait_for(lambda: decided.is_set() and launches.count("") == 2 and len(procs) == len(launches))
+ self.wait_for(lambda: agent.status == {"state": "ready"})
+ self.assertEqual(sum(not p.ended.is_set() for p in procs), 1, launches)
+ agent.stop()
+ self.wait_for(lambda: all(p.ended.is_set() for p in procs))
+
+ def test_stopped_during_the_copy_removes_it(self):
+ gate, entered = threading.Event(), threading.Event()
+ agent, launches, procs = self.lifecycle([], deliver=lambda: (entered.set(), gate.wait(5)))
+ removed = []
+ agent.discard = removed.append
+ agent.deliver = lambda report, force=False: (entered.set(), gate.wait(5), "~/incoming/x")[2]
+ self.addCleanup(lambda: self.assertEqual(removed, ["~/incoming/x"]))
+ agent.start()
+ self.assertTrue(entered.wait(5))
+ agent.stop()
+ gate.set()
+ self.wait_for(lambda: removed == ["~/incoming/x"])
+ self.assertEqual(procs, []) # no agent started for it
+
+ def test_a_copy_whose_agent_never_starts_is_removed(self):
+ agent, launches, procs = self.lifecycle([[]]) # the agent dies before saying anything
+ removed = []
+ agent.discard = removed.append
+ agent.deliver = lambda report, force=False: "~/incoming/y"
+ agent.start()
+ self.wait_for(lambda: removed == ["~/incoming/y"])
+
+ def test_discard_only_touches_copies(self):
+ calls = []
+ old = self.server.ssh, self.server.LOCAL
+ self.server.ssh, self.server.LOCAL = (lambda remote, **k: calls.append(remote)), False
+ try:
+ agent = self.server.InputAgent(packages=[])
+ agent.discard("")
+ agent.discard("~/.local/share/frame-control/kdeconnect")
+ agent.discard("~/.local/share/frame-control/kdeconnect/incoming/ab12")
+ self.server.LOCAL = True
+ agent.discard("~/.local/share/frame-control/kdeconnect/incoming/ab12")
+ finally:
+ self.server.ssh, self.server.LOCAL = old
+ self.assertEqual(calls, ["rm -rf .local/share/frame-control/kdeconnect/incoming/ab12"])
+
+ def test_a_launch_error_leaves_it_ready_to_try_again(self):
+ # Popen fails (say, out of file handles) and so does removing the copy: the
+ # page gets the error and the next start launches.
+ agent, launches, procs = self.lifecycle([])
+ agent.deliver = lambda report, force=False: "~/.local/share/frame-control/kdeconnect/incoming/z"
+ old_popen, old_ssh = self.server.subprocess.Popen, self.server.ssh
+ self.server.subprocess.Popen = self.server.ssh = lambda *a, **k: (_ for _ in ()).throw(OSError(24, "Too many open files"))
+ try:
+ agent.start()
+ self.wait_for(lambda: agent.status.get("state") == "error")
+ finally:
+ self.server.subprocess.Popen, self.server.ssh = old_popen, old_ssh
+ self.assertIsNone(agent.launching)
+ agent.start()
+ self.wait_for(lambda: agent.status == {"state": "ready"})
+
+ def test_any_unexpected_error_still_reports_and_allows_a_retry(self):
+ agent, launches, procs = self.lifecycle([])
+ old = self.server.tempfile.TemporaryFile
+ self.server.tempfile.TemporaryFile = lambda: (_ for _ in ()).throw(OSError(24, "Too many open files"))
+ try:
+ agent.start()
+ self.wait_for(lambda: agent.status.get("state") == "error")
+ finally:
+ self.server.tempfile.TemporaryFile = old
+ self.assertIsNone(agent.launching)
+ agent.deliver = lambda report, force=False: (_ for _ in ()).throw(ValueError("something odd"))
+ agent.start()
+ self.wait_for(lambda: "something odd" in agent.status.get("message", ""))
+ self.assertIsNone(agent.launching)
+
+ def test_copies_go_to_a_folder_of_their_own(self):
+ first, _, _ = self.deliver(frame_has=False)
+ second, _, _ = self.deliver(frame_has=False)
+ self.assertNotEqual(first, second)
+
+
+@unittest.skipIf(sys.platform == "win32", "the agent runs on the Frame (Linux)")
+class AgentInstall(unittest.TestCase):
+ """The agent unpacks what the server copied, after checking each SHA-256."""
+
+ @classmethod
+ def setUpClass(cls):
+ import frame_input_agent
+ cls.agent = frame_input_agent
+
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp())
+ self.addCleanup(shutil.rmtree, self.dir)
+ saved = self.agent.BASE, self.agent.ROOT, self.agent.stop_daemon, self.agent.say
+ self.addCleanup(lambda: setattr_all(self.agent, saved))
+ self.agent.BASE, self.agent.ROOT = self.dir / "base", self.dir / "base/root"
+ self.agent.stop_daemon, self.agent.say = lambda: None, lambda *a, **k: None
+ self.agent.BASE.mkdir()
+
+ def package(self):
+ src = self.dir / "src"
+ (src / "usr/lib").mkdir(parents=True)
+ (src / "usr/lib/kdeconnectd").write_text("#!/bin/sh\n")
+ out = self.dir / "incoming/kdeconnect-24.02.2-1-aarch64.pkg.tar.zst"
+ out.parent.mkdir()
+ if subprocess.run(["tar", "--zstd", "-cf", str(out), "-C", str(src), "usr"], capture_output=True).returncode:
+ self.skipTest("this tar can't write zstd")
+ return out, [(out.name, self.agent.sha256(out))]
+
+ def test_unpacks_and_stamps(self):
+ path, packages = self.package()
+ self.assertFalse(self.agent.installed(packages))
+ self.agent.install(path.parent, packages)
+ self.assertTrue((self.agent.ROOT / "usr/lib/kdeconnectd").is_file())
+ self.assertTrue(self.agent.installed(packages))
+ self.assertFalse(self.agent.installed([(path.name, "0" * 64)]))
+
+ def test_refuses_a_damaged_package(self):
+ path, packages = self.package()
+ with open(path, "ab") as f:
+ f.write(b"!")
+ with self.assertRaisesRegex(RuntimeError, "damaged"):
+ self.agent.install(path.parent, packages)
+ self.assertFalse(self.agent.ROOT.exists())
+
+ def test_missing_package_and_empty_manifest(self):
+ with self.assertRaisesRegex(RuntimeError, "didn't reach"):
+ self.agent.install(self.dir, [("nope.pkg.tar.zst", "0" * 64)])
+ with self.assertRaisesRegex(RuntimeError, "doesn't include"):
+ self.agent.install(self.dir, [])
+
+ def test_leaves_another_devices_running_copy_alone(self):
+ # A different build is running for another device: use it, don't stop it to reinstall.
+ self.agent.listening, old = (lambda: True), self.agent.listening
+ self.agent.our_daemons, old_ours = (lambda: [123]), self.agent.our_daemons
+ try:
+ self.agent.ensure_daemon("", [("new.pkg.tar.zst", "1" * 64)])
+ finally:
+ self.agent.listening, self.agent.our_daemons = old, old_ours
+ self.assertFalse(self.agent.ROOT.exists())
+
+ def test_asks_for_packages_it_was_not_sent(self):
+ self.agent.listening, old = (lambda: False), self.agent.listening
+ try:
+ for folder in ("", str(self.dir / "gone")): # none sent, or already tidied away
+ with self.assertRaises(self.agent.NeedPackages):
+ self.agent.ensure_daemon(folder, [("new.pkg.tar.zst", "1" * 64)])
+ finally:
+ self.agent.listening = old
+
+ def test_restart_can_still_unpack_its_copy_then_tidies_it(self):
+ folder = self.agent.BASE / "incoming/abc"
+ folder.mkdir(parents=True)
+ seen = []
+ stubs = {"ensure_daemon": lambda f, p: seen.append(Path(f).is_dir()),
+ "identity": lambda c: ("id", "cert", "key"), "our_daemons": lambda: [123],
+ "connect": lambda *a: (_ for _ in ()).throw(OSError("no answer"))}
+ saved = {k: getattr(self.agent, k) for k in stubs}
+ self.addCleanup(lambda: [setattr(self.agent, k, v) for k, v in saved.items()])
+ for k, v in stubs.items():
+ setattr(self.agent, k, v)
+ self.assertEqual(self.agent.run("c", "n", str(folder), []), 1)
+ self.assertEqual(seen, [True, True]) # there for the first start and the restart
+ self.assertFalse(folder.exists())
+
+ def test_tidy_keeps_copies_in_use(self):
+ incoming = self.agent.BASE / "incoming"
+ held_dir = incoming / "held"
+ held_dir.mkdir(parents=True)
+ held = self.agent.hold_incoming(str(held_dir))
+ self.addCleanup(held.close)
+ os.utime(held_dir, (time.time() - 7200,) * 2)
+ self.agent.tidy_incoming("")
+ self.assertTrue(held_dir.is_dir())
+
+ def test_tidy_keeps_other_starts_copies(self):
+ incoming = self.agent.BASE / "incoming"
+ mine, theirs, stale = incoming / "mine", incoming / "theirs", incoming / "stale"
+ for d in (mine, theirs, stale):
+ d.mkdir(parents=True)
+ os.utime(stale, (time.time() - 7200,) * 2)
+ self.agent.tidy_incoming(str(mine))
+ self.assertEqual(sorted(p.name for p in incoming.iterdir()), ["theirs"])
+
+ def test_server_and_agent_agree_on_the_stamp(self):
+ import server
+ packages = [("a.pkg.tar.zst", "1" * 64), ("b.pkg.tar.zst", "2" * 64)]
+ self.assertEqual(server.kdeconnect_stamp(packages), self.agent.stamp(packages))
+
+
+def setattr_all(module, saved):
+ module.BASE, module.ROOT, module.stop_daemon, module.say = saved
+
+
+class FakeKdeConnect:
+ """Just enough of kdeconnectd: pairs when asked and records remote-input packets."""
+
+ def __init__(self, paired=False):
+ self.listener = socket.socket()
+ self.listener.bind(("127.0.0.1", 0))
+ self.listener.listen(1)
+ self.port = self.listener.getsockname()[1]
+ self.paired, self.identity, self.received, self.pair_requests = paired, None, [], 0
+ threading.Thread(target=self.serve, daemon=True).start()
+
+ def serve(self):
+ conn, _ = self.listener.accept()
+ line = b""
+ while not line.endswith(b"\n"):
+ line += conn.recv(1)
+ self.identity = json.loads(line)
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+ ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
+ tls = ctx.wrap_socket(conn) # "Starting client ssl (but I'm the server TCP socket)"
+ tls.sendall(b'{"id": 1, "type": "kdeconnect.mousepad.keyboardstate", "body": {"state": true}}\n')
+ buf = b""
+ while True:
+ try:
+ chunk = tls.recv(65536)
+ except OSError:
+ return
+ if not chunk:
+ return
+ buf += chunk
+ while b"\n" in buf:
+ raw, buf = buf.split(b"\n", 1)
+ p = json.loads(raw)
+ if p["type"] == "kdeconnect.pair":
+ self.pair_requests += 1
+ elif p["type"] == "kdeconnect.mousepad.request" and self.paired:
+ self.received.append(p["body"])
+
+
+@unittest.skipIf(sys.platform == "win32", "the agent runs on the Frame (Linux)")
+@unittest.skipUnless(shutil.which("openssl"), "needs openssl to make a certificate")
+class AgentProtocol(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ import frame_input_agent
+ cls.agent = frame_input_agent
+ cls.dir = tempfile.TemporaryDirectory()
+ cls.cert, cls.key = Path(cls.dir.name, "cert.pem"), Path(cls.dir.name, "key.pem")
+ subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
+ "-subj", "/CN=framecontrol_test", "-keyout", str(cls.key), "-out", str(cls.cert)],
+ check=True, capture_output=True)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.dir.cleanup()
+
+ def wait_for(self, check):
+ for _ in range(100):
+ if check():
+ return
+ time.sleep(0.02)
+ self.fail("timed out")
+
+ def test_pairs_then_forwards_events(self):
+ fake = FakeKdeConnect()
+ link = self.agent.Link("framecontrol_0123456789abcdef01234567", self.cert, self.key, port=fake.port)
+ link.pair(lambda: fake.paired, lambda: setattr(fake, "paired", fake.pair_requests > 0), timeout=5)
+ self.assertEqual(fake.pair_requests, 1)
+ ident = fake.identity["body"]
+ self.assertEqual(fake.identity["type"], "kdeconnect.identity")
+ self.assertEqual(ident["protocolVersion"], 7)
+ self.assertIn("kdeconnect.mousepad.request", ident["outgoingCapabilities"])
+ link.read(0.5)
+ self.assertTrue(link.keyboard)
+ for body in self.agent.events(b'[{"dx": 4, "dy": -2}, {"key": "hi"}]'):
+ link.send(body)
+ self.wait_for(lambda: len(fake.received) == 2)
+ self.assertEqual(fake.received, [{"dx": 4, "dy": -2}, {"key": "hi"}])
+
+ def test_already_paired_sends_no_pair_request(self):
+ # A pair request to a device that's already paired makes KDE Connect unpair it.
+ fake = FakeKdeConnect(paired=True)
+ link = self.agent.Link("framecontrol_0123456789abcdef01234567", self.cert, self.key, port=fake.port)
+ link.pair(lambda: True, lambda: self.fail("accepted a pairing that wasn't needed"))
+ link.send({"singleclick": True})
+ self.wait_for(lambda: fake.received == [{"singleclick": True}])
+ self.assertEqual(fake.pair_requests, 0)
+
+ def test_client_args_are_folder_safe(self):
+ old = sys.argv
+ try:
+ sys.argv = ["-c", "../../etc x", "Alex's Mac"]
+ self.assertEqual(self.agent.client_args(), ("etcx", "Frame Control (Alex's Mac)", "", []))
+ sys.argv = ["-c"]
+ self.assertEqual(self.agent.client_args(), ("default", "Frame Control", "", []))
+ sys.argv = ["-c", "mac", "Mac", "~/x", '[["a.pkg.tar.zst", "ab"]]']
+ self.assertEqual(self.agent.client_args()[2:], (os.path.expanduser("~/x"), [("a.pkg.tar.zst", "ab")]))
+ finally:
+ sys.argv = old
+
+ def test_events_parsing(self):
+ self.assertEqual(self.agent.events(b'{"dx": 1}'), [{"dx": 1}])
+ self.assertEqual(self.agent.events(b'[{"dx": 1}, 5, {}]'), [{"dx": 1}])
+ self.assertEqual(self.agent.events(b"not json"), [])
+
+
+@unittest.skipUnless(shutil.which("node"), "needs node")
+class PageQueue(unittest.TestCase):
+ """The page's input queue (tests/page/pad_queue.mjs runs the real functions from index.html)."""
+
+ def test_queue_rules(self):
+ r = subprocess.run(["node", str(ROOT / "tests" / "page" / "pad_queue.mjs")], capture_output=True, text=True)
+ self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_library_entrypoints.py b/tests/test_library_entrypoints.py
new file mode 100644
index 0000000..2796353
--- /dev/null
+++ b/tests/test_library_entrypoints.py
@@ -0,0 +1,368 @@
+"""Every public sideload entry point reaches the mandatory five-slot writer."""
+import contextlib
+import io
+import json
+import shutil
+import subprocess
+from pathlib import Path
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / 'ui'))
+import frame_android as android
+import frame_artwork as artwork
+import frame_catalog as catalog
+import frame_apk_versions as versions
+import frame_titles as titles
+import frame_steamgriddb as sgdb
+import server
+import frame_webinstall as webinstall
+
+
+class EntryPoints(unittest.TestCase):
+ def setUp(self):
+ self.stack = contextlib.ExitStack()
+ self.addCleanup(self.stack.close)
+ self.info = {'package':'org.example.game', 'label':'Example', 'version':'1', 'version_code':1,
+ 'vr':False, 'repairable':False, 'launchable':True, 'min_sdk':24, 'abis':[], 'icon_png':None}
+ self.stack.enter_context(patch.object(android, 'apk_info', return_value=self.info))
+ self.stack.enter_context(patch.object(android, 'read_meta', return_value=None))
+ self.stack.enter_context(patch.object(android, '_copy'))
+ self.stack.enter_context(patch.object(android, 'ssh', return_value='/home/steamos'))
+ self.stack.enter_context(patch.object(artwork, 'prepare', return_value=({}, [])))
+ self.api = self.stack.enter_context(patch.object(android, 'shortcut_tool', side_effect=self.steam))
+
+ def steam(self, *args, **kwargs):
+ if args[0] == 'add': return '3346865537'
+ if args[0] == 'render': return json.dumps({'paths': {s:'/tmp/'+s+'.png' for s in artwork.SLOTS}})
+ if args[0] == 'list': return json.dumps([{'appid':3346865537,'name':'Example','devkit_gameid':'Example'}])
+ return '{"warnings":[]}'
+
+ def assert_art(self):
+ calls = [c.args for c in self.api.call_args_list]
+ self.assertEqual(sum(c[0] == 'render' for c in calls), 1)
+ configs = [c for c in calls if c[0] == 'configure']
+ self.assertEqual(len(configs), 1)
+ self.assertEqual(set(json.loads(configs[0][7])), set(artwork.SLOTS))
+
+ def test_cli_install(self):
+ with patch.object(sys, 'argv', ['frame_android.py', 'install', 'game.apk']), patch('builtins.print'):
+ android.main()
+ self.assert_art()
+
+ def test_file_upload(self):
+ class Request:
+ headers = {'X-Filename':'game.apk','X-Mode':'apk','Content-Length':'3'}
+ rfile = io.BytesIO(b'apk')
+ with patch.object(server, 'ensure_master'):
+ result = server.Handler.upload(Request())
+ self.assertEqual(result['app']['package'], self.info['package'])
+ self.assert_art()
+
+ def test_catalogue_install(self):
+ with patch.object(catalog, 'app', return_value={'r':'yes','t':False,'n':'Example'}), \
+ patch.object(catalog, 'fetch_apk', return_value='game.apk'), \
+ patch.object(catalog, 'fetch_icon', return_value=None):
+ catalog.install(self.info['package'])
+ self.assert_art()
+
+ def test_version_finder_install(self):
+ record = {'url':'https://example.org/app.apk','sha256':'fixture','version_code':1,'source':'F-Droid'}
+ with patch.object(versions, '_versions', return_value=([record], [])), \
+ patch.object(catalog, 'fetch_apk', return_value='game.apk'):
+ versions.install(self.info['package'], record['url'])
+ self.assert_art()
+
+ def test_web_download_install(self):
+ result = webinstall.dispatch('game.apk', source='https://example.org/game.apk')
+ self.assertEqual(result['kind'], 'apk')
+ self.assert_art()
+
+ def test_refresh_api_covers_android_apps_and_titles(self):
+ with patch.object(server, 'ensure_master'), \
+ patch.object(server, 'start_job', side_effect=lambda label, work: work()), \
+ patch.object(android, 'refresh_art', return_value=[]) as refresh, \
+ patch.object(titles, 'refresh_art', return_value=[{'id':'G','error':'x'}]) as title_refresh:
+ self.assertEqual(server.android({'action':'refresh-art', 'all':True}),
+ {'apps':[], 'titles':[{'id':'G','error':'x'}]})
+ refresh.assert_called_once_with(); title_refresh.assert_called_once_with()
+ server.titles({'action':'refresh-art', 'id':'G'})
+ title_refresh.assert_called_with('G')
+
+ def test_backfill_fills_only_entries_pending_since_install(self):
+ import threading
+ ran = threading.Event()
+ with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
+ patch.object(android, 'refresh_art', side_effect=[RuntimeError('odd'), None]) as refresh, \
+ patch.object(titles, 'refresh_art', side_effect=lambda gid, **kw: ran.set()) as title_refresh:
+ apps = [{'package':'org.a.x','art_pending':True}, {'package':'org.b.x','art_pending':True},
+ {'package':'org.c.x','art_missing':True}] # legacy: no record of art, but not pending
+ with contextlib.redirect_stderr(io.StringIO()):
+ self.assertTrue(server.backfill_art(apps=apps, titles=[{'id':'G','art_pending':True}]))
+ self.assertTrue(ran.wait(5))
+ self.assertFalse(server.backfill_art(apps=apps)) # throttled
+ self.assertEqual([c.args for c in refresh.call_args_list], [('org.a.x',), ('org.b.x',)])
+ self.assertTrue(all(c.kwargs == {'fill_only': True} for c in refresh.call_args_list))
+ title_refresh.assert_called_once_with('G', fill_only=True)
+
+ def test_bulk_fill_only_refresh_keeps_names_and_art(self):
+ meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
+ with patch.object(android, 'list_apps', return_value=[{'package':self.info['package']}]), \
+ patch.object(android, '_meta_or_fail', return_value=meta), \
+ patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
+ patch.object(android, '_write_meta'):
+ android.refresh_art(fill_only=True)
+ options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8])
+ self.assertTrue(options['fill_only'])
+ self.api.reset_mock()
+ with patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'Game','frame_control':True}]), \
+ patch.object(titles, '_check_id', side_effect=lambda gid: gid), \
+ patch.object(titles, '_library_shortcut', return_value=7), \
+ patch.object(titles, 'ssh', side_effect=lambda cmd, **kw: '{"name":"Game"}' if cmd.startswith('cat devkit')
+ else '{"artwork":{},"icon":""}' if cmd == 'python3 -' else '/home/steamos'):
+ titles.refresh_art(fill_only=True)
+ options = json.loads(next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')[8])
+ self.assertTrue(options['fill_only'])
+
+ def test_upgrade_leaves_legacy_customised_titles_alone(self):
+ # A title installed before art_pending existed, whose art the user has customised in Steam.
+ legacy = [{'id':'Game','settings':{'compat_tool':'proton-experimental'},'argv':['game.exe'],
+ 'meta':{'name':'Game','target':'game.exe','source':'game.zip'}}]
+ with patch.object(titles, 'ssh', return_value=json.dumps(legacy)):
+ listed = titles.list_titles()
+ self.assertEqual((listed[0]['art_pending'], listed[0]['art_missing']), (False, False))
+ with patch.dict(server._backfill, {'running': False, 'last': 0.0}), \
+ patch.object(titles, 'refresh_art') as refresh, patch.object(android, 'refresh_art') as app_refresh:
+ self.assertFalse(server.backfill_art(apps=[{'package':'org.old.app','library_version':1}], titles=listed))
+ refresh.assert_not_called(); app_refresh.assert_not_called()
+ self.api.assert_not_called()
+
+ def test_art_missing_flags(self):
+ self.assertTrue(android.art_missing({'artwork': {}}))
+ self.assertTrue(android.art_missing({'artwork': {'grid': 'x'}}))
+ self.assertFalse(android.art_missing({'artwork': {s: 'x' for s in artwork.SLOTS}}))
+
+ def test_source_search_shared_installer_contract(self):
+ # Source workers hand their download and optional images to this public seam.
+ android.install('game.apk', name='Example', source='source-search', artwork={'banner': b'fixture'})
+ self.assert_art()
+
+ def test_native_title_install(self):
+ with tempfile.TemporaryDirectory() as root:
+ plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
+ 'runtime':'proton-experimental','source':'example.zip'}
+ def ssh(cmd, **kwargs):
+ if 'test -d' in cmd: return ''
+ if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
+ if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
+ return ''
+ with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
+ patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True):
+ result = titles._install(plan, lambda *args: None)
+ self.assertEqual(result['shortcut'], 3346865537)
+ self.assert_art()
+ config = next(c.args for c in self.api.call_args_list if c.args[0] == 'configure')
+ self.assertEqual(json.loads(config[8])['category'], 'Sideloaded')
+ self.assertEqual(config[3:5], ('','')) # Never replace devkit's executable/runtime wiring.
+ self.assertEqual(config[6], '') # nor the VR flag the title declares
+ self.assertEqual(set(result['artwork']), set(artwork.SLOTS))
+
+ def test_native_renamed_shortcut_uses_saved_identity(self):
+ self.api.side_effect = lambda *args, **kw: '[{"appid":42,"name":"Renamed"}]'
+ with patch.object(titles, 'ssh', return_value='{"shortcut":42}'):
+ self.assertEqual(titles._library_shortcut('Original', '/home/steamos/devkit-game/Original'), 42)
+
+ def test_native_shortcut_never_matched_by_name_alone(self):
+ d = '/home/steamos/devkit-game/Game'
+ shortcuts = [{'appid':1,'name':'Game','exe':'"/home/steamos/.local/bin/game"','start_dir':'/home/steamos'},
+ {'appid':2,'name':'Other','exe':'"/home/steamos/devkit-game/Game2/g.exe"','start_dir':''}]
+ self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts)
+ with patch.object(titles, 'ssh', return_value=''):
+ self.assertIsNone(titles._library_shortcut('Game', d))
+ shortcuts.append({'appid':3,'name':'Renamed','exe':'"/home/steamos/devkit-game/Game/bin/g.exe"','start_dir':''})
+ self.assertEqual(titles._library_shortcut('Game', d), 3)
+ shortcuts.append({'appid':4,'name':'Copy','exe':'','start_dir':d})
+ with self.assertRaisesRegex(android.FrameError, 'ambiguous'):
+ titles._library_shortcut('Game', d)
+
+ def test_native_cleanup_failure_keeps_original_error(self):
+ with tempfile.TemporaryDirectory() as root:
+ plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
+ 'runtime':'proton-experimental','source':'example.zip'}
+ def ssh(cmd, **kwargs):
+ if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
+ if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
+ return ''
+ def steam(*args, **kwargs):
+ if args[0] == 'remove': raise android.FrameError('Steam went away')
+ return self.steam(*args)
+ self.api.side_effect = steam
+ with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
+ patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
+ patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
+ with self.assertRaisesRegex(android.FrameError, 'render failed'):
+ titles._install(plan, lambda *args: None)
+
+ def test_native_remove_survives_steam_being_down(self):
+ cmds = []
+ def ssh(cmd, **kwargs):
+ cmds.append(cmd)
+ return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else ''
+ self.api.side_effect = android.FrameError('SharedJSContext not found')
+ with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'):
+ titles.remove('Game')
+ self.assertTrue(any('steamos-delete --delete-title Game' in c for c in cmds))
+
+ def test_native_remove_deletes_before_tidying_the_shortcut(self):
+ # steamos-delete finds the Proton prefix through the shortcut, so the shortcut must still exist.
+ order = []
+ def ssh(cmd, **kwargs):
+ if 'steamos-delete' in cmd:
+ order.append('delete')
+ return 'yes' if 'test -d' in cmd else '/home/steamos' if 'HOME' in cmd else ''
+ with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
+ patch.object(titles, '_library_shortcut', return_value=42), \
+ patch.object(titles.frame_android, 'shortcut_tool', side_effect=lambda *a: order.append(a)):
+ titles.remove('Game')
+ self.assertEqual(order, ['delete', ('remove', '42')])
+
+ def test_native_refresh_art_backfills_registered_title(self):
+ meta = {'id':'Game','name':'My Game','source':'game.zip'}
+ writes = []
+ def ssh(cmd, input=None, **kwargs):
+ if 'test -d' in cmd: return 'yes'
+ if 'HOME' in cmd: return '/home/steamos'
+ if cmd.startswith('cat devkit-game/Game-framecontrol.json'): return json.dumps(meta)
+ if cmd == 'python3 -':
+ self.assertIn("/home/steamos/devkit-game/Game/.frame-artwork", input)
+ return json.dumps({'artwork': {'banner': 'YmFubmVy'}, 'icon': ''})
+ if cmd.startswith('cat > devkit-game/Game-framecontrol.json'): writes.append(json.loads(input))
+ return ''
+ shortcuts = [{'appid':7,'name':'My Game','exe':'','start_dir':'/home/steamos/devkit-game/Game'}]
+ self.api.side_effect = lambda *args, **kw: json.dumps(shortcuts) if args[0] == 'list' else self.steam(*args)
+ with patch.object(titles, 'ssh', side_effect=ssh), \
+ patch.object(artwork, 'prepare', return_value=({}, [])) as prepare:
+ result = titles.refresh_art('Game')
+ self.assertEqual(prepare.call_args.args[2], {'banner': b'banner'})
+ self.assertEqual(result['shortcut'], 7)
+ self.assertEqual(set(writes[-1]['artwork']), set(artwork.SLOTS))
+ self.assert_art()
+ shortcuts.clear()
+ with patch.object(titles, 'ssh', side_effect=ssh), \
+ patch.object(titles, 'list_titles', return_value=[{'id':'Game','name':'My Game','frame_control':True},
+ {'id':'Valve','name':'V','frame_control':False}]):
+ results = titles.refresh_art()
+ self.assertEqual(len(results), 1)
+ self.assertIn("hasn't registered", results[0]['error'])
+
+ def test_native_failure_removes_new_blank_shortcut(self):
+ with tempfile.TemporaryDirectory() as root:
+ plan = {'id':'Example','name':'Example','root':root,'size':3,'target':'game.exe',
+ 'runtime':'proton-experimental','source':'example.zip'}
+ def ssh(cmd, **kwargs):
+ if 'steamos-prepare-upload' in cmd: return '{"directory":"/home/steamos/devkit-game/Example"}'
+ if 'steam-client-create-shortcut' in cmd: return '{"success":"registered"}'
+ return ''
+ with patch.object(titles, 'ssh', side_effect=ssh), patch.object(titles, 'ensure_utils'), \
+ patch.object(titles, '_copy_tree'), patch.object(titles, '_rsync', return_value=True), \
+ patch.object(android, 'apply_library', side_effect=android.FrameError('render failed')):
+ with self.assertRaisesRegex(android.FrameError, 'render failed'):
+ titles._install(plan, lambda *args: None)
+ self.assertIn(('remove', '3346865537'), [c.args for c in self.api.call_args_list])
+
+ def test_refresh_does_not_reinstall_or_stop(self):
+ meta = {**self.info, 'instance':2800000001, 'shortcut':3346865537, 'flatscreen':False}
+ with patch.object(android, '_meta_or_fail', return_value=meta), \
+ patch.object(android, 'ssh', side_effect=lambda cmd, **kw: json.dumps({'icon_png':''}) if cmd == 'python3 -' else '/home/steamos'), \
+ patch.object(android, 'stop') as stop, patch.object(android, '_copy') as copy:
+ android.refresh_art(self.info['package'])
+ stop.assert_not_called(); copy.assert_not_called(); self.assert_art()
+
+ def test_all_refresh_reports_partial_failures(self):
+ import http.client
+ with patch.object(android, 'list_apps', return_value=[{'package':'org.a.game'},{'package':'org.b.game'}]), \
+ patch.object(android, '_meta_or_fail', side_effect=[http.client.RemoteDisconnected('gone'),
+ AttributeError('odd')]):
+ result=android.refresh_art()
+ self.assertEqual(len(result),2)
+ self.assertTrue(all('error' in a for a in result))
+
+ def test_render_failure_cannot_report_success(self):
+ self.api.side_effect = lambda *args, **kw: '3346865537' if args[0]=='add' else '{"paths":{}}'
+ with self.assertRaisesRegex(android.FrameError,'every slot'):
+ android.install('game.apk')
+
+
+class Renderer(unittest.TestCase):
+ @unittest.skipUnless(shutil.which('node'), 'Node is needed for the canvas contract fixture')
+ def test_canvas_slots_and_title_placement(self):
+ subprocess.run(['node', str(ROOT / 'tests/fixtures/library/check-renderer.js')],
+ cwd=str(ROOT), check=True, capture_output=True, timeout=30)
+
+ def test_desktop_packages_include_renderer_and_settings(self):
+ config = json.loads((ROOT / 'app/package.json').read_text())
+ # Single-file resources (licenses/notices) do not need a filter.
+ resources = {r['from']: r.get('filter', ['**/*']) for r in config['build']['extraResources']}
+ self.assertIn('*.js', resources['../ui'])
+ self.assertIn('*.js', resources['../frame/android'])
+
+
+class SteamGridDB(unittest.TestCase):
+ def test_no_key_is_silent_and_offline(self):
+ with patch.object(sgdb,'api_key',return_value=''), patch.object(sgdb,'_get') as get:
+ self.assertEqual(sgdb.lookup('Game'),({},[]))
+ get.assert_not_called()
+
+ def test_exact_match_and_top_votes_per_slot(self):
+ calls=[]
+ def get(path,key,deadline=None):
+ calls.append(path)
+ if 'search' in path: return [{'id':1,'name':'Other Game'},{'id':2,'name':'Game'}]
+ dims=(600,900) if '600x900' in path else (920,430)
+ return [{'url':'https://example.org/low.png','score':2,'width':dims[0],'height':dims[1]},
+ {'url':'https://example.org/top.png','score':20,'width':dims[0],'height':dims[1]},
+ {'url':'https://example.org/nsfw.png','score':99,'nsfw':True,'width':dims[0],'height':dims[1]}]
+ with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=get):
+ images,warnings=sgdb.lookup('Game VR')
+ self.assertEqual(set(images),set(artwork.SLOTS));self.assertEqual(warnings,[])
+ self.assertTrue(all(url.endswith('/top.png') for url in images.values()))
+ self.assertTrue(all('/game/2?' in p for p in calls[1:]))
+ # SteamGridDB rejects JPEG in logo/icon queries (the live API returned an error for SuperTux).
+ for p in calls[1:]:
+ jpeg = 'image%2Fjpeg' in p
+ self.assertEqual(jpeg, p.startswith(('/grids/', '/heroes/')), p)
+
+ def test_unicode_titles_match_exactly_and_symbols_never_match_all(self):
+ self.assertEqual(sgdb._name('ビートセイバー VR!'), 'ビートセイバーvr')
+ with patch.object(sgdb,'api_key',return_value='test-key'), \
+ patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'},{'id':2,'name':'!!!'}]) as get:
+ self.assertEqual(sgdb.lookup('★★★'),({},[]))
+ get.assert_not_called()
+ self.assertEqual(sgdb.lookup('ビートセイバー'),({},[]))
+ with patch.object(sgdb,'api_key',return_value='test-key'), \
+ patch.object(sgdb,'_get',side_effect=AttributeError("'list' object has no attribute 'get'")):
+ self.assertEqual(sgdb.lookup('Game')[0],{})
+
+ def test_wrong_title_and_failed_lookup_fall_back(self):
+ with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',return_value=[{'id':1,'name':'Unrelated'}]):
+ self.assertEqual(sgdb.lookup('Game'),({},[]))
+ with patch.object(sgdb,'api_key',return_value='test-key'),patch.object(sgdb,'_get',side_effect=OSError('private-secret')):
+ result=sgdb.lookup('Game')
+ self.assertNotIn('private-secret',str(result));self.assertEqual(result[0],{})
+
+ def test_settings_never_return_key(self):
+ with tempfile.TemporaryDirectory() as root, patch.object(sgdb,'settings_path',return_value=Path(root)/'settings.json'), \
+ patch.dict(sgdb.os.environ,{},clear=True):
+ result=sgdb.save_settings({'steamgriddb_api_key':'secret-fixture'})
+ self.assertTrue(result['steamgriddb_configured'])
+ self.assertNotIn('secret-fixture',json.dumps(result))
+ self.assertEqual(sgdb.api_key(),'secret-fixture')
+ if sys.platform!='win32':self.assertEqual((Path(root)/'settings.json').stat().st_mode&0o777,0o600)
+ sgdb.save_settings({'steamgriddb_api_key':''})
+ self.assertFalse(sgdb.settings()['steamgriddb_configured'])
+
+
+if __name__=='__main__':unittest.main()
diff --git a/tests/test_link.py b/tests/test_link.py
new file mode 100644
index 0000000..782306e
--- /dev/null
+++ b/tests/test_link.py
@@ -0,0 +1,695 @@
+"""frame_link: finding a headset among its addresses and following each stage of
+connecting, with a stand-in ssh (tests/fakessh/ssh) and real sockets on this computer.
+Also the server's /api/connection, its event stream, and /api/devices.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import http.client
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_devices as fd # noqa: E402
+import frame_link as fl # noqa: E402
+import frame_network as fn # noqa: E402
+
+FAKESSH = ROOT / "tests" / "fakessh"
+NET = {"id": "n-test", "gateway": "192.168.1.1", "gateway_mac": "aa:bb:cc:dd:ee:ff", "interface": "en0",
+ "ssid": None, "wifi": True, "local_ip": "192.168.1.9", "tailscale": {"up": False, "installed": False}}
+
+
+def explain(msg):
+ """A cut-down server.unreachable, so this needs no server import."""
+ if "Could not resolve" in msg:
+ return "Can't find the Frame on the network."
+ if "refused" in msg:
+ return "The Frame refused the connection."
+ if "timed out" in msg.lower():
+ return "The Frame isn't answering."
+ if "Permission denied" in msg:
+ return "The Frame didn't accept this computer's SSH key."
+ return None
+
+
+class Probe(unittest.TestCase):
+ def test_answers_refusals_and_unknown_names(self):
+ with socket.socket() as srv:
+ srv.bind(("127.0.0.1", 0))
+ srv.listen(4)
+ port = srv.getsockname()[1]
+ seen = []
+ res = fl.probe("127.0.0.1", port, update=lambda **f: seen.append(f["state"]))
+ self.assertEqual(res["state"], "answered")
+ self.assertEqual(res["ip"], "127.0.0.1")
+ self.assertIsInstance(res["rtt_ms"], float)
+ self.assertEqual(seen, ["resolving", "trying"])
+ # Closed now. Windows retries a refused connect for about 2 s before saying so.
+ self.assertEqual(fl.probe("127.0.0.1", port, timeout=6 if os.name == "nt" else 2)["state"], "refused")
+ self.assertEqual(fl.probe("frame-control-test.invalid", 22, timeout=2)["state"], "unresolved")
+
+ def test_failed_probes_read_like_ssh(self):
+ # So the server's UNREACHABLE table words them like any other ssh failure.
+ self.assertIn("Could not resolve hostname x", fl.probe_raw("x", 22, {"state": "unresolved"}))
+ self.assertIn("port 22: Connection refused", fl.probe_raw("x", 22, {"state": "refused"}))
+ self.assertIn("Operation timed out", fl.probe_raw("x", 22, {"state": "timeout"}))
+
+
+class Pick(unittest.TestCase):
+ def pick(self, results, tried=()):
+ return fl.Link.pick(results, set(tried), threading.Condition(), time.monotonic() + 5)
+
+ def test_best_ranked_answer_wins(self):
+ now = time.monotonic()
+ ok = lambda t=now: {"state": "answered", "t": t}
+ no = {"state": "timeout", "t": now}
+ self.assertEqual(self.pick([no, ok(), ok()]), 1)
+ self.assertEqual(self.pick([no, ok(), ok()], tried=[1]), 2)
+ self.assertIsNone(self.pick([no, no]))
+ # A worse-ranked answer waits PREFER for a better one still trying, then goes.
+ t0 = time.monotonic()
+ self.assertEqual(self.pick([None, ok(time.monotonic())]), 1)
+ self.assertGreaterEqual(time.monotonic() - t0, fl.PREFER - 0.05)
+
+ def test_gives_up_on_slow_lookups_at_the_deadline(self):
+ t0 = time.monotonic()
+ results = [None]
+ self.assertIsNone(fl.Link.pick(results, set(), threading.Condition(), time.monotonic() + 0.3))
+ self.assertLess(time.monotonic() - t0, 2)
+ self.assertEqual(results[0]["state"], "timeout")
+
+
+@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
+class Connecting(unittest.TestCase):
+ def setUp(self):
+ self.dir = Path(tempfile.mkdtemp(prefix="frame-link-"))
+ self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True)
+ (self.dir / "ssh").mkdir()
+ self.log = self.dir / "calls.jsonl"
+ env = {"FRAME_CONTROL_SSH_DIR": str(self.dir / "ssh"), "FAKESSH_LOG": str(self.log),
+ "FAKESSH_DIR": str(self.dir), "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
+ patcher = mock.patch.dict(os.environ, env)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ for name, value in (("current_network", lambda *a, **k: dict(NET)), ("fingerprint", lambda: ("192.168.1.1", "en0", "aa:bb:cc:dd:ee:ff"))):
+ p = mock.patch.object(fn, name, value)
+ p.start()
+ self.addCleanup(p.stop)
+ self.srv = socket.socket()
+ self.srv.bind(("127.0.0.1", 0))
+ self.srv.listen(16)
+ self.addCleanup(self.srv.close)
+ self.port = self.srv.getsockname()[1]
+ self.reg = fd.Registry(self.dir / "devices.json")
+ self.routes = []
+ self.link = fl.Link(self.reg, env_alias=None, mux_base=["ssh", "-o", "BatchMode=yes", "-o", "ControlPath=x"],
+ control="x", apply=lambda alias, opts: self.routes.append((alias, list(opts))),
+ explain=explain)
+ self.addCleanup(self.link.stop)
+
+ def test_start_routes_to_the_saved_headset_before_serving(self):
+ a = self.reg.add_device("frame", hosts=["192.0.2.1"])
+ b = self.reg.add_device("frame-2", hosts=["192.0.2.2"])
+ self.reg.set_active(b["id"])
+ with mock.patch.object(self.link, "run", lambda: None): # no connector: only what start() applies
+ self.link.start()
+ self.assertEqual(self.routes[0][0], "frame-2")
+ self.assertIn("HostName=192.0.2.2", self.routes[0][1])
+ self.assertNotEqual(a["id"], b["id"])
+
+ def test_headset_removed_elsewhere_mid_install_reaches_nothing(self):
+ a = self.reg.add_device("frame", hosts=["192.0.2.1"])
+ self.reg.add_device("frame-2", hosts=["192.0.2.2"])
+ self.reg.set_active(a["id"])
+ other = fd.Registry(self.dir / "devices.json") # another Frame Control server
+ other.remove_device(a["id"])
+ self.link.work = lambda: 1
+ self.assertTrue(self.link.active_device().get("none"))
+ self.link.work = lambda: 0
+ self.assertEqual(self.link.active_device()["alias"], "frame-2") # idle: move on
+
+ def test_nothing_elsewhere_moves_a_running_install(self):
+ """A bare alias in use, then another server sets up and picks a headset."""
+ self.link.override = None
+ self.hosts({"localhost": "ok"})
+ with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
+ self.link.connect(["start"])
+ started = self.routes[-1]
+ other = fd.Registry(self.dir / "devices.json")
+ other.set_active(other.add_device("frame-2", hosts=["192.0.2.2"])["id"])
+ self.link.work = lambda: 1
+ with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
+ self.link.close_master()
+ self.link.connect(["dropped"])
+ self.assertEqual(self.routes[-1][0], started[0])
+ self.assertTrue(self.link.deferred)
+
+ def listen6(self):
+ """A "different device": the same port on IPv6 loopback."""
+ try:
+ six = socket.socket(socket.AF_INET6)
+ self.addCleanup(six.close)
+ six.bind(("::1", self.port))
+ six.listen(4)
+ except OSError:
+ self.skipTest("no IPv6 loopback")
+
+ def pin(self, device_id):
+ fd.known_hosts(device_id).parent.mkdir(parents=True, exist_ok=True)
+ fd.known_hosts(device_id).write_text(f"frame-control-{device_id} ssh-ed25519 AAAA\n")
+
+ def hosts(self, mapping):
+ # An IPv4 answer is what ssh is pointed at, so localhost arrives as 127.0.0.1.
+ if "localhost" in mapping:
+ mapping = dict({"127.0.0.1": mapping["localhost"]}, **mapping)
+ os.environ["FAKESSH_HOSTS"] = json.dumps(mapping)
+
+ def calls(self):
+ return [json.loads(line) for line in self.log.read_text().splitlines()] if self.log.exists() else []
+
+ def device(self, *hosts):
+ d = self.reg.add_device("frame-t", port=self.port, hosts=[])
+ for h in hosts:
+ self.reg.add_address(d["id"], h, kind="lan")
+ return d
+
+ def test_falls_through_to_the_address_that_is_really_the_headset(self):
+ # Tried in this order: a name that doesn't resolve, a different device, the headset.
+ self.listen6()
+ d = self.device("nothing.invalid", "::1", "127.0.0.1")
+ self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual(s["via"]["host"], "127.0.0.1")
+ self.assertEqual([st["state"] for st in s["stages"]], ["done"] * 5)
+ rows = {p["host"]: p for p in s["probes"]}
+ self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
+ self.assertEqual(rows["::1"]["state"], "sshfailed")
+ self.assertIn("different headset", rows["::1"]["detail"])
+ # Every ssh command was pointed at the winner, with the host key pinned per device.
+ alias, opts = self.routes[-1]
+ self.assertEqual(alias, "frame-t")
+ self.assertIn("HostName=127.0.0.1", opts)
+ self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
+ self.assertIn(f"Port={self.port}", opts)
+ master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
+ self.assertIn("StrictHostKeyChecking=accept-new", master) # first connection: nothing pinned yet
+ # ssh takes an option's first value: accept-new must come before the commands' own "yes".
+ self.assertLess(master.index("StrictHostKeyChecking=accept-new"), master.index("StrictHostKeyChecking=yes"))
+ self.assertIn("StrictHostKeyChecking=yes", opts) # every other command checks the pinned key
+ self.assertTrue(fd.known_hosts(d["id"]).parent.is_dir()) # where ssh saves the key it accepts
+ # It learned: 127.0.0.1 works on this network.
+ learned = {a["host"]: a for a in self.reg.get(d["id"])["addresses"]}
+ self.assertEqual(learned["127.0.0.1"]["networks"], ["n-test"])
+ self.assertEqual(learned["::1"]["networks"], [])
+ self.assertTrue(self.link.alive())
+ self.link.close_master()
+ self.assertFalse(any(p.name.startswith("master-") for p in self.dir.iterdir()))
+
+ def test_stages_are_published_as_they_happen(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ steps, versions = [], []
+ real = self.link.stage
+
+ def stage(sid, state, detail=None):
+ real(sid, state, detail)
+ steps.append((sid, state))
+ versions.append(self.link.snapshot()["version"])
+ self.link.stage = stage
+ before = self.link.snapshot()["version"]
+ self.assertIsNone(self.link.wait(before, 0.05)) # nothing new yet
+ self.link.connect(["start"])
+ started = [sid for sid, state in steps if state == "active"]
+ self.assertEqual(list(dict.fromkeys(started)), ["network", "find", "ssh", "identity", "login"])
+ self.assertEqual([sid for sid, state in steps if state == "done"][-3:], ["ssh", "identity", "login"])
+ self.assertEqual(versions, sorted(versions)) # every step is a new version for the page
+ self.assertEqual(self.link.wait(before, 1)["phase"], "connected")
+
+ def test_nothing_answers(self):
+ self.device("nothing.invalid", "also-nothing.invalid")
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "failed")
+ self.assertEqual(s["error"]["stage"], "find")
+ self.assertEqual(s["error"]["message"], "Can't find the Frame on the network.")
+ self.assertGreater(s["retry_at"], time.time())
+ self.assertEqual([st["state"] for st in s["stages"]][:2], ["done", "failed"])
+
+ def test_refused_key_stops_at_login(self):
+ self.device("localhost")
+ self.hosts({"localhost": "denied"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["error"]["stage"]), ("failed", "login"))
+ self.assertIn("SSH key", s["error"]["message"])
+
+ def test_pinned_identity_is_checked_strictly(self):
+ d = self.device("localhost")
+ self.pin(d["id"])
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ master = [c for c in self.calls() if "ControlMaster=yes" in c][-1]
+ self.assertIn("StrictHostKeyChecking=yes", master)
+
+ def test_ssh_goes_to_the_ipv4_address_that_answered(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["via"]["host"], s["via"]["ip"]), ("connected", "localhost", "127.0.0.1"))
+ self.assertIn("HostName=127.0.0.1", self.routes[-1][1])
+
+ def test_no_headset_after_removing_them_all(self):
+ d = self.device("localhost")
+ self.reg.remove_device(d["id"])
+ self.link.connect(["switch"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["device"]["id"], s["retry_at"]), ("failed", "none", None))
+ self.assertIn("No headset", s["error"]["message"])
+ self.assertEqual(self.routes[-1], ("frame-control-no-headset", ["-o", "HostName=no-headset.invalid"]))
+
+ def test_a_headset_without_addresses_reaches_nothing(self):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ fl.devices_action(self.link, {"action": "address-remove", "id": d["id"], "host": "localhost"}, None)
+ self.assertIn("HostName=no-address.invalid", self.routes[-1][1]) # at once, not after a retry
+ self.link.connect(["switch"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["retry_at"]), ("failed", None))
+ self.assertIn("no addresses", s["error"]["message"])
+
+ def test_switching_back_to_the_frame_alias_the_server_started_with(self):
+ self.link.override = self.link.session_alias = "frame-bare"
+ other = self.device("localhost")
+ ids = [d["id"] for d in fl.devices_view(self.link)["devices"]]
+ self.assertEqual(ids, ["alias-frame-bare", other["id"]])
+ fl.devices_action(self.link, {"action": "use", "id": other["id"]}, None)
+ self.assertIn("alias-frame-bare", [d["id"] for d in fl.devices_view(self.link)["devices"]]) # still there
+ fl.devices_action(self.link, {"action": "use", "id": "alias-frame-bare"}, None)
+ self.assertEqual(self.link.active_device()["alias"], "frame-bare")
+ self.assertEqual(self.routes[-1][0], "frame-bare")
+
+ def test_removing_the_headset_frame_alias_named_doesnt_bring_it_back_bare(self):
+ d = self.device("localhost")
+ self.link.override = self.link.session_alias = "frame-t"
+ fl.devices_action(self.link, {"action": "remove", "id": d["id"], "config": True}, None)
+ self.assertNotIn("alias-frame-t", [x["id"] for x in fl.devices_view(self.link)["devices"]])
+
+ def test_setup_changing_the_login_waits_for_installs(self):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ cfg = self.dir / "ssh" / "config"
+ cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User steamos\n"
+ f" Port {self.port}\nHost *\n# <<< steam-frame (frame-t) <<<\n")
+ self.link.watch_config() # the block's login is recorded
+ routes = len(self.routes)
+ cfg.write_text(cfg.read_text().replace("User steamos", "User deck"))
+ running = [1]
+ self.link.work = lambda: running[0]
+ self.link.config_mtime = None
+ self.link.watch_config()
+ self.assertEqual(len(self.routes), routes) # an install is running: not yet
+ self.link.connect(["dropped"]) # a reconnect meanwhile keeps the login it started with
+ self.assertIn("User=steamos", self.routes[-1][1])
+ running[0] = 0
+ self.link.watch_config()
+ self.assertIn("User=deck", self.routes[-1][1])
+
+ def test_a_rename_leaves_the_login_in_the_config_alone(self):
+ d = self.device("localhost")
+ cfg = self.dir / "ssh" / "config"
+ cfg.write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n User deck\n"
+ "Host *\n# <<< steam-frame (frame-t) <<<\n") # setup wrote a new user, not yet imported
+ fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
+ self.assertIn("User deck", cfg.read_text())
+ out = fl.devices_action(self.link, {"action": "update", "id": d["id"], "port": 2200}, None)
+ self.assertIn("User deck", cfg.read_text()) # changed meanwhile: left as it is
+ self.assertIn("left as it is", out["message"])
+
+ def test_a_late_failure_from_the_last_headset_is_ignored(self):
+ self.link.state["phase"] = "connected"
+ self.link.gen = 3
+ self.link.lost("ssh: connect to host a port 22: Operation timed out", 2) # sent before the switch
+ self.assertEqual(self.link.kicks, [])
+ self.link.lost("ssh: connect to host b port 22: Operation timed out", 3)
+ self.assertEqual(len(self.link.kicks), 1)
+
+ def test_a_jump_hosts_login_isnt_the_headsets(self):
+ opts = ["-o", "HostName=10.0.0.5"]
+ self.assertFalse(fl.Link.is_target('Authenticated to bastion ([1.2.3.4]:22) using "publickey".', opts, "frame"))
+ self.assertTrue(fl.Link.is_target('Authenticated to 10.0.0.5 ([10.0.0.5]:22) using "publickey".', opts, "frame"))
+ self.assertTrue(fl.Link.is_target('Authenticated to frame.local ([10.0.0.5]:22) using "publickey".',
+ ["-o", "HostName=FRAME.LOCAL"], "frame"))
+
+ def test_a_forward_the_jump_host_couldnt_open_tries_the_next_address(self):
+ said = ["Authenticated to bastion ([1.2.3.4]:22) using \"publickey\".",
+ "channel 0: open failed: connect failed: Connection refused", "stdio forwarding failed"]
+ self.link.state["stages"] = [{"id": i, "state": "pending", "started": None, "ended": None, "detail": ""}
+ for i, _ in fl.STAGES]
+ self.assertEqual(self.link.failed("login", said, False, "frame"), "next")
+ self.assertEqual(self.link.failed("login", ["steamos@frame: Permission denied (publickey)."], False, "frame"),
+ "stop")
+
+ def test_a_reconnect_being_started_isnt_a_live_connection(self):
+ self.link.state["phase"] = "connected"
+ self.assertTrue(self.link.alive())
+ self.link.busy = True # the loop took a Retry off the queue and is about to reconnect
+ self.assertFalse(self.link.alive()) # so ensure() waits instead of starting work on it
+
+ def test_probes_from_an_earlier_attempt_leave_the_new_rows_alone(self):
+ self.link.state.update(attempt=2, probes=[{"host": "b", "state": "waiting"}])
+ self.link.probe_update(0, 1, state="answered", ip="10.0.0.2")
+ self.assertEqual(self.link.state["probes"][0], {"host": "b", "state": "waiting"})
+
+ def test_a_bare_alias_lets_ssh_config_decide(self):
+ self.link.override = "frame-bare"
+ self.hosts({"localhost": "ok"})
+ with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertTrue(s["device"]["transient"])
+ # Where ~/.ssh/config sends it, pinned down for the connection (ssh's own known_hosts).
+ alias, opts = self.routes[-1]
+ self.assertEqual((alias, opts[2:]), ("frame-bare", ["-o", "HostName=localhost", "-o", f"Port={self.port}",
+ "-o", "User=tester"]))
+ self.assertEqual(opts[:2], ["-o", "ControlPath=" + fl.frame_host.control_path(fl.Link.control_tag(s["device"]))])
+
+ def test_each_headset_has_its_own_shared_connection(self):
+ """ssh's %C hashes only address, user and port: two headsets at one address
+ (one moved) must still never share a ControlMaster."""
+ a, b = (dict(fl.Link.bare("x"), id=i, transient=False, user="steamos", port=22) for i in ("aaaa1111", "bbbb2222"))
+ pa, pb = (next(o for o in self.link.host_opts(d, "192.0.2.5") if o.startswith("ControlPath=")) for d in (a, b))
+ self.assertNotEqual(pa, pb)
+ self.assertIn("aaaa1111", pa)
+ long_alias = fl.Link.bare("x" * 64)
+ path = next(o for o in self.link.host_opts(long_alias, None) if o.startswith("ControlPath="))
+ self.assertLess(len(path) - len("ControlPath=") - len("%C") + 40 + 17, 104) # fits a macOS socket path
+
+ def test_a_bare_alias_keeps_its_pinned_route_for_reconnects_and_terminals(self):
+ self.link.override = "frame-bare"
+ self.hosts({"localhost": "ok"})
+ with mock.patch.object(fl, "ssh_g", return_value=("localhost", self.port, "tester", False)):
+ self.link.connect(["start"])
+ pinned = self.routes[-1][1]
+ # ~/.ssh/config now sends the alias elsewhere, but an install is running.
+ self.link.work = lambda: 1
+ with mock.patch.object(fl, "ssh_g", return_value=("elsewhere.invalid", 2222, "other", False)):
+ self.link.close_master()
+ self.link.connect(["dropped"])
+ self.assertEqual(self.link.snapshot()["probes"][0]["host"], "localhost") # probed where commands go
+ self.assertEqual(self.link.snapshot()["phase"], "connected")
+ self.assertEqual(self.link.named_route(), ("frame-bare", pinned)) # terminals go there too
+
+ def test_a_set_up_headset_behind_a_jump_host_is_left_to_ssh(self):
+ d = self.device("10.99.99.98", "10.99.99.99") # neither answers directly
+ self.hosts({"10.99.99.98": "wrong", "10.99.99.99": "ok"})
+ with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual((s["phase"], s["via"]["host"]), ("connected", "10.99.99.99"), s["error"])
+ self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", self.routes[-1][1]) # still pinned per headset
+
+ def test_a_bare_alias_behind_a_jump_host_is_left_to_ssh(self):
+ self.link.override = "frame-jump"
+ self.hosts({"10.99.99.99": "ok"}) # ssh's ProxyJump would get there
+ with mock.patch.object(fl, "ssh_g", return_value=("10.99.99.99", 22, "tester", True)):
+ self.link.connect(["start"])
+ s = self.link.snapshot()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual(s["via"]["why"], "through a jump host")
+
+ def test_changing_the_port_reroutes_even_if_the_attempt_fails(self):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ self.reg.update_device(d["id"], port=1) # nothing listens there
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "failed")
+ self.assertIn("Port=1", self.routes[-1][1])
+
+ def test_test_now_checks_every_address_without_touching_the_connection(self):
+ self.listen6()
+ d = self.device("::1", "127.0.0.1", "nothing.invalid")
+ self.pin(d["id"])
+ self.hosts({"::1": "wrong", "127.0.0.1": "ok"})
+ self.link.test(d["id"])
+ rows = {r["host"]: r for r in self.link.snapshot()["tests"][d["id"]]["rows"]}
+ self.assertEqual(rows["127.0.0.1"]["ssh"], "ok")
+ self.assertEqual(rows["::1"]["ssh"], "wrong")
+ self.assertEqual(rows["nothing.invalid"]["state"], "unresolved")
+ self.assertEqual(self.routes, [])
+ self.assertTrue(all("ControlPath=none" in c for c in self.calls() if "-G" not in c))
+
+ def test_switching_to_a_headset_that_never_answers_stops_using_the_last_one(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ other = self.reg.add_device("frame-other", port=self.port)
+ self.reg.add_address(other["id"], "nothing.invalid")
+ self.link.use(other["id"])
+ self.assertEqual(self.routes[-1][0], "frame-other") # at once, before any attempt
+ self.assertEqual(self.link.snapshot()["phase"], "connecting")
+ self.assertFalse(self.link.alive()) # so ensure() waits instead of using the old master
+ self.link.connect(["switch"])
+ self.assertEqual(self.link.snapshot()["phase"], "failed")
+ alias, opts = self.routes[-1]
+ self.assertEqual(alias, "frame-other")
+ self.assertIn("HostName=nothing.invalid", opts)
+ self.assertIn(f"HostKeyAlias=frame-control-{other['id']}", opts)
+
+ def test_an_attempt_overtaken_by_a_switch_routes_nothing_back(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ other = self.reg.add_device("frame-other", port=self.port)
+ self.reg.add_address(other["id"], "nothing.invalid")
+ pick = fl.Link.pick
+
+ def switch_then_pick(*args):
+ if not getattr(self, "switched", False):
+ self.switched = True
+ self.link.use(other["id"]) # the user switches while A is being found
+ return pick(*args)
+ with mock.patch.object(fl.Link, "pick", staticmethod(switch_then_pick)):
+ self.link.connect(["start"])
+ self.assertEqual(self.routes[-1][0], "frame-other")
+ self.assertEqual(self.link.snapshot()["phase"], "connecting")
+ self.assertFalse(self.link.alive())
+ self.assertIsNone(self.link.master)
+
+ def test_no_switching_while_something_is_installing(self):
+ d = self.device("localhost")
+ other = self.reg.add_device("frame-other")
+ for body in ({"action": "use", "id": other["id"]}, {"action": "remove", "id": d["id"]},
+ {"action": "update", "id": d["id"], "port": 2222},
+ {"action": "address-remove", "id": d["id"], "host": "localhost"},
+ {"action": "address-update", "id": d["id"], "host": "localhost", "newHost": "127.0.0.1"},
+ {"action": "forget-identity", "id": d["id"]}):
+ with self.assertRaises(fd.DeviceError, msg=body):
+ fl.devices_action(self.link, body, open_setup=None, busy=lambda: 1)
+ # Renaming, or changing another headset, is fine.
+ fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None, busy=lambda: 1)
+ fl.devices_action(self.link, {"action": "update", "id": other["id"], "port": 2222}, None, busy=lambda: 1)
+ self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
+
+ def test_no_reconnecting_under_a_running_install(self):
+ self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ with self.assertRaises(fd.DeviceError):
+ fl.devices_action(self.link, {"action": "retry"}, None, busy=lambda: 1)
+ fl.devices_action(self.link, {"action": "retry"}, None) # fine when nothing runs
+
+ def test_terminals_get_the_address_by_name(self):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ alias, opts = self.link.named_route()
+ self.assertEqual(alias, "frame-t")
+ self.assertIn("HostName=localhost", opts)
+ self.assertIn(f"HostKeyAlias=frame-control-{d['id']}", opts)
+
+ def test_renaming_during_an_install_is_fine(self):
+ d = self.device("localhost")
+ # The page sends the user and port along with the name, unchanged.
+ fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk", "user": "steamos",
+ "port": str(self.port)}, None, busy=lambda: 1)
+ self.assertEqual(self.reg.get(d["id"])["name"], "Desk")
+
+ def test_a_rename_shows_at_once(self):
+ d = self.device("localhost")
+ self.hosts({"localhost": "ok"})
+ self.link.connect(["start"])
+ fl.devices_action(self.link, {"action": "update", "id": d["id"], "name": "Desk"}, None)
+ self.assertEqual(self.link.snapshot()["device"]["name"], "Desk")
+
+ def test_stopping_mid_handshake_leaves_no_ssh_behind(self):
+ self.device("localhost")
+ self.hosts({"localhost": "slow"})
+ t = threading.Thread(target=self.link.connect, args=(["start"],), daemon=True)
+ t.start()
+ for _ in range(100):
+ if self.link.pending:
+ break
+ time.sleep(0.05)
+ proc = self.link.pending
+ self.assertIsNotNone(proc)
+ self.link.stop()
+ t.join(10)
+ self.assertFalse(t.is_alive())
+ self.assertIsNotNone(proc.poll())
+ self.assertIsNone(self.link.master)
+
+ def test_test_now_goes_through_a_jump_host(self):
+ d = self.device("10.99.99.99")
+ self.pin(d["id"])
+ self.hosts({"10.99.99.99": "ok"})
+ with mock.patch.object(fl, "ssh_g", return_value=("frame-t", 22, "steamos", True)):
+ self.link.test(d["id"])
+ row = self.link.snapshot()["tests"][d["id"]]["rows"][0]
+ self.assertEqual(row["ssh"], "ok", row)
+ self.assertIn("jump host", row["detail"])
+
+ def test_devices_api_checks_everything(self):
+ d = self.device("localhost")
+ bad = [{"action": "address-add", "id": d["id"], "host": "-oProxyCommand=touch /tmp/x"},
+ {"action": "address-add", "id": d["id"], "host": "a\nHost *"},
+ {"action": "address-add", "id": d["id"], "host": "frame.local", "kind": "wifi"},
+ {"action": "update", "id": d["id"], "user": "root; id"},
+ {"action": "update", "id": d["id"], "port": 0},
+ {"action": "address-move", "id": d["id"], "host": "localhost", "delta": 5},
+ {"action": "setup", "alias": "-F/etc/passwd"},
+ {"action": "setup", "alias": "frame-9", "host": "$(id)"},
+ {"action": "use", "id": "nope"},
+ {"action": "explode"}]
+ for body in bad:
+ with self.assertRaises(fd.DeviceError, msg=body):
+ fl.devices_action(self.link, body, open_setup=lambda *a: self.fail("setup ran"))
+ # Removing every headset leaves none in use, rather than falling back to the `frame` alias.
+ spare = self.reg.add_device("frame-spare")
+ fl.devices_action(self.link, {"action": "remove", "id": spare["id"]}, None)
+ # The only headset, whose ssh alias would stay: not without removing that too.
+ (self.dir / "ssh" / "config").write_text("# >>> steam-frame (frame-t) >>>\nHost frame-t\n HostName localhost\n"
+ "Host *\n# <<< steam-frame (frame-t) <<<\n")
+ with self.assertRaises(fd.DeviceError):
+ fl.devices_action(self.link, {"action": "remove", "id": d["id"]}, None)
+ opened = []
+ out = fl.devices_action(self.link, {"action": "setup", "alias": "frame-9", "host": "192.168.1.50"},
+ open_setup=lambda alias, host: opened.append((alias, host)) or "a terminal")
+ self.assertEqual(opened, [("frame-9", "192.168.1.50")])
+ self.assertIn("frame-9", out["message"])
+ self.assertEqual(out["active"], d["id"])
+ self.assertEqual(fl.next_alias(self.link), "frame")
+ (self.dir / "ssh" / "config").write_text("Host frame lab-*\n HostName 10.0.0.7\n") # someone's own `frame`
+ self.assertEqual(fl.next_alias(self.link), "frame-2")
+
+
+@unittest.skipIf(os.name == "nt", "the stand-in ssh is a POSIX script")
+class ServerConnection(unittest.TestCase):
+ """The real server, a Set Up Connection block in a stand-in ~/.ssh, and the stand-in ssh."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.dir = Path(tempfile.mkdtemp(prefix="frame-link-server-"))
+ ssh_dir = cls.dir / "ssh"
+ ssh_dir.mkdir()
+ cls.srv = socket.socket() # the "headset's" port 22
+ cls.srv.bind(("127.0.0.1", 0))
+ cls.srv.listen(16)
+ (ssh_dir / "config").write_text("# >>> steam-frame (frame) >>>\nHost frame\n HostName localhost\n"
+ f" Port {cls.srv.getsockname()[1]}\n"
+ " User steamos\nHost *\n# <<< steam-frame (frame) <<<\n")
+ env = {**os.environ, "PYTHONDONTWRITEBYTECODE": "1", "FRAME_CONTROL_SSH_DIR": str(ssh_dir),
+ "FRAME_CONTROL_DATA_DIR": str(cls.dir / "data"), "FAKESSH_LOG": str(cls.dir / "calls.jsonl"),
+ "FAKESSH_DIR": str(cls.dir), "FAKESSH_HOSTS": json.dumps({"localhost": "ok", "127.0.0.1": "ok"}),
+ "PATH": f"{FAKESSH}{os.pathsep}{os.environ['PATH']}"}
+ env.pop("FRAME_ALIAS", None)
+ cls.log = tempfile.TemporaryFile()
+ cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
+ stdout=subprocess.PIPE, stderr=cls.log, text=True)
+ cls.port = int(cls.proc.stdout.readline().split("127.0.0.1:")[1].split()[0])
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.proc.terminate()
+ cls.proc.wait(timeout=15)
+ cls.proc.stdout.close()
+ cls.log.close()
+ cls.srv.close()
+ shutil.rmtree(cls.dir, ignore_errors=True)
+
+ def request(self, method, path, body=None, key="1"):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
+ conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
+ headers={"X-Frame-UI": key, "Content-Type": "application/json"})
+ r = conn.getresponse()
+ data = json.loads(r.read() or b"{}")
+ conn.close()
+ return r.status, data
+
+ def wait_connected(self):
+ for _ in range(100):
+ status, s = self.request("GET", "/api/connection")
+ if s.get("phase") in ("connected", "failed"):
+ return s
+ time.sleep(0.1)
+ self.fail(f"never connected: {s}")
+
+ def test_imports_the_headset_and_connects_through_its_port(self):
+ s = self.wait_connected()
+ self.assertEqual(s["phase"], "connected", s["error"])
+ self.assertEqual(s["via"]["host"], "localhost")
+ self.assertEqual(s["device"]["alias"], "frame")
+ self.assertEqual(s["device"]["name"], "Steam Frame")
+ self.assertEqual(s["probes"][0]["host"], "localhost")
+ status, devices = self.request("GET", "/api/devices")
+ self.assertEqual(status, 200)
+ self.assertEqual([d["alias"] for d in devices["devices"]], ["frame"])
+ self.assertEqual(devices["nextAlias"], "frame-2")
+
+ def test_events_stream_the_state(self):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
+ conn.request("GET", "/api/connection/events", headers={"X-Frame-UI": "1"})
+ r = conn.getresponse()
+ self.assertEqual(r.status, 200)
+ self.assertEqual(r.getheader("Content-Type"), "text/event-stream")
+ line = r.fp.readline()
+ self.assertTrue(line.startswith(b"data: "), line)
+ self.assertIn("stages", json.loads(line[6:]))
+ conn.close()
+
+ def test_changes_meant_for_another_headset_are_refused(self):
+ conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=20)
+ conn.request("POST", "/api/launch", body=b'{"appid": "620"}',
+ headers={"X-Frame-UI": "1", "Content-Type": "application/json", "X-Frame-Device": "someoneelse"})
+ r = conn.getresponse()
+ self.assertEqual(r.status, 409)
+ self.assertIn("switched headsets", json.loads(r.read())["error"])
+ conn.close()
+
+ def test_guards_and_validation(self):
+ self.assertEqual(self.request("GET", "/api/connection", key="")[0], 403)
+ self.assertEqual(self.request("GET", "/api/devices", key="nope")[0], 403)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "address-add", "id": "x", "host": "a;b"})[0], 400)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "setup", "alias": "-oProxyCommand=x"})[0], 400)
+ self.assertEqual(self.request("POST", "/api/devices", {"action": "nope"})[0], 400)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_macview.py b/tests/test_macview.py
new file mode 100644
index 0000000..e636927
--- /dev/null
+++ b/tests/test_macview.py
@@ -0,0 +1,459 @@
+"""Mac in the headset (ui/frame_macview.py and the frame-mac-view agent).
+
+The Python checks run anywhere. On macOS the agent is built and driven over
+HTTP and WebSocket with its test pattern, which needs no Screen Recording
+permission: status, the token, the viewer page, H.264 keyframes, pointer
+input reaching the source, and closing.
+
+Run: python3 -m unittest discover -s tests
+"""
+import base64
+import http.client
+import json
+import io
+import os
+import shutil
+import socket
+import struct
+import subprocess
+import sys
+import tempfile
+import time
+import threading
+import unittest
+from unittest import mock
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_macview # noqa: E402
+
+
+class Helpers(unittest.TestCase):
+ def test_panel_id_is_stable_and_in_range(self):
+ a = frame_macview.panel_id("window:123")
+ self.assertEqual(a, frame_macview.panel_id("window:123"))
+ self.assertNotEqual(a, frame_macview.panel_id("window:124"))
+ self.assertTrue(2_001_000_000 <= a < 2_002_000_000)
+
+ def test_fit_keeps_aspect_inside_the_panel(self):
+ self.assertEqual(frame_macview.fit(2560, 1440), (1920, 1080))
+ w, h = frame_macview.fit(800, 1600)
+ self.assertEqual(h, 1080)
+ self.assertAlmostEqual(w / h, 0.5, places=2)
+
+ def test_the_tunnel_follows_the_headset(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
+ mv.retarget("frame", ["-o", "ControlPath=/tmp/x-%C", "-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
+ self.assertEqual(mv.host_opts, ["-o", "HostName=192.0.2.1", "-o", "HostKeyAlias=frame-control-a"])
+
+ class Tunnel:
+ ended = False
+ def poll(self): return None
+ def terminate(self): Tunnel.ended = True
+ mv.tunnel, mv.remote_port = Tunnel(), 47999
+ mv.retarget("frame", ["-o", "HostName=192.0.2.9"]) # another address, same headset: keep it
+ self.assertFalse(Tunnel.ended)
+ mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # another headset: never the old one's tunnel
+ self.assertTrue(Tunnel.ended)
+ self.assertIsNone(mv.tunnel)
+ self.assertEqual(mv.frame, "frame-2")
+
+ def test_a_switch_just_before_publishing_drops_the_old_headsets_tunnel(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
+ mv.port = 47000
+ ended = []
+
+ class Proc:
+ def poll(self): return None
+ def terminate(self): ended.append(self)
+ def wait(self): return 0
+ stderr = io.StringIO("")
+
+ def probe(port):
+ mv.retarget("frame-2", ["-o", "HostName=192.0.2.2"]) # the app switches right now
+ return True
+ with mock.patch.object(frame_macview.subprocess, "Popen", return_value=Proc()), \
+ mock.patch.object(frame_macview.time, "sleep"), mock.patch.object(mv, "_probe", probe):
+ self.assertFalse(mv._open_tunnel([], [47001]))
+ self.assertIsNone(mv.tunnel)
+ self.assertEqual(len(ended), 1) # the tunnel to the old headset was closed
+
+ @unittest.skipUnless(shutil.which("bash"), "needs bash")
+ @unittest.skipIf(os.name == "nt", "Windows' bash.exe is WSL's launcher, and runners have no distribution")
+ def test_launch_script_parses(self):
+ r = subprocess.run(["bash", "-n"], input=frame_macview.LAUNCH, text=True, capture_output=True)
+ self.assertEqual(r.returncode, 0, r.stderr)
+
+ def test_show_checks_the_source_before_anything_else(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
+ with self.assertRaises(frame_macview.MacViewError):
+ mv.show("rm -rf /")
+
+ def test_missing_browser_is_explained(self):
+ calls = []
+
+ def run(remote, stdin=None, timeout=30):
+ calls.append(remote)
+ e = RuntimeError("exit 3")
+ e.stdout = "NO_BROWSER\n"
+ raise e
+
+ mv = frame_macview.MacView(["ssh"], run, "frame")
+ mv.call = lambda path, **kw: {"screen": True, "ticket": "tk"}
+ mv.ensure_tunnel = lambda **kw: None
+ mv.remote_port = 47900
+ with self.assertRaises(frame_macview.MacViewError) as cm:
+ mv.show("window:5")
+ self.assertIn("Chromium", str(cm.exception))
+ self.assertTrue(calls[0].startswith("bash -s -- "))
+
+ def test_separate_windows_need_accessibility(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
+ mv.call = lambda path, **kw: {"screen": True, "accessibility": False}
+ with self.assertRaises(frame_macview.MacViewError) as cm:
+ mv.show("separate:42")
+ self.assertIn("Accessibility", str(cm.exception))
+
+ def test_screen_permission_is_checked_before_the_headset(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: self.fail("no ssh"), "frame")
+ mv.call = lambda path, **kw: {"screen": False}
+ with self.assertRaises(frame_macview.MacViewError) as cm:
+ mv.show("display:1")
+ self.assertIn("Screen Recording", str(cm.exception))
+
+ def test_stop_all_ends_the_viewer_browser_unless_shown_again(self):
+ calls = []
+ mv = frame_macview.MacView(["ssh"], lambda remote, **kw: calls.append(remote) or "", "frame")
+ mv.agent = mock.Mock(poll=lambda: None)
+ mv.call = lambda path, **kw: {"closed": 1}
+ mv.shown = {"window:5"}
+ with mock.patch.object(frame_macview.time, "sleep"):
+ gen = mv.shows
+ mv.shown.clear()
+ mv._end_viewer_browser(gen)
+ self.assertEqual(len(calls), 1)
+ self.assertIn("pkill -f '[f]rame-control/mac-view", calls[0])
+ mv.shows += 1 # Show pressed during the wait: leave the new viewer alone
+ mv._end_viewer_browser(gen)
+ self.assertEqual(len(calls), 1)
+ mv.launching = 1 # a Show replacing its own stream is still launching
+ mv._end_viewer_browser(mv.shows)
+ self.assertEqual(len(calls), 1)
+ # A Show waits while the cleanup checks and runs pkill.
+ mv.launching = 0
+ in_pkill, release, entered = threading.Event(), threading.Event(), threading.Event()
+
+ def blocking_pkill(remote, **kw):
+ in_pkill.set()
+ release.wait(5)
+ mv.run = blocking_pkill
+ mv._show = lambda *a: entered.set() or "shown"
+ with mock.patch.object(frame_macview.time, "sleep"):
+ cleanup = threading.Thread(target=mv._end_viewer_browser, args=(mv.shows,))
+ cleanup.start()
+ self.assertTrue(in_pkill.wait(2))
+ shower = threading.Thread(target=mv.show, args=("window:5",))
+ shower.start()
+ self.assertFalse(entered.wait(0.3), "Show started while the cleanup held the lock")
+ release.set()
+ self.assertTrue(entered.wait(2))
+ cleanup.join()
+ shower.join()
+
+ def test_tunnel_prefers_the_usb_c_network_when_plugged_in(self):
+ mv = frame_macview.MacView(["ssh"], lambda remote, **kw: "13: usb0 inet 10.86.200.233/29 scope global", "frame")
+ ssh_g = mock.Mock(stdout="user steamos\nhostname frame.example.ts.net\n")
+ with mock.patch.object(frame_macview.socket, "create_connection") as conn, \
+ mock.patch.object(frame_macview.subprocess, "run", return_value=ssh_g):
+ self.assertEqual(mv._usb_route(), ["-o", "HostName=10.86.200.233", "-o", "HostKeyAlias=frame.example.ts.net"])
+ conn.assert_called_once_with(("10.86.200.233", 22), timeout=1)
+ ssh_g.stdout = "hostname frame.example.ts.net\nhostkeyalias paired-frame\n" # a configured alias wins
+ conn.side_effect = None
+ self.assertIn("HostKeyAlias=paired-frame", mv._usb_route())
+ conn.side_effect = OSError("unplugged")
+ self.assertEqual(mv._usb_route(), [])
+ mv.run = lambda remote, **kw: "" # no usb0
+ self.assertEqual(mv._usb_route(), [])
+ mv.prefer_usb = False
+ mv.run = lambda remote, **kw: self.fail("no ssh when USB is off")
+ self.assertEqual(mv._usb_route(), [])
+
+ def test_a_failed_usb_tunnel_falls_back_to_the_network(self):
+ mv = frame_macview.MacView(["ssh"], lambda *a, **k: "", "frame")
+ mv._usb_route = lambda: ["-o", "HostName=10.86.200.233"]
+ tried = []
+
+ def attempt(via, ports):
+ tried.append(list(via))
+ mv._last_tunnel_error = "Connection refused"
+ return not via # USB fails, the normal path works
+ mv._open_tunnel = attempt
+ mv.tunnel_up = lambda: False
+ mv.ensure_tunnel()
+ self.assertEqual(tried, [["-o", "HostName=10.86.200.233"], []])
+ self.assertEqual(mv.route, "network")
+
+
+class WS:
+ """A minimal WebSocket client (masked frames out, plain frames in)."""
+
+ def __init__(self, port, path):
+ self.s = socket.create_connection(("127.0.0.1", port), timeout=10)
+ key = base64.b64encode(os.urandom(16)).decode()
+ self.s.sendall(f"GET {path} HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n".encode())
+ head = b""
+ while b"\r\n\r\n" not in head:
+ head += self.s.recv(1)
+ self.status = int(head.split()[1])
+ self.buf = b""
+
+ def _read(self, n):
+ while len(self.buf) < n:
+ chunk = self.s.recv(65536)
+ if not chunk:
+ raise EOFError
+ self.buf += chunk
+ out, self.buf = self.buf[:n], self.buf[n:]
+ return out
+
+ def recv(self):
+ b0, b1 = self._read(2)
+ n = b1 & 0x7F
+ if n == 126:
+ n = struct.unpack(">H", self._read(2))[0]
+ elif n == 127:
+ n = struct.unpack(">Q", self._read(8))[0]
+ return b0 & 0x0F, self._read(n)
+
+ def send_text(self, text):
+ data, mask = text.encode(), os.urandom(4)
+ head = bytes([0x81, 0x80 | len(data)]) if len(data) < 126 else bytes([0x81, 0xFE]) + struct.pack(">H", len(data))
+ self.s.sendall(head + mask + bytes(c ^ mask[i % 4] for i, c in enumerate(data)))
+
+ def close(self):
+ self.s.close()
+
+
+@unittest.skipUnless(sys.platform == "darwin" and shutil.which("xcrun"), "the agent is macOS-only")
+class Agent(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.tmp = tempfile.mkdtemp()
+ cls.bin = Path(cls.tmp) / "frame-mac-view"
+ r = subprocess.run(["/bin/sh", str(ROOT / "mac" / "frame-mac-view" / "build.sh"), str(cls.bin)],
+ capture_output=True, text=True, timeout=600)
+ if r.returncode: # a real failure on a Mac with Xcode: don't hide it as a skip
+ raise AssertionError("agent didn't build:\n" + (r.stderr or r.stdout)[-2000:])
+ cls.token = "t0ken-" + os.urandom(6).hex()
+ cls.proc = subprocess.Popen([str(cls.bin), "serve", "--port", "0", "--page", str(ROOT / "ui" / "mac-view.html"),
+ "--exit-on-eof"], env={**os.environ, "FRAME_MAC_VIEW_TOKEN": cls.token},
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
+ line = cls.proc.stdout.readline()
+ cls.port = int(line.rsplit(":", 1)[1])
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.proc.stdin.close() # --exit-on-eof
+ cls.proc.stdout.close()
+ try:
+ cls.proc.wait(5)
+ except subprocess.TimeoutExpired:
+ cls.proc.kill()
+ shutil.rmtree(cls.tmp, ignore_errors=True)
+
+ def get(self, path, method="GET"):
+ c = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
+ c.request(method, path)
+ r = c.getresponse()
+ return r.status, r.read()
+
+ def test_token_is_required(self):
+ self.assertEqual(self.get("/status")[0], 403)
+ self.assertEqual(self.get("/status?k=wrong")[0], 403)
+ status, body = self.get(f"/status?k={self.token}")
+ self.assertEqual(status, 200)
+ self.assertIn("screen", json.loads(body))
+
+ def test_serves_the_viewer_page(self):
+ status, body = self.get(f"/view?k={self.token}&src=test")
+ self.assertEqual(status, 200)
+ self.assertIn(b"VideoDecoder", body)
+
+ def test_snapshot_needs_the_key(self):
+ self.assertEqual(self.get("/snapshot?display=1")[0], 403)
+
+ def test_separate_without_accessibility_explains(self):
+ if json.loads(self.get(f"/status?k={self.token}")[1])["accessibility"]:
+ self.skipTest("this Mac allows Accessibility here")
+ ws = WS(self.port, f"/stream?k={self.token}&src=separate:1")
+ self.assertEqual(ws.status, 101)
+ for _ in range(5):
+ op, data = ws.recv()
+ msg = json.loads(data) if op == 1 else {}
+ if msg.get("t") in ("error", "closed"):
+ break
+ self.assertIn("Accessibility", msg.get("message", msg.get("reason", "")))
+ ws.close()
+
+ def test_lists_displays(self):
+ status, body = self.get(f"/displays?k={self.token}")
+ self.assertEqual(status, 200)
+ self.assertIsInstance(json.loads(body)["displays"], list)
+
+ def ticket(self, src):
+ status, body = self.get(f"/ticket?k={self.token}&src={src}", method="POST")
+ self.assertEqual(status, 200)
+ return json.loads(body)["ticket"]
+
+ def test_ping_and_page_are_open_but_streams_are_not(self):
+ self.assertEqual(self.get("/ping"), (200, b"frame-mac-view"))
+ self.assertEqual(WS(self.port, "/stream?src=test").status, 403)
+ self.assertEqual(self.get("/ticket?src=test", method="POST")[0], 403)
+
+ def test_tickets_are_single_use_and_tied_to_one_source(self):
+ t = self.ticket("test")
+ self.assertEqual(WS(self.port, f"/stream?src=display:1&t={t}").status, 403) # wrong source
+ ws = WS(self.port, f"/stream?src=test&t={t}")
+ self.assertEqual(ws.status, 101)
+ hello = json.loads(ws.recv()[1])
+ self.assertEqual(hello["t"], "hello")
+ # Until the viewer acknowledges, a retry (the hello got lost) gets the
+ # same key, and replaces the first viewer rather than adding one.
+ retry = WS(self.port, f"/stream?src=test&t={t}")
+ self.assertEqual(retry.status, 101)
+ self.assertEqual(json.loads(retry.recv()[1])["r"], hello["r"])
+ time.sleep(0.3)
+ _, body = self.get(f"/status?k={self.token}")
+ self.assertEqual(len(json.loads(body)["streams"]), 1)
+ ws.close()
+ ws = retry
+ ws.send_text(json.dumps({"t": "ack"}))
+ time.sleep(0.3)
+ self.assertEqual(WS(self.port, f"/stream?src=test&t={t}").status, 403) # spent
+ again = WS(self.port, f"/stream?src=test&r={hello['r']}") # the viewer reconnecting
+ self.assertEqual(again.status, 101)
+ again.close()
+ ws.close()
+ # Stop revokes the reconnect key.
+ self.get(f"/close?k={self.token}&src=test", method="POST")
+ self.assertEqual(WS(self.port, f"/stream?src=test&r={hello['r']}").status, 403)
+
+ def test_stop_revokes_tickets_not_yet_used(self):
+ t = self.ticket("test")
+ self.get(f"/close?k={self.token}&src=test", method="POST")
+ self.assertEqual(WS(self.port, f"/stream?src=test&t={t}").status, 403)
+
+ def test_bad_frame_lengths_close_the_socket_not_the_agent(self):
+ ws = WS(self.port, f"/stream?src=test&t={self.ticket('test')}")
+ self.assertEqual(ws.status, 101)
+ # A masked frame claiming 2^63 bytes.
+ ws.s.sendall(bytes([0x81, 0xFF]) + struct.pack(">Q", 1 << 63) + os.urandom(4))
+ with self.assertRaises((EOFError, OSError)):
+ for _ in range(1000):
+ ws.recv()
+ ws.close()
+ self.assertEqual(self.get(f"/status?k={self.token}")[0], 200)
+
+ def test_stream_input_and_close(self):
+ ws = WS(self.port, f"/stream?k={self.token}&src=test&codec=h264&fps=30&max=640")
+ self.assertEqual(ws.status, 101)
+ info = None
+ key = None
+ for _ in range(200):
+ op, data = ws.recv()
+ if op == 1:
+ msg = json.loads(data)
+ if msg["t"] == "hello":
+ continue
+ if msg["t"] == "error":
+ self.skipTest("no H.264 encoder here: " + msg["message"])
+ if msg["t"] == "info":
+ info = msg
+ elif op == 2 and data[0] == 1:
+ key = data
+ if info and key:
+ break
+ self.assertEqual(info["src"], "test")
+ self.assertTrue(info["input"])
+ # A keyframe: flags, 8-byte timestamp, sequence number, input echoed,
+ # then Annex B with the SPS first.
+ self.assertEqual(key[17:21], b"\x00\x00\x00\x01")
+ self.assertEqual(key[21] & 0x1F, 7) # NAL type 7, SPS
+ ws.send_text(json.dumps({"t": "m", "e": "down", "b": 0, "x": 0.5, "y": 0.5}))
+ ws.send_text(json.dumps({"t": "key-frame"}))
+ got_key = False
+ for _ in range(200):
+ op, data = ws.recv()
+ if op == 2 and data[0] == 1:
+ got_key = True
+ break
+ self.assertTrue(got_key, "no keyframe after asking for one")
+ _, body = self.get(f"/status?k={self.token}")
+ self.assertEqual([s["src"] for s in json.loads(body)["streams"]], ["test"])
+ status, body = self.get(f"/close?k={self.token}", method="POST")
+ self.assertEqual(json.loads(body)["closed"], 1)
+ for _ in range(400):
+ op, data = ws.recv()
+ if op == 1 and json.loads(data)["t"] == "close":
+ break
+ self.assertEqual(json.loads(data)["t"], "close")
+ # Without the viewer doing anything, the agent ends the stream itself.
+ time.sleep(1)
+ _, body = self.get(f"/status?k={self.token}")
+ self.assertEqual(json.loads(body)["streams"], [])
+ ws.close()
+
+ def test_timing_reports_and_input_echo(self):
+ # What a viewer does: sync clocks, report each frame, stamp input.
+ ws = WS(self.port, f"/stream?k={self.token}&src=test&codec=h264&fps=30&max=640")
+ self.assertEqual(ws.status, 101)
+ ws.send_text(json.dumps({"t": "w"})) # keep-warm filler: ignored
+ ws.send_text(json.dumps({"t": "ping", "c": 1.5}))
+ pong, echoed, seqs, info = None, None, [], None
+ clicked = False
+ deadline = time.time() + 10
+ while time.time() < deadline and not (pong and echoed):
+ op, data = ws.recv()
+ if op == 1:
+ msg = json.loads(data)
+ if msg["t"] == "error":
+ self.skipTest("no H.264 encoder here: " + msg["message"])
+ if msg["t"] == "pong":
+ pong = msg
+ if msg["t"] == "info":
+ info = msg
+ elif op == 2:
+ seq, echo = struct.unpack(">II", data[9:17])
+ seqs.append(seq)
+ now = pong["a"] if pong else 0
+ ws.send_text(json.dumps({"t": "rx", "s": seq, "r": now}))
+ ws.send_text(json.dumps({"t": "fd", "f": [[seq, now + 1, now + 2, now + 3]], "drop": 0}))
+ if echo == 7:
+ echoed = seq
+ if len(seqs) == 3 and not clicked:
+ ws.send_text(json.dumps({"t": "m", "e": "down", "b": 0, "x": 0.5, "y": 0.5, "i": 7, "tv": now}))
+ clicked = True
+ self.assertEqual(pong["c"], 1.5)
+ self.assertGreater(pong["a"], 0)
+ self.assertEqual(seqs[:3], sorted(seqs[:3]))
+ self.assertIsNotNone(echoed, "no frame was tagged as the first reply to the click")
+ time.sleep(1.7) # frames are reported once the viewer has had time
+ stream = json.loads(self.get(f"/stats?k={self.token}")[1])["streams"][0]
+ first = stream["frames"][0]
+ self.assertGreater(first["e1"], first["e0"])
+ self.assertGreaterEqual(first["e0"], first["cap"])
+ self.assertEqual(first["vs"] - first["rx"], 3)
+ self.assertEqual([i["frame"] for i in stream["inputs"] if i["id"] == 7], [echoed])
+ self.assertIn("total", stream["summary"])
+ self.assertEqual(info["warm"], 0) # off unless FRAME_MAC_VIEW_WARM is set
+ live = json.loads(self.get(f"/status?k={self.token}")[1])["streams"][0]
+ self.assertEqual(live["controller"]["tier"], 0)
+ self.assertIn("fps", live["stats"])
+ ws.close()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_media.py b/tests/test_media.py
new file mode 100644
index 0000000..f277341
--- /dev/null
+++ b/tests/test_media.py
@@ -0,0 +1,301 @@
+"""Owned media planning, eye isolation, decoder choice and fake-Frame ownership."""
+import argparse
+import io
+import json
+import os
+import signal
+from pathlib import Path
+import struct
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'ui'))
+import frame_media as media
+import frame_media_player as player
+import frame_media_remote as remote
+import frame_splat as splat
+import server
+
+
+def stop_now():
+ """What systemd's SIGTERM does to the player, without signalling the test process."""
+ handler = signal.getsignal(signal.SIGTERM)
+ if callable(handler):
+ handler(signal.SIGTERM, None)
+
+
+class Media(unittest.TestCase):
+ def test_layout_evidence_and_override(self):
+ for name, layout in [('film_SBS.mp4', 'sbs'), ('film.OU.mkv', 'ou'),
+ ('film_FSBS.mp4', 'full-sbs'), ('photo_TB.png', 'ou')]:
+ self.assertEqual(media.plan(name)['layout'], layout)
+ self.assertEqual(media.plan('film_SBS_OU.mp4', 'mono')['source'], 'explicit')
+ self.assertEqual(media.plan('film.mkv', metadata={'stereo_mode': 'top_bottom'})['layout'], 'full-ou')
+ for name in ('film.mp4', 'film_SBS_OU.mp4', 'businessbs.mp4'):
+ with self.assertRaises(ValueError):
+ media.plan(name)
+ with self.assertRaises(ValueError):
+ media.plan('film.mkv', metadata={'stereo_mode': 'right_left'})
+
+ def test_unsupported_containers_do_not_flatten_spatial_photos(self):
+ for name in ('spatial.HEIC', 'stereo.mpo', 'cloud.ply', 'cloud.spz', 'app.exe'):
+ with self.assertRaises(ValueError):
+ media.plan(name, 'sbs')
+
+ def test_ou_pixels_keep_each_eye_and_row(self):
+ a, b, c, d = [bytes([n])*8 for n in (1, 2, 3, 4)]
+ data, width, height = media.stereo_pixels(a+b+c+d, 2, 4, 'ou')
+ self.assertEqual((data, width, height), (a+c+b+d, 4, 2))
+ with self.assertRaises(ValueError):
+ media.stereo_pixels(b'bad', 2, 4, 'sbs')
+ self.assertEqual(media.geometry(3840, 2160, 'sbs'), (1920, 1080, 2))
+ self.assertEqual(media.geometry(1920, 1080, 'ou'), (1920, 1080, .5))
+
+ def test_decode_is_hardware_and_one_clock_for_audio(self):
+ for codec, decoder in [('h264', 'h264_v4l2m2m'), ('hevc', 'hevc_v4l2m2m')]:
+ cmd = player.decoder_command(Path('/tmp/a file.mp4'), {'codec_name': codec}, 1280, 720, True)
+ self.assertIn(decoder, cmd)
+ self.assertIn('-re', cmd)
+ self.assertIn('pulse', cmd)
+ self.assertIn(str(Path('/tmp/a file.mp4')), cmd)
+ with self.assertRaises(ValueError):
+ player.decoder_command(Path('x.webm'), {'codec_name': 'vp9'}, 640, 480, False)
+ cmd = player.decoder_command(Path('x.png'), {'codec_name': 'png'}, 640, 480, False, True)
+ self.assertNotIn('-re', cmd)
+ self.assertIn('-frames:v', cmd)
+
+ def play_with(self, name, busy=0, on_pixels=None, sleeps=None, info=None,
+ fail=None, layout='auto'):
+ """Run the player against a fake OpenVR; returns (status, pixels calls).
+
+ Handle 0 is the theatre surround and 1 the screen. `fail(handle, n)` makes
+ the n-th upload busy; every status written is kept in self.writes."""
+ calls = []
+ self.writes = []
+ write_status = player.write_status
+
+ def record(path, **values):
+ self.writes.append(values)
+ write_status(path, **values)
+
+ class FakeOverlay:
+ def create(self, *a, **k):
+ return len(calls)
+
+ def call(self, *a):
+ pass
+
+ def pixels(self, handle, data, w, h):
+ calls.append((handle, w, h))
+ if on_pixels:
+ on_pixels(len(calls))
+ if len(calls) <= busy or (fail and fail(handle, len(calls))):
+ raise player.OverlayBusy('standby')
+
+ def close(self):
+ # A Stop landing during cleanup must be ignored, not become an error.
+ # Call the installed handler directly: a real SIGTERM kills Windows.
+ stop_now()
+
+ frame = bytes(4*2*4)
+ proc = unittest.mock.MagicMock()
+ proc.stdout = io.BytesIO(frame*4)
+ proc.wait.return_value = 0
+ proc.poll.return_value = 0
+ old = signal.getsignal(signal.SIGTERM), signal.getsignal(signal.SIGINT)
+ with tempfile.TemporaryDirectory() as d, \
+ patch.object(player, 'Overlay', FakeOverlay), \
+ patch.object(player, 'probe', return_value=(info or {'codec_name': 'h264', 'width': 4, 'height': 2}, False)), \
+ patch.object(player.subprocess, 'Popen', return_value=proc), \
+ patch.object(player, 'write_status', side_effect=record), \
+ patch.object(player.frame_splat, 'render', return_value=(bytes(4*4*2), 4, 2)), \
+ patch.object(player.time, 'sleep', side_effect=sleeps):
+ path = Path(d)/name
+ path.write_bytes(b'x')
+ status = Path(d)/'status.json'
+ try:
+ player.play(argparse.Namespace(file=str(path), layout=layout, theatre=True, status=str(status)))
+ finally:
+ signal.signal(signal.SIGTERM, old[0])
+ signal.signal(signal.SIGINT, old[1])
+ return json.loads(status.read_text()), calls
+
+ def test_video_survives_standby_and_stop_after_end_stays_ended(self):
+ # Verified 2026-09-29: an unworn Frame enters standby within seconds and
+ # SetOverlayRaw then returns RequestFailed (23) until it wakes.
+ result, calls = self.play_with('clip_SBS.mp4', busy=3)
+ self.assertEqual((result['state'], result['frames']), ('ended', 4))
+ # Two video frames were dropped; the surround (handle 0) waited and was re-sent.
+ self.assertEqual(result['dropped'], 2)
+ self.assertIn((0, 1, 1), calls[3:])
+
+ def test_stop_mid_video_reports_stopped(self):
+ result, _ = self.play_with('clip_SBS.mp4', on_pixels=lambda n: n == 3 and stop_now())
+ self.assertEqual(result['state'], 'stopped')
+
+ def test_video_errors_when_steamvr_never_takes_frames(self):
+ with patch.object(player, 'BUSY_LIMIT', -1), \
+ self.assertRaisesRegex(RuntimeError, 'stopped accepting frames'):
+ self.play_with('clip_SBS.mp4', busy=99)
+
+ def test_still_waits_out_standby_without_a_limit(self):
+ # Stills have no timeline: keep retrying (here past BUSY_LIMIT) until shown.
+ ticks = iter(range(10))
+ def sleep(_):
+ if next(ticks) == 8:
+ stop_now()
+ with patch.object(player, 'BUSY_LIMIT', -1):
+ result, calls = self.play_with('photo_SBS.png', busy=5, sleeps=sleep,
+ info={'codec_name': 'png', 'width': 4, 'height': 2})
+ self.assertEqual(result['state'], 'stopped')
+ screen = [c for c in calls if c[0] == 1]
+ self.assertGreater(len(screen), 1) # retried through standby
+ self.assertEqual(calls[-1], (0, 1, 1)) # surround drained once the screen took a frame
+
+ def still_with_late_surround(self, name, **kw):
+ # The screen takes its first frame while the surround is still refused
+ # (its first upload, the drain right after the screen, and one retry).
+ ticks = iter(range(10))
+ def sleep(_):
+ if next(ticks) == 5:
+ stop_now()
+ surround_tries = []
+ def fail(handle, n):
+ if handle == 0:
+ surround_tries.append(n)
+ return len(surround_tries) <= 3
+ return False
+ result, calls = self.play_with(name, sleeps=sleep, fail=fail, **kw)
+ self.assertEqual(result['state'], 'stopped')
+ screen = [c for c in calls if c[0] == 1]
+ surround = [c for c in calls if c[0] == 0]
+ self.assertEqual(len(screen), 1) # shown once, not re-sent every second
+ self.assertEqual(len(surround), 4) # kept retrying after the screen, until it took
+ self.assertEqual(calls[-1][0], 0)
+ return calls
+
+ def test_photo_surround_recovers_after_screen_is_shown(self):
+ self.still_with_late_surround('photo_SBS.png',
+ info={'codec_name': 'png', 'width': 4, 'height': 2})
+
+ def test_splat_surround_recovers_after_screen_is_shown(self):
+ self.still_with_late_surround('scene.splat')
+
+ def test_video_standby_limit_is_five_minutes_without_an_accepted_frame(self):
+ self.assertEqual(player.BUSY_LIMIT, 300)
+ def run(times, busy):
+ # Upload n happens at times[n] seconds on a fake clock; 1 is the surround.
+ clock = [1000.0]
+ def on_pixels(n):
+ clock[0] = 1000.0 + times.get(n, times[max(times)])
+ with patch.object(player.time, 'monotonic', side_effect=lambda: clock[0]):
+ return self.play_with('clip_SBS.mp4', on_pixels=on_pixels,
+ fail=lambda h, n: h == 1 and n in busy)
+ # Busy for 299 s, then a frame lands: no error.
+ result, _ = run({1: 0, 2: 0, 3: 299, 4: 299, 5: 299}, busy={2, 3})
+ self.assertEqual((result['state'], result['dropped']), ('ended', 2))
+ # An accepted frame resets the timer: 600 s busy in total, never 300 s in a row.
+ result, _ = run({1: 0, 2: 0, 3: 200, 4: 250, 5: 450}, busy={2, 3, 5})
+ self.assertEqual((result['state'], result['dropped']), ('ended', 3))
+ # 301 s in a row without an accepted frame is an error.
+ with self.assertRaisesRegex(RuntimeError, 'stopped accepting frames for 300 s'):
+ run({1: 0, 2: 0, 3: 301}, busy={2, 3, 4, 5})
+
+ def test_status_reports_where_the_layout_came_from(self):
+ video = {'codec_name': 'h264', 'width': 4, 'height': 2}
+ for name, layout, tags, expect in [
+ ('clip_SBS.mp4', 'auto', None, ('sbs', 'filename')),
+ ('clip.mkv', 'auto', {'stereo_mode': 'left_right'}, ('full-sbs', 'metadata')),
+ ('clip_OU.mp4', 'sbs', None, ('sbs', 'explicit')),
+ ('clip.mkv', 'mono', {'stereo_mode': 'left_right'}, ('mono', 'explicit'))]:
+ with self.subTest(name=name, layout=layout):
+ self.play_with(name, layout=layout, info=dict(video, tags=tags) if tags else video)
+ playing = self.writes[0]
+ self.assertEqual(playing['state'], 'playing')
+ self.assertEqual((playing['layout'], playing['source']), expect)
+
+ def test_fake_frame_library_and_traversal(self):
+ with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)):
+ identity = 'a'*32+'/space and quote\'.png'
+ path = Path(d)/identity
+ path.parent.mkdir()
+ path.write_bytes(b'test')
+ self.assertEqual(remote.media_path(identity), path.resolve())
+ for bad in ('../../etc/passwd', '/etc/passwd', 'a'*32+'/..', 'a'*32+'/x/y', None):
+ with self.assertRaises((ValueError, FileNotFoundError)):
+ remote.media_path(bad)
+ link = path.parent/'link.png'
+ link.symlink_to(path)
+ with self.assertRaises(ValueError):
+ remote.media_path('a'*32+'/link.png')
+ with patch.object(remote, 'status', return_value={'state': 'idle'}):
+ files = remote.run({'action': 'list'})['files']
+ self.assertEqual([f['id'] for f in files], [identity])
+
+ def test_server_rejects_bad_actions_before_ssh(self):
+ with patch.object(server, 'ssh') as ssh:
+ for body in ({'action': 'delete'}, {'action': 'play', 'id': '../x'},
+ {'action': 'play', 'id': 'a'*32+'/x', 'layout': 'invalid'},
+ {'action': 'play', 'id': 'a'*32+'/x', 'theatre': 'false'}):
+ with self.assertRaises(server.Failure):
+ server.media(body)
+ ssh.assert_not_called()
+
+ def test_fake_frame_stop_only_owns_our_unit(self):
+ for rc in (0, 5): # 5: already collected ("not loaded"), a no-op
+ with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={'state': 'ended'}):
+ run.return_value.returncode = rc
+ self.assertEqual(remote.run({'action': 'stop'})['state'], 'ended')
+ self.assertEqual(run.call_args.args[0], ['systemctl', '--user', 'stop', 'frame-control-media.service'])
+ with patch.object(remote.subprocess, 'run') as run, patch.object(remote, 'status', return_value={}):
+ run.return_value.returncode, run.return_value.stderr = 1, 'Access denied'
+ with self.assertRaisesRegex(RuntimeError, 'Access denied'):
+ remote.run({'action': 'stop'})
+
+ def test_start_failure_reports_systemd_error(self):
+ with tempfile.TemporaryDirectory() as d, patch.object(remote, 'ROOT', Path(d)), \
+ patch.object(remote, 'STATUS', Path(d)/'status.json'), \
+ patch.object(remote, 'active', return_value=False), \
+ patch.object(remote.subprocess, 'run') as run:
+ identity = 'b'*32+'/still_SBS.png'
+ (Path(d)/identity).parent.mkdir()
+ (Path(d)/identity).write_bytes(b'x')
+ run.return_value.returncode, run.return_value.stderr = 1, 'Unit already exists'
+ with patch.object(remote, 'probe', return_value=({}, False)), \
+ self.assertRaisesRegex(RuntimeError, 'Unit already exists'):
+ remote.run({'action': 'play', 'id': identity})
+ self.assertEqual(run.call_args.args[0][0], 'systemd-run') # reset-failed's result is ignored
+
+ def test_upload_rejects_unplayable_names_and_keeps_copy_error(self):
+ with patch.object(server, 'ssh') as ssh, patch.object(server, 'push_file') as push:
+ # On Windows a backslash is a separator, so such a name can't reach here.
+ for name in ('.hidden.mp4',) + (('a\\b_SBS.mp4',) if os.sep == '/' else ()):
+ with self.assertRaises(server.Failure):
+ server.push_media(Path('/tmp')/name)
+ ssh.assert_not_called()
+ push.side_effect = server.Failure('copy failed')
+ ssh.side_effect = [None, server.Failure('link down')]
+ with self.assertRaisesRegex(server.Failure, 'copy failed'):
+ server.push_media(Path('/tmp/film_SBS.mp4'))
+
+ def test_splat_invalid_records_and_stereo_parallax(self):
+ with tempfile.TemporaryDirectory() as d:
+ path = Path(d)/'small.splat'
+ path.write_bytes(struct.pack('<6f8B', 0, 0, 0, .001, .001, .001,
+ 255, 0, 0, 255, 255, 128, 128, 128))
+ data, w, h = splat.render(path, 64, 48)
+ self.assertEqual((len(data), w, h), (128*48*4, 128, 48))
+ def centroid(eye):
+ weights = [(x, data[(y*w+x+eye*64)*4]) for y in range(h) for x in range(64)]
+ return sum(x*v for x,v in weights)/sum(v for x,v in weights)
+ self.assertGreater(centroid(0), centroid(1))
+ for bad in (b'', b'bad', struct.pack('<6f8B', float('nan'), 0, 0, 1, 1, 1, *([128]*8))):
+ path.write_bytes(bad)
+ with self.assertRaises(ValueError):
+ splat.read(path)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_network.py b/tests/test_network.py
new file mode 100644
index 0000000..b808dd8
--- /dev/null
+++ b/tests/test_network.py
@@ -0,0 +1,147 @@
+"""frame_network's parsers, with what macOS, Linux and Windows print.
+
+Run: python3 -m unittest discover -s tests
+"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
+import json
+import sys
+import unittest
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(ROOT / "ui"))
+
+import frame_network as fn # noqa: E402
+
+MAC_ROUTE = """ route to: default
+destination: default
+ mask: default
+ gateway: 192.168.1.1
+ interface: en0
+ flags:
+"""
+MAC_ARP = "? (192.168.1.1) at b4:fb:e4:1:87:3f on en0 ifscope [ethernet]\n"
+MAC_ARP_INCOMPLETE = "? (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]\n"
+MAC_SUMMARY = """ {
+ BSSID :
+ ConnectionID : 1
+ InterfaceType : WiFi
+ LinkStatusActive : TRUE
+ NetworkID :
+ SSID :
+ Security : WPA2_PSK
+}"""
+MAC_SUMMARY_NAMED = MAC_SUMMARY.replace("SSID : \n Security", "SSID : Home Net\n Security")
+MAC_SUMMARY_WIRED = " {\n InterfaceType : Ethernet\n LinkStatusActive : TRUE\n}"
+
+LINUX_ROUTE = """default via 10.0.0.1 dev wlp2s0 proto dhcp src 10.0.0.23 metric 600
+default via 192.168.50.1 dev enp3s0 proto dhcp src 192.168.50.9 metric 100
+"""
+LINUX_NEIGH = "192.168.50.1 dev enp3s0 lladdr 00:11:22:aa:bb:cc REACHABLE\n"
+NMCLI = "no:Neighbour\nyes:Cafe\\: upstairs\nno:\n"
+
+WIN_ROUTE = """===========================================================================
+Interface List
+ 12...00 15 5d 01 02 03 ......Intel(R) Wi-Fi 6 AX201 160MHz
+===========================================================================
+
+IPv4 Route Table
+===========================================================================
+Active Routes:
+Network Destination Netmask Gateway Interface Metric
+ 0.0.0.0 0.0.0.0 192.168.0.254 192.168.0.40 50
+ 0.0.0.0 0.0.0.0 192.168.1.1 192.168.1.50 35
+===========================================================================
+Persistent Routes:
+ None
+"""
+WIN_ARP = """
+Interface: 192.168.1.50 --- 0xc
+ Internet Address Physical Address Type
+ 192.168.1.1 b4-fb-e4-b5-67-55 dynamic
+"""
+NETSH = """
+There is 1 interface on the system:
+
+ Name : Wi-Fi
+ Description : Intel(R) Wi-Fi 6 AX201 160MHz
+ State : connected
+ SSID : Office 5G
+ BSSID : 12:34:56:78:9a:bc
+ Network type : Infrastructure
+"""
+NETSH_OFF = NETSH.replace("State : connected", "State : disconnected")
+
+TAILSCALE = json.dumps({
+ "BackendState": "Running",
+ "CurrentTailnet": {"Name": "example.github"},
+ "Self": {"HostName": "laptop", "DNSName": "laptop.tail1234.ts.net.", "TailscaleIPs": ["fd7a:115c:a1e0::1", "100.101.102.103"]},
+ "Peer": {"nodekey:1": {"HostName": "frame", "DNSName": "frame.tail1234.ts.net.", "OS": "linux", "Online": True,
+ "TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1234:5678"]},
+ "nodekey:2": {"HostName": "phone", "DNSName": "phone.tail1234.ts.net.", "OS": "iOS", "Online": False,
+ "TailscaleIPs": ["100.77.1.2"]}},
+})
+
+
+class Parsers(unittest.TestCase):
+ def test_macos(self):
+ self.assertEqual(fn.parse_route_macos(MAC_ROUTE), ("192.168.1.1", "en0"))
+ self.assertEqual(fn.parse_route_macos("route: writing to routing socket: not in table\n"), (None, None))
+ self.assertEqual(fn.parse_arp_macos(MAC_ARP, "192.168.1.1"), "b4:fb:e4:01:87:3f") # padded
+ self.assertIsNone(fn.parse_arp_macos(MAC_ARP_INCOMPLETE, "192.168.1.1"))
+ self.assertIsNone(fn.parse_arp_macos(MAC_ARP, "192.168.1.10"))
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY), (None, True)) # no Location permission
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_NAMED), ("Home Net", True))
+ self.assertEqual(fn.parse_summary_macos(MAC_SUMMARY_WIRED), (None, False))
+
+ def test_linux(self):
+ self.assertEqual(fn.parse_route_linux(LINUX_ROUTE), ("192.168.50.1", "enp3s0")) # lowest metric
+ self.assertEqual(fn.parse_route_linux(""), (None, None))
+ self.assertEqual(fn.parse_neigh_linux(LINUX_NEIGH, "192.168.50.1"), "00:11:22:aa:bb:cc")
+ self.assertIsNone(fn.parse_neigh_linux("192.168.50.1 dev enp3s0 FAILED\n", "192.168.50.1"))
+ self.assertEqual(fn.parse_nmcli(NMCLI), "Cafe: upstairs")
+ self.assertIsNone(fn.parse_nmcli("no:Neighbour\n"))
+
+ def test_windows(self):
+ self.assertEqual(fn.parse_route_windows(WIN_ROUTE), ("192.168.1.1", "192.168.1.50"))
+ self.assertEqual(fn.parse_arp_windows(WIN_ARP, "192.168.1.1"), "b4:fb:e4:b5:67:55")
+ self.assertEqual(fn.parse_netsh(NETSH), "Office 5G") # not the BSSID
+ self.assertIsNone(fn.parse_netsh(NETSH_OFF))
+
+ def test_mac_addresses(self):
+ self.assertEqual(fn.norm_mac("B4-FB-E4-B5-67-55"), "b4:fb:e4:b5:67:55")
+ for bad in ("", "(incomplete)", "ff:ff:ff:ff:ff:ff", "00:00:00:00:00:00", "b4:fb:e4:b5:67", "zz:fb:e4:b5:67:55"):
+ self.assertIsNone(fn.norm_mac(bad), bad)
+
+ def test_network_id_is_stable_and_needs_both_parts(self):
+ a = fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55")
+ self.assertEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:55"))
+ self.assertTrue(a.startswith("n-"))
+ self.assertNotEqual(a, fn.network_id("192.168.1.1", "b4:fb:e4:b5:67:56")) # same IP, another router
+ self.assertIsNone(fn.network_id("192.168.1.1", None))
+ self.assertIsNone(fn.network_id(None, "b4:fb:e4:b5:67:55"))
+
+ def test_tailscale(self):
+ ts = fn.parse_tailscale(TAILSCALE)
+ self.assertTrue(ts["up"])
+ self.assertEqual(ts["ip"], "100.101.102.103")
+ self.assertEqual(ts["name"], "laptop.tail1234.ts.net")
+ self.assertEqual(ts["tailnet"], "example.github")
+ frame = ts["peers"][0]
+ self.assertEqual((frame["name"], frame["dns"], frame["os"], frame["online"]),
+ ("frame", "frame.tail1234.ts.net", "linux", True))
+ self.assertFalse(fn.parse_tailscale(json.dumps({"BackendState": "Stopped", "Self": {}}))["up"])
+ self.assertEqual(fn.parse_tailscale("not json"), {"up": False, "peers": []})
+ self.assertEqual(fn.parse_tailscale("[]"), {"up": False, "peers": []})
+
+ def test_address_kinds(self):
+ cases = {"frame.local": "mdns", "frame.local.": "mdns", "frame.tail1234.ts.net": "tailscale",
+ "100.101.102.103": "tailscale", "fd7a:115c:a1e0::1234:5678": "tailscale",
+ "192.168.1.40": "lan", "10.0.0.5": "lan", "fe80::1%en0": "lan",
+ "frame.example.com": "manual", "8.8.8.8": "manual"}
+ for host, kind in cases.items():
+ self.assertEqual(fn.guess_kind(host), kind, host)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_panels.py b/tests/test_panels.py
new file mode 100644
index 0000000..6cfb241
--- /dev/null
+++ b/tests/test_panels.py
@@ -0,0 +1,102 @@
+"""Fake-Frame panel responses and the headset page's real HTTP guards."""
+import http.client
+import json
+from pathlib import Path
+import sys
+import threading
+import unittest
+from unittest.mock import patch
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / 'ui'))
+import frame_panels as panels
+
+# Shape verified with vrcmd on SteamVR 2.18.1 / BUILD_ID 20260925.6191901.
+OVERLAYS = """---- OVERLAYS ----
+'valve.steam.desktopgame.12' -- 'Alex's ', 1920x1080 visible VROverlayType_Dashboard_Main
+'valve.steam.desktopgame.12.thumb' -- 'Thumb', not_visible VROverlayType_Dashboard_Thumbnail
+'valve.steam.desktopgame.12.layer1' -- 'Layer', visible VROverlayType_Subview
+'system.pointer' -- 'Pointer', visible VROverlayType_Basic
+'valve.steam.desktopgame.13' -- 'Other', not_visible VROverlayType_Dashboard_Main
+"""
+
+
+class Panels(unittest.TestCase):
+ def test_enumerates_only_main_panels_including_hidden(self):
+ rows = panels.parse_overlays(OVERLAYS)
+ self.assertEqual(len(rows), 2)
+ self.assertEqual(rows[0]['title'], "Alex's ")
+ self.assertTrue(rows[0]['visible'])
+ self.assertFalse(rows[1]['visible'])
+
+ def test_unavailable_runtime_is_not_an_empty_workspace(self):
+ with self.assertRaises(panels.PanelError):
+ panels.parse_overlays('SteamVR not running')
+ self.assertEqual(panels.parse_overlays('---- OVERLAYS ----'), [])
+
+ @patch.object(panels, 'run', return_value=OVERLAYS)
+ def test_focus_revalidates_and_dispatches_without_shell(self, run):
+ key = 'valve.steam.desktopgame.12'
+ self.assertEqual(panels.focus(key)['requested'], key)
+ self.assertEqual(run.call_args.args[0], [panels.VRCMD, '--showdashboard', key])
+
+ @patch.object(panels, 'run', return_value=OVERLAYS)
+ def test_closed_or_injected_panel_never_dispatches(self, run):
+ for key in (None, 12, 'x;touch /tmp/bad', '../other', 'missing'):
+ with self.assertRaises(panels.PanelError):
+ panels.focus(key)
+ self.assertEqual(run.call_count, 1) # only valid-looking 'missing' enumerates
+
+
+class HeadsetHTTP(unittest.TestCase):
+ def setUp(self):
+ self.server = panels.HTTPServer(('127.0.0.1', 0), panels.Handler)
+ self.server.key = 'test-key'
+ self.server.closing = False
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ def tearDown(self):
+ self.server.shutdown()
+ self.thread.join()
+ self.server.server_close()
+
+ def request(self, path, body=None, headers=None):
+ c = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=5)
+ try:
+ c.request('POST' if body is not None else 'GET', path, body=body, headers=headers or {})
+ r = c.getresponse()
+ return r.status, r.read().decode()
+ finally:
+ c.close()
+
+ def test_page_is_public_but_contains_no_key_or_private_titles(self):
+ status, page = self.request('/')
+ self.assertEqual(status, 200)
+ self.assertNotIn('test-key', page)
+ self.assertIn('textContent=p.title', page) # titles never become HTML
+ self.assertEqual(self.request('/panels')[0], 403)
+
+ @patch.object(panels, 'state', return_value={'panels': []})
+ def test_auth_host_and_origin_checks(self, state):
+ auth = {'X-Panel-Key': 'test-key'}
+ self.assertEqual(self.request('/panels', headers=auth)[0], 200)
+ for extra in ({'Host': 'evil.test'}, {'Origin': 'https://evil.test'}, {'X-Panel-Key': 'wrong'}):
+ self.assertEqual(self.request('/panels', headers={**auth, **extra})[0], 403)
+ self.assertEqual(state.call_count, 1)
+
+ @patch.object(panels, 'focus', return_value={'requested': 'panel'})
+ def test_post_validation_and_close(self, focus):
+ auth = {'X-Panel-Key': 'test-key'}
+ for body in ('[]', '{broken', '0', '"text"', 'x' * 1025):
+ self.assertEqual(self.request('/focus', body, auth)[0], 400)
+ self.assertEqual(focus.call_count, 0)
+ self.assertEqual(self.request('/focus', '{"key":"panel"}', auth)[0], 200)
+ self.assertEqual(focus.call_args.args, ('panel',))
+ self.assertEqual(self.request('/close', '{}', auth)[0], 200)
+ self.assertTrue(self.server.closing)
+
+ @patch.object(panels, 'state', side_effect=panels.PanelError('offline'))
+ def test_offline_is_an_error_not_a_successful_empty_list(self, state):
+ status, body = self.request('/panels', headers={'X-Panel-Key': 'test-key'})
+ self.assertEqual(status, 502)
+ self.assertEqual(json.loads(body), {'error': 'offline'})
diff --git a/tests/test_server.py b/tests/test_server.py
index ec389bd..9805806 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -5,6 +5,7 @@ request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests
"""
+import sandbox # noqa: F401 (first: keeps tests off real data and services)
import http.client
import io
import json
@@ -33,7 +34,9 @@ class ServerGuards(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.port = free_port()
- env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1"}
+ cls.ssh_dir = tempfile.mkdtemp(prefix="frame-control-ssh-") # an empty ~/.ssh: no headsets set up
+ env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
+ "FRAME_CONTROL_SSH_DIR": cls.ssh_dir}
cls.log = tempfile.TemporaryFile()
cls.proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(cls.port)],
env=env, stdout=cls.log, stderr=subprocess.STDOUT)
@@ -83,6 +86,7 @@ class ServerGuards(unittest.TestCase):
def test_api_needs_custom_header(self):
# and plain form posts from other sites can't set it.
+ self.assertEqual(self.request("POST", "/api/comfort", {"action": "start"})[0], 403)
self.assertEqual(self.request("GET", "/api/status")[0], 403)
self.assertEqual(self.request("GET", "/api/screenshot?view=headset")[0], 403)
self.assertEqual(self.request("GET", "/api/shots")[0], 403)
@@ -98,6 +102,8 @@ class ServerGuards(unittest.TestCase):
def test_input_validation(self):
cases = [
+ ("/api/comfort", {"action": "poweroff"}),
+ ("/api/comfort", {"action": "start", "minutes": 0}),
("/api/launch", {"appid": "620; rm -rf ~"}),
("/api/launch", {"appid": ""}),
("/api/flatpak", {"id": "org.example.App;id", "action": "install"}),
@@ -234,6 +240,96 @@ class ServerGuards(unittest.TestCase):
self.assertEqual(self.post("/api/nope", {})[0], 404)
+class OneServer(unittest.TestCase):
+ """Two servers for one user would each connect and edit headsets on their own."""
+
+ def start(self, env):
+ proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ self.addCleanup(lambda: (proc.terminate(), proc.wait(10), proc.stdout.close()))
+ return proc
+
+ def test_a_second_server_is_refused_until_the_first_exits(self):
+ data = tempfile.mkdtemp(prefix="frame-one-server-")
+ env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
+ "FRAME_CONTROL_SERVER_WAIT": "1"}
+ first = self.start(env)
+ self.assertIn("Frame Control on", first.stdout.readline())
+ second = subprocess.run([sys.executable, str(ROOT / "ui" / "server.py"), "--port", "0"], env=env,
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(second.returncode, 1)
+ self.assertIn("already running", second.stderr)
+ first.terminate()
+ first.wait(10)
+ self.assertIn("Frame Control on", self.start(env).stdout.readline())
+
+ def test_a_private_server_runs_alongside_but_cant_change_headsets(self):
+ """The MCP adapter starts its own server (FRAME_PRIVATE_SSH=1) while the app runs."""
+ data = tempfile.mkdtemp(prefix="frame-one-server-")
+ env = {**os.environ, "FRAME_CONTROL_DATA_DIR": data, "FRAME_ALIAS": "frame-control-test.invalid",
+ "FRAME_CONTROL_SERVER_WAIT": "1"}
+ self.assertIn("Frame Control on", self.start(env).stdout.readline())
+ private = self.start({**env, "FRAME_PRIVATE_SSH": "1"})
+ line = private.stdout.readline()
+ self.assertIn("Frame Control on", line)
+ port = int(line.split("http://127.0.0.1:")[1].split()[0])
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
+ conn.request("POST", "/api/devices", body=json.dumps({"action": "use", "id": "x"}),
+ headers={"Content-Type": "application/json", "X-Frame-UI": "1", "Host": f"127.0.0.1:{port}"})
+ r = conn.getresponse()
+ self.assertEqual(r.status, 403, r.read())
+
+
+class ArtworkSettings(unittest.TestCase):
+ """The settings panel's endpoints, with and without the page's X-Frame-UI key."""
+
+ def test_settings_need_and_accept_the_ui_key(self):
+ with tempfile.TemporaryDirectory() as home:
+ port = free_port()
+ env = {**os.environ, "FRAME_ALIAS": "frame-control-test.invalid", "PYTHONDONTWRITEBYTECODE": "1",
+ "HOME": home, "APPDATA": home, "XDG_DATA_HOME": home}
+ for name in ("STEAMGRIDDB_API_KEY", "FRAME_STEAMGRIDDB_API_KEY", "FRAME_UI_KEY"):
+ env.pop(name, None)
+ proc = subprocess.Popen([sys.executable, str(ROOT / "ui" / "server.py"), "--port", str(port)],
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ try:
+ def request(method, path, body=None, headers=None):
+ conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
+ conn.request(method, path, body=json.dumps(body).encode() if body is not None else None,
+ headers=headers or {})
+ r = conn.getresponse()
+ payload = r.read()
+ conn.close()
+ return r.status, payload
+ for _ in range(100):
+ try:
+ if request("GET", "/")[0] == 200:
+ break
+ except OSError:
+ time.sleep(0.05)
+ key = {"X-Frame-UI": "1", "Content-Type": "application/json"}
+ self.assertEqual(request("GET", "/api/settings/artwork")[0], 403)
+ self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc"})[0], 403)
+ status, payload = request("GET", "/api/settings/artwork", headers=key)
+ self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, False))
+ status, payload = request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "abc_1"}, key)
+ self.assertEqual((status, json.loads(payload)["steamgriddb_configured"]), (200, True))
+ self.assertNotIn(b"abc_1", payload)
+ status, payload = request("GET", "/api/settings/artwork", headers=key)
+ self.assertTrue(json.loads(payload)["steamgriddb_configured"])
+ self.assertEqual(request("POST", "/api/settings/artwork", {"steamgriddb_api_key": "a b"}, key)[0], 400)
+ finally:
+ proc.terminate()
+ proc.wait(timeout=10)
+
+ def test_panel_script_uses_the_keyed_api_helper(self):
+ script = (ROOT / "ui" / "artwork-settings.js").read_text(encoding="utf-8")
+ self.assertNotIn("fetch(", script)
+ self.assertIn("api('/api/settings/artwork'", script)
+ page = (ROOT / "ui" / "index.html").read_text(encoding="utf-8")
+ self.assertLess(page.index("async function api("), page.index('
+
diff --git a/ui/frame_agent.py b/ui/frame_agent.py
new file mode 100644
index 0000000..b8a3dbe
--- /dev/null
+++ b/ui/frame_agent.py
@@ -0,0 +1,145 @@
+"""Agent actions and one-use human approvals. No model SDK or network calls here."""
+import hashlib
+import os
+from pathlib import Path
+import secrets
+import shlex
+import shutil
+import subprocess
+import threading
+import time
+
+
+class Approvals:
+ def __init__(self):
+ self.pending = {}
+ self.lock = threading.Lock()
+
+ def request(self, action):
+ with self.lock:
+ now = time.monotonic()
+ self.pending = {k: v for k, v in self.pending.items() if v['expires'] > now}
+ if len(self.pending) >= 100:
+ raise ValueError('Too many pending approvals; wait five minutes')
+ token = secrets.token_urlsafe(24)
+ self.pending[token] = {'action': action, 'approved': False, 'expires': now + 300}
+ return {'confirmation': token, 'action': action, 'approvalPath': '/assistant#confirm=' + token,
+ 'message': 'Ask the user to review and approve this action in Frame Control, then retry with confirmation. Expires in five minutes.'}
+
+ def entry(self, token):
+ entry = self.pending.get(token)
+ if not entry or entry['expires'] <= time.monotonic():
+ raise ValueError('Approval expired or unknown; request a new one')
+ return entry
+
+ def inspect(self, token):
+ with self.lock:
+ entry = self.entry(token)
+ return {'action': entry['action'], 'approved': entry['approved']}
+
+ def decide(self, token, accept):
+ with self.lock:
+ entry = self.entry(token)
+ if accept is True:
+ entry['approved'] = True
+ else:
+ del self.pending[token]
+ return {'message': 'Approved for one use' if accept is True else 'Rejected'}
+
+ def consume(self, token, action):
+ with self.lock:
+ entry = self.entry(token)
+ if entry['action'] != action or not entry['approved']:
+ raise ValueError('This exact action needs approval in Frame Control')
+ del self.pending[token] # consume before starting, including on failure
+
+
+approvals = Approvals()
+
+
+def validate(name, args):
+ fields = {
+ 'launch': {'appid'}, 'install': {'id'}, 'uninstall': {'id'},
+ 'send_text': {'text'}, 'send_file': {'path'}, 'panel': {'id'},
+ 'power': {'action'}, 'keep_awake': {'action'},
+ }
+ if name not in fields or not isinstance(args, dict) or set(args) != fields[name]:
+ raise ValueError('Unknown action or arguments')
+ if any(not isinstance(v, str) or not v or len(v) > 65536 for v in args.values()):
+ raise ValueError('Arguments must be nonempty strings (maximum 65536 characters)')
+ if name == 'power' and args['action'] not in ('suspend', 'reboot', 'poweroff'):
+ raise ValueError('Unknown power action')
+ if name == 'keep_awake' and args['action'] not in ('on', 'off', 'status'):
+ raise ValueError('Expected on, off or status')
+ action = {'name': name, 'arguments': dict(args)}
+ if name == 'send_file':
+ path = Path(args['path']).expanduser().resolve(strict=True)
+ if not path.is_file() or path.stat().st_size > 16 * 1024**2:
+ raise ValueError('Choose a regular file of at most 16 MiB')
+ # Bind approval to bytes, not just a mutable filename.
+ with path.open('rb') as stream:
+ data = stream.read(16 * 1024**2 + 1)
+ if len(data) > 16 * 1024**2:
+ raise ValueError('File grew beyond 16 MiB')
+ action['arguments']['path'] = str(path)
+ action['sha256'] = hashlib.sha256(data).hexdigest()
+ action['bytes'] = len(data)
+ return action
+
+
+def call(server, body):
+ name, args = body.get('name'), body.get('arguments', {})
+ action = validate(name, args)
+ if name in ('install', 'uninstall', 'panel') and not server.FLATPAK_ID.fullmatch(args['id']):
+ raise ValueError('Expected a Flatpak application ID')
+ if name == 'launch' and not server.APPID.fullmatch(args['appid']):
+ raise ValueError('Expected a Steam app ID')
+ if name == 'keep_awake' and args['action'] == 'status':
+ return keep_awake(server, 'status')
+ token = body.get('confirmation')
+ if not token:
+ return approvals.request(action)
+ approvals.consume(token, action)
+ if name == 'launch':
+ return server.launch(args)
+ if name in ('install', 'uninstall'):
+ return server.flatpak({**args, 'action': name})
+ if name == 'send_text':
+ return server.clipboard(args)
+ if name == 'send_file':
+ # Stage the reviewed bytes before the existing transfer helper reads them.
+ import tempfile
+ with tempfile.TemporaryDirectory(prefix='frame-agent-') as tmp:
+ source = Path(action['arguments']['path'])
+ with source.open('rb') as stream:
+ data = stream.read(16 * 1024**2 + 1)
+ if hashlib.sha256(data).hexdigest() != action['sha256']:
+ raise ValueError('File changed after approval')
+ staged = Path(tmp) / source.name
+ staged.write_bytes(data)
+ return {'message': server.push_file(staged)}
+ if name == 'power':
+ if server.LOCAL:
+ raise ValueError('Use the Frame Control power controls to enter the password; MCP never takes passwords')
+ return server.open_thing({'what': args['action']})
+ if name == 'keep_awake':
+ return keep_awake(server, args['action'])
+ return run_script(server, 'panel-on-frame.sh', [args['id']])
+
+
+def run_script(server, name, args):
+ script = server.HERE.parent / 'scripts' / name
+ if not script.exists() or not shutil.which('zsh') or server.LOCAL:
+ raise ValueError(name + ' requires a computer with zsh and the matching script installed')
+ # The headset the server is routed to, not whatever `frame` means in ~/.ssh/config.
+ env = {**os.environ, 'FRAME_ALIAS': server.FRAME,
+ 'FRAME_SSH_OPTS': shlex.join(server.SSH[1:])}
+ result = subprocess.run(['zsh', str(script), *args], capture_output=True, text=True, timeout=60, env=env)
+ if result.returncode:
+ raise ValueError(result.stderr.strip() or 'Script failed')
+ return {'message': result.stdout.strip()}
+
+
+def keep_awake(server, action):
+ # PR #16 owns this interface. Never silently change timers or claim a lease.
+ return run_script(server, 'keep-awake.sh', [action])
diff --git a/ui/frame_android.py b/ui/frame_android.py
index f060434..4345971 100644
--- a/ui/frame_android.py
+++ b/ui/frame_android.py
@@ -6,12 +6,20 @@ apps, the lepton-show-flatscreen marker; plus a non-Steam shortcut, so it shows
in the Steam library and gets its own SteamVR panel. Nothing goes through
Lepton Development, which wipes its apps on exit. See docs/apks.md.
-Python stdlib only. CLI: python3 ui/frame_android.py {info APK|versions APK-or-PKG|install APK|list|launch PKG|stop PKG|remove PKG|probe PKG}
+Python stdlib only. CLI: python3 ui/frame_android.py
+ install APK [--vr|--flat] [--no-xr-compat] | info APK | versions APK-or-PKG
+ install-obb PKG OBB [OBB ...] | backup-data PKG ARCHIVE | restore-data PKG ARCHIVE
+ refresh-art PKG|--all | patch SRC DST [--add NAME=PATH ...] | list | launch PKG | stop PKG | remove PKG | probe PKG
"""
-import json, os, re, shlex, shutil, subprocess, sys, threading, time, zlib
+import base64, json, os, re, shlex, shutil, struct, subprocess, sys, threading, time, zlib
import frame_apk
+import frame_artwork
import frame_host
+import tempfile
+import zipfile
+from frame_apk_vr import add_launcher_category
+from frame_apk_sign import repack
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRAME = os.environ.get('FRAME_ALIAS', 'frame')
@@ -20,6 +28,13 @@ COMPAT = '.local/share/Steam/steamapps/compatdata'
SHADERS = '.local/share/Steam/steamapps/shadercache'
LAUNCHER = os.path.join(ROOT, 'frame', 'android', 'lepton-app.sh')
SHORTCUTS = os.path.join(ROOT, 'frame', 'android', 'steam_shortcuts.py')
+# OpenXR API layer that lets OpenXR 1.1 apps run on SteamVR's 1.0-only Android
+# runtime (frame/openxr-compat, docs/vr-apks.md). Injected into VR APKs.
+XR_COMPAT = os.path.join(ROOT, 'frame', 'openxr-compat')
+XR_COMPAT_FILES = {
+ 'assets/openxr/1/api_layers/implicit.d/XrApiLayer_FRAME_compat.json': 'XrApiLayer_FRAME_compat.json',
+ 'lib/arm64-v8a/libXrApiLayer_FRAME_compat.so': 'prebuilt/arm64-v8a/libXrApiLayer_FRAME_compat.so',
+}
PKG_RE = re.compile(r'^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$')
SSH_OPTS = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8']
@@ -43,8 +58,12 @@ def ssh(cmd, input=None, timeout=120):
def shortcut_tool(*args, timeout=60):
with open(SHORTCUTS) as f:
- return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=f.read(),
- timeout=timeout).strip()
+ script = f.read()
+ if args and args[0] == 'render':
+ with open(os.path.join(ROOT, 'frame/android/library_artwork.js')) as f:
+ script = 'ART_RENDERER = ' + repr(f.read()) + '\n' + script
+ return ssh('python3 - ' + ' '.join(shlex.quote(a) for a in args), input=script,
+ timeout=timeout).strip()
def instance_id(pkg):
@@ -65,6 +84,22 @@ def apk_info(path):
raise FrameError(f'{os.path.basename(path)}: {e}')
+def xr_compat_files(apk_path):
+ """The layer's files to add, or {} if the APK has no OpenXR loader or already has the layer."""
+ with zipfile.ZipFile(apk_path) as z:
+ names = set(z.namelist())
+ if 'lib/arm64-v8a/libopenxr_loader.so' not in names or names & set(XR_COMPAT_FILES):
+ return {}
+ add = {}
+ for entry, rel in XR_COMPAT_FILES.items():
+ try:
+ with open(os.path.join(XR_COMPAT, rel), 'rb') as f:
+ add[entry] = f.read()
+ except OSError:
+ raise FrameError("the OpenXR compatibility layer isn't built; run frame/openxr-compat/build.sh")
+ return add
+
+
def check_installable(info):
if info['min_sdk'] and info['min_sdk'] > 30:
raise FrameError(f"{info['label']} needs Android API {info['min_sdk']}; Lepton is Android 11 (API 30)")
@@ -106,49 +141,97 @@ def _write_meta(d, meta):
ssh(f'cat > {d}/meta.json.tmp && mv {d}/meta.json.tmp {d}/meta.json', input=json.dumps(meta, indent=1))
-def install(apk_path, flatscreen=True, name=None, source=None, icon_png=None):
- info = apk_info(apk_path)
- if icon_png:
- info['icon_png'] = icon_png
- check_installable(info)
- pkg = info['package']
- if not PKG_RE.match(pkg):
- raise FrameError(f'unexpected package name {pkg!r}')
- with _install_lock:
- return _install(apk_path, info, pkg, flatscreen, name, source)
+# Called after every install, worked or not, as fn(info, meta, error, seconds):
+# info is None if the APK couldn't be read, meta None and error set if it failed.
+install_hooks = []
-def _install(apk_path, info, pkg, flatscreen, name, source):
+def install(apk_path, flatscreen=None, name=None, source=None, icon_png=None, xr_compat=None, artwork=None):
+ start, info = time.time(), None
+ try:
+ info = apk_info(apk_path)
+ if icon_png:
+ info['icon_png'] = icon_png
+ check_installable(info)
+ pkg = info['package']
+ if not PKG_RE.match(pkg):
+ raise FrameError(f'unexpected package name {pkg!r}')
+ if flatscreen is None:
+ flatscreen = not info['vr']
+ # VR apps get the OpenXR compatibility layer unless told otherwise; it only
+ # changes calls SteamVR would otherwise reject.
+ add = xr_compat_files(apk_path) if (info['vr'] if xr_compat is None else xr_compat) else {}
+ with _install_lock:
+ if add or info['repairable']:
+ with tempfile.TemporaryDirectory(prefix='frame-vr-') as tmp:
+ patched = os.path.join(tmp, 'app.apk')
+ info['patched'] = patch(apk_path, patched, add)['patched']
+ info['launchable'] = True
+ meta = _install(patched, info, pkg, flatscreen, name, source or os.path.basename(apk_path), artwork)
+ else:
+ meta = _install(apk_path, info, pkg, flatscreen, name, source, artwork)
+ except FrameError as e:
+ _after_install(info, None, e, start)
+ raise
+ _after_install(info, meta, None, start)
+ return meta
+
+
+def _after_install(info, meta, error, start):
+ for hook in install_hooks:
+ try:
+ hook(info, meta, error, time.time() - start)
+ except Exception:
+ pass # reporting must never change an install's outcome
+
+
+def _install(apk_path, info, pkg, flatscreen, name, source, artwork=None):
+ try:
+ images, art_warnings = frame_artwork.prepare(name or info['label'], info.get('icon_png'), artwork)
+ except (ValueError, OSError) as e:
+ raise FrameError(f'could not prepare artwork: {e}') from e
iid = instance_id(pkg)
d = f'{APPS_DIR}/{pkg}'
existing = read_meta(pkg)
- ok = False
+ ok, created = False, None
try:
ssh(f'mkdir -p {d}')
_copy(apk_path, f'{d}/app.apk.part')
_copy(LAUNCHER, f'{d}/launch.sh', executable=True, timeout=120)
- icon = ''
- if info['icon_png']:
- ssh(f'cat > {d}/icon.png', input=info['icon_png'])
- icon = f'$HOME/{d}/icon.png'
marker = f'touch {d}/lepton-show-flatscreen' if flatscreen else f'rm -f {d}/lepton-show-flatscreen'
ssh(f'mv {d}/app.apk.part {d}/app.apk && echo {iid} > {d}/instance.id && {marker}')
home = ssh('echo $HOME').strip()
shortcut = _int((existing or {}).get('shortcut'))
if not shortcut or shortcut not in _shortcut_ids():
reply = shortcut_tool('add', name or info['label'], f'{home}/{d}/launch.sh', f'{home}/{d}',
- icon.replace('$HOME', home))
+ '')
shortcut = _int(reply.strip().splitlines()[-1] if reply.strip() else None)
if not shortcut:
raise FrameError(f'Steam did not return a shortcut id (got {reply[:80]!r})')
+ created = shortcut
+ presentation = apply_library(shortcut, name or info['label'], d, images,
+ vr=not flatscreen, home=home,
+ exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}',
+ details={'package': pkg, 'version': info['version'],
+ 'source': source or os.path.basename(apk_path)})
+ presentation['warnings'] = art_warnings + presentation.get('warnings', [])
meta = {'package': pkg, 'label': name or info['label'], 'version': info['version'],
'instance': iid, 'shortcut': shortcut, 'game_id': game_id(shortcut),
+ 'vr': info.get('vr', False), 'vr_issues': info.get('vr_issues', []),
+ 'launchable': info.get('launchable', False), 'patched': info.get('patched', []),
'flatscreen': flatscreen, 'installed': time.strftime('%Y-%m-%dT%H:%M:%S'),
- 'source': source or os.path.basename(apk_path)}
+ 'source': source or os.path.basename(apk_path),
+ 'library_warnings': presentation.get('warnings', []),
+ 'artwork': presentation.get('artwork', {}), 'library_version': 2}
_write_meta(d, meta)
ok = True
return meta
finally:
+ if not ok and created:
+ try:
+ shortcut_tool('remove', str(created))
+ except FrameError:
+ pass
if not ok and not existing:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
@@ -157,6 +240,121 @@ def _install(apk_path, info, pkg, flatscreen, name, source):
pass
+
+def apply_library(shortcut, label, directory, images, vr=None, home=None, exe='', start_dir='', details=None,
+ category='Android', fill_only=False):
+ """Mandatory for every sideload: render all five slots before reporting success.
+
+ vr None leaves Steam's VR flag as it is (devkit titles declare their own). fill_only (automatic
+ backfill) sets only slots Steam has no art for, and never the name, exe or flags."""
+ home = home or ssh('echo $HOME').strip()
+ d = directory
+ ssh(f'mkdir -p {shlex.quote(d)}/artwork')
+ paths = {}
+ for slot, (ext, data) in images.items():
+ path = f'{d}/artwork/source-{slot}.{ext}'
+ ssh(f'cat > {shlex.quote(path)}', input=data)
+ paths[slot] = f'{home}/{path}' if not path.startswith('/') else path
+ manifest = {'label': label, 'images': paths}
+ plan = f'{d}/artwork/input.json'
+ ssh(f'cat > {shlex.quote(plan)}', input=json.dumps(manifest))
+ absolute = f'{home}/{plan}' if not plan.startswith('/') else plan
+ # The Frame retries once with generated art, each attempt allowed 75 s.
+ rendered = json.loads(shortcut_tool('render', absolute, timeout=200))
+ art = rendered['paths']
+ if set(art) != set(frame_artwork.SLOTS):
+ raise FrameError('Steam artwork renderer did not produce every slot')
+ result = json.loads(shortcut_tool('configure', str(shortcut), label, exe, start_dir, art['icon'],
+ '' if vr is None else '1' if vr else '0', json.dumps(art),
+ json.dumps({'category': category, 'details': details or {},
+ 'fill_only': fill_only}), timeout=120))
+ result['warnings'] = rendered.get('warnings', []) + result.get('warnings', [])
+ result['artwork'] = art
+ if category == 'Android':
+ ssh(f'cat > {shlex.quote(d)}/shortcut.id', input=str(int(shortcut)))
+ return result
+
+
+def cached_art_script(d):
+ """Frame-side Python that loads the source images kept beside d's last render into `cached`."""
+ return f"""import base64, json, os
+cached = {{}}
+directory = os.path.realpath({d!r})
+try:
+ with open(os.path.join(directory, 'artwork/input.json')) as f:
+ plan = json.load(f)
+ for slot, path in plan.get('images', {{}}).items():
+ if os.path.commonpath([os.path.realpath(path), directory]) != directory:
+ continue
+ with open(path, 'rb') as f:
+ data = f.read(12 * 1024 * 1024 + 1)
+ if len(data) <= 12 * 1024 * 1024:
+ cached[slot] = base64.b64encode(data).decode()
+except (OSError, ValueError, TypeError, AttributeError):
+ pass
+"""
+
+
+def art_missing(m):
+ """True when a Frame Control install has no complete Steam artwork on record."""
+ return set((m or {}).get('artwork') or {}) != set(frame_artwork.SLOTS)
+
+
+def refresh_art(pkg=None, artwork=None, fill_only=False):
+ """Refresh existing APK library entries without reinstalling or stopping them.
+
+ fill_only: the automatic backfill; it only fills empty Steam slots (see apply_library)."""
+ if pkg is None:
+ results = []
+ for app in list_apps():
+ try:
+ results.append(refresh_art(app['package'], artwork, fill_only))
+ except Exception as e: # one app's failure must not stop the others
+ results.append({'package': app['package'], 'label': app.get('label'), 'error': str(e) or type(e).__name__})
+ return results
+ with _install_lock: # shared with install and remove: a removed app is never recreated
+ m = _meta_or_fail(pkg)
+ d = f'{APPS_DIR}/{pkg}'
+ # Parse the APK on the Frame, transferring only its icon/label, not the APK.
+ modules = {}
+ for module in ('frame_apk', 'frame_apk_vr'):
+ with open(os.path.join(ROOT, 'ui', module + '.py')) as f:
+ modules[module] = f.read()
+ script = 'import sys, types, json, base64\n'
+ for module, source in modules.items():
+ script += f'm = types.ModuleType({module!r}); sys.modules[{module!r}] = m; exec({source!r}, m.__dict__)\n'
+ script += f"info = sys.modules['frame_apk'].apk_info({(d + '/app.apk')!r})\n"
+ script += "info['icon_png'] = base64.b64encode(info.get('icon_png') or b'').decode()\n"
+ script += cached_art_script(d) + "info['artwork'] = cached\nprint(json.dumps(info))\n"
+ info = json.loads(ssh('python3 -', input=script))
+ icon = base64.b64decode(info['icon_png'])
+ cached = {k: base64.b64decode(v) for k, v in info.get('artwork', {}).items()}
+ images, warnings = frame_artwork.prepare(m['label'], icon, artwork if artwork is not None else cached)
+ home = ssh('echo $HOME').strip()
+ created = False
+ if not m['shortcut'] or m['shortcut'] not in _shortcut_ids():
+ m['shortcut'] = _int(shortcut_tool('add', m['label'], f'{home}/{d}/launch.sh', f'{home}/{d}'))
+ if not m['shortcut']:
+ raise FrameError('Steam did not create the missing shortcut')
+ m['game_id'] = game_id(m['shortcut'])
+ created = True
+ try:
+ result = apply_library(m['shortcut'], m['label'], d, images, vr=not m.get('flatscreen', True),
+ home=home, exe=f'{home}/{d}/launch.sh', start_dir=f'{home}/{d}', details=m,
+ fill_only=fill_only and not created)
+ except Exception:
+ if created:
+ try:
+ shortcut_tool('remove', str(m['shortcut']))
+ except FrameError:
+ pass # keep the render error, not the cleanup's
+ raise
+ m.update(artwork=result.get('artwork', {}), library_version=2, art_pending=False)
+ m['library_warnings'] = warnings + result.get('warnings', [])
+ m['artwork_refreshed'] = time.strftime('%Y-%m-%dT%H:%M:%S')
+ _write_meta(d, m)
+ return m
+
def _int(v):
try:
n = int(v)
@@ -205,6 +403,7 @@ def list_apps():
continue
if m:
m['running'] = f"lepton-steamlaunch-{m['instance']}" in running
+ m['art_missing'] = art_missing(m)
apps.append(m)
return sorted(apps, key=lambda m: m['label'].lower())
@@ -228,18 +427,30 @@ def launch(pkg):
def stop(pkg):
m = _meta_or_fail(pkg)
+ if m['shortcut']:
+ try:
+ shortcut_tool('stop', str(int(m['shortcut'])))
+ except FrameError:
+ pass # Steam unavailable: the container stop also ends its waiting launcher.
ssh(f"podman stop -t 5 lepton-steamlaunch-{int(m['instance'])} >/dev/null 2>&1 || true", timeout=60)
return m
def remove(pkg, keep_data=False):
+ with _install_lock: # never while an install or artwork refresh of any app is writing
+ return _remove(pkg, keep_data)
+
+
+def _remove(pkg, keep_data):
m = _meta_or_fail(pkg)
stop(pkg)
if m['shortcut']:
+ # Best effort: Steam may not be running, and the files must still go.
try:
- shortcut_tool('remove', str(int(m['shortcut'])))
- except FrameError:
- pass # already gone from Steam
+ result = json.loads(shortcut_tool('remove', str(int(m['shortcut']))) or '{}')
+ m['library_warnings'] = result.get('warnings', [])
+ except (FrameError, ValueError, AttributeError) as e:
+ m['library_warnings'] = [f'Steam shortcut not removed: {e}']
iid = int(m['instance'])
extra = '' if keep_data else f' {COMPAT}/{iid} {SHADERS}/{iid}'
ssh(f'rm -rf {APPS_DIR}/{pkg}{extra}')
@@ -273,28 +484,100 @@ def probe(pkg, wait=20):
'container_up': ctr in running_instances()}
+def patch(src, dst, add=None):
+ try:
+ info = apk_info(src)
+ with zipfile.ZipFile(src) as z:
+ original = frame_apk._read(z, 'AndroidManifest.xml', frame_apk.MAX_MANIFEST)
+ manifest = add_launcher_category(original) if info['repairable'] else original
+ if not info['launchable'] and not info['repairable']:
+ raise FrameError('APK has no MAIN/LAUNCHER activity that Frame Control can patch')
+ repack(src, dst, replace={'AndroidManifest.xml': manifest}, add=add)
+ result = apk_info(dst)
+ result.pop('icon_png', None)
+ result['patched'] = (['launcher'] if manifest != original else []) + \
+ (['openxr-compat'] if add and set(XR_COMPAT_FILES) <= set(add) else [])
+ return result
+ except (OSError, ValueError, IndexError, struct.error, zipfile.BadZipFile, frame_apk.ApkError) as e:
+ raise FrameError(str(e)) from e
+
+
+def install_obb(pkg, paths):
+ import frame_android_data
+ return frame_android_data.install_obb(pkg, paths)
+
+
+def backup_data(pkg, destination):
+ import frame_android_data
+ return frame_android_data.backup_data(pkg, destination)
+
+
+def restore_data(pkg, archive):
+ import frame_android_data
+ return frame_android_data.restore_data(pkg, archive)
+
+
def main():
cmd, *args = sys.argv[1:] or ['help']
try:
if cmd in ('info', 'versions'):
import frame_apk_versions
if cmd == 'info':
- print(frame_apk_versions.describe(apk_info(args[0])))
+ info = apk_info(args[0])
+ print(frame_apk_versions.describe(info))
+ fix = '; Frame Control adds the LAUNCHER entry Lepton needs' if info.get('repairable') else ''
+ if info.get('vr') or fix:
+ print(('VR app' if info.get('vr') else 'Android app') + fix)
+ for note in info.get('vr_issues', []):
+ print(note)
return
info = apk_info(args[0]) if os.path.isfile(args[0]) or args[0].lower().endswith('.apk') else None
r = frame_apk_versions.alternatives(
info['package'] if info else args[0], info.get('version_code') if info else None)
elif cmd == 'install':
- r = install(args[0], flatscreen='--vr' not in args)
+ r = install(args[0], flatscreen=False if '--vr' in args else True if '--flat' in args else None,
+ xr_compat=False if '--no-xr-compat' in args else None)
+ elif cmd == 'refresh-art':
+ if len(args) != 1:
+ raise FrameError('usage: refresh-art PACKAGE or refresh-art --all')
+ r = refresh_art(None if args[0] == '--all' else args[0])
+ if isinstance(r, list) and any('error' in item for item in r):
+ print(json.dumps(r, indent=1))
+ raise SystemExit(1)
+ elif cmd == 'patch':
+ import argparse
+ parser = argparse.ArgumentParser(description='Patch and v2-sign an APK locally')
+ parser.add_argument('src')
+ parser.add_argument('dst')
+ parser.add_argument('--add', action='append', default=[], metavar='NAME=PATH')
+ opts = parser.parse_args(args)
+ additions = {}
+ for item in opts.add:
+ if '=' not in item:
+ raise FrameError('--add requires NAME=PATH')
+ entry, path = item.split('=', 1)
+ with open(path, 'rb') as f:
+ additions[entry] = f.read()
+ r = patch(opts.src, opts.dst, additions)
+ elif cmd == 'install-obb':
+ if len(args) < 2:
+ raise FrameError('install-obb requires PACKAGE OBB [OBB ...]')
+ r = install_obb(args[0], args[1:])
+ elif cmd in ('backup-data', 'restore-data'):
+ if len(args) != 2:
+ raise FrameError(cmd + ' requires PACKAGE ARCHIVE.tar.gz')
+ r = (backup_data if cmd == 'backup-data' else restore_data)(*args)
elif cmd == 'list':
r = list_apps()
+ if any(a['art_missing'] for a in r):
+ print('Some apps have no Steam artwork: python3 ui/frame_android.py refresh-art --all', file=sys.stderr)
elif cmd in ('launch', 'stop', 'probe'):
r = globals()[cmd](args[0])
elif cmd == 'remove':
r = remove(args[0], keep_data='--keep-data' in args)
else:
sys.exit(__doc__)
- except FrameError as e:
+ except (FrameError, OSError) as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
diff --git a/ui/frame_android_data.py b/ui/frame_android_data.py
new file mode 100644
index 0000000..5a2e31a
--- /dev/null
+++ b/ui/frame_android_data.py
@@ -0,0 +1,133 @@
+"""Expansion files and stopped-instance private-data backups. Python stdlib only."""
+import hashlib
+import json
+import os
+from pathlib import Path
+import re
+import runpy
+import shlex
+import subprocess
+import tempfile
+import uuid
+
+import frame_android as android
+
+REMOTE = Path(android.ROOT) / 'frame/android/app-data.py'
+
+
+def _stream(command, src=None, dst=None):
+ try:
+ result = subprocess.run(['ssh', *android.SSH_OPTS, android.FRAME, command],
+ stdin=src if src else subprocess.DEVNULL,
+ stdout=dst if dst else subprocess.PIPE,
+ stderr=subprocess.PIPE, timeout=1800)
+ except subprocess.TimeoutExpired:
+ raise android.FrameError('app-data transfer timed out')
+ except OSError as error:
+ raise android.FrameError('app-data transfer failed: ' + str(error))
+ if result.returncode:
+ raise android.FrameError(result.stderr.decode(errors='replace').strip()[-600:] or
+ 'app-data transfer failed')
+ return result.stdout
+
+
+def _sha256(path):
+ digest = hashlib.sha256()
+ with open(path, 'rb') as src:
+ for chunk in iter(lambda: src.read(1024 * 1024), b''):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _meta(package):
+ if not android.PKG_RE.fullmatch(package or ''):
+ raise android.FrameError('invalid package name')
+ meta = android._meta_or_fail(package)
+ if meta['package'] != package:
+ raise android.FrameError('installed app metadata has a different package')
+ return meta
+
+
+def install_obb(package, paths):
+ if not android.PKG_RE.fullmatch(package or ''):
+ raise android.FrameError('invalid package name')
+ paths = [Path(p).resolve() for p in paths]
+ if not paths:
+ raise android.FrameError('select at least one OBB file')
+ names = set()
+ for path in paths:
+ if (not re.fullmatch(r'(main|patch)\.[0-9]+\.' + re.escape(package) + r'\.obb', path.name)
+ or not path.is_file() or path.stat().st_size == 0 or path.name in names):
+ raise android.FrameError('OBB must be a nonempty main/patch..' + package + '.obb file')
+ names.add(path.name)
+ with android._install_lock:
+ meta = _meta(package)
+ container = 'lepton-steamlaunch-' + str(int(meta['instance']))
+ # No implicit launch. Android resolves /sdcard, never an assumed host path.
+ running = android.ssh('podman ps --format "{{.Names}}"').splitlines()
+ if container not in running:
+ raise android.FrameError('start this app instance before installing OBB data')
+ dest = '/sdcard/Android/obb/' + package
+ results = []
+ for path in paths:
+ digest = _sha256(path)
+ part = dest + '/.frame-' + uuid.uuid4().hex + '.part'
+ target = dest + '/' + path.name
+ script = (f'set -eu; mkdir -p {dest}; umask 002; '
+ f'trap "rm -f {part}" EXIT; cat > {part}; '
+ f'test "$(sha256sum {part} | cut -d " " -f 1)" = {digest}; '
+ f'chmod 664 {part}; mv {part} {target}')
+ command = shlex.join(['podman', 'exec', '-i', container, '/system/bin/sh', '-c', script])
+ with path.open('rb') as src:
+ _stream(command, src=src)
+ results.append({'name': path.name, 'path': target, 'sha256': digest})
+ return {'package': package, 'instance': meta['instance'], 'obb': results,
+ 'verified': True}
+
+
+def _data_command(action, meta):
+ container = 'lepton-steamlaunch-' + str(int(meta['instance']))
+ # Fail closed if podman cannot enumerate containers; don't mistake errors for stopped.
+ guard = ('running=$(podman ps --format "{{.Names}}") || exit 1; '
+ f'if printf "%s\\n" "$running" | grep -Fxq {shlex.quote(container)}; then '
+ 'echo "stop the app before backup or restore" >&2; exit 1; fi; ')
+ return guard + shlex.join(['podman', 'unshare', 'python3', '-c', REMOTE.read_text(),
+ action, meta['package'], str(int(meta['instance']))])
+
+
+def backup_data(package, destination):
+ destination = Path(destination).expanduser().absolute()
+ if destination.exists():
+ raise android.FrameError('backup destination already exists')
+ with android._install_lock:
+ meta = _meta(package)
+ fd, temporary = tempfile.mkstemp(prefix='.frame-backup-', dir=str(destination.parent))
+ try:
+ with os.fdopen(fd, 'wb') as dst:
+ _stream(_data_command('backup', meta), dst=dst)
+ result = _inspect(temporary, meta)
+ # Exclusive publication: a concurrently created backup is never overwritten.
+ os.link(temporary, str(destination))
+ return dict(result, path=str(destination), sha256=_sha256(destination))
+ finally:
+ os.unlink(temporary)
+
+
+def _inspect(path, meta):
+ import tarfile
+ try:
+ return runpy.run_path(str(REMOTE))['inspect_archive'](path, meta['package'], int(meta['instance']))
+ except (OSError, EOFError, ValueError, tarfile.TarError) as error:
+ raise android.FrameError('invalid app-data backup: ' + str(error))
+
+
+def restore_data(package, archive):
+ with android._install_lock:
+ meta = _meta(package)
+ _inspect(archive, meta)
+ with open(archive, 'rb') as src:
+ result = _stream(_data_command('restore', meta), src=src)
+ try:
+ return json.loads(result)
+ except (ValueError, TypeError):
+ raise android.FrameError('could not read restore result; inspect app data before retrying')
diff --git a/ui/frame_apk.py b/ui/frame_apk.py
index d96f95b..2212803 100644
--- a/ui/frame_apk.py
+++ b/ui/frame_apk.py
@@ -10,7 +10,8 @@ import zipfile
# android: attribute resource ids; names can be stripped by shrinkers, ids can't.
ATTR = {0x01010001: 'label', 0x01010002: 'icon', 0x01010003: 'name',
- 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion'}
+ 0x01010024: 'value', 0x0101021b: 'versionCode', 0x0101021c: 'versionName', 0x0101020c: 'minSdkVersion',
+ 0x01010202: 'targetActivity'}
T_REF, T_STRING, T_INT_DEC, T_INT_HEX = 0x01, 0x03, 0x10, 0x11
# APKs can come from websites (install links), so nothing read from one may be
# unbounded. zipfile stops at a member's declared size, so checking it is enough.
@@ -215,8 +216,11 @@ def apk_info(path):
if 'AndroidManifest.xml' not in names:
raise ApkError('not an APK: no AndroidManifest.xml')
try:
- elements = manifest_elements(_read(z, 'AndroidManifest.xml', MAX_MANIFEST))
+ manifest_data = _read(z, 'AndroidManifest.xml', MAX_MANIFEST)
+ elements = manifest_elements(manifest_data)
res = Resources(_read(z, 'resources.arsc', MAX_ARSC) if 'resources.arsc' in names else b'')
+ from frame_apk_vr import detection
+ vr_info = detection(manifest_data, names)
except (struct.error, IndexError, zipfile.BadZipFile) as e:
raise ApkError(f'could not read the APK manifest: {e}')
tags = {}
@@ -236,6 +240,7 @@ def apk_info(path):
'min_sdk': min_sdk[1] if min_sdk and min_sdk[0] in (T_INT_DEC, T_INT_HEX) else None,
'icon_png': None,
}
+ info.update(vr_info)
try:
info['icon_png'] = _icon_png(z, names, _icons(app.get('icon'), res))
except Exception: # noqa: BLE001 - any unreadable icon just means no icon
@@ -250,7 +255,10 @@ def _icon_png(z, names, icons):
# Adaptive icons are XML; fall back to the largest launcher PNG.
pngs = sorted((n for n in names if n.endswith('.png') and 'ic_launcher' in n and 'foreground' not in n),
key=lambda n: z.getinfo(n).file_size)
- return _read(z, pngs[-1], MAX_ICON) if pngs else None
+ if pngs:
+ return _read(z, pngs[-1], MAX_ICON)
+ # Godot exports can keep only an adaptive launcher plus the project icon.
+ return _read(z, 'assets/icon.png', MAX_ICON) if 'assets/icon.png' in names else None
if __name__ == '__main__':
diff --git a/ui/frame_apk_sign.py b/ui/frame_apk_sign.py
new file mode 100644
index 0000000..68580c5
--- /dev/null
+++ b/ui/frame_apk_sign.py
@@ -0,0 +1,361 @@
+"""Lossless ZIP repacking and APK v2 RSA/SHA-256 signing, Python 3.9 stdlib.
+
+Spec: https://source.android.com/docs/security/features/apksigning/v2
+Sections: APK Signing Block; APK Signature Scheme v2 Block; Integrity-protected
+contents; Verification. No verity algorithm is used, so no verity padding.
+"""
+import hashlib
+import io
+import json
+import math
+import os
+from pathlib import Path
+import re
+import secrets
+import struct
+import tempfile
+import zipfile
+import zlib
+
+import frame_host
+
+MAGIC = b'APK Sig Block 42'
+V2 = 0x7109871a
+ALG = 0x0103
+SHA256_DER = bytes.fromhex('3031300d060960864801650304020105000420')
+
+
+def u32(n):
+ return struct.pack('= n:
+ raise ValueError('invalid RSA signature size/value')
+ actual = pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big')
+ if actual != encoded_hash(data, size):
+ raise ValueError('RSA signature mismatch')
+
+
+def certificate(key):
+ name = sequence(der(0x31, sequence(bytes.fromhex('0603550403'), der(12, b'Frame Control APK signer'))))
+ validity = sequence(der(0x17, b'200101000000Z'), der(0x18, b'21200101000000Z'))
+ tbs = sequence(der(0xa0, integer(2)), integer(1), CERT_ALG, name, validity, name, public_key(key))
+ return sequence(tbs, CERT_ALG, der(3, b'\0' + rsa_sign(tbs, key)))
+
+
+def _prime(bits):
+ small = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47)
+ while True:
+ n = secrets.randbits(bits) | (3 << (bits - 2)) | 1
+ if any(n % p == 0 for p in small) or (n - 1) % 65537 == 0:
+ continue
+ d, s = n - 1, 0
+ while d % 2 == 0:
+ d //= 2
+ s += 1
+ for _ in range(40): # Miller-Rabin error bound <= 2^-80
+ x = pow(secrets.randbelow(n - 3) + 2, d, n)
+ if x in (1, n - 1):
+ continue
+ for _ in range(s - 1):
+ x = pow(x, 2, n)
+ if x == n - 1:
+ break
+ else:
+ break
+ else:
+ return n
+
+
+def signing_key(path=None):
+ """Persistent identity in app data, never an evictable cache. Atomic publication.
+
+ Hard-linking a fully written private temp file prevents concurrent first-use
+ callers from selecting different identities or reading a partial key.
+ """
+ path = Path(path) if path is not None else frame_host.data_dir('apk-signing-key.json')
+ if not path.exists():
+ p, q = _prime(1024), _prime(1024)
+ while q == p:
+ q = _prime(1024)
+ key = {'n': p * q, 'e': 65537, 'd': pow(65537, -1, math.lcm(p - 1, q - 1))}
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp = tempfile.mkstemp(prefix='.apk-key-', dir=str(path.parent))
+ try:
+ with os.fdopen(fd, 'w') as f:
+ json.dump(key, f)
+ f.flush()
+ os.fsync(f.fileno())
+ try:
+ os.link(tmp, path) # never replaces a key another process wrote first
+ except FileExistsError:
+ pass
+ except OSError: # no hard links (FAT/exFAT): plain rename
+ if not path.exists():
+ os.replace(tmp, path)
+ finally:
+ if os.path.exists(tmp):
+ os.unlink(tmp)
+ os.chmod(path, 0o600) # Windows ignores this; the per-user app-data folder is the protection there
+ key = json.loads(path.read_text())
+ if key['n'].bit_length() != 2048 or key['e'] != 65537:
+ raise ValueError(f'invalid cached APK signing key; delete {path} to make a new one '
+ '(re-signed apps then need reinstalling)')
+ rsa_verify(b'key check', rsa_sign(b'key check', key), key['n'], key['e'])
+ return key
+
+
+def _eocd(data):
+ # ZIP comments can contain the EOCD signature; accept only an exact EOF fit.
+ for at in range(len(data) - 22, max(-1, len(data) - 65558), -1):
+ if data[at:at + 4] == b'PK\5\6' and at + 22 + struct.unpack_from(' len(data):
+ raise ValueError('truncated length prefix')
+ size = struct.unpack_from(' len(data):
+ raise ValueError('length prefix outside block')
+ result.append(data[off:off + size])
+ off += size
+ return result
+
+
+def _der_parts(data):
+ result, off = [], 0
+ while off < len(data):
+ start = off
+ tag, size = data[off:off + 2]
+ off += 2
+ if size & 128:
+ count = size & 127
+ if not count or count > 4:
+ raise ValueError('invalid DER length')
+ size = int.from_bytes(data[off:off + count], 'big')
+ off += count
+ if off + size > len(data):
+ raise ValueError('truncated DER')
+ result.append((tag, data[off:off + size], data[start:off + size]))
+ off += size
+ return result
+
+
+def _cert_key(cert):
+ outer = _der_parts(cert)
+ if len(outer) != 1 or outer[0][0] != 0x30:
+ raise ValueError('invalid certificate')
+ fields = _der_parts(outer[0][1])
+ tbs = _der_parts(fields[0][1])
+ spki = tbs[6 if tbs[0][0] == 0xa0 else 5][2]
+ pub = _der_parts(_der_parts(spki)[0][1])
+ if pub[0][2] != RSA_ALG or pub[1][1][:1] != b'\0':
+ raise ValueError('certificate is not RSA')
+ numbers = _der_parts(_der_parts(pub[1][1][1:])[0][1])
+ n, e = [int.from_bytes(item[1], 'big') for item in numbers]
+ return n, e, spki
+
+
+def verify(path):
+ """Verify this v2-only format; return True or raise ValueError on corruption.
+
+ A valid signature establishes integrity, not trust in the APK publisher.
+ """
+ data = Path(path).read_bytes()
+ try:
+ eo, cd = _eocd(data)
+ if data[cd - 16:cd] != MAGIC:
+ raise ValueError('no APK signing block')
+ size = struct.unpack_from(' cd - 24:
+ raise ValueError('invalid signing pair')
+ ident = struct.unpack_from('= 0xffffffff:
+ raise ValueError('ZIP64 APKs are unsupported')
+ output.write(struct.pack('= 65535 or cd + len(directory) >= 0xffffffff:
+ raise ValueError('ZIP64 APKs are unsupported')
+ output.write(directory)
+ output.write(struct.pack('; Godot 4 puts LAUNCHER only there.
+ real = [f for f in mains if not f['alias']]
+ targets = [f for f in real if VR & f['categories']]
+ if not targets and any(LAUNCHER in f['categories'] or VR & f['categories'] for f in mains if f['alias']):
+ aimed = {f['activity'] for f in mains if f['alias'] and (LAUNCHER in f['categories'] or VR & f['categories'])}
+ targets = [f for f in real if f['templates']] # the patch copies an existing
+ targets = [f for f in targets if f['activity'] in aimed] or targets
+ launchable = any(LAUNCHER in f['categories'] for f in real)
+ return {'launchable': launchable, 'repairable': not launchable and bool(targets),
+ 'vr_activity': bool(vr_filters), 'vr': bool(vr_filters) or samsung}, targets
+
+
+def _append_string(chunk, text):
+ _, hs, size, count, styles, flags, start, style_start = struct.unpack_from('I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
+
+
+def png_size(data):
+ """IHDR dimensions of a PNG of any bit depth or interlace; Steam's Chromium decodes the pixels."""
+ if len(data) < 33 or data[12:16] != b'IHDR' or struct.unpack_from('>I', data, 8)[0] != 13:
+ raise ValueError('invalid PNG header')
+ if zlib.crc32(data[12:29]) != struct.unpack_from('>I', data, 29)[0]:
+ raise ValueError('invalid PNG checksum')
+ w, h, depth, color = struct.unpack_from('>IIBB', data, 16)
+ if color not in (0, 2, 3, 4, 6) or depth not in (1, 2, 4, 8, 16):
+ raise ValueError('unsupported PNG encoding')
+ if not w or not h or w * h > MAX_PIXELS or w > 8192 or h > 8192:
+ raise ValueError('PNG dimensions exceed limits')
+ return w, h
+
+
+def _gif_blocks(data, pos):
+ """Position after a run of GIF data sub-blocks and its terminator."""
+ while True:
+ if pos >= len(data):
+ raise ValueError('truncated GIF')
+ size = data[pos]
+ pos += 1 + size
+ if not size:
+ return pos
+
+
+def gif_frame(data):
+ """A GIF's first frame as a minimal single-frame GIF, so no frame count or oversized frame
+ reaches the Frame's Chromium. Raises ValueError for anything malformed or out of bounds."""
+ if data[:6] not in (b'GIF87a', b'GIF89a') or len(data) < 13:
+ raise ValueError('invalid GIF')
+ sw, sh, flags = struct.unpack_from(' MAX_PIXELS or max(sw, sh) > 4096:
+ raise ValueError('artwork GIF has unsupported dimensions')
+ pos = 13 + (3 << ((flags & 7) + 1) if flags & 0x80 else 0)
+ head, control = data[:pos], b''
+ if len(head) != pos:
+ raise ValueError('truncated GIF')
+ while True:
+ if pos >= len(data):
+ raise ValueError('truncated GIF')
+ if data[pos] == 0x21 and pos + 1 < len(data): # extension: keep the frame's graphic control
+ end = _gif_blocks(data, pos + 2)
+ if data[pos + 1] == 0xf9 and end - pos == 8 and data[pos + 2] == 4: # GIF89a: fixed 4-byte payload
+ control = data[pos:end]
+ pos = end
+ elif data[pos] == 0x2c and pos + 10 <= len(data): # the first image
+ x, y, w, h, local = struct.unpack_from(' sw or y + h > sh:
+ raise ValueError('artwork GIF frame exceeds its screen')
+ if not flags & 0x80 and not local & 0x80:
+ raise ValueError('GIF has no colour table')
+ start = pos
+ pos += 10 + (3 << ((local & 7) + 1) if local & 0x80 else 0)
+ if pos >= len(data) or not 2 <= data[pos] <= 8: # the LZW minimum code size
+ raise ValueError('invalid GIF image data')
+ end = _gif_blocks(data, pos + 1)
+ if end - pos <= 2: # code size then the terminator: no pixels at all
+ raise ValueError('invalid GIF image data')
+ return head + control + data[start:end] + b'\x3b'
+ else:
+ raise ValueError('invalid GIF block')
+
+
+def image_type(data):
+ if not isinstance(data, bytes) or len(data) > MAX_IMAGE:
+ raise ValueError('artwork must be image bytes or an HTTP(S) URL, at most 12 MiB')
+ if data.startswith(PNG):
+ png_size(data)
+ return 'png'
+ if data[:6] in (b'GIF87a', b'GIF89a'):
+ gif_frame(data)
+ return 'gif'
+ if data.startswith(b'\xff\xd8'):
+ # Check JPEG SOF dimensions without depending on an image library; trailing padding is fine.
+ pos = 2
+ while pos + 4 <= len(data) and data[pos] == 255:
+ marker = data[pos + 1]
+ pos += 2
+ if marker == 255:
+ pos -= 1
+ continue
+ size = struct.unpack_from('>H', data, pos)[0]
+ if size < 2 or pos + size > len(data):
+ break
+ if 0xc0 <= marker <= 0xcf and marker not in (0xc4, 0xc8, 0xcc) and size >= 8:
+ h, w = struct.unpack_from('>HH', data, pos + 3)
+ if w and h and w * h <= MAX_PIXELS and max(w, h) <= 8192:
+ return 'jpg'
+ break
+ pos += size
+ raise ValueError('artwork must be a supported PNG, JPEG or GIF')
+
+
+def fetch(value, deadline=None):
+ if isinstance(value, str):
+ from apk_sources import _images
+ # Public addresses only, at most three redirects, within the overall deadline.
+ value = _images.fetch(value, deadline=deadline, limit=MAX_IMAGE)[0]
+ kind = image_type(value)
+ return kind, gif_frame(value) if kind == 'gif' else value
+
+
+def prepare(label, icon_png=None, artwork=None, budget=90):
+ """Gather inputs; the Frame's Chromium canvas renders every final slot.
+
+ Every source is optional: any failure falls back to generated art, within budget seconds overall."""
+ import frame_steamgriddb
+ artwork = artwork or {}
+ allowed = set(SLOTS) | {'banner', 'feature_graphic', 'screenshots', 'screenshot'}
+ if not isinstance(artwork, dict) or set(artwork) - allowed:
+ raise ValueError('unknown artwork slot')
+ deadline = time.monotonic() + budget
+ supplied, warnings = {}, []
+
+ def get(value):
+ try:
+ return fetch(value, deadline)
+ except Exception: # an optional source never blocks the install; generated art covers it
+ return None
+ for slot, value in artwork.items():
+ values = value if slot == 'screenshots' and isinstance(value, (list, tuple)) else [value]
+ for candidate in values[:4]:
+ image = get(candidate)
+ if image:
+ supplied['screenshot' if slot == 'screenshots' else slot] = image
+ break
+ else:
+ warnings.append('Source ' + slot + ' unavailable; using fallback art')
+ if 'icon' not in supplied and icon_png:
+ image = get(icon_png)
+ if image:
+ supplied['icon'] = image
+ try:
+ provider, provider_warnings = frame_steamgriddb.lookup(label, deadline)
+ except Exception:
+ provider, provider_warnings = {}, ['SteamGridDB unavailable; using source or generated art']
+ warnings.extend(provider_warnings)
+ for slot, value in provider.items():
+ image = get(value)
+ if image:
+ supplied[slot] = image
+ else:
+ warnings.append('SteamGridDB ' + slot + ' download failed; using fallback art')
+ return supplied, warnings
diff --git a/ui/frame_assistant.py b/ui/frame_assistant.py
new file mode 100644
index 0000000..c76c1e0
--- /dev/null
+++ b/ui/frame_assistant.py
@@ -0,0 +1,53 @@
+"""Explicit, per-request forwarding to a user-chosen chat-completions endpoint."""
+import base64
+import json
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
+
+
+class NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ raise ValueError('Endpoint redirected; enter its final URL explicitly')
+
+
+def chat(body, screenshot):
+ if body.get('consent') is not True:
+ raise ValueError('Opt in before sending a message')
+ endpoint, model, prompt = (body.get(k) for k in ('endpoint', 'model', 'prompt'))
+ if any(not isinstance(v, str) or not v.strip() for v in (endpoint, model, prompt)):
+ raise ValueError('Endpoint, model and message are required')
+ if len(prompt) > 32000 or len(model) > 200 or len(endpoint) > 2048:
+ raise ValueError('Message, model or endpoint is too long')
+ url = urlsplit(endpoint)
+ if not url.hostname or url.username or url.password or url.fragment or url.query:
+ raise ValueError('Use an endpoint URL without credentials, query or fragment')
+ if url.scheme != 'https' and not (url.scheme == 'http' and url.hostname in ('localhost', '127.0.0.1', '::1')):
+ raise ValueError('Use HTTPS, or HTTP on loopback for a local model')
+ key = body.get('key', '')
+ if not isinstance(key, str) or len(key) > 4096 or '\n' in key or '\r' in key:
+ raise ValueError('Invalid API key')
+ content = prompt
+ if body.get('screenshot') is True:
+ png = screenshot()
+ if len(png) > 12 * 1024**2:
+ raise ValueError('Screenshot is too large')
+ content = [{'type': 'text', 'text': prompt}, {'type': 'image_url', 'image_url': {
+ 'url': 'data:image/png;base64,' + base64.b64encode(png).decode()}}]
+ payload = {'model': model, 'messages': [{'role': 'user', 'content': content}], 'stream': False}
+ headers = {'Content-Type': 'application/json'}
+ if key:
+ headers['Authorization'] = 'Bearer ' + key
+ request = Request(endpoint, data=json.dumps(payload).encode(), headers=headers)
+ # No environment proxy or redirects: credentials/context go only to the chosen URL.
+ try:
+ with build_opener(ProxyHandler({}), NoRedirect()).open(request, timeout=60) as response:
+ raw = response.read(2 * 1024**2 + 1)
+ if len(raw) > 2 * 1024**2:
+ raise ValueError('Endpoint response is too large')
+ answer = json.loads(raw)['choices'][0]['message']['content']
+ if not isinstance(answer, str):
+ raise ValueError('Expected a text reply')
+ except Exception:
+ # Provider error bodies and URLs can contain credentials or echoed prompts.
+ raise ValueError('Endpoint request failed or returned an unsupported reply; check URL, model and credentials') from None
+ return {'reply': answer}
diff --git a/ui/frame_comfort.py b/ui/frame_comfort.py
new file mode 100644
index 0000000..cb2002e
--- /dev/null
+++ b/ui/frame_comfort.py
@@ -0,0 +1,264 @@
+"""Opt-in session worker ON the Frame; no root, extra apps, or power actions.
+
+One worker per user, shared by desktop and phone. State survives companion
+connections, not headset reboots. See docs/family-comfort.md for guarantees.
+"""
+import contextlib
+import json
+import os
+from pathlib import Path
+import subprocess
+import sys
+import time
+import uuid
+
+from frame_steam import Page
+from frame_status import battery, thermal_alerts, activity_level
+
+ROOT = Path.home() / '.local/state/frame-control/comfort'
+VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
+HOME_JS = """(async () => {
+ SteamUIStore.Navigate('/library/home');
+ await SteamClient.OpenVR.VROverlay.ShowDashboard('valve.steam.gamepadui.main');
+ if (!await SteamClient.OpenVR.VROverlay.IsDashboardVisible()) throw Error('Steam dashboard did not open');
+ return {path: location.pathname};
+})()"""
+
+
+def clock():
+ # CLOCK_BOOTTIME includes headset suspend; wall-clock corrections don't alter limits.
+ return time.clock_gettime(time.CLOCK_BOOTTIME)
+
+
+def boot():
+ return Path('/proc/sys/kernel/random/boot_id').read_text().strip()
+
+
+def validate(body):
+ if not isinstance(body, dict) or body.get('action') not in ('status', 'start', 'cancel'):
+ raise ValueError('Choose status, start or cancel')
+ if body['action'] == 'start':
+ for key, low, high in (('minutes', 1, 240), ('breakMinutes', 0, 120), ('stillMinutes', 0, 240)):
+ n = body.get(key)
+ if type(n) is not int or not low <= n <= high:
+ raise ValueError(f'{key} must be a whole number from {low} to {high}')
+ for key in ('batteryAlert', 'heatAlert'):
+ if type(body.get(key)) is not bool:
+ raise ValueError(f'{key} must be true or false')
+ return body
+
+
+def new_session(body, now, boot_id):
+ return {'id': uuid.uuid4().hex, 'boot': boot_id, 'active': True,
+ 'options': {k: body[k] for k in ('minutes', 'breakMinutes', 'stillMinutes', 'batteryAlert', 'heatAlert')},
+ 'started': now, 'deadline': now + body['minutes'] * 60, 'lastSample': now,
+ 'used': 0, 'nextBreak': body['breakMinutes'] * 60, 'stillSent': False,
+ 'warned': None, 'events': [], 'seq': 0, 'latched': [], 'error': None}
+
+
+def event(s, kind, message):
+ s['seq'] += 1
+ s['events'].append({'id': s['id'] + ':' + str(s['seq']), 'kind': kind,
+ 'message': message, 'time': time.time()})
+ s['events'] = s['events'][-40:]
+
+
+def notify(message):
+ r = subprocess.run([VRCMD, '--notify', 'Frame Control: ' + message],
+ capture_output=True, text=True, timeout=20)
+ if r.returncode or 'succeeded' not in r.stdout:
+ raise RuntimeError('SteamVR could not show the reminder: ' + (r.stderr or r.stdout)[-300:])
+
+
+def home():
+ # A total process deadline also bounds a CDP peer that keeps sending events
+ # without completing the request. Keep cancellation ordered after this action.
+ r = subprocess.run([sys.executable, str(Path(__file__).resolve()), '--home'],
+ capture_output=True, text=True, timeout=15)
+ if r.returncode:
+ raise RuntimeError('Steam Home failed: ' + (r.stdout or r.stderr)[-300:])
+
+
+def open_home():
+ page = Page()
+ try:
+ result = page.eval(HOME_JS)
+ if result.get('path') != '/routes/library/home':
+ raise RuntimeError('Steam did not navigate Home')
+ finally:
+ page.sock.close()
+
+
+def tick(s, now, sample, warn=notify, go_home=home, read_clock=clock):
+ """One deterministic step; injected actions/samples also exercise a fake Frame."""
+ if not s.get('active'):
+ return
+ o = s['options']
+ s['heartbeat'] = now
+ delta = max(0, min(30, now - s['lastSample']))
+ s['lastSample'] = now
+ level = sample.get('activity')
+ b = sample.get('battery') or {}
+ s['unavailable'] = []
+ if o['batteryAlert'] and b.get('percent') is None:
+ s['unavailable'].append('battery')
+ if o['heatAlert'] and sample.get('thermal') is None:
+ s['unavailable'].append('temperature')
+ if (o['breakMinutes'] or o['stillMinutes']) and level is None:
+ s['unavailable'].append('activity')
+ s['activity'] = level
+ if level in (1, 2):
+ s['used'] += delta
+ elif level is not None:
+ s['used'] = 0
+ s['nextBreak'] = o['breakMinutes'] * 60
+ s['stillSent'] = False
+ # Missing samples never count as time worn. No catch-up burst after a disconnect.
+ if now >= s['deadline'] - 60 and s['warned'] is None:
+ warn('One minute left. Save your progress; Steam Home will open.')
+ s['warned'] = max(now, read_clock())
+ event(s, 'warning', 'One minute left. Save your progress; Steam Home will open.')
+ if s['warned'] is not None and now >= max(s['deadline'], s['warned'] + 60):
+ go_home()
+ s['active'] = False
+ event(s, 'finished', 'Session ended: Steam Home opened. Your game is still running.')
+ return
+ if o['breakMinutes'] and s['used'] >= s['nextBreak']:
+ warn('Time for a break. Take off the headset and rest your eyes.')
+ event(s, 'break', 'Time for a break. Take off the headset and rest your eyes.')
+ s['nextBreak'] = s['used'] + o['breakMinutes'] * 60
+ if o['stillMinutes'] and not s['stillSent'] and s['used'] >= o['stillMinutes'] * 60:
+ event(s, 'still', f"Headset still active after {o['stillMinutes']} active minute(s). Check in with the wearer.")
+ s['stillSent'] = True
+ low = b.get('percent') is not None and b['percent'] <= 15 and b.get('status') == 'Discharging'
+ hot = sample.get('thermal')
+ for kind, enabled, value, message in (
+ ('battery', o['batteryAlert'], low if b else None, 'Frame battery is low (15% or less).'),
+ ('heat', o['heatAlert'], bool(hot) if hot is not None else None,
+ 'Frame reports a hot/critical thermal trip or battery overheat. Ask the wearer to take a break.')):
+ if enabled and value and kind not in s['latched']:
+ event(s, kind, message)
+ s['latched'].append(kind)
+ elif value is False and kind in s['latched']:
+ # Battery hysteresis prevents repeated alerts around 15%.
+ if kind != 'battery' or b.get('status') == 'Charging' or (b.get('percent') or 0) >= 20:
+ s['latched'].remove(kind)
+
+
+@contextlib.contextmanager
+def locked(name='state.lock', nonblocking=False):
+ import fcntl # only needed ON the Linux headset, not by desktop validation/tests
+ ROOT.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with (ROOT / name).open('a') as f:
+ fcntl.flock(f, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
+ yield f
+
+
+def read_state():
+ try:
+ state = json.loads((ROOT / 'session.json').read_text())
+ if not isinstance(state, dict):
+ raise ValueError('Saved session must be an object')
+ return state
+ except FileNotFoundError:
+ return {'active': False, 'events': []}
+ except (ValueError, UnicodeDecodeError):
+ # Preserve the unreadable state for diagnosis, then allow a new session.
+ (ROOT / 'session.json').replace(ROOT / ('session-unreadable-' + uuid.uuid4().hex + '.json'))
+ return {'active': False, 'events': [],
+ 'error': 'Saved session was unreadable. Start a new session.'}
+
+
+def save(s):
+ p = ROOT / 'session.tmp'
+ p.write_text(json.dumps(s))
+ p.chmod(0o600)
+ p.replace(ROOT / 'session.json')
+
+
+def current(s, now):
+ if s.get('active') and s.get('boot') != boot():
+ s['active'] = False
+ s['error'] = 'Headset restarted. Start a new session.'
+ out = dict(s)
+ out['time'] = time.time() # event age uses the Frame's clock, not the phone's
+ out['remaining'] = max(0, max(s.get('deadline', now), (s.get('warned') or 0) + 60) - now) if s.get('active') else 0
+ beat = s.get('heartbeat', s.get('started', now)) # a hand-edited state may lack either
+ if s.get('active') and now - beat > 90:
+ out['error'] = 'Session worker is not responding. Timer enforcement is unverified; cancel and start again.'
+ return out
+
+
+def watch():
+ try:
+ with locked('worker.lock', nonblocking=True) as worker:
+ while True:
+ with locked():
+ s = current(read_state(), clock())
+ if not s.get('active'):
+ save(s)
+ # Release ownership before state.lock: a concurrent start
+ # cannot miss the gap between an old worker and its exit.
+ import fcntl
+ fcntl.flock(worker, fcntl.LOCK_UN)
+ return
+ try:
+ b = battery()
+ hot = thermal_alerts()
+ if b and b.get('health') == 'Overheat':
+ hot = (hot or []) + ['battery']
+ tick(s, clock(), {'battery': b, 'thermal': hot, 'activity': activity_level()})
+ s['error'] = None
+ except Exception as e:
+ error = str(e)
+ if s.get('error') != error:
+ event(s, 'error', 'Session action failed: ' + error)
+ s['error'] = error
+ save(s)
+ time.sleep(5)
+ except BlockingIOError:
+ pass # another connection already started the single worker
+
+
+def command(body):
+ validate(body)
+ with locked():
+ s = current(read_state(), clock())
+ if body['action'] == 'start':
+ if s.get('active'):
+ raise ValueError('A session is already running. Cancel it before starting another.')
+ s = new_session(body, clock(), boot())
+ event(s, 'started', 'Session started. Steam Home opens at the limit; games are not closed.')
+ elif body['action'] == 'cancel':
+ s['active'] = False
+ s['error'] = None
+ if s.get('id'):
+ event(s, 'cancelled', 'Session timer and monitoring cancelled.')
+ save(s)
+ if body['action'] == 'start':
+ try:
+ subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--watch'],
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ start_new_session=True, close_fds=True)
+ except OSError as e:
+ s['active'] = False
+ s['error'] = 'Could not start session worker: ' + str(e)
+ event(s, 'error', s['error'])
+ save(s)
+ raise
+ return current(s, clock())
+
+
+if __name__ == '__main__':
+ if sys.argv[1:] == ['--watch']:
+ watch()
+ else:
+ try:
+ if sys.argv[1:] == ['--home']:
+ open_home()
+ print(json.dumps({'home': True}))
+ else:
+ print(json.dumps(command(json.loads(sys.argv[1]))))
+ except Exception as e:
+ print(json.dumps({'error': str(e)}))
+ sys.exit(1)
diff --git a/ui/frame_compat_db.py b/ui/frame_compat_db.py
index 48ac81b..6371435 100644
--- a/ui/frame_compat_db.py
+++ b/ui/frame_compat_db.py
@@ -8,12 +8,18 @@ New reports go to a local outbox first and are sent from there, so nothing is
lost offline. A mirror of every report is kept for offline reads. Both live in
frame_host.data_dir('compat-db'). Python stdlib only.
-CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush}
+Everyone else can opt in to sharing (the Privacy panel): their reports then
+also go to PostHog as compat_report events (frame_telemetry.py), and the
+maintainer's `sync` pulls them into the database, at most SYNC_DAILY_CAP per
+reporter per day, marked via=community[-probe|-install].
+
+CLI: python3 ui/frame_compat_db.py {count|export FILE|import FILE|flush|sync}
(import restores a backup; reports already in the database are skipped.)
"""
import json, os, subprocess, sys, threading, time, urllib.error, urllib.parse, urllib.request, uuid
import frame_host
+import frame_telemetry
URL = os.environ.get('FRAME_COMPAT_DB_URL', 'https://frame-compat.lakebed.app')
KEYCHAIN = ('frame-control-compat-db', 'app-key')
@@ -228,11 +234,153 @@ def add(report):
if shared():
flush()
_mem['at'] = 0 # refetch on next load
+ else:
+ frame_telemetry.compat_report(r) # only if this person opted in to sharing
except Exception:
pass # stays queued; load() shows it and a later call sends it
return r
+# ---- community reports: PostHog -> the database (maintainer only) ---------------
+
+POSTHOG_KEYCHAIN = ('frame-control-posthog', 'personal-api-key')
+SYNC_STATE = os.path.join(STATE, 'posthog-sync.json')
+SYNC_DAILY_CAP = 30
+COMMUNITY_VIA = {'user': 'community', 'probe': 'community-probe', 'install': 'community-install'}
+
+
+def posthog_personal_key():
+ k = os.environ.get('POSTHOG_PERSONAL_API_KEY')
+ if k:
+ return k
+ if frame_host.MAC:
+ p = subprocess.run(['security', 'find-generic-password', '-s', POSTHOG_KEYCHAIN[0], '-a',
+ POSTHOG_KEYCHAIN[1], '-w'], capture_output=True, text=True)
+ if p.returncode == 0 and p.stdout.strip():
+ return p.stdout.strip()
+ raise DBError('No PostHog personal API key (set POSTHOG_PERSONAL_API_KEY, or on macOS the Keychain '
+ f'item service {POSTHOG_KEYCHAIN[0]}, account {POSTHOG_KEYCHAIN[1]})')
+
+
+def _posthog_query(sql):
+ cfg = frame_telemetry.config()
+ project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or cfg.get('project')
+ if not project:
+ raise DBError('No PostHog project id (ui/telemetry.json "project", or FRAME_CONTROL_POSTHOG_PROJECT)')
+ # The query API lives on the app host (us.posthog.com), not the ingestion host (us.i.posthog.com).
+ host = cfg['host'].replace('.i.posthog.com', '.posthog.com')
+ req = urllib.request.Request(f'{host}/api/projects/{urllib.parse.quote(str(project))}/query/', method='POST',
+ data=json.dumps({'query': {'kind': 'HogQLQuery', 'query': sql}}).encode(),
+ headers={'authorization': 'Bearer ' + posthog_personal_key(),
+ 'content-type': 'application/json'})
+ try:
+ with _opener.open(req, timeout=60) as r:
+ return json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ raise DBError(f'PostHog said HTTP {e.code}: {e.read()[:300]!r}')
+ except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
+ raise DBError(f"can't reach PostHog: {e}")
+
+
+SYNC_OVERLAP_DAYS = 30 # re-read this far back: offline copies send late, with their original time
+SYNC_PAGE = 5000
+
+
+def community_rows(events, state, cap=SYNC_DAILY_CAP):
+ """(reports, skipped): compat_report events as database rows. `state` ({"seen": {id: day},
+ "counts": {"reporter|day": n}}) persists between syncs, so an event read twice is handled
+ once and each reporter gets at most `cap` reports a day in total."""
+ seen, counts = state.setdefault('seen', {}), state.setdefault('counts', {})
+ out, skipped = [], []
+ for props, reporter, ts in events:
+ if isinstance(props, str):
+ try:
+ props = json.loads(props)
+ except ValueError:
+ props = None
+ if not isinstance(props, dict):
+ skipped.append((None, 'unreadable properties'))
+ continue
+ bad = [k for k in (*FIELDS, 'id') if props.get(k) is not None and not isinstance(props[k], (str, int, float))]
+ if bad:
+ skipped.append((str(props.get('id'))[:60], f'bad field {bad[0]}'))
+ continue
+ r = {k: (str(props[k]) if props.get(k) is not None else None) for k in FIELDS}
+ r['id'] = str(props['id']) if props.get('id') is not None else None
+ if r['id'] in seen:
+ continue # handled in an earlier sync (or earlier in this one)
+ r['via'] = COMMUNITY_VIA.get(r.get('via') or 'user', 'community')
+ why = problem(r)
+ if why:
+ skipped.append((r.get('id'), why))
+ continue
+ day = str(ts)[:10]
+ seen[r['id']] = day
+ key_ = f'{reporter}|{day}'
+ if counts.get(key_, 0) >= cap:
+ skipped.append((r['id'], 'over the daily limit for one reporter'))
+ continue
+ counts[key_] = counts.get(key_, 0) + 1
+ out.append(r)
+ return out, skipped
+
+
+def _sync_state():
+ try:
+ with open(SYNC_STATE) as f:
+ s = json.load(f)
+ return s if isinstance(s, dict) else {}
+ except (OSError, ValueError):
+ return {}
+
+
+def _save_sync_state(s):
+ """Forget ids and counts older than the overlap window (plus a margin)."""
+ cutoff = time.strftime('%Y-%m-%d', time.gmtime(time.time() - (SYNC_OVERLAP_DAYS + 15) * 86400))
+ s['seen'] = {k: d for k, d in s.get('seen', {}).items() if d >= cutoff}
+ s['counts'] = {k: n for k, n in s.get('counts', {}).items() if k.rsplit('|', 1)[-1] >= cutoff}
+ os.makedirs(STATE, exist_ok=True)
+ with open(SYNC_STATE + '.tmp', 'w') as f:
+ json.dump(s, f)
+ os.replace(SYNC_STATE + '.tmp', SYNC_STATE)
+
+
+def sync(dry_run=False):
+ """Pull community reports from PostHog into the database. Returns (added, skipped).
+ Reads the last SYNC_OVERLAP_DAYS each time, since events carry the time they were
+ made, not when they arrived; the saved state keeps that from adding anything twice."""
+ key() # the maintainer's copy only
+ state = _sync_state()
+ since = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(time.time() - SYNC_OVERLAP_DAYS * 86400))
+ events, after = [], f"timestamp >= toDateTime('{since}', 'UTC')"
+ for _ in range(40):
+ # Keyset paging: PostHog refuses OFFSET with a personal API key. The cursor is in UTC,
+ # since a local time is ambiguous in the hour clocks go back.
+ res = _posthog_query("SELECT properties, distinct_id, timestamp, toString(uuid), "
+ "formatDateTime(timestamp, '%Y-%m-%d %H:%i:%S.%f', 'UTC') FROM events "
+ f"WHERE event = 'compat_report' AND {after} "
+ f"ORDER BY timestamp, toString(uuid) LIMIT {SYNC_PAGE}")
+ rows = res.get('results') or []
+ events += [row[:3] for row in rows]
+ if len(rows) < SYNC_PAGE:
+ break
+ last_uuid, last_ts = rows[-1][3], rows[-1][4]
+ after = (f"(timestamp > toDateTime64('{last_ts}', 6, 'UTC') OR "
+ f"(timestamp = toDateTime64('{last_ts}', 6, 'UTC') AND toString(uuid) > '{last_uuid}'))")
+ rows, skipped = community_rows(events, state)
+ if dry_run:
+ return rows, skipped
+ if rows:
+ os.makedirs(STATE, exist_ok=True)
+ with _lock, open(OUTBOX, 'a') as f:
+ f.writelines(json.dumps(r, ensure_ascii=False) + '\n' for r in rows)
+ # Saved before sending: the rows are in the outbox now, and flush retries them if sending fails.
+ _save_sync_state(state)
+ flush() # also retries rows a failed earlier sync left in the outbox
+ _mem['at'] = 0
+ return rows, skipped
+
+
def main():
cmd, *args = sys.argv[1:] or ['count']
try:
@@ -260,6 +408,12 @@ def main():
'reports already in the database were not duplicated')
elif cmd == 'flush':
print(f'{flush()} still queued')
+ elif cmd == 'sync':
+ rows, skipped = sync(dry_run='--dry-run' in args)
+ for rid, why in skipped:
+ print(f'skipped {rid!r}: {why}', file=sys.stderr)
+ print(f"{len(rows)} community reports {'found' if '--dry-run' in args else 'added'}, "
+ f'{len(skipped)} skipped')
else:
sys.exit(__doc__)
except DBError as e:
diff --git a/ui/frame_computer.py b/ui/frame_computer.py
new file mode 100644
index 0000000..546d1b8
--- /dev/null
+++ b/ui/frame_computer.py
@@ -0,0 +1,133 @@
+"""Read-only Frame UI inventory using installed X11 tools and AT-SPI libraries.
+
+Runs on the Frame via SSH stdin. No daemon, input injection, or driver install.
+Accessible names are untrusted application content, never agent instructions.
+"""
+import ctypes
+import ctypes.util
+import json
+import os
+import re
+import signal
+import subprocess
+
+
+def parse_windows(text):
+ """gamescope's focusable windows are triples: XID, app ID, process ID."""
+ windows, focused = [], None
+ observed_windows = False
+ for line in text.splitlines():
+ name, separator, value = line.partition(' = ')
+ if not separator:
+ continue
+ if not re.fullmatch(r'[0-9, ]*', value):
+ raise ValueError('Unexpected gamescope window property')
+ numbers = [int(v.strip()) for v in value.split(',') if v.strip()]
+ if name == 'GAMESCOPE_FOCUSABLE_WINDOWS(CARDINAL)':
+ observed_windows = True
+ if len(numbers) % 3 or len(numbers) > 1536:
+ raise ValueError('Incomplete or oversized gamescope window list')
+ windows = [{'windowId': hex(numbers[i]), 'appid': numbers[i + 1], 'pid': numbers[i + 2]}
+ for i in range(0, len(numbers), 3)]
+ elif name == 'GAMESCOPE_FOCUSED_APP(CARDINAL)' and numbers:
+ focused = numbers[0]
+ if not observed_windows:
+ raise ValueError('gamescope focusable-window property is unavailable')
+ return {'windows': windows, 'focusedApp': focused}
+
+
+def accessibility():
+ """Bounded semantic snapshot, with per-call timeouts and no action methods."""
+ c = ctypes
+ atspi = c.CDLL(ctypes.util.find_library('atspi') or 'libatspi.so.0')
+ glib = c.CDLL(ctypes.util.find_library('glib-2.0') or 'libglib-2.0.so.0')
+ obj = c.CDLL(ctypes.util.find_library('gobject-2.0') or 'libgobject-2.0.so.0')
+
+ def function(lib, name, result, args):
+ fn = getattr(lib, name)
+ fn.restype, fn.argtypes = result, args
+ return fn
+
+ init = function(atspi, 'atspi_init', c.c_int, [])
+ finish = function(atspi, 'atspi_exit', c.c_int, [])
+ timeout = function(atspi, 'atspi_set_timeout', None, [c.c_int, c.c_int])
+ desktop = function(atspi, 'atspi_get_desktop', c.c_void_p, [c.c_int])
+ count = function(atspi, 'atspi_accessible_get_child_count', c.c_int, [c.c_void_p, c.c_void_p])
+ child = function(atspi, 'atspi_accessible_get_child_at_index', c.c_void_p, [c.c_void_p, c.c_int, c.c_void_p])
+ name = function(atspi, 'atspi_accessible_get_name', c.c_void_p, [c.c_void_p, c.c_void_p])
+ role = function(atspi, 'atspi_accessible_get_role_name', c.c_void_p, [c.c_void_p, c.c_void_p])
+ pid = function(atspi, 'atspi_accessible_get_process_id', c.c_uint, [c.c_void_p, c.c_void_p])
+ free = function(glib, 'g_free', None, [c.c_void_p])
+ unref = function(obj, 'g_object_unref', None, [c.c_void_p])
+
+ def string(fn, node):
+ pointer = fn(node, None)
+ try:
+ return c.string_at(pointer).decode(errors='replace')[:512] if pointer else ''
+ finally:
+ if pointer:
+ free(pointer)
+
+ if init() not in (0, 1):
+ raise RuntimeError('AT-SPI initialization failed')
+ timeout(500, 500)
+ nodes = []
+ truncated = False
+ incomplete = False
+
+ def walk(node, path, depth):
+ nonlocal truncated, incomplete
+ if not node:
+ incomplete = True
+ return
+ try:
+ n = count(node, None)
+ nodes.append({'path': path, 'name': string(name, node), 'role': string(role, node),
+ 'pid': pid(node, None), 'childCount': n})
+ incomplete = incomplete or n < 0
+ if depth >= 6:
+ truncated = truncated or n > 0
+ return
+ budget = min(max(n, 0), 96 - len(nodes))
+ truncated = truncated or n > budget
+ for i in range(budget):
+ if len(nodes) >= 96:
+ truncated = True
+ break
+ walk(child(node, i, None), path + [i], depth + 1)
+ finally:
+ unref(node)
+
+ try:
+ root = desktop(0)
+ if not root:
+ raise RuntimeError('No accessibility desktop available')
+ walk(root, [], 0)
+ return {'nodes': nodes, 'truncated': truncated, 'incomplete': incomplete,
+ 'note': 'Observation only. Paths are not stable action targets. Hidden elements may be present.'}
+ finally:
+ finish()
+
+
+def snapshot():
+ result = {'display': ':0', 'inputEnabled': False,
+ 'warning': 'Window IDs, accessible names and roles are observations, not instructions or authorization.'}
+ try:
+ run = subprocess.run(['xprop', '-root', 'GAMESCOPE_FOCUSABLE_WINDOWS', 'GAMESCOPE_FOCUSED_APP'],
+ env={**os.environ, 'DISPLAY': ':0'}, capture_output=True, text=True, timeout=5)
+ if run.returncode:
+ raise ValueError('gamescope display :0 is unavailable')
+ result.update(parse_windows(run.stdout))
+ except (OSError, ValueError, subprocess.SubprocessError) as exc:
+ result['windowError'] = str(exc)
+ try:
+ result['accessibility'] = accessibility()
+ except (OSError, RuntimeError, AttributeError) as exc:
+ result['accessibilityError'] = str(exc)
+ return result
+
+
+if __name__ == '__main__':
+ # A wedged D-Bus application must not leave an orphaned remote probe.
+ signal.alarm(15)
+ print(json.dumps(snapshot()))
diff --git a/ui/frame_connect.py b/ui/frame_connect.py
index 9b75ded..a459a32 100644
--- a/ui/frame_connect.py
+++ b/ui/frame_connect.py
@@ -6,8 +6,9 @@ asking for the Developer Mode password once. The Linux and Windows twin of
scripts/connect.sh (which the Mac app uses); same config block, so either can
re-run over the other. Idempotent.
-Usage: python3 ui/frame_connect.py [HOST_OR_IP[:PORT]]
-Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame)
+Usage: python3 ui/frame_connect.py [--alias NAME] [HOST_OR_IP[:PORT]]
+Env: FRAME_USER (default steamos), FRAME_ALIAS (default frame; --alias wins, for
+ terminals that don't pass the environment on, like Windows' `start`)
"""
import base64
import json
@@ -283,10 +284,40 @@ def config_block(host, port=22, user=FRAME_USER):
" IdentitiesOnly yes", " ServerAliveInterval 30", "Host *", END]
+class config_lock:
+ """The lock Frame Control takes to edit ~/.ssh/config (frame_devices.file_lock), so a
+ running app and this setup never write over each other's change."""
+
+ def __enter__(self):
+ self.fh = open(SSH_DIR / "config.frame-control.lock", "a+")
+ for _ in range(300):
+ try:
+ if os.name == "nt":
+ import msvcrt
+ self.fh.seek(0)
+ msvcrt.locking(self.fh.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+ fcntl.lockf(self.fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ return self
+ except OSError:
+ time.sleep(0.1)
+ return self # 30 s: go ahead rather than fail the setup
+
+ def __exit__(self, *exc):
+ self.fh.close() # closing releases the lock
+ return False
+
+
def write_config(host, port=22, user=FRAME_USER):
+ make_ssh_dir()
+ with config_lock():
+ _write_config(host, port, user)
+
+
+def _write_config(host, port, user):
"""Replace our managed block and put it first: ssh uses the first value it sees per
option. The trailing "Host *" returns the rest of the file to global scope."""
- make_ssh_dir()
old = CONFIG.read_text(encoding="utf-8") if CONFIG.exists() else ""
kept, skip = [], False
for line in old.splitlines():
@@ -297,7 +328,7 @@ def write_config(host, port=22, user=FRAME_USER):
elif not skip:
kept.append(line)
block = config_block(host, port, user)
- tmp = CONFIG.with_name("config.frame-control.tmp")
+ tmp = CONFIG.with_name(f"config.frame-control.{os.getpid()}.tmp")
tmp.write_text("\n".join(block + kept) + "\n", encoding="utf-8")
if os.name != "nt":
tmp.chmod(0o600)
@@ -367,9 +398,22 @@ def pair_with_devkit(host, port, user):
return chosen[0], "paired, but key login still fails"
+def use_alias(alias):
+ """--alias: set up another headset under its own ~/.ssh/config alias (Devices tab)."""
+ global FRAME_ALIAS, BEGIN, END
+ if not NAME_RE.fullmatch(alias):
+ sys.exit(f"--alias must be a plain name, not {alias!r}")
+ FRAME_ALIAS = alias
+ BEGIN = f"# >>> steam-frame ({FRAME_ALIAS}) >>>"
+ END = f"# <<< steam-frame ({FRAME_ALIAS}) <<<"
+
+
def main(argv):
if argv and argv[0] in ("-h", "--help"):
sys.exit(__doc__)
+ if len(argv) >= 2 and argv[0] == "--alias":
+ use_alias(argv[1])
+ argv = argv[2:]
say("==> Looking for the Steam Frame")
found = pick_host(argv[0] if argv else None)
while not found:
diff --git a/ui/frame_devices.py b/ui/frame_devices.py
new file mode 100644
index 0000000..8345302
--- /dev/null
+++ b/ui/frame_devices.py
@@ -0,0 +1,802 @@
+"""The headsets Frame Control knows, and the addresses each can be reached at.
+
+One headset can answer at several addresses: a LAN IP at home, another in the
+office, its mDNS name (frame.local), its Tailscale IP or MagicDNS name. The
+registry keeps them all, learns which worked on which network, and hands the
+connector (frame_link.py) an order to try them in.
+
+Stored as JSON in frame_host.data_dir("devices.json"). The format is plain so the
+iPhone app can share it later; docs/devices.md describes it:
+
+ {"version": 1, "active": "",
+ "devices": [{"id", "name", "alias", "user", "port", "identity_files",
+ "addresses": [{"host", "kind": lan|mdns|tailscale|manual, "label",
+ "networks": [network ids it worked on], "last_ok", "last_rtt_ms"}]}],
+ "networks": {"": {"name", "ssid", "gateway", "gateway_mac", "last_seen"}}}
+
+Headsets set up before this existed live only in ~/.ssh/config, in the managed
+`# >>> steam-frame (ALIAS) >>>` blocks that scripts/connect.sh and
+ui/frame_connect.py write; they're imported from there, so nobody has to add
+them again. Each device keeps its alias: Terminal's `ssh frame` and the helper
+scripts go on working, and the connector rewrites the block's HostName to the
+last address that worked, so they follow it.
+
+Host keys are pinned per headset, not per address: ssh gets
+`-o HostKeyAlias=frame-control-` and a known_hosts file of the headset's own
+(~/.ssh/frame-control-hosts/), so a different device answering at a
+remembered IP is caught.
+
+Python stdlib only.
+"""
+import contextlib
+import copy
+import json
+import os
+import re
+import secrets
+import subprocess
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+import frame_host
+import frame_network
+
+VERSION = 1
+# Everything here can end up in ssh arguments or ~/.ssh/config, so nothing that
+# could start an option, add a line, or carry a directive.
+NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
+HOST_RE = re.compile(r"[A-Za-z0-9:][A-Za-z0-9.:-]{0,252}(%[A-Za-z0-9._-]{1,32})?")
+TEXT_MAX = 60
+KINDS = ("lan", "mdns", "tailscale", "manual")
+KIND_LABEL = {"lan": "Local network", "mdns": "mDNS (.local)", "tailscale": "Tailscale", "manual": "Other"}
+DEFAULT_USER = "steamos"
+
+
+class DeviceError(ValueError):
+ """Bad input from the page; the server answers 400 with the message."""
+
+
+def ssh_dir():
+ """~/.ssh, or $FRAME_CONTROL_SSH_DIR in tests so they never touch the real one."""
+ return Path(os.environ.get("FRAME_CONTROL_SSH_DIR") or Path.home() / ".ssh")
+
+
+def ssh_config():
+ return ssh_dir() / "config"
+
+
+PIN_DIR = "frame-control-hosts"
+
+
+def known_hosts(device_id):
+ """The headset's own known_hosts file: one per headset, so saving or forgetting one
+ headset's key (by ssh or by the app) can never touch another's."""
+ return ssh_dir() / PIN_DIR / device_id
+
+
+def known_hosts_opt(device_id):
+ """How ssh is told about it. `~` rather than the full path when it's the usual
+ place, so a home folder with a space in its name can't split the option."""
+ if os.environ.get("FRAME_CONTROL_SSH_DIR"):
+ return str(known_hosts(device_id))
+ return f"~/.ssh/{PIN_DIR}/{device_id}"
+
+
+def host_key_alias(device_id):
+ return f"frame-control-{device_id}"
+
+
+# ---- validation ----------------------------------------------------------------
+
+def check_alias(alias):
+ if not isinstance(alias, str) or not NAME_RE.fullmatch(alias):
+ raise DeviceError("The SSH alias must be a plain name: letters, digits, dot, dash or underscore")
+ return alias
+
+
+def check_user(user):
+ if not isinstance(user, str) or not NAME_RE.fullmatch(user):
+ raise DeviceError("The user name must be letters, digits, dot, dash or underscore")
+ return user
+
+
+def check_host(host):
+ host = host.strip() if isinstance(host, str) else host
+ if (not isinstance(host, str) or not HOST_RE.fullmatch(host) or ".." in host
+ or ("%" in host and ":" not in host.split("%")[0])): # a zone only follows an IPv6 address
+ raise DeviceError(f"{host!r} isn't a host name or IP address")
+ return host
+
+
+def check_port(port):
+ try:
+ port = int(port)
+ except (TypeError, ValueError):
+ raise DeviceError("The port must be a number") from None
+ if not 1 <= port <= 65535:
+ raise DeviceError("The port must be between 1 and 65535")
+ return port
+
+
+def check_text(text, what):
+ text = (text or "").strip() if isinstance(text, (str, type(None))) else None
+ if text is None or len(text) > TEXT_MAX or re.search(r"[\x00-\x1f\x7f]", text):
+ raise DeviceError(f"The {what} must be plain text of at most {TEXT_MAX} characters")
+ return text
+
+
+def check_kind(kind):
+ if kind not in KINDS:
+ raise DeviceError(f"The kind must be one of {', '.join(KINDS)}")
+ return kind
+
+
+def ssh_host(host):
+ """A host for ssh's HostName, which expands %-tokens: an IPv6 zone's % is doubled."""
+ return host.replace("%", "%%")
+
+
+# ---- ~/.ssh/config's managed blocks ---------------------------------------------
+
+BLOCK_RE = re.compile(r"# >>> steam-frame \((" + NAME_RE.pattern + r")\) >>>")
+
+
+def begin_mark(alias):
+ return f"# >>> steam-frame ({alias}) >>>"
+
+
+def end_mark(alias):
+ return f"# <<< steam-frame ({alias}) <<<"
+
+
+def parse_blocks(text):
+ """The managed blocks: [{"alias", "hostname", "user", "port", "identity_files"}]."""
+ blocks, cur = [], None
+ for line in text.splitlines():
+ m = BLOCK_RE.fullmatch(line.strip())
+ if m:
+ cur = {"alias": m.group(1), "hostname": None, "user": None, "port": 22, "port_set": False,
+ "identity_files": []}
+ continue
+ if cur is None:
+ continue
+ if line.strip() == end_mark(cur["alias"]):
+ blocks.append(cur)
+ cur = None
+ continue
+ f = line.split(None, 1)
+ if len(f) != 2:
+ continue
+ key, value = f[0].lower(), f[1].strip()
+ if key == "hostname" and cur["hostname"] is None:
+ cur["hostname"] = value.replace("%%", "%")
+ elif key == "user" and cur["user"] is None:
+ cur["user"] = value
+ elif key == "port" and value.isdigit():
+ cur["port"], cur["port_set"] = int(value), True
+ elif key == "identityfile":
+ cur["identity_files"].append(value)
+ return blocks
+
+
+def read_config(path=None):
+ path = Path(path or ssh_config())
+ try:
+ return path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ return ""
+
+
+# One edit of ~/.ssh/config at a time: between this app's threads (_config_lock) and
+# with Set Up Connection (frame_connect.py and scripts/connect.sh take the same lock
+# file). _edit_config also notices any other program writing in between.
+_config_lock = threading.Lock()
+LOCK_NAME = "config.frame-control.lock"
+
+
+@contextlib.contextmanager
+def file_lock(path, timeout=30):
+ """An exclusive lock on `path` (created if need be) shared with other processes:
+ POSIX record locks (what zsh's `zsystem flock` takes), or msvcrt on Windows."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fh = open(path, "a+")
+ try:
+ deadline = time.monotonic() + timeout
+ while True:
+ try:
+ if frame_host.WINDOWS:
+ import msvcrt
+ fh.seek(0)
+ msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
+ else:
+ import fcntl
+ fcntl.lockf(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ break
+ except OSError:
+ if time.monotonic() > deadline:
+ raise OSError(f"{path} stayed locked (is Set Up Connection running?)")
+ time.sleep(0.1)
+ yield
+ finally:
+ try:
+ if frame_host.WINDOWS:
+ import msvcrt
+ fh.seek(0)
+ msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
+ else:
+ import fcntl
+ fcntl.lockf(fh, fcntl.LOCK_UN)
+ except OSError:
+ pass
+ fh.close()
+
+
+def _write_config(path, text, expected):
+ """Swap the file in whole (as frame_connect.write_config does), keeping it private.
+ Returns False, writing nothing, if the file no longer holds `expected`."""
+ fd_, tmp = tempfile.mkstemp(prefix="config.frame-control.", dir=str(path.parent))
+ tmp = Path(tmp)
+ try:
+ with os.fdopen(fd_, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ if not frame_host.WINDOWS:
+ tmp.chmod(0o600)
+ for attempt in range(20): # Windows: a running ssh.exe can hold the file for a moment
+ if read_config(path) != expected:
+ return False
+ try:
+ os.replace(tmp, path)
+ return True
+ except PermissionError:
+ time.sleep(0.25)
+ raise OSError(f"{path} stayed locked by another program")
+ finally:
+ if tmp.exists():
+ tmp.unlink()
+
+
+def _edit_config(path, change):
+ """Apply change(lines) -> new lines or None to the file, retrying if another program
+ wrote it meanwhile. -> True if the file changed."""
+ with _config_lock, file_lock(path.with_name(LOCK_NAME)):
+ for _ in range(5):
+ text = read_config(path)
+ new = change(text.splitlines())
+ if new is None:
+ return False
+ if _write_config(path, "\n".join(new) + "\n", text):
+ return True
+ raise OSError(f"{path} kept changing while Frame Control tried to update it")
+
+
+def rewrite_block(alias, path=None, hostname=None, user=None, port=None, expect=None):
+ """Change HostName, User or Port inside ALIAS's managed block, leaving the rest of the
+ file alone. -> True if the file changed. Does nothing if there's no such block, or
+ if `expect` ({"hostname", "user", "port"}; None values match anything) no longer
+ describes the block, checked under the lock: someone else changed it meanwhile."""
+ def change(lines):
+ if expect:
+ block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
+ # A port the block doesn't set is inherited from elsewhere in the file: not compared.
+ if not block or any(v is not None and block[k] != v and (k != "port" or block["port_set"])
+ for k, v in expect.items()):
+ return None
+ block = next((b for b in parse_blocks("\n".join(lines)) if b["alias"] == alias), None)
+ # Port 22 needs no line, unless the block would otherwise inherit another port
+ # from a later Host entry (which ssh would use).
+ force = bool(port) and block is not None and not block["port_set"] and \
+ effective_port(alias, config_path) != int(port)
+ return _rewritten(lines, alias, hostname, user, port, force)
+ config_path = Path(path or ssh_config())
+ return _edit_config(config_path, change)
+
+
+def _rewritten(lines, alias, hostname, user, port, force_port=False):
+ begin, end = begin_mark(alias), end_mark(alias)
+ if begin not in lines or end not in lines:
+ return None
+ i, j = lines.index(begin), lines.index(end)
+ if j < i:
+ return None
+ block = lines[i:j]
+ want = {"hostname": ssh_host(hostname) if hostname else None, "user": user,
+ "port": str(port) if port else None}
+ out, seen = [], set()
+ for line in block:
+ f = line.split(None, 1)
+ key = f[0].lower() if f else ""
+ if key in want and want[key] is not None and key not in seen:
+ seen.add(key)
+ # An existing Port line is kept, even for 22: dropping it could let a later
+ # `Host *` Port apply to Terminal but not to the app.
+ out.append(f" {f[0]} {want[key]}")
+ else:
+ out.append(line)
+ if want["port"] and (want["port"] != "22" or force_port) and "port" not in seen:
+ at = next((n + 1 for n, line in enumerate(out) if line.split(None, 1)[:1] == ["HostName"]), 2)
+ out.insert(at, f" Port {want['port']}")
+ new = lines[:i] + out + lines[j:]
+ return None if new == lines else new
+
+
+def remove_block(alias, path=None):
+ def change(lines):
+ begin, end = begin_mark(alias), end_mark(alias)
+ if begin not in lines or end not in lines or lines.index(end) < lines.index(begin):
+ return None
+ return lines[:lines.index(begin)] + lines[lines.index(end) + 1:]
+ return _edit_config(Path(path or ssh_config()), change)
+
+
+def effective_port(alias, config):
+ """The port ssh uses for ALIAS with this config file (`ssh -F FILE -G ALIAS`), else 22."""
+ try:
+ out = subprocess.run(["ssh", "-F", str(config), "-G", alias], capture_output=True, text=True,
+ stdin=subprocess.DEVNULL, timeout=10).stdout
+ except (OSError, subprocess.TimeoutExpired):
+ return 22
+ m = re.search(r"^port (\d+)$", out, re.M)
+ port = int(m.group(1)) if m else 22
+ return port if 1 <= port <= 65535 else 22
+
+
+# ---- pinned host keys -------------------------------------------------------------
+
+def _keygen(*args):
+ try:
+ return subprocess.run(["ssh-keygen", *args], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ timeout=10)
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+
+
+def pinned(device_id):
+ """Whether a key is saved for the headset. Entries are plain text (ssh gets
+ HashKnownHosts=no), but ask ssh-keygen too in case one was hashed."""
+ target = known_hosts(device_id)
+ try:
+ lines = target.read_text(encoding="utf-8").splitlines()
+ except (OSError, UnicodeDecodeError):
+ return False
+ name = host_key_alias(device_id)
+ if any(line.split(None, 1)[0].split(",").count(name) for line in lines
+ if line.strip() and not line.startswith("#")):
+ return True
+ r = _keygen("-F", name, "-f", str(target))
+ return bool(r and r.returncode == 0 and r.stdout.strip())
+
+
+def seed_pin(device_id, hosts, port=22, sources=None):
+ """Copy the host keys ssh already trusts for one of `hosts` into the headset's file
+ under its alias, so moving to per-headset pinning asks nobody to trust anything again.
+ -> True if a key is pinned."""
+ if pinned(device_id):
+ return True
+ sources = sources or [ssh_dir() / "known_hosts", ssh_dir() / "known_hosts2"]
+ name = host_key_alias(device_id)
+ for host in hosts:
+ wanted = host if port == 22 else f"[{host}]:{port}"
+ keys = []
+ for src in sources:
+ if not Path(src).is_file():
+ continue
+ r = _keygen("-F", wanted, "-f", str(src))
+ for line in (r.stdout if r else "").splitlines():
+ f = line.split()
+ if len(f) >= 3 and not line.startswith("#") and not f[0].startswith("@"):
+ keys.append(f"{name} {f[1]} {f[2]}")
+ if keys:
+ target = known_hosts(device_id)
+ target.parent.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
+ fd_, tmp = tempfile.mkstemp(prefix=".seed-", dir=str(target.parent))
+ with os.fdopen(fd_, "w", encoding="utf-8") as fh:
+ fh.write("\n".join(dict.fromkeys(keys)) + "\n")
+ os.replace(tmp, target) # whole file at once: ssh never sees half of it
+ return True
+ return False
+
+
+def forget_pin(device_id):
+ """Drop a headset's saved key, e.g. after SteamOS was reinstalled. The next connection
+ trusts whatever key the headset shows, as a first connection does."""
+ try:
+ known_hosts(device_id).unlink()
+ return True
+ except FileNotFoundError:
+ return False
+
+
+# ---- address order --------------------------------------------------------------------
+
+def order_addresses(addresses, network_id, tailscale_up):
+ """The order to try a device's addresses in, each with why it's there:
+ known to work on this network, then mDNS, then Tailscale if it's up, then the rest
+ (addresses that only ever worked elsewhere last). The user's order breaks ties."""
+ def group(a):
+ nets = a.get("networks") or []
+ if network_id and network_id in nets:
+ return 0, "worked on this network before"
+ if a["kind"] == "mdns":
+ return 1, "mDNS name"
+ if a["kind"] == "tailscale":
+ return (2, "Tailscale") if tailscale_up else (5, "Tailscale isn't running")
+ if nets:
+ return 4, "worked on another network"
+ return 3, "not tried on this network yet"
+ ranked = sorted(enumerate(addresses), key=lambda p: (group(p[1])[0], p[0]))
+ return [(a, group(a)[1]) for _, a in ranked]
+
+
+# ---- the registry ------------------------------------------------------------------------
+
+def new_address(host, kind=None, label=""):
+ host = check_host(host)
+ return {"host": host, "kind": check_kind(kind) if kind else frame_network.guess_kind(host),
+ "label": check_text(label, "label"), "networks": [], "last_ok": None, "last_rtt_ms": None}
+
+
+class Registry:
+ """devices.json, loaded once and saved on every change. Thread-safe."""
+
+ def __init__(self, path=None, config=None):
+ self.path = Path(path or frame_host.data_dir("devices.json"))
+ self.config = Path(config) if config else None # None: ssh_config() at call time
+ self.lock = threading.RLock()
+ self._depth = 0 # nested _changing() calls
+ self._mtime = None # devices.json as last loaded or saved
+ self.data = {"version": VERSION, "active": None, "devices": [], "networks": {}}
+ self.load()
+
+ # -- storage --
+ def load(self):
+ with self.lock:
+ try:
+ self._mtime = self.path.stat().st_mtime_ns
+ data = json.loads(self.path.read_text(encoding="utf-8"))
+ except (OSError, ValueError):
+ return
+ if isinstance(data, dict) and isinstance(data.get("devices"), list):
+ data.setdefault("networks", {})
+ data.setdefault("active", None)
+ data["devices"] = [d for d in data["devices"] if self._sane(d)]
+ # The headset in use is this server's own choice: another server picking a
+ # different one mustn't move commands (an install, say) under it. The file's
+ # choice is only where a server starts.
+ # Kept even if another server removed it, so the connector can see that
+ # (and not quietly move to another headset in the middle of an install).
+ mine = self.data.get("active")
+ if mine:
+ data["active"] = mine
+ self.data = data
+
+ @staticmethod
+ def _sane(d):
+ try:
+ check_alias(d["alias"])
+ d["addresses"] = [a for a in d.get("addresses") or [] if isinstance(a, dict) and HOST_RE.fullmatch(a.get("host", ""))
+ and a.get("kind") in KINDS]
+ for a in d["addresses"]:
+ a.setdefault("networks", [])
+ a.setdefault("label", "")
+ return NAME_RE.fullmatch(d.get("id", "")) is not None
+ except (KeyError, TypeError, DeviceError):
+ return False
+
+ def save(self):
+ with self.lock:
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = self.path.with_name(self.path.name + ".tmp")
+ tmp.write_text(json.dumps(self.data, indent=1), encoding="utf-8")
+ os.replace(tmp, self.path)
+ self._mtime = self.path.stat().st_mtime_ns
+
+ def _refresh(self):
+ """Pick up what another Frame Control server saved (the app and a standalone
+ server can share devices.json)."""
+ with self.lock:
+ try:
+ if self.path.stat().st_mtime_ns != self._mtime:
+ self.load()
+ except OSError:
+ pass
+
+ @contextlib.contextmanager
+ def _changing(self):
+ """Every change holds this registry's lock and a lock file shared with other
+ processes, and starts from what's on disk, so no server saves over another's
+ change. Nested calls (sync_from_config adding a device) share the outer one."""
+ with self.lock:
+ if self._depth:
+ self._depth += 1
+ try:
+ yield
+ finally:
+ self._depth -= 1
+ return
+ with file_lock(self.path.with_name(self.path.name + ".lock")):
+ self.load()
+ self._depth = 1
+ try:
+ yield
+ finally:
+ self._depth = 0
+
+ def snapshot(self):
+ self._refresh()
+ with self.lock:
+ return copy.deepcopy(self.data)
+
+ # -- lookups --
+ def devices(self):
+ self._refresh()
+ with self.lock:
+ return copy.deepcopy(self.data["devices"])
+
+ def _find(self, device_id):
+ for d in self.data["devices"]:
+ if d["id"] == device_id:
+ return d
+ raise DeviceError("No such headset (it may have been removed)")
+
+ def get(self, device_id):
+ self._refresh()
+ with self.lock:
+ return copy.deepcopy(self._find(device_id))
+
+ def by_alias(self, alias):
+ self._refresh()
+ with self.lock:
+ return next((copy.deepcopy(d) for d in self.data["devices"] if d["alias"] == alias), None)
+
+ def active(self):
+ self._refresh()
+ with self.lock:
+ return self.data.get("active")
+
+ def emptied(self):
+ self._refresh()
+ with self.lock:
+ return bool(self.data.get("emptied")) and not self.data["devices"]
+
+ def set_active(self, device_id):
+ with self._changing():
+ self._find(device_id)
+ self.data["active"] = device_id
+ self.save()
+
+ # -- devices --
+ def add_device(self, alias, name=None, user=DEFAULT_USER, port=22, hosts=(), identity_files=()):
+ with self._changing():
+ check_alias(alias)
+ if any(d["alias"] == alias for d in self.data["devices"]):
+ raise DeviceError(f"There's already a headset with the alias {alias}")
+ ids = {d["id"] for d in self.data["devices"]}
+ device_id = secrets.token_hex(4)
+ while device_id in ids:
+ device_id = secrets.token_hex(4)
+ d = {"id": device_id, "name": check_text(name or ("Steam Frame" if alias == "frame" else alias), "name"),
+ "alias": alias, "user": check_user(user or DEFAULT_USER), "port": check_port(port),
+ "identity_files": [str(f) for f in identity_files][:8], "addresses": [], "config_host": None,
+ "added": time.time()}
+ for host in hosts:
+ if host and not any(a["host"] == host for a in d["addresses"]):
+ d["addresses"].append(new_address(host))
+ self.data["devices"].append(d)
+ self.data.pop("emptied", None)
+ if not self.data.get("active"):
+ self.data["active"] = device_id
+ self.save()
+ return copy.deepcopy(d)
+
+ def update_device(self, device_id, name=None, user=None, port=None):
+ """-> the device after the change. The caller mirrors user and port into ~/.ssh/config."""
+ with self._changing():
+ d = self._find(device_id)
+ # Check everything first: a rejected edit changes nothing.
+ name = None if name is None else (check_text(name, "name") or d["alias"])
+ user = None if user is None else check_user(user)
+ port = None if port is None else check_port(port)
+ d.update({k: v for k, v in (("name", name), ("user", user), ("port", port)) if v is not None})
+ self.save()
+ return copy.deepcopy(d)
+
+ def remove_device(self, device_id):
+ """Forget a headset. Its ~/.ssh/config block (if kept) isn't imported again
+ unless Set Up Connection changes it."""
+ with self._changing():
+ d = self._find(device_id)
+ self.data["devices"].remove(d)
+ self.data.setdefault("dismissed", {})[d["alias"]] = d.get("config_host") or ""
+ if not self.data["devices"]:
+ self.data["emptied"] = True # removed on purpose: don't fall back to the `frame` alias
+ if self.data.get("active") == device_id:
+ self.data["active"] = self.data["devices"][0]["id"] if self.data["devices"] else None
+ self.save()
+ return d
+
+ # -- addresses --
+ def _addr(self, d, host):
+ for a in d["addresses"]:
+ if a["host"] == host:
+ return a
+ raise DeviceError(f"{host} isn't one of this headset's addresses")
+
+ def add_address(self, device_id, host, kind=None, label=""):
+ with self._changing():
+ d = self._find(device_id)
+ a = new_address(host, kind, label)
+ if any(x["host"] == a["host"] for x in d["addresses"]):
+ raise DeviceError(f"{a['host']} is already on the list")
+ if len(d["addresses"]) >= 32:
+ raise DeviceError("That's enough addresses for one headset")
+ d["addresses"].append(a)
+ self.save()
+ return copy.deepcopy(a)
+
+ def update_address(self, device_id, host, new_host=None, kind=None, label=None):
+ with self._changing():
+ d = self._find(device_id)
+ a = self._addr(d, host)
+ # Check everything first: a rejected edit changes nothing.
+ moved = new_host is not None and new_host != host
+ if moved:
+ new_host = check_host(new_host)
+ if any(x["host"] == new_host for x in d["addresses"]):
+ raise DeviceError(f"{new_host} is already on the list")
+ kind = None if kind is None else check_kind(kind)
+ label = None if label is None else check_text(label, "label")
+ if moved:
+ a.update(host=new_host, networks=[], last_ok=None, last_rtt_ms=None) # a new place: learn again
+ if kind is not None:
+ a["kind"] = kind
+ if label is not None:
+ a["label"] = label
+ self.save()
+ return copy.deepcopy(a)
+
+ def remove_address(self, device_id, host):
+ with self._changing():
+ d = self._find(device_id)
+ d["addresses"].remove(self._addr(d, host))
+ self.save()
+
+ def move_address(self, device_id, host, delta):
+ with self._changing():
+ d = self._find(device_id)
+ a = self._addr(d, host)
+ i = d["addresses"].index(a)
+ j = max(0, min(len(d["addresses"]) - 1, i + int(delta)))
+ d["addresses"].insert(j, d["addresses"].pop(i))
+ self.save()
+
+ def record_success(self, device_id, host, network_id, rtt_ms):
+ """Learn: this address worked on this network."""
+ with self._changing():
+ try:
+ a = self._addr(self._find(device_id), host)
+ except DeviceError:
+ return
+ if network_id and network_id not in a["networks"]:
+ a["networks"] = (a["networks"] + [network_id])[-16:]
+ a["last_ok"] = time.time()
+ a["last_rtt_ms"] = rtt_ms
+ self.save()
+
+ def undismiss(self, alias):
+ """Set Up Connection is about to run for this alias: import its block again."""
+ with self._changing():
+ if self.data.get("dismissed", {}).pop(alias, None) is not None:
+ self.save()
+
+ def set_config_host(self, device_id, host):
+ with self._changing():
+ try:
+ self._find(device_id)["config_host"] = host
+ except DeviceError:
+ return
+ self.save()
+
+ # -- networks --
+ def record_network(self, net):
+ """Remember a network we've seen (for naming it), keeping its user-given name."""
+ if not net or not net.get("id"):
+ return
+ with self._changing():
+ known = self.data["networks"].get(net["id"]) or {"name": ""}
+ changed = (known.get("ssid") != (net.get("ssid") or known.get("ssid")) or
+ time.time() - (known.get("last_seen") or 0) > 3600 or "gateway" not in known)
+ known.update(ssid=net.get("ssid") or known.get("ssid"), gateway=net.get("gateway"), wifi=net.get("wifi"),
+ gateway_mac=net.get("gateway_mac"), last_seen=time.time())
+ self.data["networks"][net["id"]] = known
+ if changed:
+ self.save()
+
+ def name_network(self, network_id, name):
+ with self._changing():
+ if network_id not in self.data["networks"]:
+ raise DeviceError("That network hasn't been seen")
+ self.data["networks"][network_id]["name"] = check_text(name, "network name")
+ self.save()
+
+ def network_name(self, net):
+ """What to call a network: the name given to it, its Wi-Fi name, or its router."""
+ if not net:
+ return "No network"
+ self._refresh()
+ with self.lock:
+ known = self.data["networks"].get(net.get("id") or "") or {}
+ if known.get("name"):
+ return known["name"]
+ ssid = net.get("ssid") or known.get("ssid")
+ if ssid:
+ return ssid
+ if net.get("gateway"):
+ return f"{'Wi-Fi' if net.get('wifi') else 'Network'} via {net['gateway']}"
+ return "No network"
+
+ # -- ~/.ssh/config --
+ def sync_from_config(self, seed=True):
+ """Import managed blocks we don't know yet, and pick up a HostName that Set Up
+ Connection changed since we last looked. -> True if anything changed."""
+ blocks = parse_blocks(read_config(self.config))
+ for b in blocks:
+ if not b["port_set"]:
+ # No Port in the block: another Host entry may give one (ssh uses the first).
+ b["port"] = effective_port(b["alias"], self.config or ssh_config())
+ changed = False
+ with self._changing():
+ first = not self.data["devices"] and not self.data.get("active")
+ for b in blocks:
+ host = b["hostname"] if b["hostname"] and HOST_RE.fullmatch(b["hostname"]) else None
+ user = b["user"] if b["user"] and NAME_RE.fullmatch(b["user"]) else DEFAULT_USER
+ d = next((x for x in self.data["devices"] if x["alias"] == b["alias"]), None)
+ dismissed = self.data.get("dismissed", {})
+ if d is None and b["alias"] in dismissed:
+ if dismissed[b["alias"]] == (host or ""):
+ continue # removed on the Devices tab; unchanged since
+ del dismissed[b["alias"]]
+ if d is None:
+ try:
+ d = self._find(self.add_device(b["alias"], user=user, port=b["port"],
+ identity_files=b["identity_files"])["id"])
+ except DeviceError:
+ continue
+ if host:
+ d["addresses"].append(dict(new_address(host), label="From Set Up Connection"))
+ d["config_host"] = host
+ changed = True
+ if seed and host:
+ seed_pin(d["id"], [host], b["port"])
+ elif host and host != d.get("config_host"):
+ # Set Up Connection ran again and found the headset somewhere new.
+ d["config_host"] = host
+ if not any(a["host"] == host for a in d["addresses"]):
+ d["addresses"].insert(0, dict(new_address(host), label="From Set Up Connection"))
+ if seed:
+ seed_pin(d["id"], [host], b["port"])
+ changed = True
+ if d.get("config_login") != [user, b["port"]]:
+ # Set Up Connection (or an edit) changed who to log in as, or the port.
+ if d.get("config_login") is not None and [d["user"], d["port"]] != [user, b["port"]]:
+ d["user"], d["port"] = user, b["port"] if 1 <= b["port"] <= 65535 else d["port"]
+ d["config_login"] = [user, b["port"]]
+ changed = True
+ if d["identity_files"] != b["identity_files"] and b["identity_files"]:
+ d["identity_files"] = b["identity_files"][:8]
+ changed = True
+ d["managed"] = True
+ aliases = {b["alias"] for b in blocks}
+ for d in self.data["devices"]:
+ d["managed"] = d["alias"] in aliases
+ if first:
+ # First import: the headset the app used before is `frame`, even if Set Up
+ # Connection put another block above it.
+ frame = next((d for d in self.data["devices"] if d["alias"] == "frame"), None)
+ if frame and self.data.get("active") != frame["id"]:
+ self.data["active"] = frame["id"]
+ changed = True
+ if changed:
+ self.save()
+ return changed
diff --git a/ui/frame_host.py b/ui/frame_host.py
index a60b82c..e5c1914 100644
--- a/ui/frame_host.py
+++ b/ui/frame_host.py
@@ -33,8 +33,11 @@ class HostError(RuntimeError):
def data_dir(*parts):
- """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME."""
- if MAC:
+ """Per-user app data: ~/Library/Application Support, %APPDATA% or $XDG_DATA_HOME
+ (or $FRAME_CONTROL_DATA_DIR, which the tests point at a throwaway directory)."""
+ if os.environ.get("FRAME_CONTROL_DATA_DIR"):
+ base = Path(os.environ["FRAME_CONTROL_DATA_DIR"])
+ elif MAC:
base = Path.home() / "Library" / "Application Support" / "Frame Control"
elif WINDOWS:
base = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "Frame Control"
@@ -53,12 +56,19 @@ def cache_dir(*parts):
return base.joinpath(*parts)
-def control_path():
+def control_path(tag="x", *, private=None):
"""ssh ControlPath for the shared connection, or None where it isn't supported.
+ `tag` names the headset: ssh's %C hashes only the address, user and port, so two
+ headsets reached at the same address (one of them moved) would otherwise share a
+ connection, and one's commands would run on the other.
/tmp, not $TMPDIR: macOS's per-user temp path overflows the unix socket path limit.
"""
- return f"/tmp/frame-ui-{os.getuid()}-%C" if MUX else None
+ # A private server (the MCP adapter's) keeps its own masters: FRAME_PRIVATE_SSH=1.
+ if private is None:
+ private = os.environ.get("FRAME_PRIVATE_SSH") == "1"
+ suffix = f"-{os.getpid()}" if private else ""
+ return f"/tmp/frame-ui-{os.getuid()}{suffix}-{tag}-%C" if MUX else None
def which(name, *extra):
@@ -254,9 +264,9 @@ def open_steam_link():
return "Steam Link isn't installed; opened its download page"
-def open_rdp(alias):
- """Remote desktop to the Frame's xrdp (user steamos)."""
- host = ssh_hostname(alias)
+def open_rdp(alias, host=None):
+ """Remote desktop to the Frame's xrdp (user steamos), at `host` or where the alias points."""
+ host = host or ssh_hostname(alias)
if MAC:
if subprocess.run(["open", "-a", "Windows App"], capture_output=True).returncode == 0:
return "Opened Windows App"
diff --git a/ui/frame_input_agent.py b/ui/frame_input_agent.py
new file mode 100644
index 0000000..fe437b9
--- /dev/null
+++ b/ui/frame_input_agent.py
@@ -0,0 +1,469 @@
+"""Keyboard and pointer for the Steam Frame. Frame Control's server runs this ON the Frame.
+
+It speaks KDE Connect's LAN protocol (version 7, as in KDE Connect 24.02) to the
+Frame's own kdeconnectd, as a phone would, and forwards remote-input events read
+from stdin: one JSON object (or list of them) per line, each a KDE Connect
+"mousepad" request body such as {"dx": 4, "dy": -2} or {"key": "hello"}.
+KDE Connect does the typing and clicking.
+
+KDE Connect isn't installed on the Frame. Frame Control ships Valve's build of it
+for the Frame and the few libraries the Frame lacks (frame/kdeconnect); the server
+copies them over the SSH connection and this unpacks them into
+~/.local/share/frame-control/kdeconnect: no root, no internet, and SteamOS
+updates leave it alone.
+
+argv: client id, client name, the folder holding the packages, and a JSON list
+of [file, sha256] naming them (see frame/kdeconnect/packages.json).
+
+Status goes to stdout, one JSON object per line:
+{"state": "installing" | "starting" | "pairing" | "ready" | "error" | "need-packages", ...}.
+
+Standard library only: this runs on the Frame's own Python.
+"""
+import fcntl
+import hashlib
+import json
+import os
+import selectors
+import shutil
+import signal
+import socket
+import ssl
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+BASE = Path.home() / ".local/share/frame-control/kdeconnect"
+ROOT = BASE / "root"
+BRIDGE = BASE / "bridge"
+STAMP = ".frame-control-packages" # in ROOT: which packages it was unpacked from
+PORT = int(os.environ.get("FRAME_INPUT_PORT", "1716"))
+UID = os.getuid()
+MOUSEPAD = "kdeconnect.mousepad.request"
+
+
+def say(state, **more):
+ print(json.dumps({"state": state, **more}), flush=True)
+
+
+def packet(kind, body):
+ return (json.dumps({"id": int(time.time() * 1000), "type": kind, "body": body}) + "\n").encode()
+
+
+# ---- KDE Connect on the Frame ------------------------------------------------
+
+SYSTEM_DAEMON = Path("/usr/lib/kdeconnectd")
+
+
+def stamp(packages):
+ """What ROOT/STAMP holds once these packages are unpacked (the server checks it too)."""
+ return "".join(f"{sha} {name}\n" for name, sha in packages)
+
+
+def installed(packages):
+ try:
+ return (ROOT / STAMP).read_text() == stamp(packages)
+ except OSError:
+ return False
+
+
+def sha256(path):
+ digest = hashlib.sha256()
+ with open(path, "rb") as f:
+ for block in iter(lambda: f.read(1 << 20), b""):
+ digest.update(block)
+ return digest.hexdigest()
+
+
+def install(folder, packages):
+ """Unpack the packages the server copied to `folder` into ROOT, checking each one first."""
+ if not packages:
+ raise RuntimeError("This copy of Frame Control doesn't include KDE Connect")
+ say("installing", message="Unpacking KDE Connect on the Frame")
+ stage = BASE / "root.new"
+ shutil.rmtree(stage, ignore_errors=True)
+ stage.mkdir(parents=True)
+ for name, sha in packages:
+ path = Path(folder) / name
+ if not path.is_file():
+ raise RuntimeError(f"{name} didn't reach the Frame")
+ if sha256(path) != sha:
+ raise RuntimeError(f"{name} arrived damaged (its SHA-256 doesn't match)")
+ if subprocess.run(["tar", "--zstd", "-xf", str(path), "-C", str(stage)], capture_output=True).returncode:
+ subprocess.run(["bsdtar", "-xf", str(path), "-C", str(stage)], check=True, capture_output=True)
+ (stage / STAMP).write_text(stamp(packages))
+ stop_daemon() # an older copy may still be running from ROOT
+ shutil.rmtree(ROOT, ignore_errors=True)
+ stage.rename(ROOT)
+
+
+def app_display():
+ """The X display that apps (not Steam's own VR menus) are on.
+
+ gamescope runs two Xwayland servers: on 2026-09-28 :0 held Steam's VR bar and
+ menus and ignored XTest pointer motion, while :1 held apps such as Chromium and
+ took it. Inferred to hold in general.
+ """
+ return ":1" if Path("/tmp/.X11-unix/X1").exists() else ":0"
+
+
+def daemon_env(daemon):
+ env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{UID}/bus",
+ XDG_RUNTIME_DIR=f"/run/user/{UID}", DISPLAY=app_display(), QT_QPA_PLATFORM="xcb")
+ if str(daemon).startswith(str(ROOT)):
+ env.update(LD_LIBRARY_PATH=str(ROOT / "usr/lib"), QT_PLUGIN_PATH=str(ROOT / "usr/lib/qt6/plugins"),
+ QML_IMPORT_PATH=str(ROOT / "usr/lib/qt6/qml"),
+ XDG_DATA_DIRS=f"{ROOT / 'usr/share'}:/usr/share")
+ return env
+
+
+def listening():
+ try:
+ socket.create_connection(("127.0.0.1", PORT), 1).close()
+ return True
+ except OSError:
+ return False
+
+
+def our_daemons():
+ """Process ids of the kdeconnectd that Frame Control installed (never a system one)."""
+ pids = []
+ for proc in Path("/proc").iterdir():
+ if proc.name.isdigit():
+ try:
+ if os.readlink(proc / "exe").startswith(str(ROOT) + "/"):
+ pids.append(int(proc.name))
+ except OSError:
+ pass
+ return pids
+
+
+def stop_daemon():
+ """Stop our kdeconnectd and wait until it's gone (so its port is closed too)."""
+ for sig, wait in ((signal.SIGTERM, 30), (signal.SIGKILL, 30)): # tenths of a second
+ for pid in our_daemons():
+ try:
+ os.kill(pid, sig)
+ except ProcessLookupError:
+ pass
+ for _ in range(wait):
+ if not our_daemons() and not listening():
+ return
+ time.sleep(0.1)
+
+
+class NeedPackages(Exception):
+ """This build of KDE Connect isn't unpacked and the server didn't send it (it thought it was there)."""
+
+
+def ensure_daemon(folder, packages):
+ """Start KDE Connect: the Frame's own if it ever has one, else ours, unpacked first if needed.
+
+ A copy from another Frame Control version that another device is using right
+ now is left running and used as it is (they speak the same protocol); it's
+ replaced the next time nobody is using it.
+ """
+ system = SYSTEM_DAEMON.exists()
+ if not system and not installed(packages) and not (listening() and our_daemons()):
+ if not folder or not Path(folder).is_dir():
+ raise NeedPackages()
+ install(folder, packages)
+ if listening():
+ return
+ BASE.mkdir(parents=True, exist_ok=True)
+ daemon = SYSTEM_DAEMON if system else ROOT / "usr/lib/kdeconnectd"
+ say("starting", message="Starting KDE Connect on the Frame")
+ log = open(BASE / "kdeconnectd.log", "ab")
+ # Its own session, so it outlives this connection and serves the next one.
+ subprocess.Popen([str(daemon)], env=daemon_env(daemon), cwd=str(Path.home()), stdin=subprocess.DEVNULL,
+ stdout=log, stderr=log, start_new_session=True)
+ for _ in range(40):
+ if listening():
+ return
+ time.sleep(0.25)
+ raise RuntimeError(f"KDE Connect didn't start; see {BASE / 'kdeconnectd.log'} on the Frame")
+
+
+def qdbus(device, method):
+ """Call a method on KDE Connect's D-Bus object for our device; its output, or None."""
+ env = dict(os.environ, DBUS_SESSION_BUS_ADDRESS=f"unix:path=/run/user/{UID}/bus")
+ try:
+ r = subprocess.run(["qdbus6", "org.kde.kdeconnect", f"/modules/kdeconnect/devices/{device}",
+ f"org.kde.kdeconnect.device.{method}"], capture_output=True, text=True, env=env, timeout=5)
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ return r.stdout.strip() if r.returncode == 0 else None
+
+
+# ---- our identity --------------------------------------------------------------
+
+def identity(client):
+ """A device id and certificate for this client, made once and kept (pairing is tied to them).
+
+ Each computer or phone gets its own: KDE Connect keeps one connection per device,
+ so a shared identity would make them knock each other off.
+ """
+ folder = BRIDGE / client
+ folder.mkdir(parents=True, exist_ok=True)
+ id_file, cert, key = folder / "id", folder / "cert.pem", folder / "key.pem"
+ if not (id_file.exists() and cert.exists() and key.exists()):
+ device = "framecontrol_" + os.urandom(12).hex() # KDE Connect wants 32-38 of [A-Za-z0-9_]
+ subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
+ "-nodes", "-days", "3650", "-subj", f"/O=KDE/OU=Kde connect/CN={device}",
+ "-keyout", str(key), "-out", str(cert)], check=True, capture_output=True)
+ os.chmod(key, 0o600)
+ id_file.write_text(device)
+ return id_file.read_text().strip(), cert, key
+
+
+# ---- the link ------------------------------------------------------------------
+
+class Link:
+ """One TLS connection to kdeconnectd, as a paired device that sends remote input."""
+
+ def __init__(self, device, cert, key, port=PORT, name="Frame Control"):
+ self.device, self.buf, self.keyboard = device, b"", None
+ raw = socket.create_connection(("127.0.0.1", port), 5)
+ raw.sendall(packet("kdeconnect.identity", {
+ "deviceId": device, "deviceName": name, "deviceType": "phone", "protocolVersion": 7,
+ "incomingCapabilities": [], "outgoingCapabilities": [MOUSEPAD], "tcpPort": port}))
+ # KDE Connect's rule: whoever opened the TCP connection is the TLS server.
+ ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ ctx.load_cert_chain(str(cert), str(key))
+ ctx.verify_mode = ssl.CERT_NONE # both sides are on this machine
+ self.sock = ctx.wrap_socket(raw, server_side=True)
+ self.sock.setblocking(False)
+
+ def send(self, body):
+ # Bounded: if KDE Connect stops reading, fail (and be restarted) rather than hang.
+ self.sock.settimeout(5)
+ try:
+ self.sock.sendall(packet(MOUSEPAD, body))
+ finally:
+ self.sock.setblocking(False)
+
+ def pair(self, paired, accept, timeout=15):
+ """Ask to pair and accept it on KDE Connect's side (we control both ends).
+
+ Only asks when not already paired: a pair request to a device that is
+ already paired makes KDE Connect unpair it.
+ """
+ if paired():
+ return
+ self.sock.settimeout(5)
+ self.sock.sendall(packet("kdeconnect.pair", {"pair": True}))
+ self.sock.setblocking(False)
+ end = time.time() + timeout
+ while time.time() < end:
+ accept()
+ self.read(0.5)
+ if paired():
+ return
+ raise RuntimeError("KDE Connect didn't accept the pairing")
+
+ def read(self, wait=0.0):
+ """Packets waiting from kdeconnectd; None once it has closed the connection."""
+ if wait:
+ sel = selectors.DefaultSelector()
+ sel.register(self.sock, selectors.EVENT_READ)
+ sel.select(wait)
+ sel.close()
+ try:
+ while True:
+ chunk = self.sock.recv(65536)
+ if not chunk:
+ return None
+ self.buf += chunk
+ except (ssl.SSLWantReadError, BlockingIOError):
+ pass
+ out = []
+ while b"\n" in self.buf:
+ line, self.buf = self.buf.split(b"\n", 1)
+ if line.strip():
+ p = json.loads(line)
+ if p.get("type") == "kdeconnect.mousepad.keyboardstate":
+ self.keyboard = bool(p.get("body", {}).get("state"))
+ out.append(p)
+ return out
+
+
+def events(line):
+ """The event bodies in one stdin line (an object or a list of objects)."""
+ try:
+ value = json.loads(line)
+ except ValueError:
+ return []
+ return [e for e in (value if isinstance(value, list) else [value]) if isinstance(e, dict) and e]
+
+
+def connect(device, cert, key, name):
+ link = Link(device, cert, key, name=name)
+ link.pair(lambda: qdbus(device, "isPaired") == "true", lambda: qdbus(device, "acceptPairing"))
+ link.read(0.5) # its hello, including whether it can type
+ return link
+
+
+def client_args():
+ """argv: a folder-safe id for the computer or phone, the name KDE Connect shows for it,
+ the folder holding the packages, and their [file, sha256] list."""
+ client = sys.argv[1] if len(sys.argv) > 1 else "default"
+ client = "".join(c for c in client if c.isalnum() or c in "-_")[:64] or "default"
+ name = (sys.argv[2] if len(sys.argv) > 2 else "")[:60].strip()
+ folder = os.path.expanduser(sys.argv[3]) if len(sys.argv) > 3 else ""
+ try:
+ packages = [(str(f), str(h)) for f, h in json.loads(sys.argv[4])] if len(sys.argv) > 4 else []
+ except (ValueError, TypeError):
+ packages = []
+ return client, f"Frame Control ({name})" if name else "Frame Control", folder, packages
+
+
+def main():
+ client, name, folder, packages = client_args()
+ # A dropped ssh (the Frame slept, the app quit) hangs up on us: exit through the
+ # clean-up below rather than dying on the spot.
+ for sig in (signal.SIGHUP, signal.SIGTERM):
+ signal.signal(sig, lambda *_: sys.exit(0))
+ BASE.mkdir(parents=True, exist_ok=True)
+ # Every agent holds this lock shared while it runs. The last one out gets it
+ # exclusively and stops KDE Connect, so it runs, and shows up on the network,
+ # only while something is using the keyboard and trackpad.
+ clients = open(BASE / "clients.lock", "w")
+ fcntl.flock(clients, fcntl.LOCK_SH)
+ try:
+ return run(client, name, folder, packages)
+ finally:
+ # Finish the clean-up even if a second hang-up or TERM arrives meanwhile.
+ for sig in (signal.SIGHUP, signal.SIGTERM):
+ signal.signal(sig, signal.SIG_IGN)
+ fcntl.flock(clients, fcntl.LOCK_UN)
+ try:
+ fcntl.flock(clients, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except OSError:
+ pass # another device is still using it
+ else:
+ with daemon_lock():
+ stop_daemon()
+
+
+def tidy_incoming(folder):
+ """Remove this start's copy of the packages, and others nobody is using.
+
+ Another copy goes only if no agent holds its .in-use lock and it's over an
+ hour old (so not one a server is still copying, before its agent starts).
+ """
+ incoming = BASE / "incoming"
+ if folder.startswith(str(incoming) + "/"):
+ shutil.rmtree(folder, ignore_errors=True)
+ try:
+ others = list(incoming.iterdir())
+ except OSError:
+ return
+ for other in others:
+ try:
+ if time.time() - other.stat().st_mtime < 3600:
+ continue
+ with open(other / ".in-use", "a") as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ shutil.rmtree(other, ignore_errors=True)
+ except OSError:
+ pass # in use, or already gone
+ try:
+ incoming.rmdir()
+ except OSError:
+ pass # another start's copy is still there
+
+
+def hold_incoming(folder):
+ """Mark this start's copy as in use (tidy_incoming leaves it alone); the lock lasts as long as the file."""
+ if not folder.startswith(str(BASE / "incoming") + "/"):
+ return None
+ try:
+ lock = open(Path(folder) / ".in-use", "a")
+ fcntl.flock(lock, fcntl.LOCK_SH)
+ return lock
+ except OSError:
+ return None
+
+
+class daemon_lock:
+ """Installing, starting and restarting KDE Connect happen one agent at a time."""
+
+ def __enter__(self):
+ self.file = open(BASE / "daemon.lock", "w")
+ fcntl.flock(self.file, fcntl.LOCK_EX)
+
+ def __exit__(self, *_):
+ self.file.close()
+
+
+def run(client, name, folder, packages):
+ """Set up, pair and forward events. This start's copy of the packages stays
+ until it ends, however it ends: restarting KDE Connect may need to unpack it."""
+ held = hold_incoming(folder)
+ try:
+ return serve(client, name, folder, packages)
+ finally:
+ if held:
+ held.close()
+ tidy_incoming(folder)
+
+
+def serve(client, name, folder, packages):
+ try:
+ with daemon_lock():
+ ensure_daemon(folder, packages)
+ device, cert, key = identity(client)
+ say("pairing")
+ seen = our_daemons()
+ try:
+ link = connect(device, cert, key, name)
+ except (OSError, RuntimeError):
+ if not seen:
+ raise
+ # Ours, but not answering (KDE Connect 24.02 can hang, for one after
+ # unpairing a device that's offline): start it afresh, once. If another
+ # agent already replaced it, just use the new one.
+ say("starting", message="Restarting KDE Connect on the Frame")
+ with daemon_lock():
+ if set(our_daemons()) & set(seen):
+ stop_daemon()
+ ensure_daemon(folder, packages)
+ link = connect(device, cert, key, name)
+ except NeedPackages:
+ say("need-packages") # the server copies them and starts again
+ return 1
+ except (OSError, RuntimeError, subprocess.SubprocessError) as e:
+ say("error", message=str(e))
+ return 1
+ say("ready", keyboard=link.keyboard is not False)
+ stdin, pending = sys.stdin.fileno(), b""
+ sel = selectors.DefaultSelector()
+ sel.register(stdin, selectors.EVENT_READ)
+ sel.register(link.sock, selectors.EVENT_READ)
+ while True:
+ for key_, _ in sel.select(30):
+ if key_.fileobj == stdin:
+ chunk = os.read(stdin, 65536) # raw reads: a buffered readline could strand lines select can't see
+ if not chunk: # the server went away
+ return 0
+ *lines, pending = (pending + chunk).split(b"\n")
+ try:
+ for line in lines:
+ for body in events(line):
+ link.send(body)
+ except OSError as e:
+ say("error", message=f"Lost KDE Connect: {e}")
+ return 1
+ else:
+ packets = link.read()
+ if packets is None:
+ say("error", message="KDE Connect closed the connection")
+ return 1
+ if any(p.get("type") == "kdeconnect.pair" and not p.get("body", {}).get("pair") for p in packets):
+ say("error", message="KDE Connect unpaired Frame Control")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/ui/frame_link.py b/ui/frame_link.py
new file mode 100644
index 0000000..51979c6
--- /dev/null
+++ b/ui/frame_link.py
@@ -0,0 +1,1232 @@
+"""The connection to the active headset: which address to use, and every step of getting there.
+
+A background thread (Link) keeps one SSH connection to the active headset open
+and publishes what it's doing, stage by stage, for the page's connection pill:
+
+ 1. network checking this computer's network (gateway, Wi-Fi, Tailscale)
+ 2. find finding the headset: every address probed on port 22 at once
+ 3. ssh opening SSH to the address that answered
+ 4. identity checking the headset's identity (its pinned host key)
+ 5. login logging in as the device's user
+ then connected (network, address, round trip), or failed at a stage with a
+ plain reason and a countdown to the next try.
+
+Addresses go in the order frame_devices.order_addresses gives. All are probed
+at once; the best-ranked one that answers wins, waiting a moment (PREFER) for a
+better-ranked address that's still trying, happy-eyeballs style. If SSH to the
+winner fails in a way another address could fix (a different device answered,
+the link dropped), the next one that answered is tried.
+
+The server hands in `apply(alias, host_opts)`, which points every ssh, scp and
+rsync it runs at the alias with `-o HostName=` and friends, so they all
+follow. Where ssh can share one connection (not Windows), the master connection
+lives here; it reconnects when it dies, when this computer changes networks, and
+when the page asks.
+
+Python stdlib only. Runs on this computer, never on the Frame.
+"""
+import copy
+import hashlib
+import ipaddress
+import queue
+import re
+import socket
+import subprocess
+import threading
+import time
+
+import frame_devices
+import frame_host
+import frame_network
+
+PROBE_TIMEOUT = 4 # seconds for a TCP answer on port 22
+RESOLVE_GRACE = 6 # ...after however long the name lookup took, up to this much
+PREFER = 0.35 # how long an answer waits for a better-ranked address still trying
+HANDSHAKE_TIMEOUT = 25
+TICK = 2 # the loop's heartbeat
+NETWORK_EVERY = 5 # how often the network fingerprint is read
+TAILSCALE_EVERY = 30
+RETRY = (5, 10, 20, 30) # seconds before automatic retries after a failure
+REQUEST_GAP = 5 # a request may start a new attempt this long after the last one
+
+STAGES = [("network", "Checking this computer's network"), ("find", "Finding the headset"),
+ ("ssh", "Opening SSH"), ("identity", "Checking the headset's identity"),
+ ("login", "Logging in")]
+
+# What ssh -v prints at each step (OpenSSH on macOS, Linux and Windows).
+CONNECTING = re.compile(r"Connecting to (\S+) \[([^\]]+)\] port (\d+)")
+ESTABLISHED = re.compile(r"Connection established")
+HOSTKEY = re.compile(r"Server host key: (\S+) (\S+)")
+KNOWN = re.compile(r"is known and matches")
+ADDED = re.compile(r"Permanently added")
+CHANGED = re.compile(r"REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed")
+UNKNOWN = re.compile(r"No \S+ host key is known for")
+AUTH_START = re.compile(r"Authentications that can continue|Next authentication method")
+AUTHED = re.compile(r"Authenticated to |Authentication succeeded")
+DENIED = re.compile(r"Permission denied")
+
+
+def now():
+ return time.time()
+
+
+def ssh_g(alias):
+ """(hostname, port, user, proxied) from `ssh -G ALIAS`, for a headset that's only an
+ ssh alias. proxied: it goes through ProxyJump or ProxyCommand, so only ssh can reach it."""
+ try:
+ out = subprocess.run(["ssh", "-G", alias], capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ timeout=10).stdout
+ except (OSError, subprocess.TimeoutExpired):
+ out = ""
+ got = {}
+ for line in out.splitlines():
+ k, _, v = line.partition(" ")
+ if k in ("hostname", "port", "user", "proxyjump", "proxycommand") and k not in got:
+ got[k] = v.strip()
+ port = int(got["port"]) if got.get("port", "").isdigit() else 22
+ proxied = any(got.get(k) not in (None, "", "none") for k in ("proxyjump", "proxycommand"))
+ return got.get("hostname") or alias, port, got.get("user"), proxied
+
+
+def probe(host, port, timeout=PROBE_TIMEOUT, update=None):
+ """Try a TCP connection to host:port. -> {"state", "detail", "ip", "rtt_ms"}.
+
+ state: answered, unresolved, timeout, refused, unreachable or error. update(fields)
+ reports progress (resolving, trying) as it happens."""
+ update = update or (lambda **_: None)
+ update(state="resolving", detail="Looking up the name")
+ try:
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
+ except (socket.gaierror, UnicodeError, OSError) as e:
+ return {"state": "unresolved", "detail": "Can't find this name on the network", "error": str(e)}
+ # The time out is for connecting: macOS can take 5 s to look up a .local name
+ # (it waits for an IPv6 answer that never comes), which says nothing about the headset.
+ deadline = now() + timeout
+ last = None
+ infos = infos[:4]
+ for n, (family, kind, proto, _, addr) in enumerate(infos):
+ ip = addr[0]
+ if family == socket.AF_INET6 and len(addr) > 3 and addr[3] and "%" not in ip:
+ try: # a link-local IPv6 address only works with its interface
+ ip = f"{ip}%{socket.if_indextoname(addr[3])}"
+ except (OSError, AttributeError):
+ pass
+ left = deadline - now()
+ if left <= 0:
+ break
+ update(state="trying", detail=f"Trying {ip}", ip=ip)
+ s = socket.socket(family, kind, proto)
+ # Share the time out, so one address that never answers (a dead IPv6 route,
+ # say) leaves the others their turn.
+ s.settimeout(left / (len(infos) - n))
+ t0 = time.monotonic()
+ try:
+ s.connect(addr)
+ rtt = round((time.monotonic() - t0) * 1000, 1)
+ return {"state": "answered", "detail": f"Answered in {rtt:g} ms", "ip": ip, "rtt_ms": rtt}
+ except socket.timeout:
+ last = {"state": "timeout", "detail": "No answer", "ip": ip}
+ except ConnectionRefusedError:
+ last = {"state": "refused", "detail": "Refused: SSH isn't on at this address", "ip": ip}
+ except OSError as e:
+ last = {"state": "unreachable", "detail": f"Can't get there ({e.strerror or e})", "ip": ip}
+ finally:
+ s.close()
+ return last or {"state": "timeout", "detail": "No answer"}
+
+
+def ssh_target(host, ip):
+ """Where ssh should go for an address whose probe answered from `ip`: that IP, so ssh
+ doesn't look the name up again and try an address that didn't answer."""
+ try:
+ ipaddress.ip_address((ip or "").split("%")[0])
+ return ip
+ except ValueError:
+ return host
+
+
+def probe_raw(host, port, result):
+ """ssh's own wording for a failed probe, so the server's UNREACHABLE table explains it."""
+ return {"unresolved": f"ssh: Could not resolve hostname {host}: not found",
+ "refused": f"ssh: connect to host {host} port {port}: Connection refused",
+ "unreachable": f"ssh: connect to host {host} port {port}: No route to host",
+ }.get(result["state"], f"ssh: connect to host {host} port {port}: Operation timed out")
+
+
+class Link:
+ def __init__(self, registry, *, env_alias, mux_base, control, apply, explain):
+ self.reg = registry
+ self.override = env_alias # FRAME_ALIAS, if set: the headset this server starts on
+ self.work_lock = threading.Lock() # the server's: held, no install starts (see server.working)
+ self.work = lambda: 0 # how many installs are running
+ self.deferred = False # a login change from ~/.ssh/config waiting for them
+ self.session_alias = env_alias # ...and stays selectable after switching away
+ self.mux_base = list(mux_base) # ["ssh", "-o", "BatchMode=yes", ControlPath...]
+ self.control = control # ControlPath, or None where ssh can't share connections
+ self.apply = apply # apply(alias, host_opts): point every ssh command at the headset
+ self.explain = explain # ssh error text -> plain reason, or None
+ self.cond = threading.Condition()
+ self.version = 0
+ self.stopped = False
+ self.kicks = [] # reasons someone asked for a (re)connect
+ self.busy = False # the loop is handling kicks
+ self.state = {"phase": "idle", "reason": None, "device": None, "network": None, "stages": [],
+ "probes": [], "via": None, "error": None, "retry_at": None, "attempt": 0,
+ "started": None, "finished": None, "tests": {}, "devices_rev": 0}
+ self.master = None # the ssh ControlMaster process, if we started it
+ self.opts = [] # host options of the current connection
+ self.alias = None
+ self.fails = 0
+ self.last_fp = None
+ self.last_attempt = 0
+ self.config_mtime = None
+ self.thread = None
+ self.attempt_gen = 0
+ self.pending = None # an ssh handshake still running
+ self.gen = 0 # bumped when the headset or its login changes: older attempts are void
+ self.route_lock = threading.Lock()
+ self.routed = None # the device id every ssh command points at
+ self.routed_device = None
+
+ # ---- publishing ----
+ def publish(self, **fields):
+ with self.cond:
+ self.state.update(fields)
+ self.version += 1
+ self.cond.notify_all()
+
+ def snapshot(self):
+ with self.cond:
+ snap = copy.deepcopy(self.state)
+ snap["version"] = self.version
+ snap["now"] = now()
+ return snap
+
+ def wait(self, version, timeout):
+ """The state once its version passes `version`, or None after `timeout` seconds."""
+ with self.cond:
+ if not self.cond.wait_for(lambda: self.version > version or self.stopped, timeout):
+ return None
+ return self.snapshot()
+
+ def stage(self, sid, state, detail=None):
+ """Move one stage along (pending -> active -> done or failed) and publish."""
+ with self.cond:
+ for s in self.state["stages"]:
+ if s["id"] == sid:
+ if state == "active" and s["state"] != "active":
+ s["started"] = now()
+ if state in ("done", "failed", "skipped"):
+ s["ended"] = now()
+ s["started"] = s["started"] or s["ended"]
+ s["state"] = state
+ if detail is not None:
+ s["detail"] = detail
+ self.version += 1
+ self.cond.notify_all()
+
+ def probe_update(self, index, attempt=None, **fields):
+ with self.cond:
+ if attempt is not None and attempt != self.state["attempt"]:
+ return # a probe from an earlier attempt, still finishing: not this one's row
+ if index < len(self.state["probes"]):
+ self.state["probes"][index].update(fields)
+ self.version += 1
+ self.cond.notify_all()
+
+ def devices_changed(self):
+ with self.cond:
+ self.state["devices_rev"] += 1
+ self.version += 1
+ self.cond.notify_all()
+
+ # ---- control from the server ----
+ def start(self):
+ # Route to the saved headset before the server takes requests: until the connector
+ # has run, commands (an upload by scp, say) would otherwise go to the default alias.
+ device = self.active_device()
+ with self.route_lock:
+ self.apply(device["alias"], self.first_route(device))
+ self.thread = threading.Thread(target=self.run, name="frame-link", daemon=True)
+ self.thread.start()
+
+ def kick(self, reason):
+ with self.cond:
+ self.kicks.append(reason)
+ self.cond.notify_all()
+
+ def stop(self):
+ with self.cond:
+ self.stopped = True
+ self.cond.notify_all()
+ self.close_master()
+ if self.thread:
+ self.thread.join(5) # an attempt in progress notices `stopped` and ends
+ self.close_master()
+
+ def alive(self):
+ if self.state["phase"] != "connected" or self.kicks or self.busy:
+ return False # a reconnect is queued or starting: don't begin anything on this connection
+ if not self.control:
+ return True
+ return self.master is None or self.master.poll() is None
+
+ def ensure(self, wait=20):
+ """Called before a command: make sure a connection is up, or being tried.
+
+ Waits (up to `wait` s) for an attempt already running, or starts one if the
+ last ended a while ago. Never raises: if the headset can't be reached, the
+ command runs anyway and fails with ssh's own error, as it always has."""
+ with self.cond:
+ if self.stopped or self.alive():
+ return
+ if self.state["phase"] != "connecting" and not self.kicks and now() - self.last_attempt > REQUEST_GAP:
+ self.kicks.append("request")
+ self.cond.wait_for(lambda: self.stopped or (not self.kicks and not self.busy and
+ self.state["phase"] != "connecting"), wait)
+
+ def use(self, device_id):
+ """Switch to another headset: one from the registry, or back to FRAME_ALIAS."""
+ if self.session_alias and device_id == self.bare(self.session_alias)["id"] \
+ and not self.reg.by_alias(self.session_alias):
+ self.override = self.session_alias
+ else:
+ self.reg.set_active(device_id)
+ self.override = None
+ self.invalidate()
+
+ def invalidate(self):
+ """The headset in use, or how to log in to it, changed: from now on commands go to
+ the one now selected (never the last one), any attempt still running is void,
+ and ensure() waits for the connector to reach it."""
+ device = self.active_device()
+ with self.route_lock:
+ self.gen += 1
+ self.apply(device["alias"], self.first_route(device))
+ self.routed = None # the next attempt routes again
+ with self.cond:
+ # Say so at once: the page clears the old headset's panels when the device changes.
+ self.state.update(phase="connecting", device=self.public_device(device), via=None, error=None,
+ retry_at=None, probes=[], stages=[])
+ self.kicks.append("switch")
+ self.version += 1
+ self.cond.notify_all()
+ self.devices_changed()
+
+ def named_route(self):
+ """(alias, options) for a terminal window: like every command's, but with the
+ address by name, not the IP it answered from. A zone's % can't be passed through
+ Windows' console, and ssh resolves the name itself."""
+ device = self.active_device()
+ routed = self.routed_device
+ if self.routed is not None and routed and routed["alias"] == device["alias"]:
+ device = routed # as every command has it now (a frozen route, a login change deferred)
+ via = self.state.get("via") if self.state.get("phase") == "connected" else None
+ host = via["host"] if via else (device["addresses"][0]["host"] if device.get("addresses") else None)
+ return device["alias"], self.host_opts(device, host)
+
+ def first_route(self, device):
+ """Where commands go before any address has answered: the first one, with the
+ headset's own pinned identity, so nothing reaches another device meanwhile."""
+ return self.host_opts(device, device["addresses"][0]["host"] if device["addresses"] else None)
+
+ @staticmethod
+ def route_key(device):
+ return device["id"], device.get("user"), device.get("port"), tuple(device.get("frozen") or ())
+
+ def lost(self, message, gen=None):
+ """A command couldn't reach the headset (Windows has no master to watch). `gen`:
+ the route it was sent on; one to a headset since switched away from says nothing
+ about this one."""
+ if gen is not None and gen != self.gen:
+ return
+ if self.state["phase"] == "connected":
+ self.kick(f"lost: {message}")
+
+ # ---- the device this server talks to ----
+ def active_device(self):
+ """The active headset from the registry, or a stand-in for a bare ssh alias."""
+ if self.override:
+ return self.reg.by_alias(self.override) or self.bare(self.override)
+ want = self.reg.active()
+ if want:
+ try:
+ return self.reg.get(want)
+ except frame_devices.DeviceError:
+ # Removed (by another server). Mid-install, fail closed: the rest of the
+ # install, or its clean-up, mustn't land on whichever headset comes next.
+ if self.work():
+ return self.NONE
+ devices = self.reg.devices()
+ if devices:
+ return devices[0]
+ if self.reg.emptied():
+ return self.NONE # every headset was removed: reach nothing until one is added
+ return self.bare("frame") # never set up here, or set up before the registry existed
+
+ NONE = {"id": "none", "name": "No headset", "alias": "frame-control-no-headset", "user": None, "port": None,
+ "addresses": [], "transient": True, "none": True, "identity_files": []}
+
+ @staticmethod
+ def bare(alias):
+ """A headset that's only an ssh alias (no Set Up Connection block): ssh's config decides."""
+ return {"id": f"alias-{alias}", "name": alias, "alias": alias, "user": None, "port": None,
+ "addresses": [], "transient": True, "identity_files": []}
+
+ @staticmethod
+ def control_tag(device):
+ """A short, path-safe name for the headset's ControlPath: its id, or for a bare
+ alias a hash of it (an alias can be too long for a socket path)."""
+ if device.get("transient"):
+ return "a" + hashlib.sha1(device["alias"].encode()).hexdigest()[:8]
+ return device["id"]
+
+ def host_opts(self, device, host):
+ """What every ssh command adds to reach DEVICE at HOST."""
+ if device.get("none"):
+ return ["-o", "HostName=no-headset.invalid"] # fails at once, with ssh's own "can't resolve"
+ # Each headset its own shared connection (see frame_host.control_path).
+ mux = ["-o", f"ControlPath={frame_host.control_path(self.control_tag(device))}"] if self.control else []
+ if device.get("transient"):
+ return [*mux, *(device.get("frozen") or [])] # what ~/.ssh/config said when it was routed
+ if not host: # a headset with no addresses: reach nothing, not whatever ~/.ssh/config says
+ return ["-o", "HostName=no-address.invalid"]
+ # StrictHostKeyChecking=yes: whatever ~/.ssh/config says for Host *, every command
+ # checks the headset's pinned key (only the connector's first handshake may save one).
+ return [*mux, "-o", "StrictHostKeyChecking=yes", "-o", f"HostName={frame_devices.ssh_host(host)}",
+ "-o", f"HostKeyAlias={frame_devices.host_key_alias(device['id'])}",
+ "-o", f"UserKnownHostsFile={frame_devices.known_hosts_opt(device['id'])}", "-o", "HashKnownHosts=no",
+ "-o", f"User={device['user']}", "-o", f"Port={device['port']}"]
+
+ def public_device(self, d):
+ return {k: d.get(k) for k in ("id", "name", "alias", "user", "port", "transient")}
+
+ # ---- the loop ----
+ def run(self):
+ self.kick("start")
+ last_net = last_ts = 0
+ while True:
+ with self.cond:
+ self.cond.wait_for(lambda: self.stopped or self.kicks, TICK)
+ if self.stopped:
+ return
+ reasons, self.kicks = self.kicks, []
+ self.busy = bool(reasons)
+ try:
+ t = now()
+ if t - last_net >= NETWORK_EVERY:
+ last_net = t
+ fp = frame_network.fingerprint()
+ if self.last_fp is not None and fp[::2] != self.last_fp[::2]:
+ reasons.append("network")
+ self.last_fp = fp
+ self.watch_config()
+ if self.state["phase"] == "connected" and self.control and self.master is None \
+ and not self.check(self.opts):
+ reasons.append("dropped") # a master we found open, not one we started
+ if t - last_ts >= TAILSCALE_EVERY and self.state["network"] and not reasons:
+ last_ts = t
+ self.refresh_network()
+ phase = self.state["phase"]
+ if phase == "connected" and not self.alive():
+ reasons.append("dropped")
+ if phase == "failed" and self.state["retry_at"] and now() >= self.state["retry_at"]:
+ reasons.append("retry")
+ if reasons:
+ self.connect(reasons)
+ except Exception as e: # keep the loop alive whatever happens; say what went wrong
+ self.publish(phase="failed", error={"stage": "network", "message": f"{type(e).__name__}: {e}",
+ "raw": str(e)}, retry_at=now() + RETRY[-1])
+ finally:
+ with self.cond:
+ self.busy = False
+ self.cond.notify_all()
+
+ def watch_config(self):
+ """Set Up Connection may have added a headset or found a new address: pick it up."""
+ try:
+ mtime = frame_devices.ssh_config().stat().st_mtime
+ except OSError:
+ mtime = None
+ if mtime != self.config_mtime:
+ self.config_mtime = mtime
+ before = self.active_device()
+ if before.get("transient") and not before.get("none") and self.routed is not None:
+ # A bare alias is in use: a headset set up now doesn't take over by itself.
+ self.override = self.override or before["alias"]
+ self.session_alias = self.session_alias or before["alias"] # and stays on the list
+ if self.reg.sync_from_config():
+ self.devices_changed()
+ after = self.active_device()
+ if (before.get("user"), before.get("port")) != (after.get("user"), after.get("port")):
+ self.deferred = True # Set Up Connection changed the active headset's login
+ if self.deferred:
+ with self.work_lock: # not while an install runs: it reads the route step by step
+ if not self.work():
+ self.deferred = False
+ self.invalidate()
+
+ def refresh_network(self):
+ net = frame_network.current_network(self.last_fp)
+ self.reg.record_network(net)
+ net["name"] = self.reg.network_name(net)
+ self.publish(network=net)
+
+ # ---- one attempt ----
+ def connect(self, reasons):
+ why = self.describe(reasons)
+ self.last_attempt = now()
+ self.close_master()
+ with self.work_lock, self.route_lock:
+ gen = self.attempt_gen = self.gen
+ device = self.active_device()
+ if device.get("transient") and not device.get("none") and "frozen" not in device:
+ # A bare alias: pin down where ~/.ssh/config sends it now, so an edit to that
+ # file can't move the commands of an install that's running.
+ h, p, u, proxied = ssh_g(device["alias"])
+ device = dict(device, user=device.get("user") or u, port=p, frozen_host=h, proxied=proxied, frozen=[
+ "-o", f"HostName={frame_devices.ssh_host(h)}", "-o", f"Port={p}",
+ *(["-o", f"User={u}"] if u else [])])
+ if self.routed and self.routed_device and self.route_key(device) != self.routed and self.work():
+ # While an install runs, nothing moves it: not Set Up Connection changing this
+ # headset in ~/.ssh/config, nor another Frame Control server adding, choosing
+ # or removing headsets in devices.json. (Switching here is refused meanwhile.)
+ # Reconnect as it started; the change applies once it's done (see watch_config).
+ device = self.routed_device
+ self.deferred = True
+ if self.route_key(device) != self.routed:
+ # Another headset, or a new user or port: nothing may go on using the old
+ # route, even if this attempt fails.
+ self.alias, self.opts = device["alias"], self.first_route(device)
+ self.apply(self.alias, self.opts)
+ self.routed = self.route_key(device)
+ self.routed_device = device
+ with self.cond:
+ self.state.update(phase="connecting", reason=why, device=self.public_device(device), via=None,
+ error=None, retry_at=None, attempt=self.state["attempt"] + 1, started=now(),
+ finished=None, probes=[],
+ stages=[{"id": i, "label": label, "state": "pending", "detail": "",
+ "started": None, "ended": None} for i, label in STAGES])
+ self.version += 1
+ self.cond.notify_all()
+ ok = False
+ try:
+ ok = self.attempt(device)
+ finally:
+ self.finish(gen, ok, device)
+
+ def finish(self, gen, ok, device):
+ """Publish how an attempt ended (connected, or failed with a retry time)."""
+ with self.cond:
+ if gen != self.gen:
+ # The headset changed meanwhile: this attempt's result is about the
+ # old one. Leave "connecting"; the queued switch starts the next.
+ self.state["phase"] = "connecting"
+ self.version += 1
+ self.cond.notify_all()
+ ok = None
+ if ok is None:
+ self.close_master()
+ return
+ with self.cond:
+ self.state["finished"] = now()
+ if ok:
+ self.fails = 0
+ self.state.update(phase="connected", retry_at=None, error=None)
+ else:
+ self.fails += 1
+ self.state.update(phase="failed", retry_at=None if device.get("none") or not (device.get("transient") or device["addresses"]) else
+ now() + RETRY[min(self.fails, len(RETRY)) - 1])
+ if not self.state["error"]:
+ self.state["error"] = {"stage": "find", "message": "Couldn't connect", "raw": ""}
+ self.version += 1
+ self.cond.notify_all()
+
+ @staticmethod
+ def describe(reasons):
+ for r in reasons:
+ if r == "network":
+ return "This computer changed networks"
+ if r == "dropped" or r.startswith("lost"):
+ return "The connection dropped"
+ if r == "switch":
+ return "Switched headset"
+ if "retry" in reasons:
+ return "Trying again"
+ if "start" in reasons:
+ return "Starting up"
+ return "Connecting"
+
+ def fail(self, sid, message, raw=""):
+ self.stage(sid, "failed", message)
+ with self.cond:
+ self.state["error"] = {"stage": sid, "message": message, "raw": raw}
+
+ def attempt(self, device):
+ if device.get("none"):
+ self.fail("find", "No headset is set up. Add one on the Devices tab.")
+ return False
+ if not device.get("transient") and not device["addresses"]:
+ self.fail("find", f"{device['name']} has no addresses. Add one on the Devices tab.")
+ return False
+ # 1. this computer's network
+ self.stage("network", "active")
+ net = frame_network.current_network(self.last_fp)
+ self.reg.record_network(net)
+ net["name"] = self.reg.network_name(net)
+ ts = net.get("tailscale") or {}
+ self.publish(network=net)
+ bits = [net["name"]]
+ if net.get("local_ip"):
+ bits.append(f"this computer is {net['local_ip']}")
+ bits.append("Tailscale on" if ts.get("up") else "Tailscale off" if ts.get("installed") else "no Tailscale")
+ self.stage("network", "done" if net.get("gateway") or ts.get("up") else "failed", " · ".join(bits))
+ if not net.get("gateway") and not ts.get("up"):
+ with self.cond:
+ self.state["error"] = {"stage": "network", "raw": "",
+ "message": "This computer isn't connected to a network."}
+ # Keep going anyway: a headset on a direct link or loopback could still answer.
+
+ # 2. find the headset
+ self.stage("find", "active")
+ port = device.get("port") or 22
+ if device.get("transient"):
+ # Where the route was pinned (connect), so the probe checks what commands use.
+ if "frozen_host" in device:
+ host, port, user, proxied = device["frozen_host"], device["port"], device.get("user"), device["proxied"]
+ else:
+ host, port, user, proxied = ssh_g(device["alias"])
+ if user and not device.get("user"):
+ device["user"] = user
+ a = {"host": host, "kind": frame_network.guess_kind(host), "label": "from ~/.ssh/config"}
+ if proxied:
+ # Reached through a jump host: only ssh itself can find it.
+ with self.cond:
+ self.state["probes"] = [dict(a, why="through a jump host", state="answered", ip=None, rtt_ms=None,
+ detail="ssh's ProxyJump or ProxyCommand connects")]
+ self.stage("find", "done", f"{device['alias']} goes through a jump host; ssh finds it")
+ return self.handshake(device, a, {"ip": None, "rtt_ms": None}, device.get("user") or user) == "ok" \
+ and self.finish_bare(a, net)
+ ranked = [(a, "from ~/.ssh/config")]
+ else:
+ ranked = frame_devices.order_addresses(device["addresses"], net.get("id"), bool(ts.get("up")))
+ if ssh_g(device["alias"])[3]:
+ # ~/.ssh/config sends this alias through a jump host: a direct probe says
+ # nothing, so let ssh (through the jump host) try each address in turn.
+ with self.cond:
+ self.state["probes"] = [dict(a, why=why, state="waiting", detail="Through a jump host", ip=None,
+ rtt_ms=None, label=a.get("label") or "") for a, why in ranked]
+ self.stage("find", "done", f"{device['alias']} goes through a jump host; ssh finds it")
+ for i, (a, why) in enumerate(ranked):
+ if i:
+ for sid in ("ssh", "identity", "login"):
+ self.stage(sid, "pending", "")
+ outcome = self.handshake(device, a, {"ip": None, "rtt_ms": None}, device.get("user"))
+ if outcome == "ok":
+ self.probe_update(i, state="answered", detail="Reached through the jump host")
+ self.publish(via={"host": a["host"], "kind": a["kind"], "ip": None, "rtt_ms": None,
+ "why": "through a jump host", "network": net.get("id"),
+ "network_name": net["name"]})
+ self.learn(device, a["host"], net, None)
+ return True
+ with self.cond:
+ why_not = (self.state["error"] or {}).get("message") or "SSH failed"
+ self.probe_update(i, state="sshfailed", detail=why_not)
+ if outcome != "next":
+ return False
+ return False
+ with self.cond:
+ self.state["probes"] = [{"host": a["host"], "kind": a["kind"], "label": a.get("label") or "",
+ "why": why, "state": "waiting", "detail": "Waiting", "ip": None,
+ "rtt_ms": None} for a, why in ranked]
+ self.stage("find", "active", f"Trying {len(ranked)} address{'es' * (len(ranked) != 1)} at once")
+ results = [None] * len(ranked)
+ done = threading.Condition()
+
+ attempt_no = self.state["attempt"]
+
+ def run_probe(i, host):
+ res = probe(host, port, update=lambda **f: self.probe_update(i, attempt_no, **f))
+ res.setdefault("ip", None)
+ res.setdefault("rtt_ms", None)
+ self.probe_update(i, attempt_no, **{k: res[k] for k in ("state", "detail", "ip", "rtt_ms")})
+ with done:
+ if results[i] is None: # not already given up on
+ results[i] = dict(res, t=time.monotonic())
+ done.notify_all()
+
+ for i, (a, _) in enumerate(ranked):
+ threading.Thread(target=run_probe, args=(i, a["host"]), daemon=True).start()
+
+ tried = set()
+ user = device.get("user") or "the headset's user"
+ deadline = time.monotonic() + PROBE_TIMEOUT + RESOLVE_GRACE
+ while True:
+ pick = self.pick(results, tried, done, deadline)
+ if pick is None:
+ break
+ if tried: # another address may do better (a different device answered, or it dropped)
+ for sid in ("ssh", "identity", "login"):
+ self.stage(sid, "pending", "")
+ tried.add(pick)
+ a = ranked[pick][0]
+ self.stage("find", "done", f"{a['host']} answered in {results[pick]['rtt_ms']:g} ms")
+ outcome = self.handshake(device, a, results[pick], user)
+ if outcome == "ok":
+ via = {"host": a["host"], "kind": a["kind"], "ip": results[pick]["ip"],
+ "rtt_ms": results[pick]["rtt_ms"], "why": ranked[pick][1], "network": net.get("id"),
+ "network_name": net["name"]}
+ self.publish(via=via)
+ self.learn(device, a["host"], net, results[pick]["rtt_ms"])
+ return True
+ with self.cond:
+ why_not = (self.state["error"] or {}).get("message") or "SSH failed"
+ self.probe_update(pick, state="sshfailed", detail=why_not)
+ if outcome != "next":
+ return False
+ if tried:
+ return False # the last handshake already said why
+ # Nothing answered: explain with the most useful failure.
+ with self.cond:
+ for row in self.state["probes"]:
+ if row["state"] in ("waiting", "resolving", "trying"):
+ row.update(state="timeout", detail="No answer in time")
+ states = [r["state"] for r in results if r]
+ worst = next((s for s in ("refused", "timeout", "unreachable", "unresolved") if s in states), "timeout")
+ i = states.index(worst) if worst in states else 0
+ raw = probe_raw(ranked[i][0]["host"], port, results[i] or {"state": worst})
+ message = self.explain(raw) or "The Frame isn't answering."
+ self.fail("find", message, raw)
+ return False
+
+ def finish_bare(self, a, net):
+ self.publish(via={"host": a["host"], "kind": a["kind"], "ip": None, "rtt_ms": None,
+ "why": "through a jump host", "network": net.get("id"), "network_name": net["name"]})
+ return True
+
+ @staticmethod
+ def pick(results, tried, done, deadline=None):
+ """The next address to use: the best-ranked answer once every better-ranked
+ address has failed, or once it has waited PREFER seconds for them. None when
+ nothing (else) answered. Probes still going at `deadline` (a name lookup can
+ take longer than the connect timeout) count as no answer."""
+ deadline = deadline or time.monotonic() + PROBE_TIMEOUT + RESOLVE_GRACE
+ with done:
+ while True:
+ if time.monotonic() >= deadline:
+ for i, r in enumerate(results):
+ if r is None:
+ results[i] = {"state": "timeout", "detail": "No answer", "ip": None, "rtt_ms": None,
+ "t": time.monotonic()}
+ answered = [i for i, r in enumerate(results) if r and r["state"] == "answered" and i not in tried]
+ pending = [i for i, r in enumerate(results) if r is None]
+ if answered:
+ best = answered[0]
+ better = [i for i in pending if i < best]
+ waited = time.monotonic() - results[best]["t"]
+ if not better or waited >= PREFER:
+ return best
+ done.wait(PREFER - waited)
+ elif not pending:
+ return None
+ else:
+ done.wait(min(0.5, max(0.01, deadline - time.monotonic())))
+
+ def learn(self, device, host, net, rtt):
+ if device.get("transient"):
+ return
+ self.reg.record_success(device["id"], host, net.get("id"), rtt)
+ # Set Up Connection may have changed the block while this attempt ran: take that
+ # in (and reconnect) rather than writing this attempt's older settings over it.
+ self.watch_config()
+ try:
+ now_dev = self.reg.get(device["id"])
+ except frame_devices.DeviceError:
+ return
+ if self.route_key(now_dev) != self.route_key(device):
+ return
+ # Terminal's `ssh ALIAS` and the helper scripts use ~/.ssh/config: point it here too,
+ # unless the block changed since this attempt began (checked under the file lock).
+ login = device.get("config_login") or [None, None]
+ expect = {"hostname": device.get("config_host"), "user": login[0], "port": login[1]}
+ try:
+ if frame_devices.rewrite_block(device["alias"], hostname=host, user=device["user"],
+ port=device["port"], expect=expect):
+ self.config_mtime = frame_devices.ssh_config().stat().st_mtime
+ self.reg.set_config_host(device["id"], host)
+ self.reg.sync_from_config() # records the login it now holds
+ except OSError:
+ pass # not fatal: the app itself doesn't need the file
+ self.devices_changed()
+
+ # ---- SSH ----
+ def check(self, opts, alias=None):
+ """Is a master connection up for these options? (`ssh -O check`)"""
+ if not self.control:
+ return False
+ try:
+ return subprocess.run([*self.mux_base, *opts, "-O", "check", alias or self.alias], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=5).returncode == 0
+ except (OSError, subprocess.TimeoutExpired):
+ return False
+
+ def close_master(self):
+ proc, self.master = self.master, None
+ pending, self.pending = self.pending, None
+ if pending and pending.poll() is None:
+ pending.kill()
+ if self.control and self.alias:
+ try:
+ subprocess.run([*self.mux_base, *self.opts, "-O", "exit", self.alias], capture_output=True,
+ stdin=subprocess.DEVNULL, timeout=5)
+ except (OSError, subprocess.TimeoutExpired):
+ pass
+ if proc and proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+
+ def handshake(self, device, a, found, user):
+ """SSH to one address, following ssh -v through stages 3-5.
+ -> "ok", "next" (try another address) or "stop"."""
+ # The IP that answered (with a link-local IPv6 address's zone), so ssh doesn't
+ # look the name up again and stall on an address that didn't answer.
+ opts = self.host_opts(device, ssh_target(a["host"], found.get("ip")))
+ alias = device["alias"]
+ with self.route_lock:
+ if self.attempt_gen != self.gen:
+ return "stop" # the headset changed: don't route anything back to this one
+ self.alias, self.opts = alias, opts
+ self.apply(alias, opts)
+ target = f"{a['host']}" + (f" ({found['ip']})" if found.get("ip") and found["ip"] != a["host"] else "")
+ self.stage("ssh", "active", f"Opening SSH to {target}")
+ if self.control and self.check(opts, alias):
+ for sid in ("ssh", "identity", "login"):
+ self.stage(sid, "done", "Reusing the SSH connection that's already open")
+ return "ok"
+ extra = []
+ if not device.get("transient"):
+ if frame_devices.pinned(device["id"]):
+ extra = ["-o", "StrictHostKeyChecking=yes"]
+ else:
+ # First connection since this headset was added: trust what it shows
+ # (as Set Up Connection does), and pin it from now on. ssh won't create
+ # the folder its known_hosts file goes in.
+ extra = ["-o", "StrictHostKeyChecking=accept-new"]
+ pins = frame_devices.known_hosts(device["id"]).parent
+ pins.mkdir(**({} if frame_host.WINDOWS else {"mode": 0o700}), parents=True, exist_ok=True)
+ if self.control:
+ # No ConnectTimeout: with it, OpenSSH's master takes ~5s to open its socket.
+ # `extra` first: ssh takes the first value of an option, and it may say accept-new.
+ argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ControlMaster=yes", "-o", "ServerAliveInterval=5",
+ "-o", "ServerAliveCountMax=2", "-N", alias]
+ else:
+ argv = [*self.mux_base, *extra, *opts, "-v", "-o", "ConnectTimeout=10", alias, "true"]
+ try:
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, **frame_host.DETACHED)
+ except OSError as e:
+ self.fail("ssh", f"Couldn't run ssh: {e}", str(e))
+ return "stop"
+ self.pending = proc # so stop() can end it mid-handshake
+ lines = queue.Queue()
+ collecting = [True]
+
+ def read():
+ for raw in iter(proc.stderr.readline, b""):
+ if collecting[0]:
+ lines.put(raw.decode("utf-8", "replace").rstrip())
+ lines.put(None)
+ proc.stderr.close()
+ threading.Thread(target=read, daemon=True).start()
+
+ step, said, authed = "ssh", [], False
+ deadline = time.monotonic() + HANDSHAKE_TIMEOUT
+ mismatch = False
+ while True:
+ left = deadline - time.monotonic()
+ if self.stopped:
+ proc.kill()
+ return "stop"
+ if left <= 0:
+ proc.kill()
+ self.fail(step, self.explain(f"Timed out talking to {alias}") or "The headset took too long to answer.",
+ f"Timed out talking to {alias}")
+ return "next" # silence is about this address (or a jump host's forward to it)
+ try:
+ line = lines.get(timeout=min(left, 0.25))
+ except queue.Empty:
+ line = ""
+ if authed and self.control and self.check(opts, alias):
+ break
+ if proc.poll() is not None and lines.empty():
+ line = None
+ else:
+ continue
+ if line is None: # ssh exited
+ proc.wait()
+ if not self.control and proc.returncode == 0:
+ break
+ if authed and self.control and self.check(opts, alias):
+ break
+ return self.failed(step, said, mismatch, alias)
+ if not line.startswith("debug"):
+ said.append(line)
+ m = CONNECTING.search(line)
+ if m:
+ self.stage("ssh", "active", f"Opening SSH to {a['host']}" +
+ (f" ({m.group(2)})" if m.group(2) != a["host"] else "") + f", port {m.group(3)}")
+ elif ESTABLISHED.search(line):
+ self.stage("ssh", "done", f"Connected to {target}")
+ step = "identity"
+ self.stage("identity", "active", "Waiting for the headset's host key")
+ elif HOSTKEY.search(line):
+ m = HOSTKEY.search(line)
+ self.stage("identity", "active", f"It shows {m.group(1)} key {m.group(2)[:20]}…")
+ elif KNOWN.search(line):
+ self.stage("identity", "done", "Matches the identity saved for this headset")
+ step = "login"
+ self.stage("login", "active", f"Logging in as {user}")
+ elif ADDED.search(line):
+ self.stage("identity", "done", "First connection: saved this headset's identity")
+ step = "login"
+ self.stage("login", "active", f"Logging in as {user}")
+ elif (CHANGED.search(line) or UNKNOWN.search(line)) and not device.get("transient"):
+ mismatch = True # a bare alias keeps ssh's per-address check, and its wording
+ elif AUTH_START.search(line) and step != "login":
+ self.stage("identity", "done")
+ step = "login"
+ self.stage("login", "active", f"Logging in as {user}")
+ elif AUTHED.search(line) and self.is_target(line, opts, alias):
+ authed = True
+ if step != "login":
+ self.stage("identity", "done")
+ self.stage("login", "done", f"Logged in as {user}")
+ step = "connected"
+ collecting[0] = False # the master keeps printing mux debug lines: drop them
+ self.pending = None
+ if self.stopped:
+ proc.kill()
+ return "stop"
+ for sid in ("ssh", "identity", "login"):
+ with self.cond:
+ pending = any(s["id"] == sid and s["state"] != "done" for s in self.state["stages"])
+ if pending:
+ self.stage(sid, "done")
+ if self.control:
+ self.master = proc
+ return "ok"
+
+ @staticmethod
+ def is_target(line, opts, alias):
+ """Whether an "Authenticated to X" line is about the headset, not a jump host
+ (ssh -v passes its verbosity on to ProxyJump's own ssh)."""
+ host = next((o.split("=", 1)[1].replace("%%", "%") for o in opts if o.startswith("HostName=")), alias)
+ m = re.search(r"Authenticated to (\S+)", line)
+ # OpenSSH lower-cases host names (FRAME.LOCAL logs as frame.local).
+ return not m or m.group(1).lower() in (host.lower(), alias.lower()) or "Authentication succeeded" in line
+
+ def failed(self, step, said, mismatch, alias):
+ text = "\n".join(said).strip()
+ if mismatch:
+ self.fail("identity", "This address answered as a different headset (its SSH identity doesn't match). "
+ "If SteamOS was reinstalled, use Forget Identity on the Devices tab.", text)
+ return "next"
+ # A refused key is the same at every address: stop. (Judged by ssh's own words, not
+ # the step: a jump host's progress lines look like the headset's.) A forward that
+ # a jump host couldn't open is about this address only: try the next.
+ forward = re.search(r"open failed|forwarding failed|Connection refused|Connection closed|timed out", text)
+ if re.search(r"Permission denied", text) and not forward:
+ self.fail("login", self.explain(text) or "The headset didn't accept this computer's key.", text)
+ return "stop"
+ if step == "connected":
+ self.fail("login", "Logged in, but the shared SSH connection didn't start.", text)
+ return "next"
+ self.fail(step, self.explain(text) or (text.splitlines()[-1] if text else "ssh stopped"), text)
+ return "next"
+
+ # ---- Test now ----
+ def test(self, device_id):
+ """Probe every address of a headset and check SSH on the ones that answer,
+ without touching the live connection. Results stream into state["tests"]."""
+ device = self.reg.get(device_id)
+ started = now()
+ rows = [{"host": a["host"], "kind": a["kind"], "state": "waiting", "detail": "Waiting", "ip": None,
+ "rtt_ms": None, "ssh": None} for a in device["addresses"]]
+
+ def put(**fields):
+ with self.cond:
+ self.state["tests"][device_id] = dict({"started": started, "done": False, "rows": rows}, **fields)
+ self.version += 1
+ self.cond.notify_all()
+
+ put()
+ net = self.state["network"] or {}
+
+ proxied = ssh_g(device["alias"])[3]
+
+ def one(i, a):
+ if proxied: # through a jump host: a direct probe says nothing, ssh itself is the test
+ res = {"state": "answered", "detail": "Through a jump host", "ip": None, "rtt_ms": None}
+ else:
+ res = probe(a["host"], device["port"], update=lambda **f: (rows[i].update(f), put()))
+ rows[i].update({k: res.get(k) for k in ("state", "detail", "ip", "rtt_ms")})
+ put()
+ if res["state"] != "answered":
+ return
+ lead = f"Answered in {res['rtt_ms']:g} ms" if res.get("rtt_ms") is not None else "Through the jump host"
+ rows[i]["ssh"] = "checking"
+ put()
+ argv = [*self.mux_base[:3], "-o", "ControlPath=none", "-o", "ConnectTimeout=8",
+ *self.host_opts(device, ssh_target(a["host"], res.get("ip"))),
+ "-o", "StrictHostKeyChecking=yes", device["alias"], "true"]
+ try:
+ r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, text=True,
+ errors="replace", timeout=20)
+ err = r.stderr.strip()
+ if r.returncode == 0:
+ rows[i].update(ssh="ok", detail=f"{lead} · SSH works")
+ self.reg.record_success(device_id, a["host"], net.get("id"), res["rtt_ms"])
+ elif UNKNOWN.search(err):
+ rows[i].update(ssh="unpinned", detail=f"{lead} · identity not saved yet")
+ elif CHANGED.search(err):
+ rows[i].update(ssh="wrong", detail="Answered as a different headset")
+ elif DENIED.search(err):
+ rows[i].update(ssh="denied", detail="Answered, but refused this computer's key")
+ else:
+ rows[i].update(ssh="failed", detail=self.explain(err) or (err.splitlines() or ["SSH failed"])[-1])
+ except (OSError, subprocess.TimeoutExpired):
+ rows[i].update(ssh="failed", detail="SSH took too long")
+ put()
+
+ threads = [threading.Thread(target=one, args=(i, a), daemon=True) for i, a in enumerate(device["addresses"])]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(40)
+ put(done=True, finished=now())
+ self.devices_changed()
+
+
+# ---- the page's API: /api/devices -------------------------------------------------
+
+def devices_view(link):
+ """Every headset with its addresses, the networks they worked on, and the current network."""
+ snap = link.reg.snapshot()
+ active = link.active_device()
+ names = {nid: link.reg.network_name(dict(n, id=nid)) for nid, n in snap["networks"].items()}
+ devices = []
+ bare = link.bare(link.session_alias) if link.session_alias and not link.reg.by_alias(link.session_alias) else None
+ for extra in ([active] if active.get("transient") and not active.get("none") else []) + \
+ ([bare] if bare and bare["id"] != active["id"] else []):
+ devices.append(dict(link.public_device(extra), active=extra["id"] == active["id"], addresses=[],
+ managed=False, pinned=False))
+ for d in snap["devices"]:
+ view = {k: v for k, v in d.items() if k not in ("config_host", "addresses")}
+ view["active"] = d["id"] == active["id"]
+ view["pinned"] = frame_devices.pinned(d["id"])
+ view["addresses"] = [dict(a, network_names=[names.get(n, "an unnamed network") for n in a["networks"]])
+ for a in d["addresses"]]
+ devices.append(view)
+ return {"devices": devices, "active": active["id"], "network": link.state["network"],
+ "networks": [dict(n, id=nid, display=names[nid]) for nid, n in snap["networks"].items()],
+ "kinds": frame_devices.KIND_LABEL}
+
+
+def login_change(reg, did, body):
+ """Whether an update asks for another user or port (the page sends both every time)."""
+ try:
+ d = reg.get(did)
+ except frame_devices.DeviceError:
+ return False
+ user, port = body.get("user"), body.get("port")
+ try:
+ port = int(port) if port is not None else None
+ except (TypeError, ValueError):
+ return True # it will be refused anyway
+ return (user is not None and user != d["user"]) or (port is not None and port != d["port"])
+
+
+def devices_action(link, body, open_setup, busy=lambda: 0):
+ """POST /api/devices {"action": ..., "id": device id, ...}. -> {"message", ...devices_view}.
+ busy() counts installs in progress: nothing may move them to another headset."""
+ reg = link.reg
+ action = body.get("action")
+ did = body.get("id")
+ active = link.active_device()
+ is_active = did == active["id"]
+ moves = action == "use" or (action == "retry" and link.alive()) or (is_active and (
+ # (a retry while connected would cut the install's connection)
+ action in ("remove", "address-remove", "forget-identity")
+ or (action == "update" and login_change(reg, did, body))
+ or (action == "address-update" and body.get("newHost") not in (None, body.get("host")))))
+ if moves and busy():
+ raise frame_devices.DeviceError(
+ f"Wait for what's running on {active['name']} to finish (see the activity bar), then try again")
+ if action == "use":
+ sa = link.session_alias
+ d = link.bare(sa) if sa and did == link.bare(sa)["id"] and not reg.by_alias(sa) else reg.get(did)
+ link.use(did)
+ msg = f"Switched to {d['name']}"
+ elif action == "update":
+ before = reg.get(did)
+ d = reg.update_device(did, name=body.get("name"), user=body.get("user"), port=body.get("port"))
+ login_changed = (d["user"], d["port"]) != (before["user"], before["port"])
+ if is_active and login_changed:
+ link.invalidate() # before anything else can fail: the old login mustn't stay in use
+ msg = f"Saved {d['name']}"
+ if is_active and not login_changed:
+ link.publish(device=link.public_device(link.active_device())) # a new name shows at once
+ if login_changed:
+ # Only what changed, and only if the block still says what it did: Set Up
+ # Connection may have written a new login meanwhile, which then stands.
+ try:
+ if not frame_devices.rewrite_block(d["alias"], user=d["user"], port=d["port"],
+ expect={"user": before["user"], "port": before["port"]}) \
+ and any(b["alias"] == d["alias"] and (b["user"], b["port"]) != (d["user"], d["port"])
+ for b in frame_devices.parse_blocks(frame_devices.read_config())):
+ msg += "; ~/.ssh/config changed meanwhile, so it was left as it is"
+ except OSError as e:
+ raise frame_devices.DeviceError(f"Saved, but couldn't update ~/.ssh/config: {e}")
+ elif action == "remove":
+ if not body.get("config") and len(reg.devices()) == 1 and reg.get(did)["alias"] in {
+ b["alias"] for b in frame_devices.parse_blocks(frame_devices.read_config())}:
+ # Its ssh alias would stay, and the app would go on using it as a bare alias.
+ raise frame_devices.DeviceError("This is your only headset. To remove it completely, also remove its "
+ "entry from ~/.ssh/config (the box below)")
+ d = reg.remove_device(did)
+ if d["alias"] == link.session_alias:
+ link.session_alias = None # removed on purpose: not back as a bare alias
+ if is_active:
+ link.override = None
+ link.invalidate()
+ frame_devices.forget_pin(did)
+ removed = False
+ if body.get("config"):
+ try:
+ removed = frame_devices.remove_block(d["alias"])
+ except OSError as e:
+ raise frame_devices.DeviceError(f"Removed, but couldn't edit ~/.ssh/config: {e}")
+ msg = f"Removed {d['name']}" + (f" and its '{d['alias']}' entry in ~/.ssh/config" if removed else "")
+ elif action == "address-add":
+ a = reg.add_address(did, body.get("host"), body.get("kind") or None, body.get("label") or "")
+ if is_active and link.state["phase"] == "failed":
+ link.kick("retry")
+ msg = f"Added {a['host']}"
+ elif action == "address-update":
+ a = reg.update_address(did, body.get("host"), new_host=body.get("newHost"), kind=body.get("kind"),
+ label=body.get("label"))
+ if is_active and a["host"] != body.get("host"):
+ link.invalidate() # the address in use may have moved
+ msg = f"Saved {a['host']}"
+ elif action == "address-remove":
+ reg.remove_address(did, body.get("host"))
+ if is_active:
+ link.invalidate() # it may be the address in use: stop using it now
+ msg = f"Removed {body.get('host')}"
+ elif action == "address-move":
+ delta = body.get("delta")
+ if delta not in (-1, 1):
+ raise frame_devices.DeviceError("delta must be -1 or 1")
+ reg.move_address(did, body.get("host"), delta)
+ msg = "Moved"
+ elif action == "test":
+ d = reg.get(did)
+ if not d["addresses"]:
+ raise frame_devices.DeviceError("This headset has no addresses to test yet")
+ threading.Thread(target=link.test, args=(did,), daemon=True).start()
+ msg = f"Testing {len(d['addresses'])} address{'es' * (len(d['addresses']) != 1)}"
+ elif action == "forget-identity":
+ d = reg.get(did)
+ frame_devices.forget_pin(did)
+ if is_active:
+ link.kick("switch")
+ msg = f"Forgot {d['name']}'s SSH identity; the next connection saves the one it shows"
+ elif action == "name-network":
+ reg.name_network(body.get("network"), body.get("name"))
+ if link.state["network"]:
+ link.refresh_network()
+ msg = "Saved the network's name"
+ elif action == "setup":
+ alias = frame_devices.check_alias(body.get("alias"))
+ host = frame_devices.check_host(body["host"]) if body.get("host") else None
+ link.reg.undismiss(alias)
+ where = open_setup(alias, host)
+ msg = f"Opened Set Up Connection for '{alias}' in {where}"
+ elif action == "retry":
+ link.kick("retry")
+ msg = "Connecting…"
+ else:
+ raise frame_devices.DeviceError("unknown action")
+ link.devices_changed()
+ return dict(devices_view(link), message=msg)
+
+
+def next_alias(link):
+ """A free alias for a new headset: not one Frame Control knows, nor any `Host` name
+ already in ~/.ssh/config (Set Up Connection's block would shadow it)."""
+ text = frame_devices.read_config()
+ taken = {d["alias"] for d in link.reg.devices()} | {b["alias"] for b in frame_devices.parse_blocks(text)}
+ taken |= {a for a in (link.session_alias, link.override, link.active_device().get("alias")) if a}
+ for line in text.splitlines():
+ f = line.split()
+ if f and f[0].lower() == "host":
+ taken |= {name for name in f[1:] if not any(c in name for c in "*?!")}
+ if "frame" not in taken:
+ return "frame"
+ n = 2
+ while f"frame-{n}" in taken:
+ n += 1
+ return f"frame-{n}"
+
+
+LIKELY = re.compile(r"frame|steam", re.I)
+
+
+def tailscale_find(link, device_id=None):
+ """Tailscale peers that could be a headset, likely ones first, for "Find on Tailscale"."""
+ ts = frame_network.tailscale_status()
+ device = link.reg.get(device_id) if device_id else link.active_device()
+ known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
+ if not ts.get("installed"):
+ return {"up": False, "peers": [], "message": "Tailscale isn't installed on this computer."}
+ if not ts.get("up"):
+ return {"up": False, "peers": [], "message": "Tailscale isn't running on this computer. Start it, then look again."}
+ peers = []
+ for p in ts["peers"]:
+ ip = next((i for i in p["ips"] if "." in i), p["ips"][0] if p["ips"] else None)
+ likely = p["os"] == "linux" and (LIKELY.search(p["name"]) or p["name"].lower() in (
+ device["alias"].lower(), (device.get("name") or "").lower()))
+ peers.append({"name": p["name"], "dns": p["dns"], "ip": ip, "os": p["os"], "online": p["online"],
+ "likely": bool(likely), "added": bool({p["dns"].lower(), (ip or "").lower()} & known)})
+ peers.sort(key=lambda p: (not p["likely"], p["os"] != "linux", not p["online"], p["name"].lower()))
+ return {"up": True, "peers": peers, "tailnet": ts.get("tailnet"), "message": None}
+
+
+def mdns_find(link, device_id=None):
+ """Headsets on this network: SteamOS devkit services (mDNS) and .local."""
+ device = link.reg.get(device_id) if device_id else link.active_device()
+ known = {a["host"].rstrip(".").lower() for a in device.get("addresses") or []}
+ try:
+ import frame_connect
+ found = frame_connect.discover_devkit()
+ except (ImportError, SystemExit, OSError):
+ found = []
+ names = list(dict.fromkeys([h.rstrip(".") for h in found] + [f"{device['alias']}.local", "frame.local"]))
+ rows = [None] * len(names)
+
+ def check(i, host):
+ res = probe(host, 22, timeout=3)
+ rows[i] = {"host": host, "state": res["state"], "ip": res.get("ip"), "rtt_ms": res.get("rtt_ms"),
+ "detail": res["detail"], "advertised": host in [h.rstrip(".") for h in found],
+ "added": host.lower() in known or (res.get("ip") or "").lower() in known}
+ threads = [threading.Thread(target=check, args=(i, h), daemon=True) for i, h in enumerate(names)
+ if frame_devices.HOST_RE.fullmatch(h)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join(8)
+ hosts = [r for r in rows if r and (r["advertised"] or r["state"] in ("answered", "refused"))]
+ return {"hosts": hosts, "tool": bool(frame_host.which("dns-sd") or frame_host.which("avahi-browse"))}
diff --git a/ui/frame_macview.py b/ui/frame_macview.py
new file mode 100644
index 0000000..f6b346e
--- /dev/null
+++ b/ui/frame_macview.py
@@ -0,0 +1,490 @@
+"""Mac in the headset: stream Mac windows or displays into the Steam Frame as
+panels you can place anywhere, with the laser, wheel and keys driving the Mac.
+
+Runs on the Mac, inside Frame Control's server. The pieces:
+
+- mac/bin/frame-mac-view (Swift, built from mac/frame-mac-view): captures with
+ ScreenCaptureKit, encodes with VideoToolbox, serves ui/mac-view.html and one
+ WebSocket per stream on 127.0.0.1, and plays input back with CGEvent.
+- An `ssh -R` tunnel, so the Frame reaches the agent on its own 127.0.0.1.
+ Every request carries a random token, so other programs on the Frame
+ (Android apps included) can't watch or drive the Mac.
+- A Chromium app window on the Frame per stream, on gamescope's X display
+ with its own STEAM_GAME id, which makes it its own SteamVR panel (see
+ docs/panels.md). Chromium XR (~/chromium-xr) is preferred because it's
+ built with H.264; Flathub Chromium is the fallback.
+
+Stdlib only. MacView gets a plain ssh argv for the tunnel (its own
+connection) and the server's `run(remote, stdin=, timeout=)` for commands.
+"""
+import json
+import os
+import re
+import secrets
+import shutil
+import socket
+import subprocess
+import sys
+import threading
+import time
+import urllib.error
+import urllib.request
+import zlib
+from pathlib import Path
+from urllib.parse import quote, urlencode # %20, not +: the agent's URLComponents keeps +
+
+HERE = Path(__file__).resolve().parent
+ROOT = HERE.parent
+PAGE = HERE / "mac-view.html"
+SOURCES = ROOT / "mac" / "frame-mac-view"
+AGENT = Path(os.environ.get("FRAME_MAC_VIEW") or ROOT / "mac" / "bin" / "frame-mac-view")
+REMOTE_PORTS = range(47900, 47920)
+SUPPORTED = sys.platform == "darwin"
+
+# Stream settings by name: long side in pixels, frames per second, H.264 bits
+# per pixel per frame, codec. JPEG is for a Frame browser without H.264.
+QUALITY = {
+ "sharp": {"max": 2560, "fps": 60, "bpp": 0.14, "codec": "h264"},
+ "balanced": {"max": 1920, "fps": 60, "bpp": 0.1, "codec": "h264"},
+ "light": {"max": 1280, "fps": 30, "bpp": 0.08, "codec": "h264"},
+ "compatible": {"max": 1280, "fps": 20, "bpp": 0.1, "codec": "jpeg"},
+}
+# Extra Chromium flags for viewers (see docs/mac-in-headset.md, "Measuring").
+BROWSER_FLAGS = []
+# Viewer windows are fitted inside a panel's size. gamescope made a 1280x720
+# request 1920x1080 anyway (verified 2026-09-28, build 20260925.6191901).
+PANEL_BOX = (1920, 1080)
+
+# Opens one viewer on gamescope's X display and gives its window its own panel
+# id. Args: appid url width height tag [browser flags...]. The page puts "[tag]" in its title at
+# once, which is how its X window is found (Chromium may hand the URL to an
+# instance that's already running, so there's no process to follow).
+LAUNCH = r"""set -u
+appid=$1 url=$2 w=$3 h=$4 tag=$5
+shift 5
+export DISPLAY=:0 LC_ALL=C.UTF-8
+unset WAYLAND_DISPLAY
+if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
+ echo "The headset isn't showing anything (gamescope's display :0 isn't up). Wake it and try again." >&2
+ exit 2
+fi
+common=(--ozone-platform=x11 --force-device-scale-factor=1 --no-first-run --no-default-browser-check
+ --password-store=basic --disable-session-crashed-bubble --noerrdialogs --disable-infobars
+ --disable-features=Translate,MediaRouter --autoplay-policy=no-user-gesture-required
+ "--window-size=$w,$h" "$@" "--app=$url")
+if [ -x "$HOME/chromium-xr/chrome" ]; then
+ cmd=("$HOME/chromium-xr/chrome" "--user-data-dir=$HOME/.local/share/frame-control/mac-view" "${common[@]}")
+elif flatpak info org.chromium.Chromium >/dev/null 2>&1; then
+ cmd=(flatpak run org.chromium.Chromium
+ "--user-data-dir=$HOME/.var/app/org.chromium.Chromium/data/frame-mac-view" "${common[@]}")
+else
+ echo "NO_BROWSER"
+ exit 3
+fi
+log=/tmp/frame-mac-view.log
+setsid nohup "${cmd[@]}" >>"$log" 2>&1 /dev/null | awk '/^ +0x/ {print $1}'); do
+ xprop -id "$win" _NET_WM_NAME WM_NAME 2>/dev/null | grep -qF "[$tag]" || continue
+ if xprop -id "$win" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null; then
+ echo "panel valve.steam.desktopgame.$appid window $win"
+ exit 0
+ fi
+ done
+done
+echo "The viewer started, but its window didn't appear within 30 s. Chromium's log:" >&2
+tail -n 15 "$log" >&2
+exit 1
+"""
+
+
+class MacViewError(Exception):
+ pass
+
+
+def panel_id(src):
+ """A stable panel id per source, in the range panel-on-frame.sh uses."""
+ return 2_001_000_000 + zlib.crc32(f"mac:{src}".encode()) % 1_000_000
+
+
+def fit(w, h, box=PANEL_BOX):
+ s = min(box[0] / max(w, 1), box[1] / max(h, 1))
+ return max(320, round(w * s)), max(200, round(h * s))
+
+
+class MacView:
+ def __init__(self, tunnel_ssh, run, frame, track=None):
+ self.tunnel_ssh = list(tunnel_ssh)
+ self.run = run
+ self.frame = frame
+ self.host_opts = [] # the headset in use and how to reach it (see retarget)
+ # Short, never held across ssh: retarget() and publishing a new tunnel check and
+ # change the headset together (self.lock is held while a tunnel is being opened).
+ self.route_lock = threading.Lock()
+ self.track = track or (lambda proc: None) # the server ends these on exit
+ self.lock = threading.Lock()
+ self.token = secrets.token_urlsafe(24)
+ self.agent = None
+ self.port = None
+ self.tunnel = None
+ self.remote_port = None
+ self.supervisor = None
+ self.closing = False
+ self.shows = 0 # counts Show presses, so a late cleanup can't close a new viewer
+ self.launching = 0 # Shows in progress (one may be replacing its own stream)
+ # Held by a Show while it counts itself in, and by the cleanup for its
+ # check and its pkill together, so a Show can't start in between.
+ self.viewer_lock = threading.Lock()
+ # Use the Frame's USB-C network when it's plugged into this Mac (see
+ # _usb_route); FRAME_MACVIEW_USB=0 turns that off.
+ self.prefer_usb = os.environ.get("FRAME_MACVIEW_USB") != "0"
+ self.route = "network"
+ self.shown = set() # sources with a viewer out there, connected or retrying
+ self.browser_flags = list(BROWSER_FLAGS)
+
+ # ---- the agent on this Mac ----
+
+ def unavailable(self):
+ """Why this can't work here, or None."""
+ if not SUPPORTED:
+ return "Streaming your computer into the headset needs macOS."
+ if not AGENT.exists() and not (SOURCES.exists() and shutil.which("xcrun")):
+ return "The Mac streaming helper is missing from this copy of Frame Control."
+ return None
+
+ def build(self):
+ if AGENT.exists() and not self._stale():
+ return
+ if not (SOURCES / "build.sh").exists():
+ if AGENT.exists():
+ return
+ raise MacViewError("The Mac streaming helper is missing.")
+ r = subprocess.run(["/bin/sh", str(SOURCES / "build.sh"), str(AGENT)], capture_output=True, text=True,
+ stdin=subprocess.DEVNULL, timeout=600)
+ if r.returncode != 0:
+ raise MacViewError("Couldn't build the Mac streaming helper: " + (r.stderr or r.stdout).strip()[-400:])
+
+ def _stale(self):
+ try:
+ built = AGENT.stat().st_mtime
+ return any(p.stat().st_mtime > built for p in (SOURCES / "Sources").glob("*.swift"))
+ except OSError:
+ return False
+
+ def ensure_agent(self):
+ with self.lock:
+ if self.agent and self.agent.poll() is None:
+ return
+ reason = self.unavailable()
+ if reason:
+ raise MacViewError(reason)
+ self.build()
+ env = {**os.environ, "FRAME_MAC_VIEW_TOKEN": self.token}
+ # The same port as before when restarting, so a running tunnel still
+ # fits; otherwise (or if it's gone) whatever the system gives.
+ for port in dict.fromkeys([self.port or 0, 0]):
+ self.agent = subprocess.Popen([str(AGENT), "serve", "--port", str(port), "--page", str(PAGE),
+ "--exit-on-eof"], env=env, stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
+ line = self.agent.stdout.readline()
+ m = re.search(r"listening on 127\.0\.0\.1:(\d+)", line)
+ if m:
+ break
+ self.agent.kill()
+ else:
+ raise MacViewError(f"The Mac streaming helper didn't start: {line.strip() or 'no output'}")
+ if self.port != int(m.group(1)):
+ self._drop_tunnel()
+ self.port = int(m.group(1))
+ self.track(self.agent)
+ threading.Thread(target=self.agent.stdout.read, daemon=True).start() # drain
+
+ def call(self, path, method="GET", **query):
+ """A request to the agent; starts it if needed."""
+ self.ensure_agent()
+ url = f"http://127.0.0.1:{self.port}{path}?{urlencode({**query, 'k': self.token}, quote_via=quote)}"
+ req = urllib.request.Request(url, method=method, data=b"" if method == "POST" else None)
+ try:
+ with urllib.request.urlopen(req, timeout=10) as r:
+ return json.load(r)
+ except (urllib.error.URLError, OSError, ValueError) as e:
+ raise MacViewError(f"The Mac streaming helper didn't answer: {e}")
+
+ # ---- the tunnel from the Frame ----
+
+ def _drop_tunnel(self):
+ if self.tunnel and self.tunnel.poll() is None:
+ self.tunnel.terminate()
+ self.tunnel = None
+
+ def tunnel_up(self):
+ return self.tunnel is not None and self.tunnel.poll() is None
+
+ def ensure_tunnel(self, allow_new_port=False):
+ """Open the tunnel, on the port viewers already use if there is one.
+ A new port only when nobody is watching, since viewers can't move."""
+ with self.lock:
+ if self.tunnel_up():
+ return
+ last = ""
+ ports = [self.remote_port] if self.remote_port else list(REMOTE_PORTS)
+ if self.remote_port and allow_new_port:
+ ports += [p for p in REMOTE_PORTS if p != self.remote_port]
+ usb = self._usb_route()
+ # USB-C first when it's there; if that fails (unplugged just now,
+ # something else at that address), the normal path.
+ for via in ([usb, []] if usb else [[]]):
+ self.route = "usb" if via else "network"
+ if self._open_tunnel(via, ports):
+ return
+ last = self._last_tunnel_error
+ raise MacViewError(f"Couldn't open a tunnel from {self.frame} to this Mac: {last or 'no answer through it'}")
+
+ def retarget(self, alias, host_opts):
+ """The server now reaches the headset as `alias` with `host_opts` (another address,
+ or another headset). Only the short route_lock, never self.lock: this runs while
+ the server routes, which a tunnel being opened may be waiting on."""
+ # host_opts is "-o", "Name=value" pairs; the tunnel keeps its own connection,
+ # not the shared master.
+ opts = [x for flag, value in zip(host_opts[::2], host_opts[1::2])
+ if not value.startswith("ControlPath=") for x in (flag, value)]
+ with self.route_lock:
+ moved = alias != self.frame
+ self.frame, self.host_opts = alias, opts
+ tunnel = self.tunnel
+ if moved and tunnel is not None:
+ # Another headset: its viewers can't be the old one's. The supervisor
+ # reopens a tunnel to the new one if anything is being shown.
+ self.tunnel, self.remote_port = None, None
+ if moved and tunnel is not None and tunnel.poll() is None:
+ tunnel.terminate()
+
+ def _open_tunnel(self, via, ports):
+ """Tries the ports on one route; True once the tunnel answers. With self.lock held."""
+ last = ""
+ for port in ports:
+ with self.route_lock:
+ target = (self.frame, self.host_opts) # retarget() may change these meanwhile
+ # `via` first: ssh keeps the first value of an option, so USB-C's HostName wins
+ # while the headset's pinned identity (in host_opts) still checks it.
+ proc = subprocess.Popen([*self.tunnel_ssh, "-o", "ControlPath=none", *via, *target[1],
+ "-o", "ExitOnForwardFailure=yes",
+ "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=3", "-N",
+ "-R", f"127.0.0.1:{port}:127.0.0.1:{self.port}", target[0]],
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, text=True)
+ # A taken port makes ssh exit once it's connected; a working
+ # tunnel answers the agent's status from the Frame's side.
+ ok = False
+ for _ in range(15):
+ time.sleep(0.4)
+ if proc.poll() is not None:
+ break
+ if self._probe(port):
+ ok = True
+ break
+ if ok:
+ with self.route_lock: # checked and published together, so a switch can't slip between
+ ok = target[0] == self.frame # else the app switched headset while this connected
+ if ok:
+ self.tunnel, self.remote_port = proc, port
+ if ok:
+ self.track(proc)
+ self._supervise()
+ return True
+ if proc.poll() is None:
+ proc.terminate()
+ proc.wait()
+ last = (proc.stderr.read() or "").strip()
+ if "forward" not in last.lower():
+ break # not a port clash: the Frame is unreachable this way
+ self._last_tunnel_error = last
+ return False
+
+ def _usb_route(self):
+ """ssh options to reach the Frame over its USB-C network, or [].
+
+ Plugged into a Mac, the Frame is a USB network device (macOS lists it
+ as "Steam Frame"): the Frame's usb0 answers at about 1 ms, with none
+ of Wi-Fi's stalls. Measured 2026-09-28: content latency 7 ms instead
+ of 10, click to screen 17 ms instead of 27 (docs/mac-in-headset.md).
+ The host key is the same, so it's checked against the usual name."""
+ if not self.prefer_usb:
+ return []
+ try:
+ out = self.run("ip -4 -o addr show usb0 2>/dev/null", timeout=8)
+ except Exception:
+ return []
+ m = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/", out or "")
+ if not m:
+ return []
+ ip = m.group(1)
+ try:
+ socket.create_connection((ip, 22), timeout=1).close()
+ except OSError:
+ return [] # not plugged into this Mac
+ if any(o.startswith("HostKeyAlias=") for o in self.host_opts):
+ return ["-o", f"HostName={ip}"] # checked against the headset's own pinned key
+ alias = self.frame
+ try:
+ cfg = subprocess.run(["ssh", "-G", self.frame], capture_output=True, text=True, timeout=5).stdout
+ opts = dict(line.split(None, 1) for line in cfg.splitlines() if " " in line)
+ alias = opts.get("hostkeyalias") or opts.get("hostname") or alias # a configured alias wins
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return ["-o", f"HostName={ip}", "-o", f"HostKeyAlias={alias}"]
+
+ def _supervise(self):
+ """Reopen the tunnel on the same port after the headset sleeps or the
+ network drops, so viewers that are retrying find the Mac again."""
+ if self.supervisor and self.supervisor.is_alive():
+ return
+
+ def loop():
+ while not self.closing:
+ time.sleep(5)
+ if self.closing or self.tunnel_up() or not (self.agent and self.agent.poll() is None):
+ continue
+ try:
+ self.ensure_tunnel()
+ except MacViewError:
+ pass # still unreachable; try again shortly
+
+ self.supervisor = threading.Thread(target=loop, daemon=True)
+ self.supervisor.start()
+
+ def _probe(self, port):
+ """Whether the Frame reaches the agent through the tunnel on `port`."""
+ # /ping needs no key, so none appears on the Frame's command lines.
+ cmd = f"curl -s -m 2 'http://127.0.0.1:{port}/ping'"
+ try:
+ return self.run(cmd, timeout=8).strip() == "frame-mac-view"
+ except Exception: # noqa: BLE001 - the server's Failure, timeouts: all mean "not yet"
+ return False
+
+ # ---- viewers on the Frame ----
+
+ def show(self, src, quality="balanced", width=None, height=None):
+ with self.viewer_lock:
+ self.launching += 1
+ try:
+ return self._show(src, quality, width, height)
+ finally:
+ with self.viewer_lock:
+ self.launching -= 1
+
+ def _show(self, src, quality, width, height):
+ if src != "test" and not src.startswith(("window:", "display:", "separate:")):
+ raise MacViewError("Pick a window or display to show.")
+ self.shows += 1
+ q = QUALITY.get(quality) or QUALITY["balanced"]
+ state = self.call("/status")
+ if src != "test" and not state.get("screen"):
+ raise MacViewError("Frame Control needs Screen Recording permission first (Allow… under Mac in the headset).")
+ if src.startswith("separate:") and not state.get("accessibility"):
+ raise MacViewError("A window on its own display needs the Accessibility permission too, to move it "
+ "(Allow… under Mac in the headset).")
+ self.shown -= set(state.get("finished", [])) # their Mac windows closed
+ if src in self.shown or any(st.get("src") == src for st in state.get("streams", [])):
+ # Showing it again replaces the old viewer, connected or not: this
+ # revokes its keys so it can't come back alongside the new one.
+ self.stop(src)
+ # Viewers that lost the tunnel keep retrying its old port, even though
+ # the agent no longer counts them, so only move when none are out there.
+ others = self.shown - {src}
+ self.ensure_tunnel(allow_new_port=not others)
+ appid = panel_id(src)
+ # Unique per launch, so a new window is never confused with an old one.
+ tag = "fc" + secrets.token_hex(4)
+ # A single-use ticket for this source, not Frame Control's key: the URL
+ # is visible in the Frame's process list.
+ ticket = self.call("/ticket", method="POST", src=src)["ticket"]
+ params = {"src": src, "t": ticket, "tag": tag, "codec": q["codec"], "max": q["max"], "fps": q["fps"],
+ "bpp": q["bpp"]}
+ url = f"http://127.0.0.1:{self.remote_port}/view?{urlencode(params)}"
+ w, h = fit(width or 1280, height or 720)
+ args = " ".join(_quote(str(a)) for a in (appid, url, w, h, tag, *self.browser_flags))
+ try:
+ out = self.run("bash -s -- " + args, stdin=LAUNCH, timeout=60)
+ except Exception as e: # noqa: BLE001 - the server's Failure carries the Frame's words
+ if "NO_BROWSER" in (getattr(e, "stdout", "") or ""):
+ raise MacViewError("The Frame needs a browser for this: install Chromium (Tools → Linux apps, "
+ "org.chromium.Chromium) or Chromium XR.")
+ raise MacViewError(str(e))
+ self.shown.add(src)
+ return {"panel": f"valve.steam.desktopgame.{appid}", "src": src, "detail": out.strip()}
+
+ def stop(self, src=None):
+ if src:
+ self.shown.discard(src)
+ else:
+ self.shown.clear()
+ if not (self.agent and self.agent.poll() is None):
+ return {"closed": 0}
+ out = self.call("/close", method="POST", **({"src": src} if src else {}))
+ if not self.shown:
+ threading.Thread(target=self._end_viewer_browser, args=(self.shows,), daemon=True).start()
+ return out
+
+ def _end_viewer_browser(self, shows):
+ """Chromium on the Frame outlives its last viewer window (verified
+ 2026-09-28), so once nothing is shown, end it. It runs with a profile
+ of its own, so nothing else is touched."""
+ time.sleep(2) # the viewers close their windows first
+ with self.viewer_lock:
+ if self.shown or self.shows != shows or self.launching:
+ return
+ try:
+ self.run("pkill -f '[f]rame-control/mac-view|[d]ata/frame-mac-view' || true", timeout=10)
+ except Exception:
+ pass
+
+ def state(self):
+ reason = self.unavailable()
+ if reason:
+ return {"available": False, "reason": reason}
+ status = self.call("/status")
+ windows = self.call("/windows").get("windows", []) if status.get("screen") else []
+ displays = self.call("/displays").get("displays", [])
+ return {"available": True, "screen": status.get("screen", False),
+ "accessibility": status.get("accessibility", False), "streams": status.get("streams", []),
+ "windows": windows, "displays": displays, "tunnel": self.tunnel_up(),
+ "route": self.route}
+
+ def restart_agent(self):
+ """Only if it's missing a permission: a running stream would stop."""
+ with self.lock:
+ if not (self.agent and self.agent.poll() is None):
+ return
+ status = self.call("/status")
+ if status.get("screen") and status.get("accessibility"):
+ return
+ with self.lock:
+ self.agent.terminate()
+ self.agent.wait(5)
+
+ def request_permissions(self):
+ return self.call("/permissions", method="POST")
+
+ def shutdown(self):
+ self.closing = True
+ try:
+ self.stop()
+ except MacViewError:
+ pass
+ for proc in (self.tunnel, self.agent):
+ if proc and proc.poll() is None:
+ proc.terminate()
+ # The helper puts separated windows back before it exits (about 1 s).
+ if self.agent:
+ try:
+ self.agent.wait(3)
+ except subprocess.TimeoutExpired:
+ self.agent.kill()
+
+
+def _quote(s):
+ return "'" + s.replace("'", "'\\''") + "'"
diff --git a/ui/frame_mcp.py b/ui/frame_mcp.py
new file mode 100644
index 0000000..1ccb0e6
--- /dev/null
+++ b/ui/frame_mcp.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""Key-free stdio MCP adapter; starts its own Frame Control backend by default."""
+import argparse
+import base64
+import json
+import os
+from pathlib import Path
+import queue
+import re
+import secrets
+import signal
+import subprocess
+import threading
+from contextlib import contextmanager
+import sys
+from urllib.parse import urlencode, urlsplit
+from urllib.error import HTTPError
+from urllib.request import ProxyHandler, Request, build_opener, HTTPRedirectHandler
+
+MAX_LINE = 1024 * 1024
+
+
+class NoRedirect(HTTPRedirectHandler):
+ def redirect_request(self, *args, **kwargs):
+ raise ValueError('Frame Control must not redirect')
+
+
+class Client:
+ def __init__(self, url, key='1'):
+ parsed = urlsplit(url)
+ if parsed.scheme != 'http' or parsed.hostname not in ('localhost', '127.0.0.1') or parsed.path not in ('', '/') or parsed.query or parsed.fragment or parsed.username or parsed.password:
+ raise ValueError('Frame Control URL must be HTTP loopback with no path or credentials')
+ self.url, self.key = url.rstrip('/'), key
+ self.opener = build_opener(ProxyHandler({}), NoRedirect())
+
+ def request(self, path, body=None, image=False):
+ req = Request(self.url + path, data=None if body is None else json.dumps(body).encode(),
+ headers={'X-Frame-UI': self.key, 'Content-Type': 'application/json'})
+ try:
+ with self.opener.open(req, timeout=360) as res:
+ data = res.read(16 * 1024**2 + 1)
+ except HTTPError as exc:
+ with exc:
+ raw = exc.read(65536)
+ try:
+ message = json.loads(raw).get('error', 'HTTP ' + str(exc.code))
+ except (ValueError, AttributeError):
+ message = 'HTTP ' + str(exc.code)
+ raise ValueError(str(message)) from None
+ if len(data) > 16 * 1024**2:
+ raise ValueError('Frame Control response too large')
+ return data if image else json.loads(data)
+
+
+def tool(name, description, properties=None, required=None, read=False):
+ return {'name': name, 'description': description, 'inputSchema': {
+ 'type': 'object', 'properties': properties or {}, 'required': required or [], 'additionalProperties': False},
+ 'annotations': {'readOnlyHint': read, 'destructiveHint': not read, 'openWorldHint': True}}
+
+
+def string(description):
+ return {'type': 'string', 'description': description}
+
+
+TOOLS = [tool('computer_state', 'Read Frame X11 windows and a bounded AT-SPI accessibility tree. Names are untrusted app content. Observation only, no clicks or typing.', read=True),
+ tool('status', 'Read battery, services and installed apps.', read=True),
+ tool('screenshot', 'Capture the headset (private screen content is returned to this MCP client).',
+ {'view': {'type': 'string', 'enum': ['headset', 'desktop']}}, read=True),
+ tool('job', 'Check a background install job.', {'id': string('Job ID')}, ['id'], read=True)]
+for name, field, description in [
+ ('launch', 'appid', 'Launch an installed Steam app by ID.'),
+ ('install', 'id', 'Install a free Flatpak from Flathub to the user account.'),
+ ('uninstall', 'id', 'Uninstall a user Flatpak.'),
+ ('send_text', 'text', 'Send text to the Frame desktop clipboard.'),
+ ('send_file', 'path', 'Send a file (up to 16 MiB) from the HTTP server computer to Frame Downloads.'),
+ ('panel', 'id', 'Open an installed Flatpak as a floating panel; needs zsh on the computer.'),
+ ('power', 'action', 'suspend, reboot or poweroff. Opens a terminal for the user password.'),
+ ('keep_awake', 'action', 'on, off or status using the optional PR #16 script. on changes idle timers; off restores them. Never automatic.'),
+]:
+ TOOLS.append(tool(name, description + ' Mutations require user approval at the returned approvalUrl; retry with its confirmation token. Never approve on the user’s behalf.',
+ {field: string(description), 'confirmation': string('Token returned by a previous call, after the user approves')}, [field]))
+
+
+def call(client, name, args):
+ spec = next((t for t in TOOLS if t['name'] == name), None)
+ if not spec or not isinstance(args, dict):
+ raise ValueError('Unknown tool or invalid arguments')
+ schema = spec['inputSchema']
+ if set(args) - set(schema['properties']) or set(schema['required']) - set(args):
+ raise ValueError('Unknown or missing arguments')
+ if any(not isinstance(v, str) for v in args.values()):
+ raise ValueError('Arguments must be strings')
+ if name == 'screenshot':
+ view = args.get('view', 'headset')
+ if view not in ('headset', 'desktop'):
+ raise ValueError('Unknown screenshot view')
+ png = client.request('/api/screenshot?' + urlencode({'view': view}), image=True)
+ return {'content': [{'type': 'image', 'mimeType': 'image/png', 'data': base64.b64encode(png).decode()}]}
+ if name == 'computer_state':
+ result = client.request('/api/computer/state')
+ elif name in ('status', 'job'):
+ result = client.request('/api/' + name + ('?' + urlencode(args) if args else ''))
+ else:
+ args = dict(args)
+ confirmation = args.pop('confirmation', None)
+ result = client.request('/api/agent/call', {'name': name, 'arguments': args, 'confirmation': confirmation})
+ if 'approvalPath' in result:
+ result['approvalUrl'] = client.url + result['approvalPath']
+ return {'content': [{'type': 'text', 'text': json.dumps(result)}]}
+
+
+def dispatch(client, message):
+ if not isinstance(message, dict) or message.get('jsonrpc') != '2.0' or not isinstance(message.get('method'), str):
+ return {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32600, 'message': 'Invalid request'}}
+ if 'id' not in message:
+ return None
+ method, params = message['method'], message.get('params', {})
+ response = {'jsonrpc': '2.0', 'id': message['id']}
+ if not isinstance(params, dict):
+ return {**response, 'error': {'code': -32602, 'message': 'Invalid params'}}
+ if method == 'initialize':
+ requested = params.get('protocolVersion')
+ result = {'protocolVersion': requested if requested in ('2024-11-05', '2025-03-26', '2025-06-18') else '2025-06-18',
+ 'capabilities': {'tools': {}}, 'serverInfo': {'name': 'frame-control', 'version': '1.0.0'}}
+ elif method == 'ping':
+ result = {}
+ elif method == 'tools/list':
+ result = {'tools': TOOLS}
+ elif method == 'tools/call':
+ try:
+ result = call(client, params.get('name'), params.get('arguments', {}))
+ except Exception as exc:
+ result = {'isError': True, 'content': [{'type': 'text', 'text': 'Frame Control: ' + str(exc)}]}
+ else:
+ return {**response, 'error': {'code': -32601, 'message': 'Method not found'}}
+ return {**response, 'result': result}
+
+
+@contextmanager
+def backend(url=None):
+ """Own one private HTTP backend per MCP process, or use an explicit existing one."""
+ if url:
+ yield Client(url, os.environ.get('FRAME_UI_KEY', '1'))
+ return
+ key = secrets.token_urlsafe(32)
+ env = {**os.environ, 'FRAME_UI_KEY': key, 'DO_NOT_TRACK': '1', 'FRAME_PRIVATE_SSH': '1'}
+ proc = subprocess.Popen([sys.executable, str(Path(__file__).with_name('server.py')),
+ '--port', '0', '--exit-on-eof'],
+ env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
+ stderr=sys.stderr, text=True)
+ lines = queue.Queue()
+
+ def read_banner():
+ lines.put(proc.stdout.readline())
+
+ threading.Thread(target=read_banner, daemon=True).start()
+ try:
+ try:
+ banner = lines.get(timeout=10)
+ except queue.Empty:
+ raise RuntimeError('Frame Control backend did not start within 10 seconds') from None
+ match = re.fullmatch(r'Frame Control on (http://127\.0\.0\.1:[0-9]+) .*\n?', banner)
+ if not match:
+ raise RuntimeError('Frame Control backend failed to start; see stderr')
+ yield Client(match.group(1), key)
+ finally:
+ # Closing stdin asks server.py to clean up its SSH master and jobs.
+ proc.stdin.close()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ proc.stdout.close()
+
+
+def serve(client):
+ while True:
+ line = sys.stdin.buffer.readline(MAX_LINE + 1)
+ if not line:
+ break
+ if len(line) > MAX_LINE:
+ print('MCP request too large', file=sys.stderr)
+ return 1
+ try:
+ response = dispatch(client, json.loads(line))
+ except (ValueError, UnicodeError):
+ response = {'jsonrpc': '2.0', 'id': None, 'error': {'code': -32700, 'message': 'Parse error'}}
+ if response is not None:
+ print(json.dumps(response), flush=True)
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--url', help='Use an existing HTTP server instead of starting a private backend')
+ args = parser.parse_args()
+ signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt))
+ try:
+ with backend(args.url) as client:
+ return serve(client)
+ except KeyboardInterrupt:
+ return 0
+ except (OSError, RuntimeError) as exc:
+ print(str(exc), file=sys.stderr)
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/ui/frame_media.py b/ui/frame_media.py
new file mode 100644
index 0000000..857ee91
--- /dev/null
+++ b/ui/frame_media.py
@@ -0,0 +1,67 @@
+"""Media planning shared by Frame Control and its own Frame-side player.
+
+No viewer dependencies. Filename hints are suggestions, never guesses from
+resolution. Explicit layout wins; conflicting hints require a choice.
+"""
+import re
+from pathlib import Path
+
+LAYOUTS = ('auto', 'mono', 'sbs', 'ou', 'full-sbs', 'full-ou')
+VIDEO = {'.mp4', '.mkv', '.mov', '.webm', '.m4v'}
+PHOTO = {'.png', '.jpg', '.jpeg'}
+
+
+def plan(name, layout='auto', metadata=None):
+ if layout not in LAYOUTS:
+ raise ValueError('Choose auto, mono, sbs, ou, full-sbs or full-ou')
+ suffix = Path(name).suffix.lower()
+ if suffix in {'.heic', '.heif', '.avif', '.mpo'}:
+ raise ValueError('Native spatial-photo containers are not supported yet; export both eyes as SBS or OU PNG/JPEG')
+ if suffix == '.splat':
+ return {'kind': 'splat', 'layout': 'sbs', 'source': 'renderer'}
+ if suffix not in VIDEO | PHOTO:
+ raise ValueError('Use MP4/MKV/MOV/WebM video, PNG/JPEG stereo photos, or a .splat file')
+ source = 'explicit'
+ if layout == 'auto':
+ tokens = set(re.split(r'[^a-z0-9]+', Path(name).stem.lower()))
+ hints = set()
+ for value, tags in [('full-sbs', {'fsbs'}), ('full-ou', {'fou', 'ftb'}),
+ ('sbs', {'sbs', 'hsbs', 'lr'}), ('ou', {'ou', 'hou', 'tb', 'htb'})]:
+ if tokens & tags:
+ hints.add(value)
+ if len(hints) > 1:
+ raise ValueError('Conflicting stereo filename tags; choose the layout explicitly')
+ layout = next(iter(hints), None)
+ source = 'filename'
+ if not layout:
+ # Matroska StereoMode/FFmpeg stereo_mode: only known left-first modes.
+ mode = (metadata or {}).get('stereo_mode')
+ layout = {'left_right': 'full-sbs', 'top_bottom': 'full-ou', 'mono': 'mono'}.get(mode)
+ source = 'metadata'
+ if mode and layout is None:
+ raise ValueError('Unsupported stereo metadata; choose the eye order/layout explicitly')
+ if not layout:
+ raise ValueError('No stereo layout found; choose mono, SBS or OU (left/top eye first)')
+ return {'kind': 'video' if suffix in VIDEO else 'photo', 'layout': layout, 'source': source}
+
+
+def geometry(width, height, layout):
+ """Bound transfer to 1920x1080; return packed dimensions and texel aspect."""
+ if not 0 < width <= 32768 or not 0 < height <= 32768:
+ raise ValueError('Invalid media dimensions')
+ if layout not in LAYOUTS[1:]:
+ raise ValueError('Resolve the layout before playback')
+ scale = min(1, 1920 / width, 1080 / height)
+ w, h = max(2, int(width * scale) // 2 * 2), max(2, int(height * scale) // 2 * 2)
+ return w, h, {'mono': 1, 'sbs': 2, 'ou': .5, 'full-sbs': 1, 'full-ou': 1}[layout]
+
+
+def stereo_pixels(data, width, height, layout):
+ """Normalize top/bottom to OpenVR's left/right texture; preserve eye order."""
+ if len(data) != width * height * 4:
+ raise ValueError('Incomplete RGBA frame')
+ if layout not in ('ou', 'full-ou'):
+ return data, width, height
+ stride, half = width * 4, height // 2
+ return b''.join(data[y*stride:(y+1)*stride] +
+ data[(y+half)*stride:(y+half+1)*stride] for y in range(half)), width*2, half
diff --git a/ui/frame_media_cli.py b/ui/frame_media_cli.py
new file mode 100644
index 0000000..0d1abb1
--- /dev/null
+++ b/ui/frame_media_cli.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""Send local media to Frame Control's own OpenVR player."""
+import argparse
+import json
+from pathlib import Path
+import sys
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import frame_media
+import server
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument('files', nargs='*', type=Path)
+ ap.add_argument('--launch', action='store_true', help='play the one file being sent')
+ ap.add_argument('--layout', choices=frame_media.LAYOUTS, default='auto')
+ ap.add_argument('--theatre', action='store_true', help='bigger screen and dark surround')
+ ap.add_argument('--list', action='store_true')
+ ap.add_argument('--stop', action='store_true')
+ args = ap.parse_args()
+ if args.launch and len(args.files) != 1:
+ ap.error('--launch needs exactly one file')
+ if not args.files and not (args.list or args.stop):
+ ap.error('choose files, --list or --stop')
+ for path in args.files:
+ if not path.is_file():
+ ap.error('not a file: %s' % path)
+ frame_media.plan(path.name, 'mono')
+ if args.stop:
+ print(json.dumps(server.media({'action': 'stop'})))
+ for path in args.files:
+ result = server.push_media(path.resolve())
+ print(json.dumps(result))
+ if args.launch:
+ print(json.dumps(server.media({'action': 'play', 'id': result['id'],
+ 'layout': args.layout, 'theatre': args.theatre})))
+ if args.list:
+ print(json.dumps(server.media({'action': 'list'})))
+
+
+if __name__ == '__main__':
+ try:
+ main()
+ except (ValueError, server.Failure) as e:
+ sys.exit(str(e))
diff --git a/ui/frame_media_player.py b/ui/frame_media_player.py
new file mode 100644
index 0000000..5227da2
--- /dev/null
+++ b/ui/frame_media_player.py
@@ -0,0 +1,279 @@
+#!/usr/bin/env python3
+"""Frame Control's local-media OpenVR player. Runs on the Frame, no third-party app.
+
+SteamOS ffmpeg does hardware video decoding, scaling and audio output. OpenVR
+owns only our screen and optional black surround. Exiting destroys both.
+"""
+import argparse
+import ctypes as C
+import json
+import os
+from pathlib import Path
+import signal
+import subprocess
+import time
+
+import frame_media
+import frame_splat
+
+LIB = '/opt/steamvr/bin/linuxarm64/libopenvr_api.so'
+H = C.c_uint64
+# Slots from Valve's openvr_capi.h, IVROverlay_028. Fail closed on another ABI.
+SLOTS = {
+ 'CreateOverlay': (1, [C.c_char_p, C.c_char_p, C.POINTER(H)]),
+ 'DestroyOverlay': (3, [H]),
+ 'SetOverlayFlag': (11, [H, C.c_int, C.c_bool]),
+ 'SetOverlayAlpha': (16, [H, C.c_float]),
+ 'SetOverlayTexelAspect': (18, [H, C.c_float]),
+ 'SetOverlaySortOrder': (20, [H, C.c_uint32]),
+ 'SetOverlayWidthInMeters': (22, [H, C.c_float]),
+ 'SetOverlayTransformTrackedDeviceRelative': (35, [H, C.c_uint32, C.c_void_p]),
+ 'ShowOverlay': (43, [H]),
+ 'SetOverlayRaw': (62, [H, C.c_void_p, C.c_uint32, C.c_uint32, C.c_uint32]),
+}
+
+
+class OverlayBusy(RuntimeError):
+ """SetOverlayRaw's RequestFailed (23): SteamVR isn't taking frames, e.g. the
+ unworn headset is in standby (verified 2026-09-29). Transient, not fatal."""
+
+
+# A screen that can't take a frame for this long is broken, not asleep.
+BUSY_LIMIT = 300
+
+
+class Overlay:
+ def __init__(self):
+ self.handles = []
+ self.vr = C.CDLL(LIB)
+ self.vr.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
+ self.vr.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
+ self.vr.VR_GetGenericInterface.restype = C.c_void_p
+ err = C.c_int()
+ self.vr.VR_InitInternal2(C.byref(err), 2, None)
+ if err.value:
+ raise RuntimeError('SteamVR init failed: %s' % err.value)
+ ptr = self.vr.VR_GetGenericInterface(b'FnTable:IVROverlay_028', C.byref(err))
+ if not ptr or err.value:
+ self.vr.VR_ShutdownInternal()
+ raise RuntimeError('SteamVR needs IVROverlay_028: %s' % err.value)
+ self.table = C.cast(ptr, C.POINTER(C.c_void_p))
+
+ def call(self, name, *values):
+ slot, args = SLOTS[name]
+ rc = C.CFUNCTYPE(C.c_int, *args)(self.table[slot])(*values)
+ if rc == 23 and name == 'SetOverlayRaw':
+ raise OverlayBusy('SteamVR is not accepting frames (standby?)')
+ if rc:
+ raise RuntimeError('OpenVR %s failed: %s' % (name, rc))
+
+ def create(self, key, width, distance, stereo=False, aspect=1, order=1):
+ handle = H()
+ self.call('CreateOverlay', key.encode(), b'Frame Control media', C.byref(handle))
+ self.handles.append(handle)
+ self.call('SetOverlayWidthInMeters', handle, width)
+ self.call('SetOverlaySortOrder', handle, order)
+ self.call('SetOverlayTexelAspect', handle, aspect)
+ if stereo:
+ self.call('SetOverlayFlag', handle, 1024, True) # SideBySide_Parallel
+ matrix = (C.c_float * 12)(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -distance)
+ self.call('SetOverlayTransformTrackedDeviceRelative', handle, 0, matrix)
+ return handle
+
+ def pixels(self, handle, data, width, height):
+ buf = C.create_string_buffer(data)
+ self.call('SetOverlayRaw', handle, buf, width, height, 4)
+ self.call('ShowOverlay', handle)
+
+ def close(self):
+ # Best effort: SteamVR removes a disconnected client's overlays anyway,
+ # and a teardown error must not overwrite a finished playback's status.
+ for h in reversed(self.handles):
+ try:
+ self.call('DestroyOverlay', h)
+ except RuntimeError:
+ pass
+ self.vr.VR_ShutdownInternal()
+
+
+def probe(path):
+ result = subprocess.run(['ffprobe', '-v', 'error', '-show_streams', '-of', 'json', str(path)],
+ capture_output=True, text=True, timeout=30)
+ if result.returncode:
+ raise ValueError(result.stderr[-2000:] or 'Cannot read media')
+ streams = json.loads(result.stdout)['streams']
+ video = next((s for s in streams if s['codec_type'] == 'video'), None)
+ if not video:
+ raise ValueError('No image or video stream')
+ return video, any(s['codec_type'] == 'audio' for s in streams)
+
+
+def decoder_command(path, info, width, height, audio, photo=False):
+ cmd = ['ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error']
+ if not photo:
+ cmd += ['-re', '-readrate_initial_burst', '0']
+ codec = {'h264': 'h264_v4l2m2m', 'hevc': 'hevc_v4l2m2m'}.get(info['codec_name'])
+ if not codec:
+ raise ValueError('Hardware playback currently supports H.264 and H.265 only')
+ cmd += ['-c:v', codec]
+ cmd += ['-i', str(path), '-map', '0:v:0', '-vf', 'scale=%s:%s' % (width, height),
+ '-pix_fmt', 'rgba']
+ if photo:
+ cmd += ['-frames:v', '1']
+ else:
+ cmd += ['-r', '30']
+ cmd += ['-f', 'rawvideo', 'pipe:1']
+ if audio and not photo:
+ cmd += ['-map', '0:a:0', '-f', 'pulse', 'Frame Control Media']
+ return cmd
+
+
+def ignore_signals():
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+
+
+def write_status(path, **values):
+ tmp = path.with_suffix('.tmp')
+ tmp.write_text(json.dumps(values))
+ tmp.replace(path)
+
+
+def play(args):
+ path = Path(args.file).resolve(strict=True)
+ status = Path(args.status)
+ splat = path.suffix.lower() == '.splat'
+ if splat:
+ data, width, height = frame_splat.render(path)
+ plan = frame_media.plan(path.name)
+ aspect, photo, command = 1, True, None
+ else:
+ info, audio = probe(path)
+ plan = frame_media.plan(path.name, args.layout, info.get('tags'))
+ width, height, aspect = frame_media.geometry(info['width'], info['height'], plan['layout'])
+ photo = plan['kind'] == 'photo'
+ command = decoder_command(path, info, width, height, audio, photo)
+ vr, proc, frames, started = None, None, 0, time.monotonic()
+ dropped, busy_since, pending = 0, None, []
+
+ def show(handle, data, w, h, video=False):
+ """Submit a frame. During standby return False; a video frame is dropped."""
+ nonlocal dropped, busy_since
+ try:
+ vr.pixels(handle, data, w, h)
+ except OverlayBusy:
+ if not video:
+ return False # stills and the surround just wait; nothing is lost
+ dropped += 1
+ busy_since = busy_since or time.monotonic()
+ if time.monotonic() - busy_since > BUSY_LIMIT:
+ raise RuntimeError('SteamVR stopped accepting frames for %d s' % BUSY_LIMIT)
+ return False
+ if video:
+ busy_since = None
+ drain()
+ return True
+
+ def drain():
+ """Re-send anything that arrived during standby (e.g. the theatre surround)."""
+ while pending:
+ item = pending.pop(0)
+ try:
+ vr.pixels(*item)
+ except OverlayBusy:
+ pending.insert(0, item)
+ return
+
+ def hold(handle, data, w, h):
+ """Keep a still (photo or splat) up until Stop, retrying through standby."""
+ shown = False
+ while True:
+ if shown:
+ drain()
+ else:
+ shown = show(handle, data, w, h)
+ time.sleep(1)
+
+ # systemd sends SIGTERM to the whole unit, including ffmpeg. Python unwinds
+ # ownership; no unrelated Steam/SteamVR process or setting is touched.
+ def stop(signum, frame):
+ raise InterruptedError('Stopped')
+ signal.signal(signal.SIGTERM, stop)
+ signal.signal(signal.SIGINT, stop)
+ try:
+ vr = Overlay()
+ if args.theatre:
+ surround = vr.create('framecontrol.media.surround', 40, 4, order=0)
+ vr.call('SetOverlayAlpha', surround, .85)
+ if not show(surround, b'\x00\x00\x00\xff', 1, 1):
+ pending.append((surround, b'\x00\x00\x00\xff', 1, 1))
+ screen = vr.create('framecontrol.media.screen', 3 if args.theatre else 1.6, 2,
+ plan['layout'] != 'mono', aspect)
+ if splat:
+ write_status(status, state='playing', file=path.name, frames=1, **plan)
+ hold(screen, data, width, height)
+ proc = subprocess.Popen(command, stdout=subprocess.PIPE)
+ video_start = time.monotonic()
+ while True:
+ data = proc.stdout.read(width * height * 4)
+ if not data:
+ break
+ data, outw, outh = frame_media.stereo_pixels(data, width, height, plan['layout'])
+ if not photo:
+ time.sleep(max(0, video_start + frames/30 - time.monotonic()))
+ if photo:
+ still = data, outw, outh
+ else:
+ show(screen, data, outw, outh, video=True)
+ frames += 1
+ if frames == 1 or frames % 30 == 0:
+ write_status(status, state='playing', file=path.name, frames=frames,
+ dropped=dropped, seconds=time.monotonic()-started, **plan)
+ if not photo:
+ time.sleep(max(0, video_start + frames/30 - time.monotonic()))
+ rc = proc.wait(timeout=10)
+ if rc:
+ raise RuntimeError('ffmpeg exited %s; see media log' % rc)
+ if not frames:
+ raise RuntimeError('Decoder produced no frames')
+ if photo:
+ hold(screen, *still)
+ # From here on a Stop can't change the outcome; don't let it turn
+ # 'ended' into an error while we write status and clean up.
+ ignore_signals()
+ write_status(status, state='ended', frames=frames, dropped=dropped,
+ seconds=time.monotonic()-started)
+ except InterruptedError:
+ ignore_signals()
+ write_status(status, state='stopped', frames=frames, dropped=dropped)
+ finally:
+ ignore_signals()
+ if proc:
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ proc.stdout.close()
+ if vr:
+ vr.close()
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument('file')
+ ap.add_argument('--layout', choices=frame_media.LAYOUTS, default='auto')
+ ap.add_argument('--theatre', action='store_true')
+ ap.add_argument('--status', required=True)
+ args = ap.parse_args()
+ try:
+ play(args)
+ except Exception as e:
+ write_status(Path(args.status), state='error', error=str(e))
+ raise
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/frame_media_remote.py b/ui/frame_media_remote.py
new file mode 100644
index 0000000..fc0b2ae
--- /dev/null
+++ b/ui/frame_media_remote.py
@@ -0,0 +1,109 @@
+"""Frame-side library and process ownership for Frame Control media.
+
+Only the dedicated systemd user unit is controlled. No SteamVR settings change.
+"""
+import argparse
+import json
+from pathlib import Path
+import re
+import subprocess
+import sys
+
+import frame_media
+from frame_media_player import probe
+
+ROOT = Path.home() / 'Videos' / 'FrameControl'
+RUNTIME = Path.home() / '.local' / 'share' / 'frame-control' / 'media'
+UNIT = 'frame-control-media.service'
+STATUS = RUNTIME / 'status.json'
+
+
+def media_path(identity):
+ if not isinstance(identity, str) or '\\' in identity or '\x00' in identity:
+ raise ValueError('Invalid media id')
+ parts = Path(identity).parts
+ if len(parts) != 2 or not re.fullmatch('[0-9a-f]{32}', parts[0]) or parts[1].startswith('.'):
+ raise ValueError('Invalid media id')
+ candidate = ROOT / identity
+ if candidate.is_symlink() or candidate.parent.is_symlink():
+ raise ValueError('Media links are not supported')
+ path = candidate.resolve(strict=True)
+ if not path.is_file() or ROOT.resolve() not in path.parents:
+ raise ValueError('Media file is outside the library')
+ return path
+
+
+def active():
+ return subprocess.run(['systemctl', '--user', 'is-active', '--quiet', UNIT]).returncode == 0
+
+
+def status():
+ running = active()
+ try:
+ state = json.loads(STATUS.read_text())
+ except (OSError, ValueError):
+ state = {'state': 'idle'}
+ if not running and state.get('state') in ('playing', 'starting', 'paused'):
+ state = {'state': 'stopped', 'message': 'Player exited; check the media log if this was unexpected'}
+ return dict(state, running=running)
+
+
+def run(body):
+ action = body.get('action')
+ if action == 'list':
+ files = []
+ if ROOT.exists():
+ for folder in sorted(ROOT.iterdir()):
+ if not re.fullmatch('[0-9a-f]{32}', folder.name) or not folder.is_dir() or folder.is_symlink():
+ continue
+ for path in sorted(folder.iterdir()):
+ if path.is_file() and not path.is_symlink() and not path.name.startswith('.'):
+ files.append({'id': folder.name+'/'+path.name, 'name': path.name, 'bytes': path.stat().st_size})
+ return {'files': files, 'player': status()}
+ if action == 'status':
+ return status()
+ if action == 'stop':
+ # --collect unloads the unit after it exits; systemctl then exits 5
+ # ("not loaded", verified on the Frame). That's a finished player, not an error.
+ stopped = subprocess.run(['systemctl', '--user', 'stop', UNIT], capture_output=True, text=True, timeout=15)
+ if stopped.returncode not in (0, 5):
+ raise RuntimeError('Could not stop the media player: ' + (stopped.stderr.strip() or 'exit %s' % stopped.returncode))
+ return {'message': 'Media player stopped', **status()}
+ if action != 'play':
+ raise ValueError('Media action must be list, status, play or stop')
+ path = media_path(body.get('id'))
+ if type(body.get('theatre', False)) is not bool:
+ raise ValueError('theatre must be true or false')
+ info = {} if path.suffix.lower() == '.splat' else probe(path)[0]
+ plan = frame_media.plan(path.name, body.get('layout', 'auto'), info.get('tags'))
+ if active():
+ raise ValueError('Stop the current media before starting another file')
+ # systemd owns the process group and refuses a concurrent start of this name.
+ # The runtime cap also cleans up if the controlling computer disconnects.
+ subprocess.run(['systemctl', '--user', 'reset-failed', UNIT], stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, timeout=10)
+ STATUS.write_text(json.dumps({'state': 'starting', 'file': path.name}))
+ command = ['systemd-run', '--user', '--quiet', '--collect', '--unit='+UNIT,
+ '--property=RuntimeMaxSec=14400', '--property=TimeoutStopSec=8',
+ '--property=StandardOutput=append:'+str(RUNTIME/'player.log'),
+ '--property=StandardError=append:'+str(RUNTIME/'player.log'),
+ 'python3', str(RUNTIME/'frame_media_player.py'), str(path),
+ '--layout', body.get('layout', 'auto'), '--status', str(STATUS)]
+ if body.get('theatre'):
+ command.append('--theatre')
+ started = subprocess.run(command, capture_output=True, text=True, timeout=15)
+ if started.returncode:
+ raise RuntimeError('Could not start the media player: ' + (started.stderr.strip() or 'systemd-run exited %s' % started.returncode))
+ return {'message': 'Starting Frame Control media', 'plan': plan}
+
+
+def main():
+ try:
+ print(json.dumps(run(json.load(sys.stdin))))
+ except Exception as e:
+ print(json.dumps({'error': str(e)}))
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/frame_network.py b/ui/frame_network.py
new file mode 100644
index 0000000..252be4f
--- /dev/null
+++ b/ui/frame_network.py
@@ -0,0 +1,310 @@
+"""Which network this computer is on, and whether Tailscale is up.
+
+Frame Control remembers which of a headset's addresses worked on which network,
+so it needs a stable name for "this network". The Wi-Fi name (SSID) is the
+friendly one, but macOS 14+ hides it from apps without Location permission, and
+wired networks have none. So every network is identified by a fingerprint of
+its default gateway: the router's IP and MAC address, which stay the same for a
+given home or office network. The user can give a fingerprint a name.
+
+Runs on this computer (macOS, Linux, Windows). Python stdlib only; every probe
+is a short command with a timeout, and each parser has fixtures in
+tests/test_network.py.
+"""
+import hashlib
+import ipaddress
+import json
+import os
+import re
+import socket
+import subprocess
+import time
+
+import frame_host
+
+TIMEOUT = 3
+
+
+def run(argv, timeout=TIMEOUT):
+ """A command's stdout, or "" if it's missing, fails or takes too long."""
+ try:
+ r = subprocess.run(argv, capture_output=True, stdin=subprocess.DEVNULL, timeout=timeout,
+ **({"creationflags": subprocess.CREATE_NO_WINDOW} if frame_host.WINDOWS else {}))
+ except (OSError, subprocess.TimeoutExpired):
+ return ""
+ return r.stdout.decode("utf-8", "replace") if r.returncode == 0 else ""
+
+
+def valid_ip(text):
+ try:
+ ipaddress.ip_address(text)
+ return True
+ except ValueError:
+ return False
+
+
+def norm_mac(text):
+ """"b4:fb:e4:1:87:3f" or "B4-FB-E4-01-87-3F" -> "b4:fb:e4:01:87:3f"; None if it isn't a MAC."""
+ parts = re.split(r"[:-]", (text or "").strip())
+ if len(parts) != 6 or not all(re.fullmatch(r"[0-9A-Fa-f]{1,2}", p) for p in parts):
+ return None
+ mac = ":".join(p.lower().zfill(2) for p in parts)
+ return None if mac in ("00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff") else mac
+
+
+# ---- default gateway -------------------------------------------------------
+
+def parse_route_macos(text):
+ """`route -n get default` -> (gateway, interface)."""
+ gw = re.search(r"^\s*gateway:\s*(\S+)", text, re.M)
+ iface = re.search(r"^\s*interface:\s*(\S+)", text, re.M)
+ gateway = gw.group(1) if gw and valid_ip(gw.group(1)) else None
+ return gateway, iface.group(1) if iface else None
+
+
+def parse_route_linux(text):
+ """`ip -4 route show default` -> (gateway, interface) of the lowest-metric route."""
+ best = None
+ for line in text.splitlines():
+ m = re.search(r"^default via (\S+) dev (\S+)", line.strip())
+ if not m or not valid_ip(m.group(1)):
+ continue
+ metric = re.search(r"\bmetric (\d+)", line)
+ key = int(metric.group(1)) if metric else 0
+ if best is None or key < best[0]:
+ best = (key, m.group(1), m.group(2))
+ return (best[1], best[2]) if best else (None, None)
+
+
+def parse_route_windows(text):
+ """`route print -4 0.0.0.0` -> (gateway, local IP of the interface), lowest metric wins."""
+ best = None
+ for line in text.splitlines():
+ f = line.split()
+ if len(f) == 5 and f[0] == "0.0.0.0" and f[1] == "0.0.0.0" and valid_ip(f[2]) and f[4].isdigit():
+ if best is None or int(f[4]) < best[0]:
+ best = (int(f[4]), f[2], f[3])
+ return (best[1], best[2]) if best else (None, None)
+
+
+# ---- the gateway's MAC address ----------------------------------------------
+
+def parse_arp_macos(text, ip):
+ """`arp -n IP` -> MAC ("? (192.168.1.1) at b4:fb:e4:b5:67:55 on en0 ifscope [ethernet]")."""
+ m = re.search(r"\(" + re.escape(ip) + r"\) at (\S+)", text)
+ return norm_mac(m.group(1)) if m else None
+
+
+def parse_neigh_linux(text, ip):
+ """`ip neigh show IP` -> MAC ("192.168.1.1 dev wlan0 lladdr b4:fb:... REACHABLE")."""
+ for line in text.splitlines():
+ f = line.split()
+ if f and f[0] == ip and "lladdr" in f:
+ return norm_mac(f[f.index("lladdr") + 1]) if f.index("lladdr") + 1 < len(f) else None
+ return None
+
+
+def parse_arp_windows(text, ip):
+ """`arp -a IP` -> MAC (" 192.168.1.1 b4-fb-e4-b5-67-55 dynamic")."""
+ for line in text.splitlines():
+ f = line.split()
+ if len(f) >= 2 and f[0] == ip:
+ return norm_mac(f[1])
+ return None
+
+
+# ---- Wi-Fi name --------------------------------------------------------------
+
+def parse_summary_macos(text):
+ """`ipconfig getsummary IFACE` -> (ssid, is_wifi). macOS prints "" without Location permission."""
+ kind = re.search(r"^\s*InterfaceType\s*:\s*(\S+)", text, re.M)
+ ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
+ name = ssid.group(1) if ssid else None
+ if name in ("", ""):
+ name = None
+ return name, (kind.group(1).lower() == "wifi") if kind else None
+
+
+def parse_nmcli(text):
+ """`nmcli -t -f active,ssid dev wifi` -> the active SSID (colons in names come escaped as \\:)."""
+ for line in text.splitlines():
+ if line.startswith("yes:"):
+ return line[4:].replace("\\:", ":") or None
+ return None
+
+
+def parse_netsh(text):
+ """`netsh wlan show interfaces` -> the connected SSID (not the BSSID line)."""
+ state = re.search(r"^\s*State\s*:\s*(\S+)", text, re.M)
+ ssid = re.search(r"^\s*SSID\s*:\s*(.+?)\s*$", text, re.M)
+ if not ssid or (state and state.group(1).lower() != "connected"):
+ return None
+ return ssid.group(1)
+
+
+# ---- Tailscale -----------------------------------------------------------------
+
+def tailscale_cli():
+ extra = []
+ if frame_host.MAC:
+ extra.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
+ elif frame_host.WINDOWS:
+ for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)")):
+ if base:
+ extra.append(os.path.join(base, "Tailscale", "tailscale.exe"))
+ return frame_host.which("tailscale", *extra)
+
+
+def parse_tailscale(text):
+ """`tailscale status --json` -> {"up", "ip", "name", "tailnet", "peers": [...]}.
+
+ Each peer: {"name", "dns" (MagicDNS name, no trailing dot), "ips", "os", "online"}.
+ """
+ try:
+ data = json.loads(text)
+ except ValueError:
+ return {"up": False, "peers": []}
+ if not isinstance(data, dict):
+ return {"up": False, "peers": []}
+ me = data.get("Self") or {}
+ tailnet = (data.get("CurrentTailnet") or {}).get("Name") if isinstance(data.get("CurrentTailnet"), dict) else None
+ out = {"up": data.get("BackendState") == "Running",
+ "ip": next((ip for ip in me.get("TailscaleIPs") or [] if "." in ip), None),
+ "name": (me.get("DNSName") or "").rstrip(".") or None,
+ "tailnet": tailnet, "peers": []}
+ for p in (data.get("Peer") or {}).values():
+ if not isinstance(p, dict):
+ continue
+ out["peers"].append({"name": p.get("HostName") or "", "dns": (p.get("DNSName") or "").rstrip("."),
+ "ips": [ip for ip in p.get("TailscaleIPs") or [] if isinstance(ip, str)],
+ "os": p.get("OS") or "", "online": bool(p.get("Online"))})
+ return out
+
+
+def tailscale_status():
+ cli = tailscale_cli()
+ if not cli:
+ return {"up": False, "installed": False, "peers": []}
+ out = parse_tailscale(run([cli, "status", "--json"], timeout=4) or "{}")
+ out["installed"] = True
+ return out
+
+
+TAILNET_V4 = ipaddress.ip_network("100.64.0.0/10")
+TAILNET_V6 = ipaddress.ip_network("fd7a:115c:a1e0::/48")
+
+
+def is_tailscale(host):
+ host = host.lower().rstrip(".")
+ if host.endswith(".ts.net"):
+ return True
+ try:
+ ip = ipaddress.ip_address(host)
+ except ValueError:
+ return False
+ return ip in (TAILNET_V4 if ip.version == 4 else TAILNET_V6)
+
+
+def guess_kind(host):
+ """What sort of address a host is: mdns, tailscale, lan or manual."""
+ h = host.lower().rstrip(".")
+ if h.endswith(".local"):
+ return "mdns"
+ if is_tailscale(h):
+ return "tailscale"
+ try:
+ ip = ipaddress.ip_address(h.split("%")[0])
+ if ip.is_private or ip.is_link_local:
+ return "lan"
+ except ValueError:
+ pass
+ return "manual"
+
+
+# ---- putting it together ----------------------------------------------------------
+
+def network_id(gateway, mac):
+ """A short, stable id for a network: its gateway's IP and MAC. None until both are known."""
+ if not gateway or not mac:
+ return None
+ return "n-" + hashlib.sha1(f"{gateway}|{mac}".encode()).hexdigest()[:10]
+
+
+def local_ip(towards="192.0.2.1"):
+ """This computer's address on the default route (UDP connect sends nothing)."""
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
+ s.connect((towards, 9))
+ return s.getsockname()[0]
+ except OSError:
+ return None
+
+
+def gateway():
+ """(gateway IP, interface) of the default route."""
+ if frame_host.MAC:
+ return parse_route_macos(run(["route", "-n", "get", "default"]))
+ if frame_host.WINDOWS:
+ return parse_route_windows(run(["route", "print", "-4", "0.0.0.0"]))
+ return parse_route_linux(run(["ip", "-4", "route", "show", "default"]))
+
+
+def gateway_mac(ip):
+ if frame_host.MAC:
+ return parse_arp_macos(run(["arp", "-n", ip]), ip)
+ if frame_host.WINDOWS:
+ return parse_arp_windows(run(["arp", "-a", ip]), ip)
+ return parse_neigh_linux(run(["ip", "neigh", "show", ip]), ip)
+
+
+def poke(ip):
+ """Make the system look up the gateway's MAC (an ARP entry can expire)."""
+ try:
+ with socket.create_connection((ip, 53), timeout=0.3):
+ pass
+ except OSError:
+ pass
+
+
+def wifi(interface):
+ """(ssid or None, is_wifi or None) for the default route's interface."""
+ if frame_host.MAC:
+ if interface:
+ ssid, is_wifi = parse_summary_macos(run(["ipconfig", "getsummary", interface]))
+ if ssid or is_wifi is False:
+ return ssid, is_wifi
+ m = re.search(r"Current Wi-Fi Network: (.+)", run(["networksetup", "-getairportnetwork", interface]))
+ return (m.group(1).strip() if m else None), is_wifi
+ return None, None
+ if frame_host.WINDOWS:
+ ssid = parse_netsh(run(["netsh", "wlan", "show", "interfaces"]))
+ return ssid, True if ssid else None
+ if frame_host.which("nmcli"):
+ ssid = parse_nmcli(run(["nmcli", "-t", "-f", "active,ssid", "dev", "wifi"]))
+ else:
+ ssid = run(["iwgetid", "-r"]).strip() or None
+ return ssid, True if ssid else (interface.startswith(("wl", "wlan")) if interface else None)
+
+
+def fingerprint():
+ """The cheap part, polled every few seconds: (gateway, interface, gateway MAC)."""
+ gw, iface = gateway()
+ mac = None
+ if gw:
+ mac = gateway_mac(gw)
+ if not mac:
+ poke(gw)
+ mac = gateway_mac(gw)
+ return gw, iface, mac
+
+
+def current_network(fp=None, with_tailscale=True):
+ """Everything the connection status shows about this computer's network."""
+ gw, iface, mac = fp or fingerprint()
+ ssid, is_wifi = wifi(iface) if gw else (None, None)
+ net = {"id": network_id(gw, mac), "gateway": gw, "gateway_mac": mac, "interface": iface,
+ "ssid": ssid, "wifi": is_wifi, "local_ip": local_ip(gw) if gw else None, "checked": time.time()}
+ if with_tailscale:
+ ts = tailscale_status()
+ net["tailscale"] = {k: ts.get(k) for k in ("up", "installed", "ip", "name", "tailnet")}
+ return net
diff --git a/ui/frame_panels.py b/ui/frame_panels.py
new file mode 100644
index 0000000..4bf753c
--- /dev/null
+++ b/ui/frame_panels.py
@@ -0,0 +1,261 @@
+"""Panel switcher. Runs on the Frame, either piped over SSH or installed with
+--open for its loopback-only headset page. Uses Valve's shipped vrcmd and
+Chromium, not an overlay app. Spatial layout limitations: docs/panels.md.
+"""
+import argparse
+import hmac
+import json
+import os
+from pathlib import Path
+import re
+import secrets
+import signal
+import subprocess
+import sys
+import time
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+VRCMD = '/opt/steamvr/bin/linuxarm64/vrcmd'
+PANEL_ID = 2000999030
+PANEL_KEY = 'valve.steam.desktopgame.' + str(PANEL_ID)
+KEY = re.compile(r'[A-Za-z0-9_.:-]{1,200}\Z')
+
+
+class PanelError(Exception):
+ pass
+
+
+def run(args):
+ try:
+ p = subprocess.run(args, capture_output=True, text=True, timeout=10,
+ env={**os.environ, 'DISPLAY': ':0', 'LC_ALL': 'C.UTF-8'})
+ except (OSError, subprocess.TimeoutExpired) as e:
+ raise PanelError('The panel service did not answer: ' + str(e))
+ if p.returncode:
+ raise PanelError((p.stderr or p.stdout).strip()[-400:] or 'Panel command failed')
+ return p.stdout
+
+
+def parse_overlays(text):
+ """Only main dashboard panels, never their thumbnails, layers or cursors.
+ vrcmd output verified on SteamVR 2.18.1, BUILD_ID 20260925.6191901.
+ """
+ if '---- OVERLAYS ----' not in text:
+ raise PanelError('SteamVR did not return its panel list. Is the headset awake?')
+ panels = []
+ for line in text.splitlines():
+ m = re.fullmatch(r"'([^']+)' -- '(.*)', (.*?) VROverlayType_Dashboard_Main\s*", line)
+ if m and KEY.fullmatch(m[1]):
+ panels.append({'key': m[1], 'title': 'Panel switcher' if m[1] == PANEL_KEY else m[2] or m[1],
+ 'visible': 'not_visible' not in m[3]})
+ return panels
+
+
+def state():
+ return {'panels': parse_overlays(run([VRCMD, '--overlays']))}
+
+
+def focus(key):
+ if not isinstance(key, str) or not KEY.fullmatch(key):
+ raise PanelError('Choose an open panel.')
+ if key not in {p['key'] for p in state()['panels']}:
+ raise PanelError('That panel has closed. Refresh the list.')
+ run([VRCMD, '--showdashboard', key])
+ # vrcmd acknowledges dispatch, not final focus (a game or the user can
+ # switch again). Do not report a focus success without observing it.
+ return {'requested': key, 'message': 'Asked SteamVR to show the panel.'}
+
+
+PAGE = '''
+Panel switcher [fc-panels]
+
+Panel switcher Choose a panel to show it. Open this panel again from Steam's dashboard.
+Refresh Close switcher
+
+'''
+
+
+class Handler(BaseHTTPRequestHandler):
+ def setup(self):
+ super().setup()
+ self.connection.settimeout(5)
+
+ def log_message(self, *args):
+ pass # never log the page's access key
+
+ def reply(self, code, value, html=False):
+ data = value.encode() if html else json.dumps(value).encode()
+ self.send_response(code)
+ self.send_header('Content-Type', 'text/html; charset=utf-8' if html else 'application/json')
+ self.send_header('Content-Length', str(len(data)))
+ self.send_header('Cache-Control', 'no-store')
+ self.send_header('X-Content-Type-Options', 'nosniff')
+ self.send_header('Referrer-Policy', 'no-referrer')
+ self.send_header('Content-Security-Policy', "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
+ self.end_headers()
+ self.wfile.write(data)
+
+ def allowed(self):
+ host = '127.0.0.1:' + str(self.server.server_port)
+ origin = self.headers.get('Origin')
+ return (self.headers.get('Host') == host and
+ (origin is None or origin == 'http://' + host) and
+ hmac.compare_digest(self.headers.get('X-Panel-Key', '').encode(), self.server.key.encode()))
+
+ def do_GET(self):
+ if self.path == '/':
+ return self.reply(200, PAGE, html=True) # no data or access key in the page
+ if not self.allowed():
+ return self.reply(403, {'error': 'Open the switcher from Frame Control.'})
+ try:
+ if self.path == '/panels':
+ return self.reply(200, state())
+ self.reply(404, {'error': 'Not found'})
+ except PanelError as e:
+ self.reply(502, {'error': str(e)})
+
+ def do_POST(self):
+ if not self.allowed():
+ return self.reply(403, {'error': 'Forbidden'})
+ try:
+ size = int(self.headers.get('Content-Length', '0'))
+ if not 0 < size <= 1024:
+ raise ValueError('Invalid request size')
+ body = json.loads(self.rfile.read(size))
+ if not isinstance(body, dict):
+ raise ValueError('Expected an object')
+ if self.path == '/focus':
+ return self.reply(200, focus(body.get('key')))
+ if self.path == '/close':
+ self.server.closing = True
+ return self.reply(200, {'closed': True})
+ self.reply(404, {'error': 'Not found'})
+ except (ValueError, PanelError) as e:
+ self.reply(400, {'error': str(e)})
+
+
+def serve():
+ """Own only our Chromium profile and process group. No changes to Steam,
+ SteamVR, other Chromium sessions, or global power settings.
+ """
+ import fcntl # only on the Frame; module/tests also import on Windows
+ folder = Path.home() / '.local/share/frame-control/panels'
+ folder.mkdir(parents=True, exist_ok=True, mode=0o700)
+ with (folder / 'lock').open('w') as lock:
+ try:
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except BlockingIOError:
+ print(json.dumps(focus(PANEL_KEY)), flush=True)
+ return
+ chrome = Path.home() / 'chromium-xr/chrome'
+ if chrome.is_file():
+ command = [str(chrome)]
+ profile = folder / 'chromium'
+ elif Path('/usr/bin/chromium').is_file():
+ command = ['/usr/bin/chromium']
+ profile = folder / 'chromium'
+ elif subprocess.run(['flatpak', 'info', 'org.chromium.Chromium'],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0:
+ command = ['flatpak', 'run', 'org.chromium.Chromium']
+ profile = Path.home() / '.var/app/org.chromium.Chromium/data/frame-panel-switcher'
+ else:
+ raise PanelError('The headset switcher needs Chromium. The companion switcher still works.')
+ server = HTTPServer(('127.0.0.1', 0), Handler)
+ server.key = secrets.token_urlsafe(32)
+ server.closing = False
+ server.timeout = .5
+ url = 'http://127.0.0.1:%d/#%s' % (server.server_port, server.key)
+ env = {**os.environ, 'DISPLAY': ':0'}
+ env.pop('WAYLAND_DISPLAY', None)
+ browser = subprocess.Popen([*command, '--ozone-platform=x11',
+ '--user-data-dir=' + str(profile),
+ '--no-first-run', '--no-default-browser-check',
+ '--password-store=basic', '--window-size=1200,800', '--app=' + url],
+ env=env, start_new_session=True, stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ def stop(signum, frame):
+ server.closing = True
+ signal.signal(signal.SIGTERM, stop)
+ win = None
+ try:
+ deadline = time.monotonic() + 30
+ while not win and time.monotonic() < deadline and browser.poll() is None:
+ server.handle_request() # Chromium must fetch the page before it has a title
+ for line in run(['xwininfo', '-root', '-children']).splitlines():
+ m = re.match(r'\s*(0x[0-9a-fA-F]+) .*\[fc-panels\]', line)
+ if m:
+ win = m[1]
+ break
+ if not win:
+ raise PanelError('The switcher window did not appear within 30 seconds.')
+ run(['xprop', '-id', win, '-f', 'STEAM_GAME', '32c', '-set', 'STEAM_GAME', str(PANEL_ID)])
+ print(json.dumps({'message': 'Opened the panel switcher in the headset.'}), flush=True)
+ # Caller reads exactly one line, then disconnects; no more stdout.
+ while not server.closing and browser.poll() is None:
+ server.handle_request()
+ # Closing the last app window need not exit Chromium.
+ if win not in run(['xwininfo', '-root', '-children']):
+ break
+ finally:
+ server.server_close()
+ if browser.poll() is None:
+ os.killpg(browser.pid, signal.SIGTERM)
+ try:
+ browser.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ os.killpg(browser.pid, signal.SIGKILL)
+ browser.wait()
+
+
+def open_switcher():
+ # This command runs from an installed path, never from the SSH stdin copy.
+ proc = subprocess.Popen([sys.executable, str(Path(__file__).resolve()), '--serve'],
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
+ text=True, start_new_session=True)
+ line = proc.stdout.readline()
+ proc.stdout.close()
+ if not line:
+ raise PanelError('The headset switcher could not start.')
+ result = json.loads(line)
+ if 'error' in result:
+ raise PanelError(result['error'])
+ return result
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--focus')
+ parser.add_argument('--open', action='store_true')
+ parser.add_argument('--serve', action='store_true')
+ args = parser.parse_args()
+ try:
+ if args.serve:
+ serve()
+ else:
+ print(json.dumps(open_switcher() if args.open else focus(args.focus) if args.focus else state()))
+ except (PanelError, OSError) as e:
+ print(json.dumps({'error': str(e)}), flush=True)
+ return 1
+ return 0
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/ui/frame_report.py b/ui/frame_report.py
new file mode 100644
index 0000000..ffcfa9d
--- /dev/null
+++ b/ui/frame_report.py
@@ -0,0 +1,161 @@
+"""Report a problem from inside Frame Control. Python stdlib only.
+
+The page's Report a problem dialog shows the diagnostics below before anything
+is sent, then this sends the report privately to Frame Control's PostHog
+project as a `problem_report` event: only the maintainer can read it, and
+nothing is published. It is sent whatever the analytics settings are, because
+the person sends it deliberately. Diagnostics are scrubbed first
+(frame_telemetry.scrub); the person's own words are sent as written.
+"""
+import os
+import platform
+import sys
+import time
+import uuid
+
+import frame_host
+import frame_telemetry
+
+KINDS = ('bug', 'idea', 'question', 'other')
+TEXT_MAX = 5000 # the person's own text, in JavaScript (UTF-16) units like the page's maxlength
+DIAG_MAX = 8000 # the diagnostics block
+LOG_LINES = 60
+ACTIVITY_LINES = 25
+
+frame = {} # the Frame's last known SteamOS build, set by server.status()
+
+
+def u16(s):
+ """Length as the website's validator counts it (JavaScript strings are UTF-16)."""
+ return len(s.encode('utf-16-le')) // 2
+
+
+def cut(s, n):
+ """s shortened to at most n UTF-16 units, never splitting a character."""
+ while u16(s) > n:
+ s = s[:max(0, len(s) - max(1, (u16(s) - n) // 2))]
+ return s
+
+
+def _log_tail():
+ """The last lines of the server log the app writes (FRAME_CONTROL_LOG), newest first."""
+ path = os.environ.get('FRAME_CONTROL_LOG')
+ if not path:
+ return []
+ try:
+ with open(path, 'rb') as f:
+ f.seek(0, os.SEEK_END)
+ f.seek(max(0, f.tell() - 64 * 1024))
+ lines = f.read().decode('utf-8', 'replace').splitlines()
+ except OSError:
+ return []
+ # Request lines ("GET /api/status ...") are noise; keep what went wrong.
+ keep = [ln for ln in lines if ln.strip() and not ln.startswith(('GET ', 'POST '))]
+ return list(reversed(keep[-LOG_LINES:]))
+
+
+def diagnostics(activity=(), include_logs=False, limit=DIAG_MAX):
+ """What a report includes, scrubbed and at most `limit` UTF-16 units. Always the versions
+ and builds; recent activity and the server log only when asked for, since they can name
+ files. Sections are filled in order of use, newest lines first, so trimming drops the oldest."""
+ t = frame_telemetry.state()
+ levels = ', '.join(f"{name} {'on' if on else 'off'}" for name, on in
+ (('usage', t['usage']), ('compat', t['compat']), ('error details', t['diagnostics'])))
+ env = [
+ f"Frame Control {frame_telemetry.app_version()}"
+ f"{' (built app)' if os.environ.get('FRAME_CONTROL_PACKAGED') else ' (source checkout)'}",
+ f"Computer: {frame_host.NAME} {platform.release()} {platform.machine()}, Python {'%d.%d.%d' % sys.version_info[:3]}",
+ f"SteamOS: {frame.get('build') or 'unknown'} ({frame.get('version') or 'not connected since start'})",
+ f"Analytics: {levels}",
+ f"Report time: {time.strftime('%Y-%m-%d %H:%M %Z')}",
+ ]
+ out = frame_telemetry.scrub('\n'.join(env), limit=limit)
+ if not include_logs:
+ return cut(out, limit)
+ sections = [('Recent activity (newest first):', [str(a)[:300] for a in list(activity)[:ACTIVITY_LINES] if isinstance(a, str)]),
+ ('Server log (newest first):', _log_tail())]
+ for title, lines in sections:
+ if not lines:
+ continue
+ block = '\n\n' + title
+ if u16(out + block) > limit:
+ break
+ out += block
+ for line in lines:
+ line = '\n' + frame_telemetry.scrub(line, 300)
+ if u16(out + line) > limit:
+ break
+ out += line
+ return out
+
+
+def compose(body):
+ """(title, text, diagnostics): the diagnostics exactly as the dialog previewed them (passed
+ back, scrubbed again and bounded here)."""
+ title = ' '.join(str(body.get('title') or '').split())
+ text = str(body.get('message') or '').strip()
+ if len(title) < 5:
+ raise ValueError('give it a short title (at least 5 characters)')
+ if len(text) < 10:
+ raise ValueError('say a little more about what happened (at least 10 characters)')
+ diag = body.get('diagnostics')
+ diag = cut(frame_telemetry.scrub(diag, 40000), DIAG_MAX) if isinstance(diag, str) and diag.strip() else ''
+ return cut(title, 120), cut(text, TEXT_MAX), diag
+
+
+def send(body):
+ """Send the report to PostHog. Returns {"id", "message"}; raises ReportError."""
+ kind = body.get('kind') if body.get('kind') in KINDS else 'bug'
+ title, text, diag = compose(body)
+ ref = uuid.uuid4().hex[:8].upper()
+ props = {**frame_telemetry.common(), 'kind': kind, 'title': title, 'message': text,
+ 'contact': str(body.get('contact') or '').strip()[:120], 'diagnostics': diag,
+ 'report_id': ref, 'steamos': str(frame.get('build') or '')[:120], 'level': 'report'}
+ # Its own random id: a report can carry contact details, so it isn't linked to this copy's analytics.
+ event = {'event': 'problem_report', 'distinct_id': str(uuid.uuid4()), 'uuid': str(uuid.uuid4()),
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'properties': props}
+ try:
+ frame_telemetry.post([event], timeout=30)
+ except frame_telemetry.SendError as e:
+ raise ReportError(str(e))
+ try:
+ frame_telemetry.record_sent([event])
+ except OSError:
+ pass # it was sent; failing to log it here mustn't make the person send it again
+ return {'id': ref, 'message': f'Sent privately to the Frame Control developer (report {ref}).'}
+
+
+class ReportError(RuntimeError):
+ pass
+
+
+def inbox(days=30):
+ """The maintainer's recent reports from PostHog, newest first (needs the personal API key
+ frame_compat_db.sync uses)."""
+ import frame_compat_db
+ res = frame_compat_db._posthog_query(
+ "SELECT timestamp, properties.report_id, properties.kind, properties.title, properties.message, "
+ "properties.contact, properties.app_version, properties.os, properties.steamos, properties.diagnostics "
+ f"FROM events WHERE event = 'problem_report' AND timestamp > now() - INTERVAL {int(days)} DAY "
+ "ORDER BY timestamp DESC LIMIT 200")
+ return res.get('results') or []
+
+
+def main():
+ cmd, *args = sys.argv[1:] or ['inbox']
+ if cmd != 'inbox':
+ sys.exit('usage: frame_report.py inbox [days]')
+ for row in inbox(*(args[:1] or [30])):
+ if not isinstance(row, list) or len(row) != 10:
+ continue
+ ts, ref, kind, title, text, contact, version, osname, steamos, diag = (str(v or '') for v in row)
+ print(f"== {ts[:16].replace('T', ' ')} {ref} [{kind}] {title}")
+ print(f" {version} on {osname}, SteamOS {steamos or 'unknown'}{', reply to ' + contact if contact else ''}")
+ print(' ' + text.replace('\n', '\n '))
+ if diag:
+ print(' --- diagnostics\n ' + diag.replace('\n', '\n '))
+ print()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/ui/frame_splat.py b/ui/frame_splat.py
new file mode 100644
index 0000000..e3b149d
--- /dev/null
+++ b/ui/frame_splat.py
@@ -0,0 +1,83 @@
+"""Small, bounded CPU Gaussian-splat preview renderer (Frame Control-owned).
+
+Reads the common 32-byte .splat record: position/scale float32 triplets,
+RGBA bytes, then normalized quaternion bytes (wxyz). Two perspective cameras,
+projected 3D covariance, back-to-front alpha compositing. This is a stationary
+stereo preview, not a six-degree-of-freedom scene or a large-scene renderer.
+"""
+import math
+from pathlib import Path
+import struct
+
+MAX_SPLATS = 20000
+RECORD = struct.Struct('<6f8B')
+
+
+def read(path):
+ size = Path(path).stat().st_size
+ if not size or size % RECORD.size or size > MAX_SPLATS * RECORD.size:
+ raise ValueError('Use a 32-byte .splat file with 1–20,000 Gaussians; PLY/SPZ and larger scenes are not supported yet')
+ values = []
+ with open(path, 'rb') as stream:
+ for row in RECORD.iter_unpack(stream.read(MAX_SPLATS * RECORD.size + 1)):
+ xyz, scales = row[:3], row[3:6]
+ if not all(math.isfinite(v) and abs(v) <= 1e6 for v in row[:6]) or min(scales) <= 0:
+ raise ValueError('Invalid splat position or scale')
+ q = [(v - 128) / 128 for v in row[10:14]]
+ length = math.sqrt(sum(v*v for v in q))
+ if length < .01:
+ raise ValueError('Invalid splat quaternion')
+ w, x, y, z = [v / length for v in q]
+ rotation = ((1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w)),
+ (2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w)),
+ (2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)))
+ cov = [[sum(rotation[i][k]*rotation[j][k]*scales[k]**2 for k in range(3))
+ for j in range(3)] for i in range(3)]
+ values.append((xyz, cov, row[6:10]))
+ return values
+
+
+def render(path, width=320, height=240):
+ values = read(path)
+ lo = [min(p[0][i] for p in values) for i in range(3)]
+ hi = [max(p[0][i] for p in values) for i in range(3)]
+ center = [(a+b)/2 for a, b in zip(lo, hi)]
+ radius = max(max(b-a for a, b in zip(lo, hi))/2, .01)
+ # Normalize captures to a two-metre box. Source units are not assumed metres.
+ normalized = [([(xyz[i]-center[i])/radius for i in range(3)],
+ [[v/radius**2 for v in row] for row in cov], color)
+ for xyz, cov, color in values]
+ normalized.sort(key=lambda p: p[0][2]) # camera is at z=3; farthest first
+ focal = width * .8
+ eyes = []
+ for eye in (-.032, .032):
+ pixels = bytearray(b'\x00\x00\x00\xff' * (width*height))
+ for (x, y, z), cov, color in normalized:
+ x -= eye
+ depth = 3-z
+ px, py = width/2+focal*x/depth, height/2-focal*y/depth
+ jac = ((focal/depth, 0, focal*x/depth**2),
+ (0, -focal/depth, -focal*y/depth**2))
+ screen = [[sum(jac[i][a]*cov[a][b]*jac[j][b] for a in range(3) for b in range(3))
+ for j in range(2)] for i in range(2)]
+ a, b, c = screen[0][0]+.3, screen[0][1], screen[1][1]+.3
+ det = a*c-b*b
+ if det <= 0 or not math.isfinite(det):
+ raise ValueError('Splat covariance is not renderable')
+ # A footprint cap bounds work on malformed or oversized Gaussians.
+ rx, ry = min(32, math.ceil(3*math.sqrt(a))), min(32, math.ceil(3*math.sqrt(c)))
+ for sy in range(max(0, int(py)-ry), min(height, int(py)+ry+1)):
+ dy = sy+.5-py
+ for sx in range(max(0, int(px)-rx), min(width, int(px)+rx+1)):
+ dx = sx+.5-px
+ power = (c*dx*dx-2*b*dx*dy+a*dy*dy)/det
+ if power > 9:
+ continue
+ alpha = color[3]/255 * math.exp(-.5*power)
+ offset = (sy*width+sx)*4
+ for k in range(3):
+ pixels[offset+k] = round(color[k]*alpha+pixels[offset+k]*(1-alpha))
+ eyes.append(pixels)
+ stride = width*4
+ return b''.join(eyes[0][y*stride:(y+1)*stride]+eyes[1][y*stride:(y+1)*stride]
+ for y in range(height)), width*2, height
diff --git a/ui/frame_status.py b/ui/frame_status.py
index 2047abf..92ef7a1 100644
--- a/ui/frame_status.py
+++ b/ui/frame_status.py
@@ -1,9 +1,12 @@
"""Runs ON the Steam Frame (piped over SSH as `python3 -`); prints one JSON object.
Read-only. Every probe is best effort: a missing tool or file gives null, not an
-error. Paths verified on SteamOS 0.3.0 (vr), build 20260922.
+error. Sensor paths verified on SteamOS 0.3.0; OpenVR timing on 0.4.1
+(build 20260925.6191901). See docs/vr-utilities.md.
"""
+import ctypes as C
import glob
+import math
import json
import os
import re
@@ -156,27 +159,156 @@ def flatpaks():
return out
-uptime = read("/proc/uptime")
-procs = process_names()
-print(json.dumps({
- "time": time.time(),
- "hostname": socket.gethostname(),
- "os": os_release(),
- "uptime": float(uptime.split()[0]) if uptime else None,
- "battery": battery(),
- "power": power_source(),
- "disk": {"root": disk("/"), "home": disk("/home")},
- "memory": memory(),
- "temp": max_temp(),
- "wifi": wifi(),
- "ip": ip_addr(),
- "volume": volume(),
- "services": {
- "steamvr": "vrserver" in procs,
- "desktop": "plasmashell" in procs,
- "lepton": port_listening(5555),
- "rdp": "xrdp" in procs,
- },
- "games": games(),
- "flatpaks": flatpaks(),
-}))
+def thermal_alerts():
+ """Use the kernel's per-zone hot/critical trips, never a guessed chip limit."""
+ alerts, known = [], False
+ for z in glob.glob("/sys/class/thermal/thermal_zone*"):
+ t = num(z + "/temp", 0.001)
+ for trip in glob.glob(z + "/trip_point_*_type"):
+ if read(trip) not in ("hot", "critical"):
+ continue
+ limit = num(trip[:-4] + "temp", 0.001)
+ if t is not None and limit is not None and limit > 0:
+ known = True
+ if t >= limit:
+ alerts.append({"zone": read(z + "/type"), "tempC": t, "limitC": limit})
+ return alerts if known else None
+
+
+def activity_level():
+ try:
+ rows = json.loads(run("/opt/steamvr/bin/linuxarm64/vrcmd", "--stats"))
+ if not isinstance(rows, list):
+ return None
+ return next((r.get("activity_level") for r in rows
+ if isinstance(r, dict) and r.get("operation") == "status"), None)
+ except (ValueError, TypeError):
+ return None
+
+
+# OpenVR C ABI, ValveSoftware/openvr headers/openvr_capi.h (IVRCompositor_029).
+# Exact interface version: never guess an index against a different table.
+class Pose(C.Structure):
+ _fields_ = [('matrix', C.c_float * 12), ('velocity', C.c_float * 3),
+ ('angular', C.c_float * 3), ('result', C.c_int),
+ ('valid', C.c_bool), ('connected', C.c_bool)]
+
+
+class Timing(C.Structure):
+ _fields_ = [(n, C.c_uint32) for n in ('size', 'index', 'presents', 'mis', 'dropped', 'flags')] + [
+ ('time', C.c_double)] + [(n, C.c_float) for n in (
+ 'gpuPre', 'gpuPost', 'gpu', 'compositorGpu', 'compositorCpu', 'idleCpu', 'interval',
+ 'presentCpu', 'waitCpu', 'submit', 'posesCalled', 'posesReady', 'frameReady',
+ 'updateStart', 'updateEnd', 'renderStart')] + [
+ ('pose', Pose), ('ready', C.c_uint32), ('first', C.c_uint32), ('transfer', C.c_float)]
+
+
+class OpenVR:
+ def __enter__(self):
+ self.lib = C.CDLL('/opt/steamvr/bin/linuxarm64/libopenvr_api.so')
+ self.lib.VR_InitInternal2.argtypes = [C.POINTER(C.c_int), C.c_int, C.c_char_p]
+ self.lib.VR_GetGenericInterface.argtypes = [C.c_char_p, C.POINTER(C.c_int)]
+ self.lib.VR_GetGenericInterface.restype = C.c_void_p
+ err = C.c_int()
+ self.lib.VR_InitInternal2(C.byref(err), 3, None) # background: never start or keep SteamVR running
+ if err.value:
+ raise RuntimeError(f'SteamVR unavailable (init {err.value})')
+ return self
+
+ def __exit__(self, *args):
+ self.lib.VR_ShutdownInternal()
+
+ def function(self, interface, index, result, *args):
+ err = C.c_int()
+ ptr = self.lib.VR_GetGenericInterface(('FnTable:' + interface).encode(), C.byref(err))
+ if err.value or not ptr:
+ raise RuntimeError(f'{interface} unavailable ({err.value})')
+ return C.CFUNCTYPE(result, *args)(C.cast(ptr, C.POINTER(C.c_void_p))[index])
+
+
+def cpu_ticks():
+ line = (read('/proc/stat') or '').splitlines()
+ if not line or not line[0].startswith('cpu '):
+ return None
+ try:
+ # guest/guest_nice are already included in user/nice.
+ ticks = [int(x) for x in line[0].split()[1:9]]
+ return sum(ticks), ticks[3] + ticks[4]
+ except (ValueError, IndexError):
+ return None
+
+
+def cpu_percent(before, after):
+ if before is None or after is None or after[0] <= before[0]:
+ return None
+ return round(max(0, min(100, 100 * (1 - (after[1] - before[1]) / (after[0] - before[0])))), 1)
+
+
+def positive(value):
+ return round(value, 2) if math.isfinite(value) and value > 0 else None
+
+
+def timing_values(before, after):
+ dt, frames = after.time - before.time, after.index - before.index
+ if dt <= 0 or frames <= 0 or frames / dt > 1000:
+ return {} # standby or old data; never present stale timings as live
+ return {'compositorFps': positive(frames / dt),
+ 'frameMs': positive(1000 * dt / frames),
+ 'appFps': positive(1000 / after.interval) if after.interval > 0 else None,
+ 'gpuMs': positive(after.gpu), 'compositorCpuMs': positive(after.compositorCpu)}
+
+
+def performance():
+ out = {'compositorFps': None, 'frameMs': None, 'appFps': None,
+ 'gpuMs': None, 'compositorCpuMs': None, 'cpuPercent': None,
+ 'gpuMHz': num('/sys/class/devfreq/3d00000.gpu/cur_freq', 1e-6)}
+ before = cpu_ticks()
+ try:
+ with OpenVR() as vr:
+ get = vr.function('IVRCompositor_029', 10, C.c_bool, C.POINTER(Timing), C.c_uint32)
+ a, b = Timing(), Timing()
+ a.size = b.size = C.sizeof(Timing)
+ first = get(C.byref(a), 0)
+ time.sleep(0.2)
+ if first and get(C.byref(b), 0):
+ out.update(timing_values(a, b))
+ except (OSError, RuntimeError, AttributeError): # AttributeError: a SteamVR build without these exports
+ time.sleep(0.2)
+ out['cpuPercent'] = cpu_percent(before, cpu_ticks())
+ return out
+
+
+def status():
+ uptime = read("/proc/uptime")
+ procs = process_names()
+ return {
+ "time": time.time(),
+ "performance": performance(),
+ "hostname": socket.gethostname(),
+ "os": os_release(),
+ "uptime": float(uptime.split()[0]) if uptime else None,
+ "battery": battery(),
+ "power": power_source(),
+ "disk": {"root": disk("/"), "home": disk("/home")},
+ "memory": memory(),
+ "temp": max_temp(),
+ "wifi": wifi(),
+ "ip": ip_addr(),
+ "volume": volume(),
+ "services": {
+ "steamvr": "vrserver" in procs,
+ "desktop": "plasmashell" in procs,
+ "lepton": port_listening(5555),
+ "rdp": "xrdp" in procs,
+ },
+ "games": games(),
+ "flatpaks": flatpaks(),
+ }
+
+
+def main():
+ print(json.dumps(status()))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ui/frame_steam.py b/ui/frame_steam.py
index 5c57d54..343200b 100644
--- a/ui/frame_steam.py
+++ b/ui/frame_steam.py
@@ -141,6 +141,21 @@ OWNED_JS = r"""
})()
"""
+# Software has app_type 2, so utility ownership must not use OWNED_JS's games filter.
+# Steam prices checked 2026-09-28: OVR Advanced Settings is paid on Steam too.
+UTILITY_IDS = (1009850, 1173510, 1068820, 908520, 1494460)
+FREE_UTILITIES = (1494460,)
+UTILITIES_JS = """(() => {
+ const apps = appStore.allApps;
+ if (!apps || !apps.length) throw new Error("Steam library is not loaded; ownership is unknown");
+ return [1009850,1173510,1068820,908520,1494460].map(id => {
+ const a = apps.find(a => a.appid === id);
+ return {id, owned: !!a, installed: !!a?.local_per_client_data?.installed,
+ frame: a ? (a.steam_hw_compat_category_packed >> 8) & 3 : 0};
+ });
+})()"""
+
+
WIZARD_JS = """SteamClient.Installs.GetInstallManagerInfo().then(i => ({
state: i?.eInstallState ?? 0, app: i?.currentAppID ?? 0, need: i?.nDiskSpaceRequired || 0,
free: i?.nDiskSpaceAvailable || 0, error: i?.eAppError, detail: i?.errorDetail }))"""
@@ -158,6 +173,10 @@ def owned():
def install(appid):
page = Page()
+ if appid in UTILITY_IDS and appid not in FREE_UTILITIES:
+ rows = page.eval(UTILITIES_JS)
+ if not any(r['id'] == appid and r['owned'] for r in rows):
+ raise Fail("This paid utility is not owned by the Frame account. No install or store action was taken.")
app = page.eval(f"(a => a && {{name: a.display_name, installed: !!a.local_per_client_data?.installed}})"
f"(appStore.GetAppOverviewByAppID({appid}))")
if app and app["installed"]:
@@ -216,6 +235,8 @@ def main():
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "owned":
out = owned()
+ elif cmd == "utilities":
+ out = {"utilities": Page().eval(UTILITIES_JS)}
elif cmd in ("install", "store") and len(sys.argv) == 3 and sys.argv[2].isdigit():
out = (install if cmd == "install" else store)(int(sys.argv[2]))
else:
diff --git a/ui/frame_steamgriddb.py b/ui/frame_steamgriddb.py
new file mode 100644
index 0000000..6a18bba
--- /dev/null
+++ b/ui/frame_steamgriddb.py
@@ -0,0 +1,105 @@
+"""Optional SteamGridDB artwork. Credentials stay on the host, never in app metadata."""
+import json
+import os
+import re
+import tempfile
+import time
+import unicodedata
+import urllib.parse
+
+import frame_host
+
+API = 'https://www.steamgriddb.com/api/v2'
+MAX_JSON = 2 * 1024 * 1024
+
+
+def settings_path():
+ return frame_host.data_dir('artwork-settings.json')
+
+
+def api_key():
+ env = os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY')
+ if env:
+ return env.strip()
+ try:
+ return str(json.loads(settings_path().read_text()).get('steamgriddb_api_key') or '')
+ except (OSError, ValueError, AttributeError):
+ return ''
+
+
+def settings():
+ return {'steamgriddb_configured': bool(api_key()),
+ 'environment': bool(os.environ.get('STEAMGRIDDB_API_KEY') or os.environ.get('FRAME_STEAMGRIDDB_API_KEY'))}
+
+
+def save_settings(body):
+ key = body.get('steamgriddb_api_key')
+ if not isinstance(key, str) or len(key) > 200 or (key and not re.fullmatch(r'[A-Za-z0-9_-]+', key)):
+ raise ValueError('enter a valid SteamGridDB API key, or an empty value to remove it')
+ path = settings_path()
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fd, temp = tempfile.mkstemp(prefix='.artwork-', dir=str(path.parent))
+ try:
+ with os.fdopen(fd, 'w') as f:
+ json.dump({'steamgriddb_api_key': key}, f)
+ os.replace(temp, path)
+ finally:
+ if os.path.exists(temp):
+ os.remove(temp)
+ return settings()
+
+
+def _get(path, key, deadline=None):
+ from apk_sources import _images
+ # No redirects: the credential never goes anywhere but the API. Errors never contain it.
+ data = _images.get(API + path, {'Authorization': 'Bearer ' + key, 'Accept': 'application/json'}, redirects=0,
+ deadline=time.monotonic() + 12 if deadline is None else min(deadline, time.monotonic() + 12),
+ limit=MAX_JSON)
+ result = json.loads(data)
+ if not isinstance(result, dict) or not result.get('success') or not isinstance(result.get('data'), list):
+ raise ValueError('SteamGridDB lookup failed')
+ return result['data']
+
+
+def _name(value):
+ # Letters and digits of any script, so a CJK title never normalises to ''.
+ return ''.join(c for c in unicodedata.normalize('NFKC', str(value or '')).casefold() if c.isalnum())
+
+
+def lookup(name, deadline=None):
+ """Best-voted art per slot for an exact title match; unrelated games are never guessed."""
+ key = api_key()
+ if not key:
+ return {}, []
+ try:
+ names = {_name(name), _name(re.sub(r'\s+VR$', '', name, flags=re.I))} - {''}
+ if not names:
+ return {}, []
+ matches = _get('/search/autocomplete/' + urllib.parse.quote(name, safe=''), key, deadline)
+ game = next((g for g in matches if isinstance(g, dict) and _name(g.get('name')) in names), None)
+ if not game:
+ return {}, []
+ gid = int(game['id'])
+ result, warnings = {}, []
+ for slot, kind, dimensions in [('grid', 'grids', '600x900'), ('wide', 'grids', '920x430'),
+ ('hero', 'heroes', ''), ('logo', 'logos', ''), ('icon', 'icons', '')]:
+ try:
+ # Logos and icons only come as PNG (or WebP/ICO); asking for JPEG there is rejected outright.
+ mimes = 'image/png,image/jpeg' if kind in ('grids', 'heroes') else 'image/png'
+ query = {'types': 'static', 'nsfw': 'false', 'humor': 'false', 'mimes': mimes}
+ if dimensions:
+ query['dimensions'] = dimensions
+ records = _get('/' + kind + '/game/' + str(gid) + '?' + urllib.parse.urlencode(query), key, deadline)
+ records = [r for r in records if isinstance(r, dict) and str(r.get('url', '')).startswith('https://')
+ and not r.get('nsfw')]
+ if dimensions:
+ w, h = map(int, dimensions.split('x'))
+ records = [r for r in records if (r.get('width'), r.get('height')) == (w, h)]
+ records.sort(key=lambda r: (int(r.get('score') or 0), int(r.get('upvotes') or 0)), reverse=True)
+ if records:
+ result[slot] = records[0]['url']
+ except Exception: # HTTPException, odd JSON: this slot falls back
+ warnings.append('SteamGridDB ' + slot + ' unavailable; using source or generated art')
+ return result, warnings
+ except Exception:
+ return {}, ['SteamGridDB unavailable; using source or generated art']
diff --git a/ui/frame_telemetry.py b/ui/frame_telemetry.py
new file mode 100644
index 0000000..5771ac7
--- /dev/null
+++ b/ui/frame_telemetry.py
@@ -0,0 +1,545 @@
+"""Anonymous analytics for Frame Control, sent to PostHog. Python stdlib only.
+
+Three levels, each chosen in the page's Privacy panel (docs/privacy.md lists
+every event and property):
+
+- usage (on by default, after the first-run notice has been shown): installs of
+ Frame Control, daily opens, updates, which tabs are used, and whether installs
+ on the Frame worked, with an error category from a fixed list. Never file
+ names, paths, hostnames, IP addresses, window titles or account data.
+- compat (opt-in): Android compatibility reports, the same fields the Report
+ dialog shows, so they reach the shared database (frame_compat_db.py). The
+ maintainer's sync (python3 ui/frame_compat_db.py sync) moves them there.
+- diagnostics (opt-in): error messages and Python tracebacks, scrubbed of
+ home folders, user names, addresses and keys.
+
+The first-run notice offers compat and diagnostics together, and the page's
+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
+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.
+
+Events wait in an outbox file and are sent in batches from a background thread,
+so going offline loses nothing. The last SENT_KEEP sent events are kept on this
+computer so the page can show exactly what left it.
+"""
+import ipaddress
+import json
+import os
+import platform
+import re
+import sys
+import threading
+import time
+import traceback
+import urllib.error
+import urllib.request
+import uuid
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import frame_host
+
+HERE = Path(__file__).resolve().parent
+STATE = frame_host.data_dir('telemetry')
+SETTINGS = STATE / 'settings.json'
+OUTBOX = STATE / 'outbox.jsonl'
+SENT = STATE / 'sent.jsonl'
+SENT_KEEP = 200
+OUTBOX_MAX = 2000 # events kept while offline; the oldest go first
+FLUSH_EVERY = 60
+REPEAT_WINDOW = 600 # the same diagnostic error is sent at most once in this many seconds
+DEFAULT_HOST = 'https://us.i.posthog.com'
+
+LEVELS = ('usage', 'compat', 'diagnostics')
+# 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'}
+
+_lock = threading.RLock()
+_send_lock = threading.Lock() # held while sending; consent changes wait for it
+_seen_errors = {}
+_flusher = None
+_wake = threading.Event()
+
+
+# ---- configuration and settings -------------------------------------------------
+
+def config():
+ """PostHog host and project key: the environment, else ui/telemetry.json."""
+ try:
+ with open(HERE / 'telemetry.json') as f:
+ c = json.load(f)
+ except (OSError, ValueError):
+ c = {}
+ host = os.environ.get('FRAME_CONTROL_POSTHOG_HOST') or c.get('host') or DEFAULT_HOST
+ key = os.environ.get('FRAME_CONTROL_POSTHOG_KEY') or c.get('key') or ''
+ project = os.environ.get('FRAME_CONTROL_POSTHOG_PROJECT') or c.get('project') or ''
+ return {'host': host.rstrip('/'), 'key': key, 'project': str(project)}
+
+
+def blocked():
+ """Why nothing may be sent at all, whatever the settings say, or None."""
+ if os.environ.get('DO_NOT_TRACK') == '1' or os.environ.get('FRAME_CONTROL_TELEMETRY') == '0':
+ return 'turned off by DO_NOT_TRACK or FRAME_CONTROL_TELEMETRY=0'
+ if not config()['key']:
+ return 'no PostHog project key in this build'
+ if not os.environ.get('FRAME_CONTROL_PACKAGED') and os.environ.get('FRAME_CONTROL_TELEMETRY') != '1':
+ return 'running from a source checkout (set FRAME_CONTROL_TELEMETRY=1 to send)'
+ return None
+
+
+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,
+ 'frames_seen': [], 'compat_sent': []}
+
+
+def settings():
+ with _lock:
+ s = _defaults()
+ try:
+ with open(SETTINGS) as f:
+ saved = json.load(f)
+ if isinstance(saved, dict):
+ s.update({k: v for k, v in saved.items() if k in s})
+ except (OSError, ValueError):
+ pass
+ if not SETTINGS.exists():
+ _save(s) # keep the id stable from the first call
+ return s
+
+
+def _save(s):
+ try:
+ STATE.mkdir(parents=True, exist_ok=True)
+ tmp = SETTINGS.with_suffix('.tmp')
+ tmp.write_text(json.dumps(s, indent=1))
+ os.replace(tmp, SETTINGS)
+ except OSError:
+ pass
+
+
+def enabled(level):
+ """Whether events of this level are collected: never when sending is blocked, so a
+ source checkout or a test run leaves nothing behind."""
+ if blocked():
+ return False
+ return bool(settings().get(level))
+
+
+def update_settings(changes):
+ """Apply the page's choices. Turning a level off drops its unsent events; a send already
+ 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']
+ for level in LEVELS:
+ if level in changes:
+ s[level] = bool(changes[level])
+ s['notice_shown'] = True
+ _save(s)
+ _drop_unwanted(s)
+ if changes.get('compat'):
+ backfill_compat()
+ _wake.set()
+ return state()
+
+
+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'],
+ 'diagnostics': s['diagnostics'],
+ 'blocked': blocked(), 'id': s['id'], 'queued': len(_read_lines(OUTBOX)),
+ 'sent': list(reversed(_read_lines(SENT)))[:50]}
+
+
+# ---- scrubbing and error categories ---------------------------------------------
+
+def _user_names():
+ names = set()
+ for v in (os.environ.get('USER'), os.environ.get('USERNAME'), Path.home().name):
+ if v and len(v) > 2:
+ names.add(v)
+ return names
+
+
+URL_RE = re.compile(r'[A-Za-z][A-Za-z0-9+.-]*://[^\s\'"<>]+')
+SCRUBS = [
+ (re.compile(r'ssh-(?:rsa|ed25519|dss)\s+\S+'), ''),
+ (re.compile(r'-----BEGIN [^-]+-----.*?-----END [^-]+-----', re.S), ''),
+ (re.compile(r'\b(?:phc|phx|ghp|gho|ghu|ghs|github_pat|sk|pk|rk|xox[abpr])[_-][A-Za-z0-9_-]{12,}'), ''),
+ (re.compile(r'(?i)\b(token|key|secret|password|passwd|pwd|auth|signature|sig)=[^\s&]+'), r'\1='),
+ (re.compile(r'[\w.+-]+@[\w-]+(?:\.[\w-]+)+'), ''),
+ (re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'), ''),
+ (re.compile(r'\b(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\b'), ''),
+ (re.compile(r'\b7656119\d{10}\b'), ''),
+ (re.compile(r'\b(?:[\w-]+\.)+(?:local|lan|home|internal|localdomain|ts\.net)\b'), ''),
+ (re.compile(r'\b[0-9a-fA-F]{32,}\b'), ''),
+]
+IPV6_RE = re.compile(r'(?'
+ except ValueError:
+ return m.group(0)
+
+
+def public_host(host):
+ """A host name that's safe to send: not an address, not a private or single-label name."""
+ host = (host or '').lower().rstrip('.')
+ if not host or '.' not in host:
+ return None
+ try:
+ ipaddress.ip_address(host.strip('[]'))
+ return None
+ except ValueError:
+ pass
+ if re.search(r'\.(?:local|lan|home|internal|localdomain|ts\.net|arpa)$', host) or not re.fullmatch(r'[a-z0-9.-]+', host):
+ return None
+ return host
+
+
+def _scrub_url(u):
+ """Only the scheme and a public host name of a URL; never user names, passwords, ports,
+ paths or queries."""
+ try:
+ parts = urlsplit(u)
+ host = public_host(parts.hostname)
+ except ValueError:
+ host = None
+ return f'{parts.scheme}://{host}/…' if host else ''
+
+
+def scrub(text, limit=2000):
+ """Text with URLs, home folders, user names, addresses, hosts, ids and keys replaced."""
+ if text is None:
+ return None
+ t = URL_RE.sub(lambda m: _scrub_url(m.group(0)), str(text)) # first, before anything splits a URL
+ home = str(Path.home())
+ if len(home) > 3:
+ t = t.replace(home, '~')
+ t = re.sub(r'(/Users/|/home/|[A-Za-z]:\\Users\\)[^/\\\s]+', r'\1', t)
+ for pattern, repl in SCRUBS:
+ t = pattern.sub(repl, t)
+ t = IPV6_RE.sub(_ipv6, t)
+ for name in _user_names():
+ t = re.sub(r'\b%s\b' % re.escape(name), '