Merge origin/main into pad-gamescope
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -6,3 +6,8 @@
|
|||||||
*.json text eol=lf
|
*.json text eol=lf
|
||||||
*.md text eol=lf
|
*.md text eol=lf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
# Test fixtures are byte-exact (hashes, signatures): never convert line endings.
|
||||||
|
tests/fixtures/** -text
|
||||||
|
*.apk binary
|
||||||
|
*.jar binary
|
||||||
|
*.obb binary
|
||||||
@@ -93,9 +93,15 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
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
|
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
|
## Install
|
||||||
|
|
||||||
@@ -209,7 +215,7 @@ 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
| [VR mods and custom songs](docs/mods.md) | Per-game feasibility, real-Frame results and blockers; no installer yet |
|
||||||
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Frame Control as a desktop app (macOS, Windows, Linux): starts ui/server.py on
|
// 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
|
// 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.
|
// 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, nativeImage, shell } = require("electron");
|
||||||
const { execFile, spawn } = require("child_process");
|
const { execFile, spawn } = require("child_process");
|
||||||
const { promisify } = require("util");
|
const { promisify } = require("util");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
@@ -149,7 +149,7 @@ async function startServer() {
|
|||||||
const target = `http://127.0.0.1:${port}/`;
|
const target = `http://127.0.0.1:${port}/`;
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
if (exited !== null) throw new Error(`The server exited (${exited}). See ${LOG}.`);
|
||||||
if (await ping(target)) { url = target; return; }
|
if (await ping(target)) { url = target; serverStarted = Date.now(); return; }
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
}
|
}
|
||||||
if (server === child) server = null;
|
if (server === child) server = null;
|
||||||
@@ -159,51 +159,76 @@ async function startServer() {
|
|||||||
|
|
||||||
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
// Closing stdin lets server.py close its SSH connections and exit (the only clean
|
||||||
// way on Windows); SIGTERM does the same elsewhere.
|
// 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) {
|
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 {}
|
try { child.stdin.end(); } catch {}
|
||||||
if (!IS_WIN) child.kill("SIGTERM");
|
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() {
|
function stopServer() {
|
||||||
if (server) endServer(server);
|
if (server) endServer(server);
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorPage(message) {
|
function errorPage(message, title = "Frame Control couldn't start") {
|
||||||
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
|
||||||
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
const html = `<!doctype html><meta charset="utf-8"><body style="margin:0;height:100vh;display:grid;
|
||||||
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
place-items:center;background:${BG};color:#e6edf3;font:14px -apple-system,sans-serif">
|
||||||
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>Frame Control couldn't start</h2>
|
<div style="max-width:560px;padding:32px;line-height:1.5"><h2>${esc(title)}</h2>
|
||||||
<p>${esc(message)}</p><p style="color:#8b98a8">Fix it, then choose Frame → Restart Server.</p></div>`;
|
<p>${esc(message)}</p>
|
||||||
|
<p><button onclick="this.disabled = true; frameApp.restartServer()" style="font:inherit;padding:6px 16px;
|
||||||
|
border-radius:6px;border:1px solid #30363d;background:#21262d;color:inherit;cursor:pointer">Try Again</button></p>
|
||||||
|
<p style="color:#8b98a8">Frame → Restart Server does the same.</p></div>`;
|
||||||
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
return "data:text/html;charset=utf-8," + encodeURIComponent(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A server that had been running starts again by itself (something stopped it: a
|
||||||
|
// signal, a crash). One that stops again within a minute shows the error instead,
|
||||||
|
// so a server that can't stay up doesn't restart forever.
|
||||||
|
let serverStarted = 0;
|
||||||
function serverDied(why) {
|
function serverDied(why) {
|
||||||
url = null;
|
url = null;
|
||||||
if (win) win.loadURL(errorPage(`The server stopped unexpectedly (${why}). See ${LOG}.`));
|
if (!win) return;
|
||||||
|
if (Date.now() - serverStarted > 60000) restartServer();
|
||||||
|
else win.loadURL(errorPage(`Its server stopped unexpectedly (${why}). See ${LOG}.`, "Frame Control stopped"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartServer() {
|
// Restarts that overlap share one: two could each start a server, and the one
|
||||||
const old = server;
|
// that lost the lock would leave the app pointing at nothing.
|
||||||
server = null;
|
let restarting = null;
|
||||||
url = null;
|
function restartServer() {
|
||||||
if (old) endServer(old);
|
if (!restarting) {
|
||||||
await load();
|
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.
|
// On macOS the page's sticky header becomes the title bar, clear of the traffic lights.
|
||||||
const CHROME_CSS = IS_MAC && `
|
const CHROME_CSS = IS_MAC && `
|
||||||
header { padding-left: 92px !important; -webkit-app-region: drag; user-select: none; }
|
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
|
// 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.
|
// its server; only the newest load may touch the window.
|
||||||
let loadGen = 0;
|
let loadGen = 0;
|
||||||
|
let starting = null; // loads that overlap share one server start
|
||||||
async function load() {
|
async function load() {
|
||||||
const gen = ++loadGen;
|
const gen = ++loadGen;
|
||||||
try {
|
try {
|
||||||
if (!url) await startServer();
|
if (!url) await (starting ||= startServer().finally(() => { starting = null; }));
|
||||||
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
if (gen === loadGen && win) { await win.loadURL(url); firstRunCheck(); }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
if (gen === loadGen && win) await win.loadURL(errorPage(e.message));
|
||||||
@@ -247,13 +272,81 @@ function fromUi(e) {
|
|||||||
} catch { return false; }
|
} catch { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The error page's Try Again button. The error page is the only data: page the window
|
||||||
|
// shows (`url` can still be set then: the server answered but the page failed to load).
|
||||||
|
ipcMain.handle("server:restart", (e) => {
|
||||||
|
if (win && e.sender === win.webContents && e.senderFrame && e.senderFrame.url.startsWith("data:")) restartServer();
|
||||||
|
});
|
||||||
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
ipcMain.handle("clipboard:read", (e) => fromUi(e) ? clipboard.readText() : "");
|
||||||
|
// A PNG or JPEG (a screenshot) onto the clipboard as an image.
|
||||||
|
ipcMain.handle("clipboard:writeImage", (e, bytes) => {
|
||||||
|
if (!fromUi(e) || !(bytes instanceof Uint8Array)) return false;
|
||||||
|
const img = nativeImage.createFromBuffer(Buffer.from(bytes));
|
||||||
|
if (img.isEmpty()) throw new Error("not an image");
|
||||||
|
clipboard.writeImage(img);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
ipcMain.handle("connection:setup", (e) => { if (fromUi(e)) setUpConnection(); });
|
||||||
ipcMain.on("keys:capture", (e, on) => { if (fromUi(e)) win.webContents.setIgnoreMenuShortcuts(on === true); });
|
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:get", (e) => fromUi(e) ? publicUpdate() : null);
|
||||||
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
ipcMain.handle("update:check", (e) => fromUi(e) ? checkForUpdate({ manual: true }).then(publicUpdate) : null);
|
||||||
ipcMain.handle("update:install", (e) => { if (fromUi(e)) installUpdate(); });
|
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
|
// 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),
|
// arrive before the window or server exists (macOS open-url on a cold launch),
|
||||||
// so they wait here until the page asks for them. The page checks the link with
|
// so they wait here until the page asks for them. The page checks the link with
|
||||||
@@ -376,7 +469,7 @@ function createWindow() {
|
|||||||
title: "Frame Control", backgroundColor: BG, show: false,
|
title: "Frame Control", backgroundColor: BG, show: false,
|
||||||
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 18, y: 26 } }
|
||||||
: { icon: path.join(__dirname, "build", "icon.png") }),
|
: { 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") },
|
preload: path.join(__dirname, "preload.js") },
|
||||||
});
|
});
|
||||||
win.once("ready-to-show", () => win.show());
|
win.once("ready-to-show", () => win.show());
|
||||||
@@ -410,13 +503,14 @@ async function runInTerminal(argv) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setUpConnection() {
|
// Set Up Connection for the headset in use (or another alias, from the Devices tab).
|
||||||
const alias = `FRAME_ALIAS=${FRAME}`;
|
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")]);
|
if (IS_MAC) return runInTerminal(["env", alias, "zsh", path.join(SCRIPTS, "connect.sh")]);
|
||||||
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
const py = python || await findPython({ ...process.env, PATH: await loginPath() });
|
||||||
const setup = [py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py")];
|
// --alias, since a new console on Windows (and some Linux terminals) doesn't get our environment.
|
||||||
// A new console inherits our environment on Windows; Linux terminals may not.
|
runInTerminal([py || "python3", ...PY_FLAGS, path.join(ROOT, "ui", "frame_connect.py"), "--alias", name]);
|
||||||
runInTerminal(IS_WIN ? setup : ["env", alias, ...setup]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMenu() {
|
function buildMenu() {
|
||||||
@@ -431,8 +525,15 @@ function buildMenu() {
|
|||||||
{
|
{
|
||||||
label: "Frame",
|
label: "Frame",
|
||||||
submenu: [
|
submenu: [
|
||||||
{ label: "Set Up Connection…", click: setUpConnection },
|
{ label: "Set Up Connection…", click: () => setUpConnection() },
|
||||||
{ label: IS_MAC ? "Open SSH in Terminal" : "Open SSH in a Terminal", click: () => runInTerminal(["ssh", FRAME]) },
|
{ 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" },
|
{ type: "separator" },
|
||||||
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
{ label: "Open in Browser", click: () => url && shell.openExternal(url) },
|
||||||
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
{ label: "Restart Server", click: () => win ? restartServer() : createWindow() },
|
||||||
|
|||||||
@@ -48,9 +48,18 @@
|
|||||||
"filter": [
|
"filter": [
|
||||||
"*.py",
|
"*.py",
|
||||||
"*.html",
|
"*.html",
|
||||||
|
"*.js",
|
||||||
"telemetry.json"
|
"telemetry.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "../ui/apk_sources",
|
||||||
|
"to": "ui/apk_sources",
|
||||||
|
"filter": [
|
||||||
|
"*.py",
|
||||||
|
"*.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "../scripts",
|
"from": "../scripts",
|
||||||
"to": "scripts",
|
"to": "scripts",
|
||||||
@@ -63,7 +72,8 @@
|
|||||||
"to": "frame/android",
|
"to": "frame/android",
|
||||||
"filter": [
|
"filter": [
|
||||||
"*.sh",
|
"*.sh",
|
||||||
"*.py"
|
"*.py",
|
||||||
|
"*.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,15 +2,25 @@
|
|||||||
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
|
// 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
|
// 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).
|
// 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 put a screenshot on the clipboard as an image, 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
|
// 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.
|
// what the link asked for, never an install; the page asks the user first.
|
||||||
// And it passes update state both ways: see app/updater.js.
|
// And it passes update state both ways: see app/updater.js.
|
||||||
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
const { contextBridge, ipcRenderer, webUtils } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("frameApp", {
|
contextBridge.exposeInMainWorld("frameApp", {
|
||||||
|
notify: (message, request) => ipcRenderer.invoke("comfort:notify", message, request),
|
||||||
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
|
||||||
|
writeImage: (bytes) => ipcRenderer.invoke("clipboard:writeImage", bytes),
|
||||||
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
setUpConnection: () => ipcRenderer.invoke("connection:setup"),
|
||||||
|
restartServer: () => ipcRenderer.invoke("server:restart"), // the "couldn't start" page's Try Again
|
||||||
|
// 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.
|
// 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),
|
captureKeys: (on) => ipcRenderer.send("keys:capture", !!on),
|
||||||
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
|
||||||
|
|||||||
@@ -168,6 +168,43 @@ on this branch (run 36421345682).
|
|||||||
**Verified on the same Frame/build:** both Ctrl-C and SIGTERM close the dedicated
|
**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.
|
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
|
## Computer-use coverage
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 <apk>` 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.
|
||||||
@@ -331,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.
|
whether the settings survive the app or its Lepton instance relaunching.
|
||||||
Lepton Development rebuilds its Android data on exit, so there they probably
|
Lepton Development rebuilds its Android data on exit, so there they probably
|
||||||
don't.
|
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.
|
||||||
@@ -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/<id>`, 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=<address> -o HostKeyAlias=frame-control-<id>
|
||||||
|
-o UserKnownHostsFile=~/.ssh/frame-control-hosts/<id> -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.
|
||||||
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -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.
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 269 KiB |
@@ -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).
|
||||||
@@ -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/<content-hash>/`. 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.
|
||||||
@@ -17,13 +17,16 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
|
|||||||
|
|
||||||
## Features
|
## 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,
|
**Games** (installed games, sideloaded titles, getting games), **Android** (apps,
|
||||||
the catalogue, display settings, reports) and **Tools** (sending files and text,
|
the catalogue, display settings, reports), **Tools** (sending files and text,
|
||||||
Flatpaks, remote and power). Keys 1–4 switch between them. Files can be dropped
|
Flatpaks, remote and power) and **Devices** (your headsets and their addresses).
|
||||||
anywhere in the window. When the Frame can't be reached, one banner says why in
|
Keys 1–5 switch between them. Files can be dropped anywhere in the window. A
|
||||||
plain words and the app retries every few seconds, filling everything in once it
|
connection pill in the header always shows which headset, which network this
|
||||||
answers. Flatpak and Android installs run in the background; the bottom bar
|
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.
|
counts them while they run.
|
||||||
|
|
||||||
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
- **Headset view**: what the lenses show, as SteamVR composites it (the room,
|
||||||
@@ -78,6 +81,11 @@ counts them while they run.
|
|||||||
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
an SSH tunnel; see [mac-in-headset.md](mac-in-headset.md).
|
||||||
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
|
||||||
Remmina).
|
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
|
- **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
|
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
|
Linux). Sleep, restart and shut down open a terminal window because SteamOS
|
||||||
@@ -101,7 +109,9 @@ Frame for the keyboard and trackpad.
|
|||||||
The server is Python stdlib only and listens on 127.0.0.1. It rejects requests
|
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
|
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
|
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.
|
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
|
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
|
its own and the app is a little slower. What differs between the three
|
||||||
|
|||||||
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 74 KiB |
@@ -26,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,
|
display settings use `podman exec` into each Lepton container instead of adb,
|
||||||
which the Frame doesn't have.
|
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).
|
files stay in `~/.cache/frame-control` (delete it any time).
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -108,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`,
|
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
|
`FRAME_TEST_JS`, and the tunnel URL in the app's Caches folder); release builds
|
||||||
don't.
|
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).
|
||||||
@@ -38,6 +38,33 @@ after its container exited. No headset was worn and no host was connected.
|
|||||||
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
All test app files, compatdata, shortcuts and containers were removed afterwards.
|
||||||
SteamVR's original process remained running. No global settings changed.
|
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
|
### Relation to the VR APK branch
|
||||||
|
|
||||||
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
**Documented from source:** [PR #20](https://github.com/saphid/frame-control/pull/20)
|
||||||
|
|||||||
@@ -468,3 +468,18 @@ versus on, medians of the runs, ms):
|
|||||||
window to the front first.
|
window to the front first.
|
||||||
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
- Ctrl stays Ctrl. On the Mac, copy is ⌘C, so use Meta+C on a keyboard paired
|
||||||
with the Frame.
|
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).
|
||||||
@@ -121,8 +121,165 @@ a limit on the number of floating panels.
|
|||||||
script needed.
|
script needed.
|
||||||
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
- **Inside the desktop panel**: KWin tiling (Meta+arrow keys with a Bluetooth
|
||||||
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
keyboard) or virtual desktops arrange windows within the 1280×800 rectangle.
|
||||||
- **Windows-only overlay tools** (Desktop+, OVR Toolkit, OVRdrop) do this for a
|
- **Optional overlay tools:** Desktop+, OVR Toolkit and similar software are
|
||||||
PC's desktop in SteamVR. They don't run on the Frame's standalone Linux.
|
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 <key>` 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
|
## Frame Control's media theatre
|
||||||
|
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ 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
|
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
|
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
|
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,
|
wrote, a short reference shown after sending, and the diagnostics below. Your
|
||||||
and the diagnostics below. It has its own random id, so it isn't linked to
|
email address goes with it only if you tick **The maintainer may contact me
|
||||||
|
with follow-up questions** (the report then carries `contact_followup: true`);
|
||||||
|
it's filled in from **Contact email** below when you've agreed there. It has its own random id, so it isn't linked to
|
||||||
your analytics events.
|
your analytics events.
|
||||||
|
|
||||||
With **Include diagnostics** ticked (the default), the report adds:
|
With **Include diagnostics** ticked (the default), the report adds:
|
||||||
@@ -128,11 +130,60 @@ 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
|
with `python3 ui/frame_report.py inbox [days]`, which uses the same personal
|
||||||
API key as `frame_compat_db.py sync`.
|
API key as `frame_compat_db.py sync`.
|
||||||
|
|
||||||
|
## Contact email (optional)
|
||||||
|
|
||||||
|
Frame Control never needs an email address. If you'd like to leave one, there
|
||||||
|
are two separate choices, both off until you tick them:
|
||||||
|
|
||||||
|
| Choice | What it's for |
|
||||||
|
|---|---|
|
||||||
|
| **Email me about Frame Control updates** | Occasional notices about new releases and updates |
|
||||||
|
| **The maintainer may contact me with follow-up questions** | Questions about problem reports you send, mostly |
|
||||||
|
|
||||||
|
You're asked once, in a bar at the top of the page, after the Frame has
|
||||||
|
connected for the first time, and never while or straight after the
|
||||||
|
first-run privacy notice is showing. **No thanks** hides it for good, and it isn't
|
||||||
|
shown again even if you ignore it. **Contact email** in **Privacy & updates**
|
||||||
|
is where you add, change or remove the address and either choice at any time.
|
||||||
|
|
||||||
|
**What's sent, and where.** The address and the two choices go privately to
|
||||||
|
Frame Control's PostHog project, the same place as problem reports, as a
|
||||||
|
`contact_consent` event with `email`, `updates`, `followup`, `action` (`set`
|
||||||
|
or `withdraw`) and the common properties above. Only the maintainer can read
|
||||||
|
that project, and nothing in it is published or shared. It's sent only when
|
||||||
|
you save, whatever the analytics settings are, because you chose to. It
|
||||||
|
carries its own random contact id, not the analytics id, so it isn't linked
|
||||||
|
to your usage events, and a `rev` number that goes up with each change, so
|
||||||
|
the newest choice always wins. Like everything else sent, it's listed under
|
||||||
|
**Show what's been sent**. On this computer the address and choices are kept in
|
||||||
|
`contact/contact.json` in Frame Control's data folder. An address is only
|
||||||
|
kept with at least one choice ticked.
|
||||||
|
|
||||||
|
**Removing it.** **Remove my email** (or clearing the address and saving)
|
||||||
|
deletes it from this computer, including from the **Show what's been sent**
|
||||||
|
log (in earlier contact events and problem reports), and sends a `withdraw`
|
||||||
|
event with no address in it. The maintainer's list only uses the newest event from each copy, so from
|
||||||
|
then on the address isn't listed for either choice. Unticking one choice
|
||||||
|
works the same way for that choice. If you're offline, the change waits on
|
||||||
|
this computer and is sent when PostHog can be reached. The earlier event
|
||||||
|
stays in PostHog until its data retention removes it; to have it deleted
|
||||||
|
sooner, ask the maintainer (for example in a problem report).
|
||||||
|
|
||||||
|
Nothing sends email yet: this only records who agreed to what. The
|
||||||
|
maintainer lists the addresses with
|
||||||
|
`python3 ui/frame_report.py contacts [updates|followup]`, which uses the same
|
||||||
|
personal API key as `inbox`.
|
||||||
|
|
||||||
## Turning it all off
|
## Turning it all off
|
||||||
|
|
||||||
Untick the boxes, or set `DO_NOT_TRACK=1` or `FRAME_CONTROL_TELEMETRY=0` in
|
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
|
the environment that starts Frame Control. A copy run from a source checkout
|
||||||
never sends anything unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
never sends analytics unless `FRAME_CONTROL_TELEMETRY=1` is set.
|
||||||
|
|
||||||
|
These switches cover the analytics above. A problem report or a contact email
|
||||||
|
is sent only because you pressed its Send or Save button, so those still go
|
||||||
|
when you choose to send them (a contact change saved while offline is sent
|
||||||
|
by itself once PostHog can be reached); if you don't, nothing is sent.
|
||||||
|
|
||||||
## Update checks
|
## Update checks
|
||||||
|
|
||||||
|
|||||||
@@ -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.<versionCode>.<package>.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/<package>/`. `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/<instance>/internal/<package>` is documented as `/data/data/<package>`;
|
||||||
|
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/<instance>/internal/<package>`, 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
|
||||||
|
`.<package>.before-restore-<timestamp>`; 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.
|
||||||
@@ -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.
|
- Installing when there's more than one library folder, such as a microSD card.
|
||||||
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
- Uninstalling. `steam://uninstall/<appid>` should open a confirmation in the
|
||||||
headset.
|
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.
|
||||||
@@ -104,6 +104,20 @@ switch while SSH is down:
|
|||||||
|
|
||||||
## Headset smoke test
|
## 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
|
```sh
|
||||||
scripts/frame-smoke.sh # needs `ssh frame` to work without a password
|
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
|
scripts/frame-smoke.sh --pair # also pairs a throwaway key: approve it in the headset
|
||||||
@@ -170,3 +184,29 @@ 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
|
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
|
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).
|
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 <key>` 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.
|
||||||
@@ -84,6 +84,132 @@ not being worn, so it did not reach `FOCUSED`).
|
|||||||
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
The loader was never the problem: Wolvic's Quest `libopenxr_loader.so` is a
|
||||||
Khronos-style loader and found SteamVR through `/vendor`.
|
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/<id>/`, 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/<id>/<exe>"`), 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
|
## Out of scope
|
||||||
|
|
||||||
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
- **Meta entitlement.** Apps that call the Oculus Platform SDK
|
||||||
|
|||||||
@@ -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:<appid>"`, 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).
|
||||||
@@ -75,6 +75,34 @@ All test media were generated by us. No paid content or DRM was involved.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
**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
|
**Verified failed route:** GStreamer 1.24.2's `playbin` selected
|
||||||
`v4l2h264dec`, delivered the first RGBA sample and then segfaulted (exit 139)
|
`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.
|
in the basic appsink probe and the OpenVR probe. We do not ship that route.
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -19,6 +19,25 @@ done
|
|||||||
|
|
||||||
# A number that isn't a real Steam app; it names this app's Lepton context.
|
# A number that isn't a real Steam app; it names this app's Lepton context.
|
||||||
export SteamAppId="$(cat "$DIR/instance.id")"
|
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"
|
export STEAM_COMPAT_INSTALL_PATH="$DIR"
|
||||||
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
|
# 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"
|
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
|
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
|
||||||
# and SSH don't have; give it its own session instead.
|
# and SSH don't have; give it its own session instead.
|
||||||
export IS_PARENT=true
|
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"
|
||||||
@@ -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;q<queue.length;q++) {
|
||||||
|
const i=queue[q];if(seen[i])continue;seen[i]=1;
|
||||||
|
if(Math.max(d[i*4],d[i*4+1],d[i*4+2])>24)continue;
|
||||||
|
d[i*4+3]=0;
|
||||||
|
if(i%w)queue.push(i-1);if(i%w<w-1)queue.push(i+1);
|
||||||
|
if(i>=w)queue.push(i-w);if(i<w*(h-1))queue.push(i+w);
|
||||||
|
}
|
||||||
|
c.putImageData(pixels,0,0);icon=cut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Quantized, saturated dominant colors avoid white/black icon margins.
|
||||||
|
let colors = [[48,91,137], [24,36,63]];
|
||||||
|
if (icon) {
|
||||||
|
const sample = document.createElement('canvas'); sample.width=48; sample.height=48;
|
||||||
|
const s=sample.getContext('2d'); s.drawImage(icon,0,0,48,48);
|
||||||
|
const data=s.getImageData(0,0,48,48).data, bins=new Map();
|
||||||
|
for (let i=0;i<data.length;i+=4) {
|
||||||
|
const rgb=[data[i],data[i+1],data[i+2]], hi=Math.max(...rgb), lo=Math.min(...rgb);
|
||||||
|
if (data[i+3]<150 || hi<45 || lo>220 || 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};
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
|
|||||||
|
|
||||||
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
|
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 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
|
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'
|
DEVTOOLS = 'http://127.0.0.1:8080/json'
|
||||||
|
|
||||||
@@ -22,10 +24,10 @@ def target_ws():
|
|||||||
class WS:
|
class WS:
|
||||||
"""Just enough RFC 6455 for one CDP request/response on loopback."""
|
"""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, path = url[len('ws://'):].split('/', 1)
|
||||||
host, port = host_port.split(':')
|
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()
|
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'
|
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'
|
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
|
||||||
@@ -69,8 +71,8 @@ class WS:
|
|||||||
return msg.decode()
|
return msg.decode()
|
||||||
|
|
||||||
|
|
||||||
def evaluate(js):
|
def evaluate(js, timeout=20):
|
||||||
ws = WS(target_ws())
|
ws = WS(target_ws(), timeout)
|
||||||
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
|
||||||
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
|
||||||
while True:
|
while True:
|
||||||
@@ -83,6 +85,206 @@ def evaluate(js):
|
|||||||
return res.get('result', {}).get('value')
|
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():
|
def main():
|
||||||
cmd, args = sys.argv[1], sys.argv[2:]
|
cmd, args = sys.argv[1], sys.argv[2:]
|
||||||
if cmd == 'add':
|
if cmd == 'add':
|
||||||
@@ -97,12 +299,30 @@ def main():
|
|||||||
}})()'''
|
}})()'''
|
||||||
print(evaluate(js))
|
print(evaluate(js))
|
||||||
elif cmd == 'list':
|
elif cmd == 'list':
|
||||||
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
|
# Overviews carry no exe or devkit id (checked 2026-09-28); app details do, once registered.
|
||||||
.map(a => ({appid: a.appid, name: a.display_name})))()'''
|
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)))
|
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':
|
elif cmd == 'remove':
|
||||||
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
|
print(json.dumps(remove(int(args[0]))))
|
||||||
print('removed')
|
|
||||||
else:
|
else:
|
||||||
sys.exit(__doc__)
|
sys.exit(__doc__)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
78427FC66780623F31E7501E /* FrameControlApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93C8E0D7C3F4F628941B3D5A /* FrameControlApp.swift */; };
|
||||||
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
84423CB45629465420180A64 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F2CB550FC81C01E6BDD5A71 /* Assets.xcassets */; };
|
||||||
9657F7BC23E3352E5AB30777 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB544223FC60A59CC3E8EF5F /* SetupView.swift */; };
|
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 */; };
|
A8C7AED25A6280682FCE45DC /* Citadel in Frameworks */ = {isa = PBXBuildFile; productRef = 6BA549B6CC0A0CB847126456 /* Citadel */; };
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */; };
|
||||||
E6898C714A92D3979F73B6E1 /* FrameFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F288DF6636A417F0CA3A6CD /* FrameFinder.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 = "<group>"; };
|
BF0FCA7117DA3ABA449B4EE0 /* InstallLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallLink.swift; sourceTree = "<group>"; };
|
||||||
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
D6C4E6C28315CA8729FCAAEA /* WebShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebShell.swift; sourceTree = "<group>"; };
|
||||||
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
DB544223FC60A59CC3E8EF5F /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
|
||||||
|
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComfortNotificationTests.swift; sourceTree = "<group>"; };
|
||||||
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
EDC7BA8014DBC302D08FD397 /* HeadsetServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadsetServer.swift; sourceTree = "<group>"; };
|
||||||
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
F3E2F5607DD877272483D64E /* FrameControl.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = FrameControl.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -107,6 +109,7 @@
|
|||||||
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
75A17B1C79C8C3C60FEABBA6 /* FrameControlTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
E81218B75FEEE47B8D8BAE20 /* ComfortNotificationTests.swift */,
|
||||||
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
1740B691F9C25E5FB6F9EFC3 /* FrameControlTests.swift */,
|
||||||
);
|
);
|
||||||
path = FrameControlTests;
|
path = FrameControlTests;
|
||||||
@@ -274,6 +277,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A0C5B00E257230A38DBD9E54 /* ComfortNotificationTests.swift in Sources */,
|
||||||
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
DC043FB74BE2D23F3A5826BF /* FrameControlTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebKit
|
import WebKit
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
/// The Frame Control page, served by the server on the headset, in a web view.
|
/// 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
|
/// window.frameApp (the same bridge the desktop app's preload.js provides) lets
|
||||||
@@ -48,6 +49,7 @@ struct WebShell: UIViewRepresentable {
|
|||||||
let installCb = null;
|
let installCb = null;
|
||||||
window.frameApp = {
|
window.frameApp = {
|
||||||
platform: "ios",
|
platform: "ios",
|
||||||
|
notify: (message, request) => call("notify", { message, request }),
|
||||||
readClipboard: () => call("readClipboard"),
|
readClipboard: () => call("readClipboard"),
|
||||||
setUpConnection: () => call("setUpConnection"),
|
setUpConnection: () => call("setUpConnection"),
|
||||||
open: (what) => call("open", what),
|
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
|
let model: AppModel
|
||||||
weak var web: WKWebView?
|
weak var web: WKWebView?
|
||||||
var loaded: URL?
|
var loaded: URL?
|
||||||
private var installReady = false
|
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
|
// MARK: bridge
|
||||||
|
|
||||||
@@ -76,6 +96,28 @@ struct WebShell: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
let arg = body["arg"]
|
let arg = body["arg"]
|
||||||
switch name {
|
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":
|
case "readClipboard":
|
||||||
replyHandler(UIPasteboard.general.string ?? "", nil)
|
replyHandler(UIPasteboard.general.string ?? "", nil)
|
||||||
case "setUpConnection":
|
case "setUpConnection":
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,10 +101,22 @@ make_key() { # path type comment [extra ssh-keygen args]
|
|||||||
fi
|
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() {
|
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
|
touch "$CONFIG" && chmod 600 "$CONFIG" || return 1
|
||||||
local tmp
|
local tmp new="$CONFIG.frame-control.$$"
|
||||||
tmp=$(mktemp) || return 1
|
tmp=$(mktemp) || return 1
|
||||||
# Drop any previous managed block, then PREPEND a fresh one: ssh uses the first
|
# 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"
|
# 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 -- "Host *"
|
||||||
print -r -- "$END_MARK"
|
print -r -- "$END_MARK"
|
||||||
cat "$tmp"
|
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"
|
rm -f "$tmp"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
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}
|
HERE=${0:A:h}
|
||||||
cmd=${1:-status}
|
cmd=${1:-status}
|
||||||
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
case $cmd in on|off|status) ;; *) echo "usage: keep-awake.sh on|off|status" >&2; exit 2 ;; esac
|
||||||
|
|
||||||
ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
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"
|
'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
|
# Runs on the Frame. Verified 2026-09-28 (BUILD_ID 20260925.6191901): the
|
||||||
@@ -27,7 +29,7 @@ ssh -o ConnectTimeout=8 "$FRAME_ALIAS" \
|
|||||||
# written the way Steam's settings page does (steamui module exporting the
|
# written the way Steam's settings page does (steamui module exporting the
|
||||||
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
# SetSetting wrapper). logind refuses an inhibitor from an SSH session
|
||||||
# ("Interactive authentication required") but allows one from a user unit.
|
# ("Interactive authentication required") but allows one from a user unit.
|
||||||
ssh "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
ssh "${ssh_opts[@]}" "$FRAME_ALIAS" python3 - "$cmd" <<'EOF'
|
||||||
import json, os, subprocess, sys
|
import json, os, subprocess, sys
|
||||||
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
sys.path.insert(0, os.path.expanduser("~/.cache/frame-control"))
|
||||||
from frame_steam import Page
|
from frame_steam import Page
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
FRAME_ALIAS=${FRAME_ALIAS:-frame}
|
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"
|
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
|
||||||
id="" name=""
|
id="" name=""
|
||||||
|
|
||||||
@@ -109,4 +111,4 @@ EOF
|
|||||||
)
|
)
|
||||||
b64=$(print -rn -- "$remote" | base64)
|
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}}"
|
||||||
@@ -33,8 +33,8 @@ class AndroidApps(harness.FrameTestCase):
|
|||||||
self.assertEqual(shortcut['name'], 'App label')
|
self.assertEqual(shortcut['name'], 'App label')
|
||||||
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
self.assertEqual(shortcut['exe'], f'{APP_DIR}/launch.sh')
|
||||||
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
self.assertEqual(shortcut['start_dir'], APP_DIR)
|
||||||
self.assertEqual(shortcut['icon'], f'{APP_DIR}/icon.png')
|
self.assertEqual(shortcut['icon'], f'{APP_DIR}/artwork/icon.png')
|
||||||
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'icon.png', 'lepton-show-flatscreen'):
|
for f in ('app.apk', 'launch.sh', 'instance.id', 'meta.json', 'artwork/icon.png', 'lepton-show-flatscreen'):
|
||||||
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
self.assertTrue(exists(f'{APP_DIR}/{f}'), f)
|
||||||
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
self.assertEqual(meta['game_id'], (meta['shortcut'] << 32) | 0x02000000)
|
||||||
|
|
||||||
|
|||||||
@@ -83,5 +83,27 @@ class Device(harness.FrameTestCase):
|
|||||||
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
self.assertEqual(state()['steam']['pages'][0]['title'], 'Hades on Steam')
|
||||||
|
|
||||||
|
|
||||||
|
class VRUtilities(harness.FrameTestCase):
|
||||||
|
def test_missing_vr_runtime_is_unavailable_not_zero_fps(self):
|
||||||
|
data = ok('GET', '/api/status')
|
||||||
|
self.assertIsNone(data['performance']['compositorFps'])
|
||||||
|
self.assertIsNone(data['performance']['appFps'])
|
||||||
|
self.assertEqual(data['temp'], 41.5)
|
||||||
|
self.assertEqual(data['battery']['percent'], 76)
|
||||||
|
|
||||||
|
def test_optional_paid_utilities_are_not_installed(self):
|
||||||
|
# The fake library contains games but none of these paid software titles.
|
||||||
|
for appid in (1009850, 1173510, 1068820, 908520):
|
||||||
|
code, body, _ = api('POST', '/api/steam', {'action': 'install', 'appid': appid})
|
||||||
|
self.assertEqual(code, 502, body)
|
||||||
|
self.assertIn('not owned', body['error'])
|
||||||
|
self.assertEqual(launches('install'), [])
|
||||||
|
|
||||||
|
def test_unverified_controls_are_not_exposed(self):
|
||||||
|
for action in ('recenter', 'adjust', 'restore'):
|
||||||
|
code, body, _ = api('POST', '/api/vr', {'action': action, 'origin': 'seated', 'y': .1})
|
||||||
|
self.assertEqual(code, 400, body)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -25,6 +25,10 @@ containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
|
|||||||
if cmd == 'ps':
|
if cmd == 'ps':
|
||||||
for name, c in sorted(containers.items()):
|
for name, c in sorted(containers.items()):
|
||||||
print(f"{name} {c['port']}")
|
print(f"{name} {c['port']}")
|
||||||
|
elif cmd == 'inspect':
|
||||||
|
if args[-1] not in containers:
|
||||||
|
sys.exit(1)
|
||||||
|
print('true')
|
||||||
elif cmd == 'stop':
|
elif cmd == 'stop':
|
||||||
name = args[-1]
|
name = args[-1]
|
||||||
c = containers.get(name)
|
c = containers.get(name)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Synthetic canvas for API-path tests only. It emits transparent PNGs, not visual proof.
|
||||||
|
const zlib = require('zlib');
|
||||||
|
function crc(data) {
|
||||||
|
let c=0xffffffff;
|
||||||
|
for(const b of data) {c^=b;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}
|
||||||
|
return (c^0xffffffff)>>>0;
|
||||||
|
}
|
||||||
|
function chunk(name,data) {
|
||||||
|
const body=Buffer.concat([Buffer.from(name),data]), n=Buffer.alloc(4), sum=Buffer.alloc(4);
|
||||||
|
n.writeUInt32BE(data.length);sum.writeUInt32BE(crc(body));return Buffer.concat([n,body,sum]);
|
||||||
|
}
|
||||||
|
function png(w,h) {
|
||||||
|
const header=Buffer.alloc(13);header.writeUInt32BE(w);header.writeUInt32BE(h,4);header[8]=8;header[9]=6;
|
||||||
|
return Buffer.concat([Buffer.from('89504e470d0a1a0a','hex'),chunk('IHDR',header),
|
||||||
|
chunk('IDAT',zlib.deflateSync(Buffer.alloc((w*4+1)*h))),chunk('IEND',Buffer.alloc(0))]).toString('base64');
|
||||||
|
}
|
||||||
|
function surface() {
|
||||||
|
const canvases=[];
|
||||||
|
const document={fonts:{load:async()=>[]},createElement(tag) {
|
||||||
|
if(tag!=='canvas')throw Error('unexpected element');
|
||||||
|
const canvas={width:1,height:1,text:[],draws:0};
|
||||||
|
const ctx={measureText:t=>({width:String(t).length*30}),fillText(t){canvas.text.push(t);},
|
||||||
|
drawImage(){canvas.draws++;},getImageData:()=>({data:new Uint8ClampedArray(canvas.width*canvas.height*4)}),
|
||||||
|
createLinearGradient:()=>({addColorStop(){}}),createRadialGradient:()=>({addColorStop(){}})};
|
||||||
|
for(const method of ['save','restore','beginPath','rect','roundRect','clip','fillRect','putImageData','arc','fill','stroke'])ctx[method]=()=>{};
|
||||||
|
canvas.getContext=()=>ctx;canvas.toDataURL=type=>type==='image/jpeg'?'data:image/jpeg;base64,'+Buffer.from('ffd8ffe000104a464946','hex').toString('base64'):
|
||||||
|
'data:image/png;base64,'+png(canvas.width,canvas.height);
|
||||||
|
canvases.push(canvas);return canvas;
|
||||||
|
}};
|
||||||
|
class Image {constructor(){this.width=2;this.height=2;} async decode(){if(this.src.includes('YmFk'))throw Error('bad image');}}
|
||||||
|
return {document,Image,canvases};
|
||||||
|
}
|
||||||
|
module.exports={surface};
|
||||||
@@ -27,7 +27,7 @@ function newShortcutId(steam) {
|
|||||||
function build(steam) {
|
function build(steam) {
|
||||||
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
|
||||||
const gameOverview = a => ({
|
const gameOverview = a => ({
|
||||||
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
|
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: a.app_type ?? 1,
|
||||||
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
|
||||||
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
|
||||||
rt_last_time_played: a.last_played || 0,
|
rt_last_time_played: a.last_played || 0,
|
||||||
@@ -37,7 +37,8 @@ function build(steam) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const shortcutOverview = s => ({
|
const shortcutOverview = s => ({
|
||||||
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
|
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE, devkit_gameid: s.devkit_gameid,
|
||||||
|
icon_data: s.icon ? 'fake-icon' : undefined,
|
||||||
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
|
||||||
});
|
});
|
||||||
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
|
||||||
@@ -54,7 +55,30 @@ function build(steam) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Library API shapes from SteamTracking / decky-frontend-lib (2026-09-28).
|
||||||
|
// Not yet verified on this Frame build: Steam was unavailable during testing.
|
||||||
|
steam.collections ||= [];
|
||||||
|
const collection = value => ({
|
||||||
|
...value, displayName: value.name, bIsDynamic: !!value.dynamic, bAllowsDragAndDrop: true,
|
||||||
|
AsDragDropCollection() { return this; },
|
||||||
|
AddApps(apps) { value.apps = [...new Set([...value.apps, ...apps.map(a => a.appid)])]; },
|
||||||
|
RemoveApps(apps) { value.apps = value.apps.filter(id => !apps.some(a => a.appid === id)); },
|
||||||
|
value,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
|
...require('./canvas_stub').surface(),
|
||||||
|
collectionStore: {
|
||||||
|
GetUserCollectionsByName(name) { return steam.collections.filter(c => c.name === name).map(collection); },
|
||||||
|
NewUnsavedCollection(name, filter, apps) { return collection({name, apps: apps.map(a => a.appid)}); },
|
||||||
|
async SaveCollection(c) { if (!steam.collections.includes(c.value)) steam.collections.push(c.value); },
|
||||||
|
},
|
||||||
|
// Shortcut exe/start folder live in app details, not overviews (Frame, 2026-09-28).
|
||||||
|
appDetailsStore: {
|
||||||
|
GetAppDetails(id) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
return s ? { strShortcutExe: s.exe, strShortcutStartDir: s.start_dir, bShortcutIsVR: !!s.vr } : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
appStore: {
|
appStore: {
|
||||||
get allApps() { return allApps(); },
|
get allApps() { return allApps(); },
|
||||||
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
|
||||||
@@ -75,6 +99,17 @@ function build(steam) {
|
|||||||
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
|
||||||
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
|
||||||
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
|
||||||
|
SetShortcutIsVR(id, vr) { const s = findShortcut(id); if (s) s.vr = vr; },
|
||||||
|
async SetCustomArtworkForApp(id, data, ext, type) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
if (s) { s.artwork ||= {}; s.artwork[type] = {data, ext}; }
|
||||||
|
},
|
||||||
|
async ClearCustomArtworkForApp(id, type) {
|
||||||
|
const s = findShortcut(id);
|
||||||
|
if (s?.artwork) delete s.artwork[type];
|
||||||
|
},
|
||||||
|
// Container fallback in frame_android.stop performs the simulated stop.
|
||||||
|
TerminateApp(gameid) { steam.last_terminate = gameid; },
|
||||||
RemoveShortcut(id) {
|
RemoveShortcut(id) {
|
||||||
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
|
||||||
delete steam.compat_tools[String(id)];
|
delete steam.compat_tools[String(id)];
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A stand-in for OpenSSH's ssh, for tests/test_link.py: prints what `ssh -v` prints at
|
||||||
|
each step of a connection, and plays a ControlMaster. What each HostName does comes
|
||||||
|
from $FAKESSH_HOSTS (JSON: host -> "ok", "wrong" (a different host key), "denied" or
|
||||||
|
"slow" (hangs after connecting);
|
||||||
|
every call is appended to $FAKESSH_LOG as a JSON line. POSIX only."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
with open(os.environ["FAKESSH_LOG"], "a") as f:
|
||||||
|
f.write(json.dumps(args) + "\n")
|
||||||
|
hosts = json.loads(os.environ.get("FAKESSH_HOSTS", "{}"))
|
||||||
|
opts = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(args) and args[i].startswith("-"):
|
||||||
|
if args[i] in ("-o", "-O", "-p", "-l"):
|
||||||
|
key = args[i]
|
||||||
|
val = args[i + 1]
|
||||||
|
if key == "-o":
|
||||||
|
k, _, v = val.partition("=")
|
||||||
|
opts[k.lower()] = v
|
||||||
|
else:
|
||||||
|
opts[key] = val
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
opts[args[i]] = True
|
||||||
|
i += 1
|
||||||
|
alias = args[i] if i < len(args) else ""
|
||||||
|
host = opts.get("hostname", alias).replace("%%", "%")
|
||||||
|
marker = os.path.join(os.environ["FAKESSH_DIR"], "master-" + host.replace("/", "_"))
|
||||||
|
say = lambda s: (sys.stderr.write(s + "\n"), sys.stderr.flush())
|
||||||
|
|
||||||
|
if "-G" in opts:
|
||||||
|
print(f"hostname {alias}\nport 22\nuser tester")
|
||||||
|
sys.exit(0)
|
||||||
|
if opts.get("-O") == "check":
|
||||||
|
sys.exit(0 if os.path.exists(marker) else 255)
|
||||||
|
if opts.get("-O") == "exit":
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
what = hosts.get(host)
|
||||||
|
if what is None:
|
||||||
|
say(f"ssh: Could not resolve hostname {host}: nodename nor servname provided, or not known")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Connecting to {host} [127.0.0.1] port {opts.get('port', 22)}.")
|
||||||
|
say("debug1: Connection established.")
|
||||||
|
if what == "slow":
|
||||||
|
time.sleep(30)
|
||||||
|
say(f"debug1: Authenticating to {host}:22 as '{opts.get('user', 'tester')}'")
|
||||||
|
say("debug1: Server host key: ssh-ed25519 SHA256:fakefakefakefakefakefakefakefakefakefakefak")
|
||||||
|
if what == "wrong":
|
||||||
|
say("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@")
|
||||||
|
say("@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @")
|
||||||
|
say("Host key verification failed.")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f"debug1: Host '{opts.get('hostkeyalias', host)}' is known and matches the ED25519 host key.")
|
||||||
|
say("debug1: Next authentication method: publickey")
|
||||||
|
if what == "denied":
|
||||||
|
say(f"tester@{host}: Permission denied (publickey).")
|
||||||
|
sys.exit(255)
|
||||||
|
say(f'Authenticated to {host} ([127.0.0.1]:22) using "publickey".')
|
||||||
|
if opts.get("controlmaster") == "yes":
|
||||||
|
open(marker, "w").close()
|
||||||
|
def bye(*_):
|
||||||
|
if os.path.exists(marker):
|
||||||
|
os.unlink(marker)
|
||||||
|
sys.exit(0)
|
||||||
|
signal.signal(signal.SIGTERM, bye)
|
||||||
|
while True:
|
||||||
|
time.sleep(0.2)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
sys.exit(0)
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Store preview artwork
|
||||||
|
|
||||||
|
Recorded public artwork for offline UI verification, fetched 2026-09-28.
|
||||||
|
`urls.json` records each original URL. No unit test downloads these files.
|
||||||
|
|
||||||
|
- Open Brush banner, icon and screenshots: Icosa Foundation's public
|
||||||
|
`icosa-foundation/openbrush.app` website assets. Artwork remains credited to
|
||||||
|
its creators; used here to preview the Open Brush listing.
|
||||||
|
- Mindustry, AntennaPod and NewPipe icons/screenshots: their public F-Droid
|
||||||
|
listings. Corresponding projects use GPL licences; these images represent
|
||||||
|
those same apps in the store preview.
|
||||||
|
- Luanti, SuperTuxKart and other social previews: public GitHub-generated
|
||||||
|
repository preview images. Project names/logos belong to their owners.
|
||||||
|
|
||||||
|
`../store.json` contains illustrative listing metadata, including mock package
|
||||||
|
names, popularity, dates, version/size and compatibility fields. It is not a
|
||||||
|
catalogue or evidence that a particular release works on the Frame. `_demo.py`
|
||||||
|
is opt-in and cannot download APKs. `tests/search_preview.py` preloads these
|
||||||
|
recordings into the image cache and simulates installation without a headset.
|
||||||
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 559 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 821 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"brush-banner.jpg": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||||
|
"brush-icon.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"brush-shot1.png": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||||
|
"brush-shot2.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||||
|
"brush-shot3.webp": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp",
|
||||||
|
"luanti.png": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||||
|
"kart.png": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||||
|
"luanti-icon.png": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||||
|
"pod-icon.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||||
|
"mindustry-icon.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||||
|
"mindustry-shot.png": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||||
|
"pod-shot.png": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||||
|
"newpipe-icon.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||||
|
"newpipe-shot.png": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{"source":"one","id":"brush","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":true,"version":"1","version_code":1,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2025-01-01"},
|
||||||
|
{"source":"two","id":"brush2","package":"org.brush","name":"Open Brush","free":true,"downloadable":true,"verified":false,"version":"2","version_code":2,"min_sdk":29,"abis":["arm64-v8a"],"vr":true,"updated":"2026-01-01"},
|
||||||
|
{"source":"one","id":"other","package":"org.other","name":"Open Brush","free":true,"downloadable":true,"min_sdk":31,"abis":["arm64-v8a"],"vr":true},
|
||||||
|
{"source":"one","id":"unknown","package":null,"name":"Pocket Radio!","free":true,"downloadable":false,"vr":false},
|
||||||
|
{"source":"two","id":"unknown2","package":null,"name":"pocket radio","free":true,"downloadable":false,"vr":false}
|
||||||
|
]
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "brush",
|
||||||
|
"package": "org.preview.brush",
|
||||||
|
"name": "Open Brush",
|
||||||
|
"summary": "Make the world your canvas. Paint, sculpt and create in a space without limits.",
|
||||||
|
"description": "Your imagination deserves more room. Open Brush turns the space around you into a canvas, with expressive brushes, vivid colors and light you can paint with.\n\nCreate something small, build something extraordinary, or just enjoy making your first mark in VR. This community-led painting app is free and open source.",
|
||||||
|
"developer": "Icosa Foundation",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "2.32.29",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": true,
|
||||||
|
"updated": "2026-09-27",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 100,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/bg.jpg",
|
||||||
|
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/1.png",
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/2.webp",
|
||||||
|
"https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/carousel/3.webp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engine": "Unity OpenXR",
|
||||||
|
"frame_tested": true,
|
||||||
|
"icon": "https://raw.githubusercontent.com/icosa-foundation/openbrush.app/main/assets/icon.png",
|
||||||
|
"page": "https://openbrush.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mindustry",
|
||||||
|
"package": "org.preview.mindustry",
|
||||||
|
"name": "Mindustry",
|
||||||
|
"summary": "Build a factory. Defend your world.",
|
||||||
|
"description": "Build a factory. Defend your world.",
|
||||||
|
"developer": "Anuken",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.2",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-26",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 92,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png",
|
||||||
|
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/io.anuke.mindustry/en-US/phoneScreenshots/1.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/io.anuke.mindustry/en-US/icon_Eno3XvqCZUcHRm3eMjiUleAxgzLopPe6-hkI7BHx1lU=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "luanti",
|
||||||
|
"package": "org.preview.luanti",
|
||||||
|
"name": "Luanti",
|
||||||
|
"summary": "A world of blocks. Endless possibilities.",
|
||||||
|
"description": "A world of blocks. Endless possibilities.",
|
||||||
|
"developer": "Luanti contributors",
|
||||||
|
"license": "LGPL-2.1",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.3",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-25",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 84,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://opengraph.githubassets.com/1/luanti-org/luanti",
|
||||||
|
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://opengraph.githubassets.com/1/luanti-org/luanti"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://raw.githubusercontent.com/luanti-org/luanti/master/textures/base/pack/logo.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pod",
|
||||||
|
"package": "org.preview.pod",
|
||||||
|
"name": "AntennaPod",
|
||||||
|
"summary": "Your favorite stories, wherever you listen.",
|
||||||
|
"description": "Your favorite stories, wherever you listen.",
|
||||||
|
"developer": "AntennaPod contributors",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.4",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-24",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 76,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png",
|
||||||
|
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/de.danoeh.antennapod/en-US/phoneScreenshots/00.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/de.danoeh.antennapod/en-US/icon_w44b41PyuNt3pI7Gh8zYHJrWgu__3HT7YSWZtttfenk=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "newpipe",
|
||||||
|
"package": "org.preview.newpipe",
|
||||||
|
"name": "NewPipe",
|
||||||
|
"summary": "Your videos and music, without the distractions.",
|
||||||
|
"description": "Your videos and music, without the distractions.",
|
||||||
|
"developer": "Team NewPipe",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": true,
|
||||||
|
"version": "1.5",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-23",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 68,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png",
|
||||||
|
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png",
|
||||||
|
"screenshots": [
|
||||||
|
"https://f-droid.org/repo/org.schabi.newpipe/en-US/phoneScreenshots/00.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": "https://f-droid.org/repo/org.schabi.newpipe/en-US/icon_OHy4y1W-fJCNhHHOBCM9V_cxZNJJgbcNkB-x7UDTY9Q=.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kart",
|
||||||
|
"package": "org.preview.kart",
|
||||||
|
"name": "SuperTuxKart",
|
||||||
|
"summary": "A little friendly competition. A lot of colorful chaos.",
|
||||||
|
"description": "A little friendly competition. A lot of colorful chaos.",
|
||||||
|
"developer": "SuperTuxKart Team",
|
||||||
|
"license": "GPL-3.0",
|
||||||
|
"free": true,
|
||||||
|
"downloadable": false,
|
||||||
|
"version": "1.6",
|
||||||
|
"version_code": 1,
|
||||||
|
"min_sdk": 26,
|
||||||
|
"abis": [
|
||||||
|
"arm64-v8a"
|
||||||
|
],
|
||||||
|
"vr": false,
|
||||||
|
"updated": "2026-09-22",
|
||||||
|
"size": 85000000,
|
||||||
|
"popularity": 60,
|
||||||
|
"images": {
|
||||||
|
"banner": "https://opengraph.githubassets.com/1/supertuxkart/stk-code",
|
||||||
|
"icon": null,
|
||||||
|
"screenshots": [
|
||||||
|
"https://opengraph.githubassets.com/1/supertuxkart/stk-code"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"icon": null,
|
||||||
|
"page": "https://supertuxkart.net"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# F-Droid verification fixtures
|
||||||
|
|
||||||
|
`entry.jar` and `index-v1.jar` are synthetic RSA-2048/SHA-256 signed JARs,
|
||||||
|
including CMS signed attributes. `fingerprint.txt` identifies their throwaway
|
||||||
|
certificate. Their JSON describes org.example.app; `example.apk` is deliberately
|
||||||
|
plain test data, not an installable app. The v2 index includes incompatible
|
||||||
|
Android-31 and x86-only versions to exercise the shared reducer.
|
||||||
|
|
||||||
|
`izzy-entry.jar` was recorded from
|
||||||
|
https://apt.izzysoft.de/fdroid/repo/entry.jar on 2026-09-28. Its certificate
|
||||||
|
fingerprint matches the operator's published fingerprint:
|
||||||
|
3BF0D6ABFEAE2F401707B6D966BE743BF0EEE49C2561B9BA39073711F628937A.
|
||||||
|
It exercises an independent production JAR/CMS encoder without network access.
|
||||||
|
The index it references is not needed by this signature-only fixture test.
|
||||||
|
|
||||||
|
`artwork-v1.json` and `artwork-v2.json` are unsigned metadata/reducer fixtures
|
||||||
|
based on the synthetic indexes above. They exercise en-US preference, per-field
|
||||||
|
locale fallback, v1 artwork paths, phone/tablet ordering, the six-image cap,
|
||||||
|
author names and HTML/multiline summaries. The signed integrity fixtures remain
|
||||||
|
unchanged; artwork tests feed these JSON files directly through the reducer and
|
||||||
|
then round-trip the resulting entries through the source cache.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"apps": [
|
||||||
|
{
|
||||||
|
"packageName": "org.example.app",
|
||||||
|
"name": "Example",
|
||||||
|
"license": "MIT",
|
||||||
|
"authorName": "Example Developer",
|
||||||
|
"summary": "Fallback summary",
|
||||||
|
"localized": {
|
||||||
|
"de": {
|
||||||
|
"name": "Beispiel",
|
||||||
|
"summary": "Deutsch",
|
||||||
|
"icon": "german.png",
|
||||||
|
"phoneScreenshots": [
|
||||||
|
"german.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"name": "Example",
|
||||||
|
"summary": "Offline <b>fixture</b> & music.\n One\t line.",
|
||||||
|
"icon": "icon.png",
|
||||||
|
"phoneScreenshots": [
|
||||||
|
"1.png",
|
||||||
|
"2.png",
|
||||||
|
"3.png",
|
||||||
|
"4.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"featureGraphic": "featureGraphic.png",
|
||||||
|
"sevenInchScreenshots": [
|
||||||
|
"1.png",
|
||||||
|
"2.png",
|
||||||
|
"3.png",
|
||||||
|
"4.png"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages": {
|
||||||
|
"org.example.app": [
|
||||||
|
{
|
||||||
|
"versionName": "1",
|
||||||
|
"versionCode": 1,
|
||||||
|
"apkName": "example1.apk",
|
||||||
|
"hash": "3e1e2658aef79aaf21aeb8d6705dbc5b251e51627bfdfc53650da50ed2c38c79",
|
||||||
|
"hashType": "sha256",
|
||||||
|
"size": 51,
|
||||||
|
"minSdkVersion": 21
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{
|
||||||
|
"repo": {
|
||||||
|
"name": {
|
||||||
|
"en-US": "Fixture"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"org.example.app": {
|
||||||
|
"metadata": {
|
||||||
|
"name": {
|
||||||
|
"en-US": "Example"
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"en-US": "<p>Offline <b>fixture</b> & music.</p>\n<p>One\t line.</p><script>hidden()</script>"
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Fixture APK payload, deliberately not installable.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0e87b227cd414d7093fb150fda81f1754900f3abc810e6785d8e0767c6eb798a
|
||||||
@@ -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}}}}}
|
||||||
@@ -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.
|
||||||
@@ -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)});
|
||||||
|
After Width: | Height: | Size: 834 B |
|
After Width: | Height: | Size: 77 B |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"home": "/home/steamos",
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"appid": 3346865537,
|
||||||
|
"name": "Open Saber Plus"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configure": {
|
||||||
|
"warnings": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?><rss version="2.0"><channel><title>Top free games for Android tagged openxr - itch.io</title><link>https://itch.io/games/free/platform-android/tag-openxr</link><item><guid>https://leandrodreamer.itch.io/open-saber</guid><title>Open Saber [Free] [Rhythm] [Windows] [Linux] [Android]</title><plainTitle>Open Saber</plainTitle><imageurl>https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif</imageurl><price>$0.00</price><currency>USD</currency><link>https://leandrodreamer.itch.io/open-saber</link><description><![CDATA[Open Source Rythmic Block Cutting Game :)
|
||||||
|
<img src="https://img.itch.zone/aW1nLzEzMzgzMzgzLmdpZg==/original/P7L1sC.gif" alt="Open Saber"/>]]></description><pubDate>Thu, 07 Sep 2023 02:11:50 GMT</pubDate><createDate>Thu, 07 Sep 2023 02:11:50 GMT</createDate><updateDate>Wed, 08 Jan 2025 02:24:29 GMT</updateDate><platforms><html>yes</html></platforms></item><item><guid>https://absyo.itch.io/off-nominal</guid><title>Off Nominal [Free] [Puzzle] [Windows] [Linux] [Android]</title><plainTitle>Off Nominal</plainTitle><imageurl>https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://absyo.itch.io/off-nominal</link><description><![CDATA[Adrift in space. Fix the ship. Return home.
|
||||||
|
<img src="https://img.itch.zone/aW1nLzMwMjk0MDA1LnBuZw==/315x250%23c/QSbOIy.png" alt="Off Nominal"/>]]></description><pubDate>Fri, 25 Sep 2026 19:54:48 GMT</pubDate><createDate>Fri, 25 Sep 2026 19:54:48 GMT</createDate><updateDate>Sun, 27 Sep 2026 23:25:20 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://somar-project.itch.io/somar-project</guid><title>Somar-project [Free] [Educational] [Android]</title><plainTitle>Somar-project</plainTitle><imageurl>https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://somar-project.itch.io/somar-project</link><description><![CDATA[Open-Source OpenXR application for raising awareness about negative impacts of underwater noise pollution on marine life
|
||||||
|
<img src="https://img.itch.zone/aW1nLzIwNDM2MzM1LnBuZw==/315x250%23c/Xswj5t.png" alt="Somar-project"/>]]></description><pubDate>Wed, 26 Mar 2025 17:17:58 GMT</pubDate><createDate>Wed, 26 Mar 2025 17:17:58 GMT</createDate><updateDate>Fri, 28 Mar 2025 15:52:59 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://5imon.itch.io/buggenesis</guid><title>Bug Genesis VR [Free] [Interactive Fiction] [Windows] [Android]</title><plainTitle>Bug Genesis VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://5imon.itch.io/buggenesis</link><description><![CDATA[Use the bug generator to populate the planet
|
||||||
|
<img src="https://img.itch.zone/aW1nLzIxMzYzODczLmpwZw==/315x250%23c/LVpznb.jpg" alt="Bug Genesis VR"/>]]></description><pubDate>Sun, 25 May 2025 20:22:49 GMT</pubDate><createDate>Sun, 25 May 2025 20:22:49 GMT</createDate><updateDate>Sun, 25 May 2025 20:37:39 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://benmclean.itch.io/wolfsharp</guid><title>WolfSharp [Free] [Shooter] [Windows] [Linux] [Android]</title><plainTitle>WolfSharp</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://benmclean.itch.io/wolfsharp</link><description><![CDATA[Wolfenstein 3-D for VR
|
||||||
|
<img src="https://img.itch.zone/aW1nLzI4NzMyNjQyLnBuZw==/315x250%23c/heg%2BmL.png" alt="WolfSharp"/>]]></description><pubDate>Sat, 25 Jul 2026 12:16:01 GMT</pubDate><createDate>Sat, 25 Jul 2026 12:16:01 GMT</createDate><updateDate>Sat, 25 Jul 2026 13:45:38 GMT</updateDate><platforms><windows>yes</windows><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://mimekunst.itch.io/winter-solitude-vr</guid><title>Winter Solitude VR [Free] [Simulation] [Windows] [macOS] [Linux] [Android]</title><plainTitle>Winter Solitude VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://mimekunst.itch.io/winter-solitude-vr</link><description><![CDATA[Step into the Frozen Abyss: FREE VR Game Experience
|
||||||
|
<img src="https://img.itch.zone/aW1nLzE0NDA4NzQxLnBuZw==/315x250%23c/EOaygC.png" alt="Winter Solitude VR"/>]]></description><pubDate>Tue, 19 Dec 2023 19:19:59 GMT</pubDate><createDate>Tue, 19 Dec 2023 19:19:59 GMT</createDate><updateDate>Wed, 20 Dec 2023 22:49:23 GMT</updateDate><platforms><windows>yes</windows><osx>yes</osx><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://salmondev.itch.io/evil-miner-vr</guid><title>EVIL MINER VR [Free] [Other] [Windows] [Android]</title><plainTitle>EVIL MINER VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://salmondev.itch.io/evil-miner-vr</link><description><![CDATA[
|
||||||
|
<img src="https://img.itch.zone/aW1nLzIxNjY2NDA0LnBuZw==/315x250%23c/n4bF8N.png" alt="EVIL MINER VR"/>]]></description><pubDate>Fri, 13 Jun 2025 16:48:01 GMT</pubDate><createDate>Fri, 13 Jun 2025 16:48:01 GMT</createDate><updateDate>Sun, 15 Jun 2025 15:19:53 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item><item><guid>https://robinhuud.itch.io/winter-challenge-north-pole-defense</guid><title>North Pole Defense VR [Free] [Action] [Android]</title><plainTitle>North Pole Defense VR</plainTitle><imageurl>https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png</imageurl><price>$0.00</price><currency>USD</currency><link>https://robinhuud.itch.io/winter-challenge-north-pole-defense</link><description><![CDATA[Defend the north pole against giant snowmen using VR snowballs
|
||||||
|
<img src="https://img.itch.zone/aW1nLzc2NzU1ODMucG5n/315x250%23c/71b4aj.png" alt="North Pole Defense VR"/>]]></description><pubDate>Thu, 16 Dec 2021 01:33:33 GMT</pubDate><createDate>Thu, 16 Dec 2021 01:33:33 GMT</createDate><updateDate>Thu, 16 Dec 2021 01:51:29 GMT</updateDate><platforms><android>yes</android></platforms></item><item><guid>https://envemos.itch.io/ambly-native-xr</guid><title>Ambly Native XR [Free] [Linux] [Android]</title><plainTitle>Ambly Native XR</plainTitle><imageurl>https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://envemos.itch.io/ambly-native-xr</link><description><![CDATA[OpenXR games for Meta Quest, PICO and Linux.
|
||||||
|
<img src="https://img.itch.zone/aW1nLzI4NzEwMTc0LmpwZw==/315x250%23c/4XHeya.jpg" alt="Ambly Native XR"/>]]></description><pubDate>Wed, 22 Jul 2026 18:38:55 GMT</pubDate><createDate>Wed, 22 Jul 2026 18:38:55 GMT</createDate><updateDate>Fri, 07 Aug 2026 16:04:20 GMT</updateDate><platforms><linux>yes</linux><android>yes</android></platforms></item><item><guid>https://andyman404.itch.io/glow-up-garden</guid><title>Glow Up Garden (VR) [Free] [Action] [Windows] [Android]</title><plainTitle>Glow Up Garden (VR)</plainTitle><imageurl>https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg</imageurl><price>$0.00</price><currency>USD</currency><link>https://andyman404.itch.io/glow-up-garden</link><description><![CDATA[Speak positive affirmations out loud to grow your garden (voice-driven VR game)
|
||||||
|
<img src="https://img.itch.zone/aW1nLzE2NDE3NjE3LmpwZw==/315x250%23c/nHkM4g.jpg" alt="Glow Up Garden (VR)"/>]]></description><pubDate>Mon, 03 Jun 2024 20:36:53 GMT</pubDate><createDate>Mon, 03 Jun 2024 20:36:53 GMT</createDate><updateDate>Tue, 04 Jun 2024 04:20:37 GMT</updateDate><platforms><windows>yes</windows><android>yes</android></platforms></item></channel></rss>
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -213,8 +213,9 @@ class ManagedBackend(unittest.TestCase):
|
|||||||
with mock.patch.object(server.frame_host, 'MUX', True), \
|
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, 'getuid', return_value=501, create=True), \
|
||||||
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
mock.patch.object(server.frame_host.os, 'getpid', return_value=123):
|
||||||
self.assertEqual(server.frame_host.control_path(), '/tmp/frame-ui-501-%C')
|
# Per headset (its tag), and per process for a private server.
|
||||||
self.assertEqual(server.frame_host.control_path(private=True), '/tmp/frame-ui-501-123-%C')
|
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):
|
class ComputerState(unittest.TestCase):
|
||||||
@@ -251,3 +252,23 @@ class ComputerState(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.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:])
|
||||||
@@ -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'<svg onload="evil()"/>')
|
||||||
|
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()
|
||||||
@@ -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'<html>'), 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()
|
||||||
@@ -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')
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||